@react-three/tsl 10.0.0-canary.975e1e7 → 10.0.0-canary.a9bd42a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -312,33 +312,129 @@ function ensureTSLExtension() {
312
312
  });
313
313
  }
314
314
 
315
+ const SCOPE = Symbol("readTracking.scope");
316
+ const LEAF_GUARDS = {
317
+ uniforms: isUniformNode,
318
+ nodes: isTSLNode,
319
+ buffers: isBufferLike,
320
+ gpuStorage: isStorageLike
321
+ };
322
+ function isScope(kind, value) {
323
+ return !!value && typeof value === "object" && !LEAF_GUARDS[kind](value);
324
+ }
325
+ function classify(kind, value) {
326
+ return kind !== "textures" && isScope(kind, value) ? SCOPE : value;
327
+ }
328
+ function resolveContainer(kind, root, scopePath) {
329
+ let container = root;
330
+ for (const key of scopePath) {
331
+ const next = container?.[key];
332
+ container = isScope(kind, next) ? next : void 0;
333
+ }
334
+ return container;
335
+ }
336
+ function observeNow(read, view) {
337
+ if (read.kind === "textures") {
338
+ const map = view("textures");
339
+ if (read.op === "keys") return [...map.keys()];
340
+ return read.op === "has" ? map.has(read.path[0]) : map.get(read.path[0]);
341
+ }
342
+ const root = view(read.kind);
343
+ if (read.op === "keys") return Object.keys(resolveContainer(read.kind, root, read.path) ?? {});
344
+ const container = resolveContainer(read.kind, root, read.path.slice(0, -1));
345
+ const key = read.path[read.path.length - 1];
346
+ if (read.op === "has") return !!container && key in container;
347
+ return classify(read.kind, container?.[key]);
348
+ }
349
+ function sameKeys(a, b) {
350
+ return a.length === b.length && a.every((key, i) => key === b[i]);
351
+ }
352
+ function hasChanged(read, view) {
353
+ const now = observeNow(read, view);
354
+ return read.op === "keys" ? !sameKeys(read.seen, now) : !Object.is(read.seen, now);
355
+ }
356
+ const warned = /* @__PURE__ */ new Set();
357
+ function warnOnce(message) {
358
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
359
+ if (warned.has(message)) return;
360
+ warned.add(message);
361
+ console.warn(message);
362
+ }
363
+ function describeRead(kind, path) {
364
+ if (kind === "textures") return `textures.get('${path[0]}')`;
365
+ const scopes = path.slice(0, -1).map((key) => `.scope('${key}')`);
366
+ return `${kind}${scopes.join("")}.${path[path.length - 1] ?? ""}`;
367
+ }
368
+ function createReadTracker(hookName) {
369
+ const tracker = {
370
+ reads: [],
371
+ closed: false,
372
+ stale: false,
373
+ observe: (read) => {
374
+ if (tracker.closed) {
375
+ warnOnce(
376
+ `[${hookName}] ${describeRead(read.kind, read.path)} was read after the creator returned, probably inside Fn(). That read is not tracked, so replacing the resource will not rebuild this graph. Read the resource in the creator and close over it in Fn.`
377
+ );
378
+ return;
379
+ }
380
+ tracker.reads.push(read);
381
+ }
382
+ };
383
+ return tracker;
384
+ }
385
+ function isTrackerStale(tracker, view) {
386
+ if (!tracker.stale) tracker.stale = tracker.reads.some((read) => hasChanged(read, view));
387
+ return tracker.stale;
388
+ }
389
+ function warnMissingReads(hookName) {
390
+ return {
391
+ nested: false,
392
+ observe: (read) => {
393
+ if (read.op !== "get" || read.seen !== void 0) return;
394
+ warnOnce(
395
+ `[${hookName}] The creator read ${describeRead(read.kind, read.path)}, which does not exist yet. ${hookName} creators run once per generation, so this one will not re-run when it appears. Register it earlier (above this hook, or higher in the tree), or call rebuild* once it exists. To probe on purpose, use .has().`
396
+ );
397
+ }
398
+ };
399
+ }
400
+
315
401
  var __defProp = Object.defineProperty;
316
402
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
317
403
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
318
- var _a, _b;
404
+ var _a, _b, _c, _d;
319
405
  const INTERNAL_DATA = Symbol("ScopedStore.data");
320
406
  const INTERNAL_IS_LEAF = Symbol("ScopedStore.isLeaf");
321
- _b = INTERNAL_DATA, _a = INTERNAL_IS_LEAF;
407
+ const INTERNAL_READS = Symbol("ScopedStore.reads");
408
+ const INTERNAL_CHILDREN = Symbol("ScopedStore.children");
409
+ _d = INTERNAL_DATA, _c = INTERNAL_IS_LEAF, _b = INTERNAL_READS, _a = INTERNAL_CHILDREN;
322
410
  const _ScopedStore = class _ScopedStore {
323
- constructor(data, isLeaf) {
411
+ constructor(data, isLeaf, reads) {
412
+ /** @internal */
413
+ __publicField(this, _d);
414
+ /** @internal */
415
+ __publicField(this, _c);
416
+ /** @internal */
324
417
  __publicField(this, _b);
418
+ /** @internal */
325
419
  __publicField(this, _a);
326
420
  this[INTERNAL_DATA] = data;
327
421
  this[INTERNAL_IS_LEAF] = isLeaf;
422
+ this[INTERNAL_READS] = reads;
328
423
  return new Proxy(this, {
329
424
  get(target, prop, receiver) {
330
425
  if (typeof prop === "string") {
331
426
  if (prop === "scope" || prop === "has" || prop === "keys") {
332
427
  return Reflect.get(target, prop, receiver);
333
428
  }
334
- return target[INTERNAL_DATA][prop];
429
+ return readEntry(target, prop);
335
430
  }
336
431
  return Reflect.get(target, prop, receiver);
337
432
  },
338
433
  has(target, prop) {
339
- return typeof prop === "string" ? prop in target[INTERNAL_DATA] : Reflect.has(target, prop);
434
+ return typeof prop === "string" ? target.has(prop) : Reflect.has(target, prop);
340
435
  },
341
436
  ownKeys(target) {
437
+ observeKeys(target);
342
438
  return Reflect.ownKeys(target[INTERNAL_DATA]);
343
439
  },
344
440
  getOwnPropertyDescriptor(target, prop) {
@@ -360,54 +456,152 @@ const _ScopedStore = class _ScopedStore {
360
456
  scope(key) {
361
457
  const value = this[INTERNAL_DATA][key];
362
458
  const isLeaf = this[INTERNAL_IS_LEAF];
459
+ const reads = this[INTERNAL_READS];
363
460
  const scope = value && typeof value === "object" && !isLeaf(value) ? value : {};
364
- return new _ScopedStore(scope, isLeaf);
461
+ const child = reads ? { ...reads, path: [...reads.path, key] } : void 0;
462
+ return new _ScopedStore(scope, isLeaf, child);
365
463
  }
366
464
  /**
367
465
  * Check if a key exists in the store.
368
466
  */
369
467
  has(key) {
370
- return key in this[INTERNAL_DATA];
468
+ const found = key in this[INTERNAL_DATA];
469
+ const reads = this[INTERNAL_READS];
470
+ reads?.observe({ kind: reads.kind, op: "has", path: [...reads.path, key], seen: found });
471
+ return found;
371
472
  }
372
473
  /**
373
474
  * Get all keys in the store.
374
475
  */
375
476
  keys() {
477
+ observeKeys(this);
376
478
  return Object.keys(this[INTERNAL_DATA]);
377
479
  }
378
480
  };
379
481
  let ScopedStore = _ScopedStore;
482
+ function readEntry(target, key) {
483
+ const value = target[INTERNAL_DATA][key];
484
+ const reads = target[INTERNAL_READS];
485
+ if (!reads) return value;
486
+ const isLeaf = target[INTERNAL_IS_LEAF];
487
+ const isScope = !!value && typeof value === "object" && !isLeaf(value);
488
+ const path = [...reads.path, key];
489
+ reads.observe({ kind: reads.kind, op: "get", path, seen: isScope ? SCOPE : value });
490
+ if (!isScope || !reads.nested) return value;
491
+ const children = target[INTERNAL_CHILDREN] ?? (target[INTERNAL_CHILDREN] = /* @__PURE__ */ new Map());
492
+ let child = children.get(key);
493
+ if (!child) {
494
+ child = new ScopedStore(value, isLeaf, { ...reads, path });
495
+ children.set(key, child);
496
+ }
497
+ return child;
498
+ }
499
+ function observeKeys(target) {
500
+ const reads = target[INTERNAL_READS];
501
+ reads?.observe({ kind: reads.kind, op: "keys", path: reads.path, seen: Object.keys(target[INTERNAL_DATA]) });
502
+ }
380
503
  function createScopedStore(data, isLeaf) {
381
504
  return new ScopedStore(data, isLeaf);
382
505
  }
383
- function createLazyCreatorState(state, store) {
506
+ function observeTextures(map, observe) {
507
+ const observeKeys2 = () => observe({ kind: "textures", op: "keys", path: [], seen: [...map.keys()] });
508
+ const observeAll = () => {
509
+ observeKeys2();
510
+ for (const [url, texture] of map) observe({ kind: "textures", op: "get", path: [url], seen: texture });
511
+ };
512
+ return new Proxy(map, {
513
+ get(target, prop) {
514
+ switch (prop) {
515
+ case "get":
516
+ return (url) => {
517
+ const texture = target.get(url);
518
+ observe({ kind: "textures", op: "get", path: [url], seen: texture });
519
+ return texture;
520
+ };
521
+ case "has":
522
+ return (url) => {
523
+ const found = target.has(url);
524
+ observe({ kind: "textures", op: "has", path: [url], seen: found });
525
+ return found;
526
+ };
527
+ case "size":
528
+ observeKeys2();
529
+ return target.size;
530
+ case "keys":
531
+ return () => {
532
+ observeKeys2();
533
+ return target.keys();
534
+ };
535
+ case "values":
536
+ return () => {
537
+ observeAll();
538
+ return target.values();
539
+ };
540
+ case "entries":
541
+ case Symbol.iterator:
542
+ return () => {
543
+ observeAll();
544
+ return target.entries();
545
+ };
546
+ case "forEach":
547
+ return (callback, thisArg) => {
548
+ observeAll();
549
+ target.forEach(callback, thisArg);
550
+ };
551
+ }
552
+ const value = Reflect.get(target, prop, target);
553
+ return typeof value === "function" ? value.bind(target) : value;
554
+ }
555
+ });
556
+ }
557
+ function createResourceView(primary, local = primary) {
558
+ return ((kind) => kind === "textures" ? extension.getTextureView(local) : withStagedOverlay(primary, kind, primary.getState()[kind]));
559
+ }
560
+ function createLazyCreatorState(state, store, options = {}) {
561
+ const { reads } = options;
562
+ const view = options.view ?? (store ? createResourceView(store) : void 0);
384
563
  let _uniforms = null;
385
564
  let _nodes = null;
386
565
  let _buffers = null;
387
566
  let _gpuStorage = null;
388
- const view = (kind) => store ? withStagedOverlay(store, kind, state[kind]) : state[kind];
389
- return Object.create(state, {
567
+ let _textures = null;
568
+ const read = (kind) => view ? view(kind) : state[kind];
569
+ const wrap = (kind, isLeaf) => new ScopedStore(
570
+ read(kind),
571
+ isLeaf,
572
+ reads && { ...reads, kind, path: [] }
573
+ );
574
+ const properties = {
390
575
  uniforms: {
391
576
  get() {
392
- return _uniforms ?? (_uniforms = createScopedStore(view("uniforms"), isUniformNode));
577
+ return _uniforms ?? (_uniforms = wrap("uniforms", isUniformNode));
393
578
  }
394
579
  },
395
580
  nodes: {
396
581
  get() {
397
- return _nodes ?? (_nodes = createScopedStore(view("nodes"), isTSLNode));
582
+ return _nodes ?? (_nodes = wrap("nodes", isTSLNode));
398
583
  }
399
584
  },
400
585
  buffers: {
401
586
  get() {
402
- return _buffers ?? (_buffers = createScopedStore(view("buffers"), isBufferLike));
587
+ return _buffers ?? (_buffers = wrap("buffers", isBufferLike));
403
588
  }
404
589
  },
405
590
  gpuStorage: {
406
591
  get() {
407
- return _gpuStorage ?? (_gpuStorage = createScopedStore(view("gpuStorage"), isStorageLike));
592
+ return _gpuStorage ?? (_gpuStorage = wrap("gpuStorage", isStorageLike));
408
593
  }
409
594
  }
410
- });
595
+ };
596
+ if (options.view) {
597
+ properties.textures = {
598
+ get() {
599
+ const textures = options.view("textures");
600
+ return _textures ?? (_textures = reads ? observeTextures(textures, reads.observe) : textures);
601
+ }
602
+ };
603
+ }
604
+ return Object.create(state, properties);
411
605
  }
412
606
 
413
607
  function resolvePrimary(local) {
@@ -483,7 +677,7 @@ function useUniforms(creatorOrScope, scope) {
483
677
  const processedInput = React.useMemo(() => {
484
678
  let raw = creatorOrScope;
485
679
  if (typeof creatorOrScope === "function") {
486
- const wrappedState = createLazyCreatorState(store.getState(), store);
680
+ const wrappedState = createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useUniforms") });
487
681
  raw = creatorOrScope(wrappedState);
488
682
  }
489
683
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
@@ -626,7 +820,16 @@ function useNodes(creatorOrScope, scope) {
626
820
  isLeaf: isTSLNode,
627
821
  create: () => {
628
822
  if (isReader) return {};
629
- return creatorOrScope(createLazyCreatorState(store.getState(), store));
823
+ const nodes2 = creatorOrScope(
824
+ createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useNodes") })
825
+ );
826
+ if (nodes2 == null) {
827
+ warnOnce(
828
+ "[useNodes] The creator returned nothing, so nothing was registered. useNodes registers the nodes its creator returns. To put a node onto a Three object (scene.fogNode = ...), use useLocalNodes and return a function that assigns it: it runs after commit and may return a cleanup."
829
+ );
830
+ return {};
831
+ }
832
+ return nodes2;
630
833
  },
631
834
  prepare: (name, node) => {
632
835
  const setName = Reflect.get(node, "setName");
@@ -646,16 +849,100 @@ function useNodes(creatorOrScope, scope) {
646
849
  function rebuildAllNodes(store, scope) {
647
850
  rebuildResource(store, "nodes", scope);
648
851
  }
649
- function useLocalNodes(creator) {
852
+ function areDepsEqual(next, prev) {
853
+ if (next.length !== prev.length) return false;
854
+ for (let i = 0; i < next.length; i++) if (!Object.is(next[i], prev[i])) return false;
855
+ return true;
856
+ }
857
+ function useDependencyToken(deps) {
858
+ const previous = React.useRef(null);
859
+ const record = previous.current;
860
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && record) {
861
+ const hadDeps = record.deps !== void 0;
862
+ const hasDeps = deps !== void 0;
863
+ if (hadDeps !== hasDeps) {
864
+ console.warn(
865
+ `[useLocalNodes] The dependency array was ${hasDeps ? "added" : "omitted"} between renders. Pass an array on every render or on none; the mode must not change for a mounted component.`
866
+ );
867
+ } else if (hasDeps && record.deps.length !== deps.length) {
868
+ console.warn(
869
+ `[useLocalNodes] The dependency array length changed between renders (${record.deps.length} \u2192 ${deps.length}). Declare a fixed-length list; conditional dependencies belong inside the array as values.`
870
+ );
871
+ }
872
+ }
873
+ if (deps === void 0) {
874
+ previous.current = { deps: void 0, token: {} };
875
+ } else if (!record || record.deps === void 0 || !areDepsEqual(deps, record.deps)) {
876
+ previous.current = { deps, token: {} };
877
+ } else if (record.deps !== deps) {
878
+ previous.current = { deps, token: record.token };
879
+ }
880
+ return previous.current.token;
881
+ }
882
+ function useLocalNodes(creator, deps) {
883
+ const local = extension.useStore();
650
884
  const store = usePrimaryStore();
651
- const uniforms = usePrimaryThree((s) => s.uniforms);
652
- const nodes = usePrimaryThree((s) => s.nodes);
653
- const textures = usePrimaryThree((s) => s.textures);
885
+ const view = React.useMemo(() => createResourceView(store, local), [store, local]);
654
886
  const hmrVersion = usePrimaryThree((s) => s._hmrVersion);
655
- return React.useMemo(() => {
656
- const wrappedState = createLazyCreatorState(store.getState(), store);
657
- return creator(wrappedState);
658
- }, [store, creator, uniforms, nodes, textures, hmrVersion]);
887
+ const depsToken = useDependencyToken(deps);
888
+ const committedReads = React.useRef(null);
889
+ const readsVersion = React.useRef(0);
890
+ const getReadsVersion = () => {
891
+ const tracker2 = committedReads.current;
892
+ if (tracker2 && !tracker2.stale && isTrackerStale(tracker2, view)) readsVersion.current++;
893
+ return readsVersion.current;
894
+ };
895
+ const subscribe = React.useCallback(
896
+ (onChange) => {
897
+ const unsubscribe = store.subscribe(onChange);
898
+ if (local === store) return unsubscribe;
899
+ const unsubscribeLocal = local.subscribe(onChange);
900
+ return () => {
901
+ unsubscribe();
902
+ unsubscribeLocal();
903
+ };
904
+ },
905
+ [store, local]
906
+ );
907
+ const resourceVersion = React.useSyncExternalStore(subscribe, getReadsVersion, getReadsVersion);
908
+ const evaluation = React.useMemo(() => {
909
+ const tracker2 = createReadTracker("useLocalNodes");
910
+ const reads = { observe: tracker2.observe, nested: true };
911
+ const value2 = creator(createLazyCreatorState(local.getState(), store, { reads, view }));
912
+ tracker2.closed = true;
913
+ return { value: value2, tracker: tracker2 };
914
+ }, [view, hmrVersion, depsToken, resourceVersion]);
915
+ const { value, tracker } = evaluation;
916
+ const installing = typeof value === "function";
917
+ useModeDiagnostics(value, installing);
918
+ useIsomorphicLayoutEffect(() => {
919
+ committedReads.current = tracker;
920
+ if (!installing) return;
921
+ tracker.closed = false;
922
+ let cleanup;
923
+ try {
924
+ cleanup = value();
925
+ } finally {
926
+ tracker.closed = true;
927
+ }
928
+ return typeof cleanup === "function" ? cleanup : void 0;
929
+ }, [evaluation]);
930
+ return installing ? void 0 : value;
931
+ }
932
+ function useModeDiagnostics(value, installing) {
933
+ const previous = React.useRef(null);
934
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
935
+ if (value === void 0 || value === null) {
936
+ warnOnce(
937
+ "[useLocalNodes] The creator returned nothing. To put a node onto a Three object (scene.fogNode = ...), build it in the creator and return a function that assigns it: that function runs after commit and may return a cleanup. Assigning inside the creator runs during render, which React may discard or repeat."
938
+ );
939
+ }
940
+ if (previous.current !== null && previous.current !== installing) {
941
+ warnOnce(
942
+ "[useLocalNodes] The creator switched between returning a record and returning an install function. Keep one form for a mounted component."
943
+ );
944
+ }
945
+ previous.current = installing;
659
946
  }
660
947
 
661
948
  const disposeBuffer = (buffer) => {
@@ -701,7 +988,9 @@ function useBuffers(creatorOrScope, scope) {
701
988
  isLeaf: isBufferLike,
702
989
  create: () => {
703
990
  if (isReader) return {};
704
- return creatorOrScope(createLazyCreatorState(store.getState(), store));
991
+ return creatorOrScope(
992
+ createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useBuffers") })
993
+ );
705
994
  },
706
995
  prepare: (name, buffer) => {
707
996
  const setName = Reflect.get(buffer, "setName");
@@ -765,7 +1054,9 @@ function useGPUStorage(creatorOrScope, scope) {
765
1054
  isLeaf: isStorageLike,
766
1055
  create: () => {
767
1056
  if (isReader) return {};
768
- return creatorOrScope(createLazyCreatorState(store.getState(), store));
1057
+ return creatorOrScope(
1058
+ createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useGPUStorage") })
1059
+ );
769
1060
  },
770
1061
  prepare: (name, storage) => {
771
1062
  const label = scopedNodeName(scope, name);
package/dist/index.d.cts CHANGED
@@ -381,10 +381,17 @@ type ResourceLeafGuard<T> = (value: unknown) => value is T;
381
381
  *
382
382
  * @example
383
383
  * ```tsx
384
+ * // With uniforms registered (see `Register` and the Typed Uniforms guide), reads are typed by name
384
385
  * useLocalNodes(({ uniforms }) => ({
385
- * wobble: sin(uniforms.uTime.mul(2)), // No cast needed!
386
- * playerHealth: uniforms.scope('player').uHealth // Explicit scope access
387
- * }))
386
+ * wobble: sin(uniforms.uTime.mul(2)),
387
+ * playerHealth: uniforms.scope('player').uHealth, // or uniforms.player.uHealth
388
+ * }), [])
389
+ *
390
+ * // Without registration, give a scope its schema
391
+ * useLocalNodes(({ uniforms }) => {
392
+ * const player = uniforms.scope<{ uHealth: UniformNode<'float', number> }>('player')
393
+ * return { damage: player.uHealth.mul(2) }
394
+ * }, [])
388
395
  * ```
389
396
  */
390
397
 
@@ -619,32 +626,81 @@ declare function rebuildAllNodes(store: ReturnType<typeof useStore>, scope?: str
619
626
  /** Creator receives CreatorState with ScopedStore wrappers for type-safe access. Returns any record. */
620
627
  type LocalNodeCreator<T extends Record<string, unknown>> = (state: CreatorState) => T;
621
628
  /**
622
- * Creates local values that rebuild when uniforms, nodes, or textures change.
629
+ * The install step an install-form creator returns: it runs after commit (as a layout effect) and
630
+ * may return a cleanup, which runs before the next install and on unmount.
631
+ */
632
+ type LocalNodeInstall = () => void | (() => void);
633
+ /** A creator that builds during render and returns an install step instead of a record. */
634
+ type LocalNodeInstaller = (state: CreatorState) => LocalNodeInstall;
635
+ /**
636
+ * Creates component-local values from the rendering context and the shared TSL resources.
637
+ *
638
+ * Unlike `useNodes`, this does NOT register to the global store — nothing is published during
639
+ * render or commit. The creator runs in the render phase and is pure computation.
640
+ *
641
+ * **When the creator re-runs** is controlled by the optional `deps` array, mirroring `useMemo`:
642
+ *
643
+ * | Call | Ordinary component renders |
644
+ * | -------------------------------- | ----------------------------------------------------------- |
645
+ * | `useLocalNodes(creator)` | Re-evaluate every render, even with a `useCallback` creator |
646
+ * | `useLocalNodes(creator, [])` | Reuse the result |
647
+ * | `useLocalNodes(creator, [a, b])` | Reuse until a declared dependency changes by `Object.is` |
648
+ *
649
+ * Independently of `deps`, three things re-run the creator: a change to a shared resource the
650
+ * creator READ (replaced, removed, or appearing where it read nothing), a change of the owning
651
+ * (primary) store, and an HMR / `rebuild*` invalidation. Registrations the creator did not read
652
+ * change nothing, and writing `.value` on a uniform it read is not a change. `[]` therefore means
653
+ * "no JavaScript construction inputs", not "never rebuild". Whenever it re-runs, the creator from
654
+ * the CURRENT render is used; creator identity itself is never a rebuild trigger once an array is
655
+ * supplied.
623
656
  *
624
- * Unlike `useNodes`, this does NOT register to the global store.
625
- * Use for component-specific nodes/values that depend on shared resources.
657
+ * Only reads the creator makes before it returns are tracked. Inside `Fn(() => …)` the body runs
658
+ * later, while three builds the shader, so read the resource in the creator and close over it.
659
+ *
660
+ * `[]` is the normal case. A value that changes (a color prop, a slider) belongs in a uniform: the
661
+ * graph references the `UniformNode`, so updating its `.value` needs no rebuild and must not be a
662
+ * dependency. Declare only inputs that decide the graph's structure and cannot be uniforms: which
663
+ * node to use, a loop count, whether a branch exists.
664
+ *
665
+ * **Install form.** To put a node onto a Three object (`scene.fogNode`, `scene.backgroundNode`),
666
+ * return a function instead of a record. The creator still builds during render; the returned
667
+ * function runs after commit and may return a cleanup, which runs before the next install and on
668
+ * unmount. Mutating a Three object inside the creator itself is unsafe: React can discard a
669
+ * render, and StrictMode renders twice. The hook returns nothing in this form.
670
+ *
671
+ * ```tsx
672
+ * useLocalNodes(({ scene, uniforms }) => {
673
+ * const fogNode = fog(uniforms.fogColor, rangeFogFactor(uniforms.near, uniforms.far))
674
+ * return () => {
675
+ * scene.fogNode = fogNode
676
+ * return () => { scene.fogNode = null }
677
+ * }
678
+ * }, [])
679
+ * ```
626
680
  *
627
681
  * @example
628
682
  * ```tsx
629
- * // Destructure what you need from state
683
+ * // Resource-driven composition: no surrounding JS inputs. `uniforms.uTime` is typed when the
684
+ * // app registers its uniforms (see `Register`); otherwise give a scope its schema or cast.
630
685
  * const { wobble, uTime } = useLocalNodes(({ uniforms, nodes }) => ({
631
686
  * wobble: sin(uniforms.uTime.mul(2)),
632
- * uTime: uniforms.uTime, // can return uniforms too
633
- * }))
687
+ * uTime: uniforms.uTime, // can return uniforms too
688
+ * }), [])
634
689
  *
635
- * // Or access anything else from RootState
636
- * const { scaled } = useLocalNodes(({ camera, nodes }) => ({
637
- * scaled: nodes.basePos.mul(camera.zoom),
638
- * }))
690
+ * // `pattern` picks which node the graph is built from: a structural input.
691
+ * const { result } = useLocalNodes(({ nodes }) => ({
692
+ * result: pattern === 'noise' ? nodes.noise : nodes.stripes,
693
+ * }), [pattern])
639
694
  *
640
- * // Type-safe uniform access
695
+ * // An unregistered uniform, cast to its type
641
696
  * const { colorNode } = useLocalNodes(({ uniforms }) => {
642
- * const uValue = uniforms.myUniform as UniformNode<number>
697
+ * const uValue = uniforms.myUniform as UniformNode<'float', number>
643
698
  * return { colorNode: mix(colorA, colorB, uValue) }
644
- * })
699
+ * }, [])
645
700
  * ```
646
701
  */
647
- declare function useLocalNodes<T extends Record<string, unknown>>(creator: LocalNodeCreator<T>): T;
702
+ declare function useLocalNodes(creator: LocalNodeInstaller, deps?: React.DependencyList): void;
703
+ declare function useLocalNodes<T extends Record<string, unknown>>(creator: LocalNodeCreator<T>, deps?: React.DependencyList): T;
648
704
 
649
705
  /**
650
706
  * A record of buffer-like values - allows mixed types (TypedArrays, BufferAttributes, TSL nodes)
@@ -774,4 +830,4 @@ declare function rebuildAllStorage(store: ReturnType<typeof useStore>, scope?: s
774
830
  declare function useRenderPipeline(mainCB?: RenderPipelineMainCallback, setupCB?: RenderPipelineSetupCallback): UseRenderPipelineReturn;
775
831
 
776
832
  export { configureTSL, createScopedStore, rebuildAllBuffers, rebuildAllNodes, rebuildAllStorage, rebuildAllUniforms, useBuffers, useGPUStorage, useLocalNodes, useNodes, useRenderPipeline, useUniform, useUniforms };
777
- export type { AppUniforms, BufferCreator, BufferLike, BufferRecord, BufferStore, BuffersWithUtils, ClearBuffersFn, ClearNodesFn, ClearStorageFn, ClearUniformsFn, CreatorState, DisposeBuffersFn, DisposeStorageFn, LocalNodeCreator, NodeCreator, NodeLike, NodeRecord, NodeStore, NodesWithUtils, RebuildBuffersFn, RebuildNodesFn, RebuildStorageFn, RebuildUniformsFn, Register, RegisteredScopeUniforms, RegisteredScopes, RegisteredUniform, RegisteredUniforms, RemoveBuffersFn, RemoveNodesFn, RemoveStorageFn, RemoveUniformsFn, RootUniformInput, ScopeUniformInput, ScopedStoreType, StorageCreator, StorageLike, StorageRecord, StorageStore, StorageWithUtils, TSLConfig, TSLNode, TSLNodeLike, TSLRootState, UniformCreator, UniformValue, UniformsWithUtils };
833
+ export type { AppUniforms, BufferCreator, BufferLike, BufferRecord, BufferStore, BuffersWithUtils, ClearBuffersFn, ClearNodesFn, ClearStorageFn, ClearUniformsFn, CreatorState, DisposeBuffersFn, DisposeStorageFn, LocalNodeCreator, LocalNodeInstall, LocalNodeInstaller, NodeCreator, NodeLike, NodeRecord, NodeStore, NodesWithUtils, RebuildBuffersFn, RebuildNodesFn, RebuildStorageFn, RebuildUniformsFn, Register, RegisteredScopeUniforms, RegisteredScopes, RegisteredUniform, RegisteredUniforms, RemoveBuffersFn, RemoveNodesFn, RemoveStorageFn, RemoveUniformsFn, RootUniformInput, ScopeUniformInput, ScopedStoreType, StorageCreator, StorageLike, StorageRecord, StorageStore, StorageWithUtils, TSLConfig, TSLNode, TSLNodeLike, TSLRootState, UniformCreator, UniformValue, UniformsWithUtils };
package/dist/index.d.mts CHANGED
@@ -381,10 +381,17 @@ type ResourceLeafGuard<T> = (value: unknown) => value is T;
381
381
  *
382
382
  * @example
383
383
  * ```tsx
384
+ * // With uniforms registered (see `Register` and the Typed Uniforms guide), reads are typed by name
384
385
  * useLocalNodes(({ uniforms }) => ({
385
- * wobble: sin(uniforms.uTime.mul(2)), // No cast needed!
386
- * playerHealth: uniforms.scope('player').uHealth // Explicit scope access
387
- * }))
386
+ * wobble: sin(uniforms.uTime.mul(2)),
387
+ * playerHealth: uniforms.scope('player').uHealth, // or uniforms.player.uHealth
388
+ * }), [])
389
+ *
390
+ * // Without registration, give a scope its schema
391
+ * useLocalNodes(({ uniforms }) => {
392
+ * const player = uniforms.scope<{ uHealth: UniformNode<'float', number> }>('player')
393
+ * return { damage: player.uHealth.mul(2) }
394
+ * }, [])
388
395
  * ```
389
396
  */
390
397
 
@@ -619,32 +626,81 @@ declare function rebuildAllNodes(store: ReturnType<typeof useStore>, scope?: str
619
626
  /** Creator receives CreatorState with ScopedStore wrappers for type-safe access. Returns any record. */
620
627
  type LocalNodeCreator<T extends Record<string, unknown>> = (state: CreatorState) => T;
621
628
  /**
622
- * Creates local values that rebuild when uniforms, nodes, or textures change.
629
+ * The install step an install-form creator returns: it runs after commit (as a layout effect) and
630
+ * may return a cleanup, which runs before the next install and on unmount.
631
+ */
632
+ type LocalNodeInstall = () => void | (() => void);
633
+ /** A creator that builds during render and returns an install step instead of a record. */
634
+ type LocalNodeInstaller = (state: CreatorState) => LocalNodeInstall;
635
+ /**
636
+ * Creates component-local values from the rendering context and the shared TSL resources.
637
+ *
638
+ * Unlike `useNodes`, this does NOT register to the global store — nothing is published during
639
+ * render or commit. The creator runs in the render phase and is pure computation.
640
+ *
641
+ * **When the creator re-runs** is controlled by the optional `deps` array, mirroring `useMemo`:
642
+ *
643
+ * | Call | Ordinary component renders |
644
+ * | -------------------------------- | ----------------------------------------------------------- |
645
+ * | `useLocalNodes(creator)` | Re-evaluate every render, even with a `useCallback` creator |
646
+ * | `useLocalNodes(creator, [])` | Reuse the result |
647
+ * | `useLocalNodes(creator, [a, b])` | Reuse until a declared dependency changes by `Object.is` |
648
+ *
649
+ * Independently of `deps`, three things re-run the creator: a change to a shared resource the
650
+ * creator READ (replaced, removed, or appearing where it read nothing), a change of the owning
651
+ * (primary) store, and an HMR / `rebuild*` invalidation. Registrations the creator did not read
652
+ * change nothing, and writing `.value` on a uniform it read is not a change. `[]` therefore means
653
+ * "no JavaScript construction inputs", not "never rebuild". Whenever it re-runs, the creator from
654
+ * the CURRENT render is used; creator identity itself is never a rebuild trigger once an array is
655
+ * supplied.
623
656
  *
624
- * Unlike `useNodes`, this does NOT register to the global store.
625
- * Use for component-specific nodes/values that depend on shared resources.
657
+ * Only reads the creator makes before it returns are tracked. Inside `Fn(() => …)` the body runs
658
+ * later, while three builds the shader, so read the resource in the creator and close over it.
659
+ *
660
+ * `[]` is the normal case. A value that changes (a color prop, a slider) belongs in a uniform: the
661
+ * graph references the `UniformNode`, so updating its `.value` needs no rebuild and must not be a
662
+ * dependency. Declare only inputs that decide the graph's structure and cannot be uniforms: which
663
+ * node to use, a loop count, whether a branch exists.
664
+ *
665
+ * **Install form.** To put a node onto a Three object (`scene.fogNode`, `scene.backgroundNode`),
666
+ * return a function instead of a record. The creator still builds during render; the returned
667
+ * function runs after commit and may return a cleanup, which runs before the next install and on
668
+ * unmount. Mutating a Three object inside the creator itself is unsafe: React can discard a
669
+ * render, and StrictMode renders twice. The hook returns nothing in this form.
670
+ *
671
+ * ```tsx
672
+ * useLocalNodes(({ scene, uniforms }) => {
673
+ * const fogNode = fog(uniforms.fogColor, rangeFogFactor(uniforms.near, uniforms.far))
674
+ * return () => {
675
+ * scene.fogNode = fogNode
676
+ * return () => { scene.fogNode = null }
677
+ * }
678
+ * }, [])
679
+ * ```
626
680
  *
627
681
  * @example
628
682
  * ```tsx
629
- * // Destructure what you need from state
683
+ * // Resource-driven composition: no surrounding JS inputs. `uniforms.uTime` is typed when the
684
+ * // app registers its uniforms (see `Register`); otherwise give a scope its schema or cast.
630
685
  * const { wobble, uTime } = useLocalNodes(({ uniforms, nodes }) => ({
631
686
  * wobble: sin(uniforms.uTime.mul(2)),
632
- * uTime: uniforms.uTime, // can return uniforms too
633
- * }))
687
+ * uTime: uniforms.uTime, // can return uniforms too
688
+ * }), [])
634
689
  *
635
- * // Or access anything else from RootState
636
- * const { scaled } = useLocalNodes(({ camera, nodes }) => ({
637
- * scaled: nodes.basePos.mul(camera.zoom),
638
- * }))
690
+ * // `pattern` picks which node the graph is built from: a structural input.
691
+ * const { result } = useLocalNodes(({ nodes }) => ({
692
+ * result: pattern === 'noise' ? nodes.noise : nodes.stripes,
693
+ * }), [pattern])
639
694
  *
640
- * // Type-safe uniform access
695
+ * // An unregistered uniform, cast to its type
641
696
  * const { colorNode } = useLocalNodes(({ uniforms }) => {
642
- * const uValue = uniforms.myUniform as UniformNode<number>
697
+ * const uValue = uniforms.myUniform as UniformNode<'float', number>
643
698
  * return { colorNode: mix(colorA, colorB, uValue) }
644
- * })
699
+ * }, [])
645
700
  * ```
646
701
  */
647
- declare function useLocalNodes<T extends Record<string, unknown>>(creator: LocalNodeCreator<T>): T;
702
+ declare function useLocalNodes(creator: LocalNodeInstaller, deps?: React.DependencyList): void;
703
+ declare function useLocalNodes<T extends Record<string, unknown>>(creator: LocalNodeCreator<T>, deps?: React.DependencyList): T;
648
704
 
649
705
  /**
650
706
  * A record of buffer-like values - allows mixed types (TypedArrays, BufferAttributes, TSL nodes)
@@ -774,4 +830,4 @@ declare function rebuildAllStorage(store: ReturnType<typeof useStore>, scope?: s
774
830
  declare function useRenderPipeline(mainCB?: RenderPipelineMainCallback, setupCB?: RenderPipelineSetupCallback): UseRenderPipelineReturn;
775
831
 
776
832
  export { configureTSL, createScopedStore, rebuildAllBuffers, rebuildAllNodes, rebuildAllStorage, rebuildAllUniforms, useBuffers, useGPUStorage, useLocalNodes, useNodes, useRenderPipeline, useUniform, useUniforms };
777
- export type { AppUniforms, BufferCreator, BufferLike, BufferRecord, BufferStore, BuffersWithUtils, ClearBuffersFn, ClearNodesFn, ClearStorageFn, ClearUniformsFn, CreatorState, DisposeBuffersFn, DisposeStorageFn, LocalNodeCreator, NodeCreator, NodeLike, NodeRecord, NodeStore, NodesWithUtils, RebuildBuffersFn, RebuildNodesFn, RebuildStorageFn, RebuildUniformsFn, Register, RegisteredScopeUniforms, RegisteredScopes, RegisteredUniform, RegisteredUniforms, RemoveBuffersFn, RemoveNodesFn, RemoveStorageFn, RemoveUniformsFn, RootUniformInput, ScopeUniformInput, ScopedStoreType, StorageCreator, StorageLike, StorageRecord, StorageStore, StorageWithUtils, TSLConfig, TSLNode, TSLNodeLike, TSLRootState, UniformCreator, UniformValue, UniformsWithUtils };
833
+ export type { AppUniforms, BufferCreator, BufferLike, BufferRecord, BufferStore, BuffersWithUtils, ClearBuffersFn, ClearNodesFn, ClearStorageFn, ClearUniformsFn, CreatorState, DisposeBuffersFn, DisposeStorageFn, LocalNodeCreator, LocalNodeInstall, LocalNodeInstaller, NodeCreator, NodeLike, NodeRecord, NodeStore, NodesWithUtils, RebuildBuffersFn, RebuildNodesFn, RebuildStorageFn, RebuildUniformsFn, Register, RegisteredScopeUniforms, RegisteredScopes, RegisteredUniform, RegisteredUniforms, RemoveBuffersFn, RemoveNodesFn, RemoveStorageFn, RemoveUniformsFn, RootUniformInput, ScopeUniformInput, ScopedStoreType, StorageCreator, StorageLike, StorageRecord, StorageStore, StorageWithUtils, TSLConfig, TSLNode, TSLNodeLike, TSLRootState, UniformCreator, UniformValue, UniformsWithUtils };
package/dist/index.d.ts CHANGED
@@ -381,10 +381,17 @@ type ResourceLeafGuard<T> = (value: unknown) => value is T;
381
381
  *
382
382
  * @example
383
383
  * ```tsx
384
+ * // With uniforms registered (see `Register` and the Typed Uniforms guide), reads are typed by name
384
385
  * useLocalNodes(({ uniforms }) => ({
385
- * wobble: sin(uniforms.uTime.mul(2)), // No cast needed!
386
- * playerHealth: uniforms.scope('player').uHealth // Explicit scope access
387
- * }))
386
+ * wobble: sin(uniforms.uTime.mul(2)),
387
+ * playerHealth: uniforms.scope('player').uHealth, // or uniforms.player.uHealth
388
+ * }), [])
389
+ *
390
+ * // Without registration, give a scope its schema
391
+ * useLocalNodes(({ uniforms }) => {
392
+ * const player = uniforms.scope<{ uHealth: UniformNode<'float', number> }>('player')
393
+ * return { damage: player.uHealth.mul(2) }
394
+ * }, [])
388
395
  * ```
389
396
  */
390
397
 
@@ -619,32 +626,81 @@ declare function rebuildAllNodes(store: ReturnType<typeof useStore>, scope?: str
619
626
  /** Creator receives CreatorState with ScopedStore wrappers for type-safe access. Returns any record. */
620
627
  type LocalNodeCreator<T extends Record<string, unknown>> = (state: CreatorState) => T;
621
628
  /**
622
- * Creates local values that rebuild when uniforms, nodes, or textures change.
629
+ * The install step an install-form creator returns: it runs after commit (as a layout effect) and
630
+ * may return a cleanup, which runs before the next install and on unmount.
631
+ */
632
+ type LocalNodeInstall = () => void | (() => void);
633
+ /** A creator that builds during render and returns an install step instead of a record. */
634
+ type LocalNodeInstaller = (state: CreatorState) => LocalNodeInstall;
635
+ /**
636
+ * Creates component-local values from the rendering context and the shared TSL resources.
637
+ *
638
+ * Unlike `useNodes`, this does NOT register to the global store — nothing is published during
639
+ * render or commit. The creator runs in the render phase and is pure computation.
640
+ *
641
+ * **When the creator re-runs** is controlled by the optional `deps` array, mirroring `useMemo`:
642
+ *
643
+ * | Call | Ordinary component renders |
644
+ * | -------------------------------- | ----------------------------------------------------------- |
645
+ * | `useLocalNodes(creator)` | Re-evaluate every render, even with a `useCallback` creator |
646
+ * | `useLocalNodes(creator, [])` | Reuse the result |
647
+ * | `useLocalNodes(creator, [a, b])` | Reuse until a declared dependency changes by `Object.is` |
648
+ *
649
+ * Independently of `deps`, three things re-run the creator: a change to a shared resource the
650
+ * creator READ (replaced, removed, or appearing where it read nothing), a change of the owning
651
+ * (primary) store, and an HMR / `rebuild*` invalidation. Registrations the creator did not read
652
+ * change nothing, and writing `.value` on a uniform it read is not a change. `[]` therefore means
653
+ * "no JavaScript construction inputs", not "never rebuild". Whenever it re-runs, the creator from
654
+ * the CURRENT render is used; creator identity itself is never a rebuild trigger once an array is
655
+ * supplied.
623
656
  *
624
- * Unlike `useNodes`, this does NOT register to the global store.
625
- * Use for component-specific nodes/values that depend on shared resources.
657
+ * Only reads the creator makes before it returns are tracked. Inside `Fn(() => …)` the body runs
658
+ * later, while three builds the shader, so read the resource in the creator and close over it.
659
+ *
660
+ * `[]` is the normal case. A value that changes (a color prop, a slider) belongs in a uniform: the
661
+ * graph references the `UniformNode`, so updating its `.value` needs no rebuild and must not be a
662
+ * dependency. Declare only inputs that decide the graph's structure and cannot be uniforms: which
663
+ * node to use, a loop count, whether a branch exists.
664
+ *
665
+ * **Install form.** To put a node onto a Three object (`scene.fogNode`, `scene.backgroundNode`),
666
+ * return a function instead of a record. The creator still builds during render; the returned
667
+ * function runs after commit and may return a cleanup, which runs before the next install and on
668
+ * unmount. Mutating a Three object inside the creator itself is unsafe: React can discard a
669
+ * render, and StrictMode renders twice. The hook returns nothing in this form.
670
+ *
671
+ * ```tsx
672
+ * useLocalNodes(({ scene, uniforms }) => {
673
+ * const fogNode = fog(uniforms.fogColor, rangeFogFactor(uniforms.near, uniforms.far))
674
+ * return () => {
675
+ * scene.fogNode = fogNode
676
+ * return () => { scene.fogNode = null }
677
+ * }
678
+ * }, [])
679
+ * ```
626
680
  *
627
681
  * @example
628
682
  * ```tsx
629
- * // Destructure what you need from state
683
+ * // Resource-driven composition: no surrounding JS inputs. `uniforms.uTime` is typed when the
684
+ * // app registers its uniforms (see `Register`); otherwise give a scope its schema or cast.
630
685
  * const { wobble, uTime } = useLocalNodes(({ uniforms, nodes }) => ({
631
686
  * wobble: sin(uniforms.uTime.mul(2)),
632
- * uTime: uniforms.uTime, // can return uniforms too
633
- * }))
687
+ * uTime: uniforms.uTime, // can return uniforms too
688
+ * }), [])
634
689
  *
635
- * // Or access anything else from RootState
636
- * const { scaled } = useLocalNodes(({ camera, nodes }) => ({
637
- * scaled: nodes.basePos.mul(camera.zoom),
638
- * }))
690
+ * // `pattern` picks which node the graph is built from: a structural input.
691
+ * const { result } = useLocalNodes(({ nodes }) => ({
692
+ * result: pattern === 'noise' ? nodes.noise : nodes.stripes,
693
+ * }), [pattern])
639
694
  *
640
- * // Type-safe uniform access
695
+ * // An unregistered uniform, cast to its type
641
696
  * const { colorNode } = useLocalNodes(({ uniforms }) => {
642
- * const uValue = uniforms.myUniform as UniformNode<number>
697
+ * const uValue = uniforms.myUniform as UniformNode<'float', number>
643
698
  * return { colorNode: mix(colorA, colorB, uValue) }
644
- * })
699
+ * }, [])
645
700
  * ```
646
701
  */
647
- declare function useLocalNodes<T extends Record<string, unknown>>(creator: LocalNodeCreator<T>): T;
702
+ declare function useLocalNodes(creator: LocalNodeInstaller, deps?: React.DependencyList): void;
703
+ declare function useLocalNodes<T extends Record<string, unknown>>(creator: LocalNodeCreator<T>, deps?: React.DependencyList): T;
648
704
 
649
705
  /**
650
706
  * A record of buffer-like values - allows mixed types (TypedArrays, BufferAttributes, TSL nodes)
@@ -774,4 +830,4 @@ declare function rebuildAllStorage(store: ReturnType<typeof useStore>, scope?: s
774
830
  declare function useRenderPipeline(mainCB?: RenderPipelineMainCallback, setupCB?: RenderPipelineSetupCallback): UseRenderPipelineReturn;
775
831
 
776
832
  export { configureTSL, createScopedStore, rebuildAllBuffers, rebuildAllNodes, rebuildAllStorage, rebuildAllUniforms, useBuffers, useGPUStorage, useLocalNodes, useNodes, useRenderPipeline, useUniform, useUniforms };
777
- export type { AppUniforms, BufferCreator, BufferLike, BufferRecord, BufferStore, BuffersWithUtils, ClearBuffersFn, ClearNodesFn, ClearStorageFn, ClearUniformsFn, CreatorState, DisposeBuffersFn, DisposeStorageFn, LocalNodeCreator, NodeCreator, NodeLike, NodeRecord, NodeStore, NodesWithUtils, RebuildBuffersFn, RebuildNodesFn, RebuildStorageFn, RebuildUniformsFn, Register, RegisteredScopeUniforms, RegisteredScopes, RegisteredUniform, RegisteredUniforms, RemoveBuffersFn, RemoveNodesFn, RemoveStorageFn, RemoveUniformsFn, RootUniformInput, ScopeUniformInput, ScopedStoreType, StorageCreator, StorageLike, StorageRecord, StorageStore, StorageWithUtils, TSLConfig, TSLNode, TSLNodeLike, TSLRootState, UniformCreator, UniformValue, UniformsWithUtils };
833
+ export type { AppUniforms, BufferCreator, BufferLike, BufferRecord, BufferStore, BuffersWithUtils, ClearBuffersFn, ClearNodesFn, ClearStorageFn, ClearUniformsFn, CreatorState, DisposeBuffersFn, DisposeStorageFn, LocalNodeCreator, LocalNodeInstall, LocalNodeInstaller, NodeCreator, NodeLike, NodeRecord, NodeStore, NodesWithUtils, RebuildBuffersFn, RebuildNodesFn, RebuildStorageFn, RebuildUniformsFn, Register, RegisteredScopeUniforms, RegisteredScopes, RegisteredUniform, RegisteredUniforms, RemoveBuffersFn, RemoveNodesFn, RemoveStorageFn, RemoveUniformsFn, RootUniformInput, ScopeUniformInput, ScopedStoreType, StorageCreator, StorageLike, StorageRecord, StorageStore, StorageWithUtils, TSLConfig, TSLNode, TSLNodeLike, TSLRootState, UniformCreator, UniformValue, UniformsWithUtils };
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { registerRootExtension, useStore, useThree, setRenderOverride } from '@react-three/fiber/extension';
1
+ import { registerRootExtension, getTextureView, useStore, useThree, setRenderOverride } from '@react-three/fiber/extension';
2
2
  import { uniform, pass } from 'three/tsl';
3
3
  import * as THREE from 'three/webgpu';
4
4
  import { Color } from 'three/webgpu';
5
5
  import * as React from 'react';
6
- import { useMemo, useRef, useCallback, useState, useEffect, useLayoutEffect } from 'react';
6
+ import { useMemo, useRef, useCallback, useSyncExternalStore, useState, useEffect, useLayoutEffect } from 'react';
7
7
  import { dequal } from 'dequal/lite';
8
8
  import 'zustand/shallow';
9
9
 
@@ -297,33 +297,129 @@ function ensureTSLExtension() {
297
297
  });
298
298
  }
299
299
 
300
+ const SCOPE = Symbol("readTracking.scope");
301
+ const LEAF_GUARDS = {
302
+ uniforms: isUniformNode,
303
+ nodes: isTSLNode,
304
+ buffers: isBufferLike,
305
+ gpuStorage: isStorageLike
306
+ };
307
+ function isScope(kind, value) {
308
+ return !!value && typeof value === "object" && !LEAF_GUARDS[kind](value);
309
+ }
310
+ function classify(kind, value) {
311
+ return kind !== "textures" && isScope(kind, value) ? SCOPE : value;
312
+ }
313
+ function resolveContainer(kind, root, scopePath) {
314
+ let container = root;
315
+ for (const key of scopePath) {
316
+ const next = container?.[key];
317
+ container = isScope(kind, next) ? next : void 0;
318
+ }
319
+ return container;
320
+ }
321
+ function observeNow(read, view) {
322
+ if (read.kind === "textures") {
323
+ const map = view("textures");
324
+ if (read.op === "keys") return [...map.keys()];
325
+ return read.op === "has" ? map.has(read.path[0]) : map.get(read.path[0]);
326
+ }
327
+ const root = view(read.kind);
328
+ if (read.op === "keys") return Object.keys(resolveContainer(read.kind, root, read.path) ?? {});
329
+ const container = resolveContainer(read.kind, root, read.path.slice(0, -1));
330
+ const key = read.path[read.path.length - 1];
331
+ if (read.op === "has") return !!container && key in container;
332
+ return classify(read.kind, container?.[key]);
333
+ }
334
+ function sameKeys(a, b) {
335
+ return a.length === b.length && a.every((key, i) => key === b[i]);
336
+ }
337
+ function hasChanged(read, view) {
338
+ const now = observeNow(read, view);
339
+ return read.op === "keys" ? !sameKeys(read.seen, now) : !Object.is(read.seen, now);
340
+ }
341
+ const warned = /* @__PURE__ */ new Set();
342
+ function warnOnce(message) {
343
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
344
+ if (warned.has(message)) return;
345
+ warned.add(message);
346
+ console.warn(message);
347
+ }
348
+ function describeRead(kind, path) {
349
+ if (kind === "textures") return `textures.get('${path[0]}')`;
350
+ const scopes = path.slice(0, -1).map((key) => `.scope('${key}')`);
351
+ return `${kind}${scopes.join("")}.${path[path.length - 1] ?? ""}`;
352
+ }
353
+ function createReadTracker(hookName) {
354
+ const tracker = {
355
+ reads: [],
356
+ closed: false,
357
+ stale: false,
358
+ observe: (read) => {
359
+ if (tracker.closed) {
360
+ warnOnce(
361
+ `[${hookName}] ${describeRead(read.kind, read.path)} was read after the creator returned, probably inside Fn(). That read is not tracked, so replacing the resource will not rebuild this graph. Read the resource in the creator and close over it in Fn.`
362
+ );
363
+ return;
364
+ }
365
+ tracker.reads.push(read);
366
+ }
367
+ };
368
+ return tracker;
369
+ }
370
+ function isTrackerStale(tracker, view) {
371
+ if (!tracker.stale) tracker.stale = tracker.reads.some((read) => hasChanged(read, view));
372
+ return tracker.stale;
373
+ }
374
+ function warnMissingReads(hookName) {
375
+ return {
376
+ nested: false,
377
+ observe: (read) => {
378
+ if (read.op !== "get" || read.seen !== void 0) return;
379
+ warnOnce(
380
+ `[${hookName}] The creator read ${describeRead(read.kind, read.path)}, which does not exist yet. ${hookName} creators run once per generation, so this one will not re-run when it appears. Register it earlier (above this hook, or higher in the tree), or call rebuild* once it exists. To probe on purpose, use .has().`
381
+ );
382
+ }
383
+ };
384
+ }
385
+
300
386
  var __defProp = Object.defineProperty;
301
387
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
302
388
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
303
- var _a, _b;
389
+ var _a, _b, _c, _d;
304
390
  const INTERNAL_DATA = Symbol("ScopedStore.data");
305
391
  const INTERNAL_IS_LEAF = Symbol("ScopedStore.isLeaf");
306
- _b = INTERNAL_DATA, _a = INTERNAL_IS_LEAF;
392
+ const INTERNAL_READS = Symbol("ScopedStore.reads");
393
+ const INTERNAL_CHILDREN = Symbol("ScopedStore.children");
394
+ _d = INTERNAL_DATA, _c = INTERNAL_IS_LEAF, _b = INTERNAL_READS, _a = INTERNAL_CHILDREN;
307
395
  const _ScopedStore = class _ScopedStore {
308
- constructor(data, isLeaf) {
396
+ constructor(data, isLeaf, reads) {
397
+ /** @internal */
398
+ __publicField(this, _d);
399
+ /** @internal */
400
+ __publicField(this, _c);
401
+ /** @internal */
309
402
  __publicField(this, _b);
403
+ /** @internal */
310
404
  __publicField(this, _a);
311
405
  this[INTERNAL_DATA] = data;
312
406
  this[INTERNAL_IS_LEAF] = isLeaf;
407
+ this[INTERNAL_READS] = reads;
313
408
  return new Proxy(this, {
314
409
  get(target, prop, receiver) {
315
410
  if (typeof prop === "string") {
316
411
  if (prop === "scope" || prop === "has" || prop === "keys") {
317
412
  return Reflect.get(target, prop, receiver);
318
413
  }
319
- return target[INTERNAL_DATA][prop];
414
+ return readEntry(target, prop);
320
415
  }
321
416
  return Reflect.get(target, prop, receiver);
322
417
  },
323
418
  has(target, prop) {
324
- return typeof prop === "string" ? prop in target[INTERNAL_DATA] : Reflect.has(target, prop);
419
+ return typeof prop === "string" ? target.has(prop) : Reflect.has(target, prop);
325
420
  },
326
421
  ownKeys(target) {
422
+ observeKeys(target);
327
423
  return Reflect.ownKeys(target[INTERNAL_DATA]);
328
424
  },
329
425
  getOwnPropertyDescriptor(target, prop) {
@@ -345,54 +441,152 @@ const _ScopedStore = class _ScopedStore {
345
441
  scope(key) {
346
442
  const value = this[INTERNAL_DATA][key];
347
443
  const isLeaf = this[INTERNAL_IS_LEAF];
444
+ const reads = this[INTERNAL_READS];
348
445
  const scope = value && typeof value === "object" && !isLeaf(value) ? value : {};
349
- return new _ScopedStore(scope, isLeaf);
446
+ const child = reads ? { ...reads, path: [...reads.path, key] } : void 0;
447
+ return new _ScopedStore(scope, isLeaf, child);
350
448
  }
351
449
  /**
352
450
  * Check if a key exists in the store.
353
451
  */
354
452
  has(key) {
355
- return key in this[INTERNAL_DATA];
453
+ const found = key in this[INTERNAL_DATA];
454
+ const reads = this[INTERNAL_READS];
455
+ reads?.observe({ kind: reads.kind, op: "has", path: [...reads.path, key], seen: found });
456
+ return found;
356
457
  }
357
458
  /**
358
459
  * Get all keys in the store.
359
460
  */
360
461
  keys() {
462
+ observeKeys(this);
361
463
  return Object.keys(this[INTERNAL_DATA]);
362
464
  }
363
465
  };
364
466
  let ScopedStore = _ScopedStore;
467
+ function readEntry(target, key) {
468
+ const value = target[INTERNAL_DATA][key];
469
+ const reads = target[INTERNAL_READS];
470
+ if (!reads) return value;
471
+ const isLeaf = target[INTERNAL_IS_LEAF];
472
+ const isScope = !!value && typeof value === "object" && !isLeaf(value);
473
+ const path = [...reads.path, key];
474
+ reads.observe({ kind: reads.kind, op: "get", path, seen: isScope ? SCOPE : value });
475
+ if (!isScope || !reads.nested) return value;
476
+ const children = target[INTERNAL_CHILDREN] ?? (target[INTERNAL_CHILDREN] = /* @__PURE__ */ new Map());
477
+ let child = children.get(key);
478
+ if (!child) {
479
+ child = new ScopedStore(value, isLeaf, { ...reads, path });
480
+ children.set(key, child);
481
+ }
482
+ return child;
483
+ }
484
+ function observeKeys(target) {
485
+ const reads = target[INTERNAL_READS];
486
+ reads?.observe({ kind: reads.kind, op: "keys", path: reads.path, seen: Object.keys(target[INTERNAL_DATA]) });
487
+ }
365
488
  function createScopedStore(data, isLeaf) {
366
489
  return new ScopedStore(data, isLeaf);
367
490
  }
368
- function createLazyCreatorState(state, store) {
491
+ function observeTextures(map, observe) {
492
+ const observeKeys2 = () => observe({ kind: "textures", op: "keys", path: [], seen: [...map.keys()] });
493
+ const observeAll = () => {
494
+ observeKeys2();
495
+ for (const [url, texture] of map) observe({ kind: "textures", op: "get", path: [url], seen: texture });
496
+ };
497
+ return new Proxy(map, {
498
+ get(target, prop) {
499
+ switch (prop) {
500
+ case "get":
501
+ return (url) => {
502
+ const texture = target.get(url);
503
+ observe({ kind: "textures", op: "get", path: [url], seen: texture });
504
+ return texture;
505
+ };
506
+ case "has":
507
+ return (url) => {
508
+ const found = target.has(url);
509
+ observe({ kind: "textures", op: "has", path: [url], seen: found });
510
+ return found;
511
+ };
512
+ case "size":
513
+ observeKeys2();
514
+ return target.size;
515
+ case "keys":
516
+ return () => {
517
+ observeKeys2();
518
+ return target.keys();
519
+ };
520
+ case "values":
521
+ return () => {
522
+ observeAll();
523
+ return target.values();
524
+ };
525
+ case "entries":
526
+ case Symbol.iterator:
527
+ return () => {
528
+ observeAll();
529
+ return target.entries();
530
+ };
531
+ case "forEach":
532
+ return (callback, thisArg) => {
533
+ observeAll();
534
+ target.forEach(callback, thisArg);
535
+ };
536
+ }
537
+ const value = Reflect.get(target, prop, target);
538
+ return typeof value === "function" ? value.bind(target) : value;
539
+ }
540
+ });
541
+ }
542
+ function createResourceView(primary, local = primary) {
543
+ return ((kind) => kind === "textures" ? getTextureView(local) : withStagedOverlay(primary, kind, primary.getState()[kind]));
544
+ }
545
+ function createLazyCreatorState(state, store, options = {}) {
546
+ const { reads } = options;
547
+ const view = options.view ?? (store ? createResourceView(store) : void 0);
369
548
  let _uniforms = null;
370
549
  let _nodes = null;
371
550
  let _buffers = null;
372
551
  let _gpuStorage = null;
373
- const view = (kind) => store ? withStagedOverlay(store, kind, state[kind]) : state[kind];
374
- return Object.create(state, {
552
+ let _textures = null;
553
+ const read = (kind) => view ? view(kind) : state[kind];
554
+ const wrap = (kind, isLeaf) => new ScopedStore(
555
+ read(kind),
556
+ isLeaf,
557
+ reads && { ...reads, kind, path: [] }
558
+ );
559
+ const properties = {
375
560
  uniforms: {
376
561
  get() {
377
- return _uniforms ?? (_uniforms = createScopedStore(view("uniforms"), isUniformNode));
562
+ return _uniforms ?? (_uniforms = wrap("uniforms", isUniformNode));
378
563
  }
379
564
  },
380
565
  nodes: {
381
566
  get() {
382
- return _nodes ?? (_nodes = createScopedStore(view("nodes"), isTSLNode));
567
+ return _nodes ?? (_nodes = wrap("nodes", isTSLNode));
383
568
  }
384
569
  },
385
570
  buffers: {
386
571
  get() {
387
- return _buffers ?? (_buffers = createScopedStore(view("buffers"), isBufferLike));
572
+ return _buffers ?? (_buffers = wrap("buffers", isBufferLike));
388
573
  }
389
574
  },
390
575
  gpuStorage: {
391
576
  get() {
392
- return _gpuStorage ?? (_gpuStorage = createScopedStore(view("gpuStorage"), isStorageLike));
577
+ return _gpuStorage ?? (_gpuStorage = wrap("gpuStorage", isStorageLike));
393
578
  }
394
579
  }
395
- });
580
+ };
581
+ if (options.view) {
582
+ properties.textures = {
583
+ get() {
584
+ const textures = options.view("textures");
585
+ return _textures ?? (_textures = reads ? observeTextures(textures, reads.observe) : textures);
586
+ }
587
+ };
588
+ }
589
+ return Object.create(state, properties);
396
590
  }
397
591
 
398
592
  function resolvePrimary(local) {
@@ -468,7 +662,7 @@ function useUniforms(creatorOrScope, scope) {
468
662
  const processedInput = useMemo(() => {
469
663
  let raw = creatorOrScope;
470
664
  if (typeof creatorOrScope === "function") {
471
- const wrappedState = createLazyCreatorState(store.getState(), store);
665
+ const wrappedState = createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useUniforms") });
472
666
  raw = creatorOrScope(wrappedState);
473
667
  }
474
668
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
@@ -611,7 +805,16 @@ function useNodes(creatorOrScope, scope) {
611
805
  isLeaf: isTSLNode,
612
806
  create: () => {
613
807
  if (isReader) return {};
614
- return creatorOrScope(createLazyCreatorState(store.getState(), store));
808
+ const nodes2 = creatorOrScope(
809
+ createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useNodes") })
810
+ );
811
+ if (nodes2 == null) {
812
+ warnOnce(
813
+ "[useNodes] The creator returned nothing, so nothing was registered. useNodes registers the nodes its creator returns. To put a node onto a Three object (scene.fogNode = ...), use useLocalNodes and return a function that assigns it: it runs after commit and may return a cleanup."
814
+ );
815
+ return {};
816
+ }
817
+ return nodes2;
615
818
  },
616
819
  prepare: (name, node) => {
617
820
  const setName = Reflect.get(node, "setName");
@@ -631,16 +834,100 @@ function useNodes(creatorOrScope, scope) {
631
834
  function rebuildAllNodes(store, scope) {
632
835
  rebuildResource(store, "nodes", scope);
633
836
  }
634
- function useLocalNodes(creator) {
837
+ function areDepsEqual(next, prev) {
838
+ if (next.length !== prev.length) return false;
839
+ for (let i = 0; i < next.length; i++) if (!Object.is(next[i], prev[i])) return false;
840
+ return true;
841
+ }
842
+ function useDependencyToken(deps) {
843
+ const previous = useRef(null);
844
+ const record = previous.current;
845
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && record) {
846
+ const hadDeps = record.deps !== void 0;
847
+ const hasDeps = deps !== void 0;
848
+ if (hadDeps !== hasDeps) {
849
+ console.warn(
850
+ `[useLocalNodes] The dependency array was ${hasDeps ? "added" : "omitted"} between renders. Pass an array on every render or on none; the mode must not change for a mounted component.`
851
+ );
852
+ } else if (hasDeps && record.deps.length !== deps.length) {
853
+ console.warn(
854
+ `[useLocalNodes] The dependency array length changed between renders (${record.deps.length} \u2192 ${deps.length}). Declare a fixed-length list; conditional dependencies belong inside the array as values.`
855
+ );
856
+ }
857
+ }
858
+ if (deps === void 0) {
859
+ previous.current = { deps: void 0, token: {} };
860
+ } else if (!record || record.deps === void 0 || !areDepsEqual(deps, record.deps)) {
861
+ previous.current = { deps, token: {} };
862
+ } else if (record.deps !== deps) {
863
+ previous.current = { deps, token: record.token };
864
+ }
865
+ return previous.current.token;
866
+ }
867
+ function useLocalNodes(creator, deps) {
868
+ const local = useStore();
635
869
  const store = usePrimaryStore();
636
- const uniforms = usePrimaryThree((s) => s.uniforms);
637
- const nodes = usePrimaryThree((s) => s.nodes);
638
- const textures = usePrimaryThree((s) => s.textures);
870
+ const view = useMemo(() => createResourceView(store, local), [store, local]);
639
871
  const hmrVersion = usePrimaryThree((s) => s._hmrVersion);
640
- return useMemo(() => {
641
- const wrappedState = createLazyCreatorState(store.getState(), store);
642
- return creator(wrappedState);
643
- }, [store, creator, uniforms, nodes, textures, hmrVersion]);
872
+ const depsToken = useDependencyToken(deps);
873
+ const committedReads = useRef(null);
874
+ const readsVersion = useRef(0);
875
+ const getReadsVersion = () => {
876
+ const tracker2 = committedReads.current;
877
+ if (tracker2 && !tracker2.stale && isTrackerStale(tracker2, view)) readsVersion.current++;
878
+ return readsVersion.current;
879
+ };
880
+ const subscribe = useCallback(
881
+ (onChange) => {
882
+ const unsubscribe = store.subscribe(onChange);
883
+ if (local === store) return unsubscribe;
884
+ const unsubscribeLocal = local.subscribe(onChange);
885
+ return () => {
886
+ unsubscribe();
887
+ unsubscribeLocal();
888
+ };
889
+ },
890
+ [store, local]
891
+ );
892
+ const resourceVersion = useSyncExternalStore(subscribe, getReadsVersion, getReadsVersion);
893
+ const evaluation = useMemo(() => {
894
+ const tracker2 = createReadTracker("useLocalNodes");
895
+ const reads = { observe: tracker2.observe, nested: true };
896
+ const value2 = creator(createLazyCreatorState(local.getState(), store, { reads, view }));
897
+ tracker2.closed = true;
898
+ return { value: value2, tracker: tracker2 };
899
+ }, [view, hmrVersion, depsToken, resourceVersion]);
900
+ const { value, tracker } = evaluation;
901
+ const installing = typeof value === "function";
902
+ useModeDiagnostics(value, installing);
903
+ useIsomorphicLayoutEffect(() => {
904
+ committedReads.current = tracker;
905
+ if (!installing) return;
906
+ tracker.closed = false;
907
+ let cleanup;
908
+ try {
909
+ cleanup = value();
910
+ } finally {
911
+ tracker.closed = true;
912
+ }
913
+ return typeof cleanup === "function" ? cleanup : void 0;
914
+ }, [evaluation]);
915
+ return installing ? void 0 : value;
916
+ }
917
+ function useModeDiagnostics(value, installing) {
918
+ const previous = useRef(null);
919
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
920
+ if (value === void 0 || value === null) {
921
+ warnOnce(
922
+ "[useLocalNodes] The creator returned nothing. To put a node onto a Three object (scene.fogNode = ...), build it in the creator and return a function that assigns it: that function runs after commit and may return a cleanup. Assigning inside the creator runs during render, which React may discard or repeat."
923
+ );
924
+ }
925
+ if (previous.current !== null && previous.current !== installing) {
926
+ warnOnce(
927
+ "[useLocalNodes] The creator switched between returning a record and returning an install function. Keep one form for a mounted component."
928
+ );
929
+ }
930
+ previous.current = installing;
644
931
  }
645
932
 
646
933
  const disposeBuffer = (buffer) => {
@@ -686,7 +973,9 @@ function useBuffers(creatorOrScope, scope) {
686
973
  isLeaf: isBufferLike,
687
974
  create: () => {
688
975
  if (isReader) return {};
689
- return creatorOrScope(createLazyCreatorState(store.getState(), store));
976
+ return creatorOrScope(
977
+ createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useBuffers") })
978
+ );
690
979
  },
691
980
  prepare: (name, buffer) => {
692
981
  const setName = Reflect.get(buffer, "setName");
@@ -750,7 +1039,9 @@ function useGPUStorage(creatorOrScope, scope) {
750
1039
  isLeaf: isStorageLike,
751
1040
  create: () => {
752
1041
  if (isReader) return {};
753
- return creatorOrScope(createLazyCreatorState(store.getState(), store));
1042
+ return creatorOrScope(
1043
+ createLazyCreatorState(store.getState(), store, { reads: warnMissingReads("useGPUStorage") })
1044
+ );
754
1045
  },
755
1046
  prepare: (name, storage) => {
756
1047
  const label = scopedNodeName(scope, name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-three/tsl",
3
- "version": "10.0.0-canary.975e1e7",
3
+ "version": "10.0.0-canary.a9bd42a",
4
4
  "description": "TSL resource hooks for react-three-fiber: uniforms, nodes, buffers, GPU storage and render pipelines on WebGPU",
5
5
  "keywords": [
6
6
  "react",
@@ -49,14 +49,14 @@
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@react-three/fiber": "^10.0.0-alpha.6",
52
- "react": ">=19.0 <19.3",
52
+ "react": ">=19.0 <19.4",
53
53
  "three": ">=0.185.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/three": "^0.185.0",
57
57
  "@webgpu/types": "^0.1.64",
58
58
  "three": ">=0.185.0",
59
- "@react-three/fiber": "10.0.0-canary.975e1e7"
59
+ "@react-three/fiber": "10.0.0-canary.a9bd42a"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "unbuild",