[v10] Track useLocalNodes resource reads to narrow registry subscriptions

Author: DennisSmolekCreated Sep 16, 2026Updated Sep 16, 2026
Labelsenhancementv10WebGPU

Summary

Implementation follow-up to #3888, part 2: automatically subscribe local compositions to the shared resource identities they actually read. Keep inline creators and scoped lookup; callers should not have to assemble selectors or list every shared resource manually.

The explicit dependency-array follow-up handles captured JavaScript construction inputs separately. Neither change alone solves both causes of graph churn in #3888.

Current behavior and scope

Verified on remote v10 at 979dc2d2e39cf0f8787714a81f47ff19ade384fb: useLocalNodes subscribes to entire uniforms, nodes, and textures registries plus _hmrVersion. An unrelated resource registration can rerender and reconstruct every local consumer. CreatorState also exposes buffers/storage without equivalent dedicated subscriptions in this hook.

Relevant implementation:

  • packages/fiber/src/webgpu/hooks/useNodes.tsx
  • packages/fiber/src/webgpu/hooks/ScopedStore.ts
  • packages/fiber/src/core/utils/resourceRegistry (staged overlays and rebuild generations)

Desired behavior

typescript
const local = useLocalNodes(({ uniforms, nodes }) => ({
  result: nodes.noise.mul(uniforms.scope('surface').strength),
}), [])

This reads nodes.noise and uniforms.surface.strength. A replacement or removal at either path invalidates it. Adding or replacing an unrelated resource does not. Mutating the existing strength uniform's value does not rebuild its consuming graph.

The array in this example is the proposed explicit-dependency API, not an existing API at the verified revision.

Suggested implementation

Extend the existing scoped wrappers with per-evaluation read tracking. Record registry kind, scope/key path, operation, and observed leaf identity/existence. Return original Three nodes, uniforms, textures, and buffers, not deep proxies of their internals.

On registry notifications, compare only the committed dependency set and reuse a cached snapshot when those dependencies are unchanged. Integrate through React's external-store subscription semantics. A first implementation may still receive broad Zustand notifications: eliminating unnecessary React renders and graph reconstruction is the first goal. Per-key notification routing is a separate optimization if measurement justifies it.

Cover all resource families exposed to these creators: uniforms, nodes, buffers, gpuStorage, and textures. Texture access is a URL-keyed Map (textures.get(url) / has(url)), requiring a Map-aware read facade; a property-access proxy alone is insufficient. Preserve method receivers and the existing public API.

Dependency semantics

Operation Dependency
nodes.noise Identity at this path, including an absent entry.
uniforms.scope('surface').strength Leaf path; unrelated changes within the scope must not invalidate it.
.has(key) or key in scope Existence, not arbitrary other entries.
.keys() / Object.keys(scope) Scope membership/key enumeration.
Spreading a scope Membership plus values actually read.
textures.get(url) Texture identity at that URL, including absence.
Map iteration / .size The appropriate entry/key/membership dependencies; define and test each supported operation.

Changing a scope between absent/scope/leaf states must invalidate reads whose meaning changed. Preserve independent subscriptions for consumers with different paths. Recompute the dependency set after every actual creator evaluation, including conditional branches and explicit dependency changes.

Three/TSL boundary

This is shared-resource identity tracking, not a general reactive proxy for RootState or Three objects. Updating .value, camera matrices, or other values handled by live TSL nodes should not reconstruct graphs through React. JavaScript primitives sampled during construction remain construction inputs. Do not subscribe to NodeBuilder's internal property traversal by recursively proxying returned node objects.

Deferred Fn callbacks need an explicit decision

typescript
useLocalNodes(({ uniforms }) => ({
  effect: Fn(() => uniforms.strength.mul(2)),
}), [])

The registry lookup may execute later during Three graph building. Tracking only the synchronous outer creator cannot guarantee detection of this dependency.

For an initial synchronous-tracking contract, document capture-before-Fn:

typescript
useLocalNodes(({ uniforms }) => {
  const strength = uniforms.strength
  return { effect: Fn(() => strength.mul(2)) }
}, [])

Do not silently claim deferred reads are covered. Before landing, decide whether to require eager resource capture (with practical diagnostics), conservatively subscribe escaped scopes, or integrate deferred dependency collection with Three's build lifecycle. Document the correctness and subscription-cost tradeoff. A complete automatic deferred tracker is not a prerequisite if the supported contract is clear.

Concurrency, staging, and HMR

  • Collect reads as render-local work; publish dependency metadata with the corresponding committed result. Aborted/suspended attempts cannot overwrite the committed subscription.
  • Handle updates between reading a snapshot and subscribing/committing without missed updates or tearing. Cache snapshots: allocating a new object on every getSnapshot call can create update loops.
  • Preserve staged uniform/node/buffer/storage visibility for later creators in the same render and generation-consistent publication. Coordinate texture staging with #3895; this issue must not pretend it fixes missing first-render texture registration by itself.
  • Never publish Zustand resource state from a render-time tracker.
  • Respect primary-store resolution for shared canvases; changing the owning store updates subscriptions and invalidates appropriately.
  • Keep a deliberate HMR/manual invalidation channel. A global hot edit may rebuild broadly even if identities have not swapped yet. Scoped rebuilds must retain their documented boundaries; audit the global _hmrVersion interaction instead of assuming it is already fine-grained.
  • Keep resource disposal/refcounts under their existing ownership contracts; this tracker should not dispose shared resources when a reader disappears.

Acceptance criteria

  • Unrelated registration/replacement/removal causes neither a subscription-driven render nor local graph reconstruction for an explicit-dependency consumer.
  • Replacing/removing an accessed resource invalidates; in-place live uniform updates do not.
  • Missing resource later appearing, missing scope appearing, and scope/leaf transitions behave correctly.
  • Reads of two leaves in one scope remain unaffected by changes to a third leaf.
  • Conditional reads update dependencies: old branches stop invalidating and new branches begin invalidating.
  • Test uniforms, nodes, buffers, gpuStorage, and URL-keyed texture Map operations, including enumeration.
  • Original Three object identity and method behavior are preserved; no deep node proxy escapes.
  • Same-render staged reads, multi-canvas primary sharing, HMR, manual/scoped rebuilds, and unmount work without extra registration/disposal.
  • StrictMode, initial suspension, abandoned renders, and updates between render and subscription are covered by regression tests.
  • Deferred Fn lookup behavior is explicitly tested and documented under the chosen contract.
  • Repeat the unrelated-registration reproduction from #3888 and report renders, creator calls, and node identities separately; GPU compilation claims require separate measurement.
  • Update the TSL guide with resource identity versus live value semantics, enumeration breadth, and deferred reads; retain explicit selectors as a possible escape hatch without requiring them for ordinary scope/leaf access.

Related: #3888 (original report), #3895 (texture staging), #3890 / #3893 (separate effect/cleanup API proposals). Reference: https://react.dev/reference/react/useSyncExternalStore

Companion implementation issue: #3918 (explicit dependency-array semantics).

Source: pmndrs/react-three-fiber