doxum 0.1.41 → 0.1.42

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.
@@ -261,7 +261,8 @@ var ProjectionDisposedError = class extends Error {
261
261
  //#endregion
262
262
  //#region core/src/projection/graph/scheduler.ts
263
263
  const createScheduler = (onError) => {
264
- let disposed = false;
264
+ let lifecycle = "active";
265
+ let lockDepth = 0;
265
266
  let depth = 0;
266
267
  let batchSequence = 0;
267
268
  let activeBatch;
@@ -279,13 +280,39 @@ const createScheduler = (onError) => {
279
280
  let reportingFailures = [];
280
281
  const guards = /* @__PURE__ */ new Set();
281
282
  const assertActive = () => {
282
- if (disposed) throw new ProjectionDisposedError();
283
+ if (lifecycle === "disposed" || phase === "release") throw new ProjectionDisposedError();
283
284
  };
284
285
  const assertIdle = () => {
285
286
  assertActive();
286
- if (phase !== "idle") throw new Error("Projection cannot be re-entered during input callbacks, processing or notification.");
287
+ if (phase !== "idle" && phase !== "cleanup") throw new Error("Projection cannot be re-entered during input callbacks, processing or notification.");
288
+ };
289
+ const assertCommand = () => {
290
+ assertActive();
291
+ if (lifecycle === "closing" || phase === "cleanup") throw new Error("Projection cleanup only permits reads and unsubscribe.");
292
+ assertIdle();
293
+ };
294
+ const lock = (value) => {
295
+ const previous = lockDepth;
296
+ lockDepth += value ? 1 : -1;
297
+ if (!previous || !lockDepth) guards.forEach((guard) => guard(value));
298
+ };
299
+ const beginClose = () => {
300
+ if (lifecycle !== "active") return false;
301
+ assertCommand();
302
+ lifecycle = "closing";
303
+ return true;
304
+ };
305
+ const withPhase = (next, run) => {
306
+ const previous = phase;
307
+ phase = next;
308
+ lock(true);
309
+ try {
310
+ return run();
311
+ } finally {
312
+ lock(false);
313
+ phase = previous;
314
+ }
287
315
  };
288
- const lock = (value) => guards.forEach((guard) => guard(value));
289
316
  const markDirty = (producer) => {
290
317
  const pending = [producer];
291
318
  for (let index = 0; index < pending.length; index++) {
@@ -302,7 +329,7 @@ const createScheduler = (onError) => {
302
329
  };
303
330
  const errorFor = (producer, cause, kind = producer.kind === "source" ? "source" : "processor") => new ProjectionError(kind, cause);
304
331
  const capture = (source) => {
305
- if (disposed) return;
332
+ if (lifecycle === "disposed") return;
306
333
  if (phase !== "idle") {
307
334
  source.fault = errorFor(source, /* @__PURE__ */ new Error("Source changed while projection was running."), "source");
308
335
  errors.push(source.fault);
@@ -422,18 +449,9 @@ const createScheduler = (onError) => {
422
449
  }
423
450
  }
424
451
  };
425
- const compute = (run) => {
426
- phase = "compute";
427
- lock(true);
428
- try {
429
- return run();
430
- } finally {
431
- lock(false);
432
- phase = "idle";
433
- }
434
- };
452
+ const compute = (run) => withPhase("compute", run);
435
453
  const settle = () => {
436
- if (disposed || depth || phase !== "idle") return;
454
+ if (lifecycle !== "active" || depth || phase !== "idle") return;
437
455
  const cause = settleCause();
438
456
  compute(() => {
439
457
  for (const producer of dirty) advance(producer, cause);
@@ -449,7 +467,7 @@ const createScheduler = (onError) => {
449
467
  }
450
468
  };
451
469
  const flush = () => {
452
- if (disposed || depth) return [];
470
+ if (lifecycle !== "active" || depth || phase === "cleanup" || phase === "release") return [];
453
471
  if (emissions.size || completed.size) profile.projection("flushes");
454
472
  phase = "notify";
455
473
  lock(true);
@@ -497,7 +515,7 @@ const createScheduler = (onError) => {
497
515
  return result;
498
516
  };
499
517
  const batch = (callback, options) => {
500
- assertIdle();
518
+ assertCommand();
501
519
  if (depth === 0) activeBatch = Object.freeze({
502
520
  id: ++batchSequence,
503
521
  ...options?.cause === void 0 ? {} : { cause: options.cause }
@@ -524,7 +542,8 @@ const createScheduler = (onError) => {
524
542
  }
525
543
  };
526
544
  const releaseProducer = (producer) => {
527
- assertIdle();
545
+ if (producer.disposed) return;
546
+ if (phase !== "release") assertIdle();
528
547
  if (producer.outputs.some((output) => output.hasConsumers())) throw new Error("Projection producer is still used by another processor.");
529
548
  producer.disposed = true;
530
549
  dirty.delete(producer);
@@ -554,6 +573,16 @@ const createScheduler = (onError) => {
554
573
  return {
555
574
  assertActive,
556
575
  assertIdle,
576
+ assertCommand,
577
+ beginClose,
578
+ cleanup(run) {
579
+ assertIdle();
580
+ return withPhase("cleanup", run);
581
+ },
582
+ releaseOwned(run) {
583
+ assertIdle();
584
+ return withPhase("release", run);
585
+ },
557
586
  capture,
558
587
  settle,
559
588
  ensureCurrent,
@@ -562,15 +591,17 @@ const createScheduler = (onError) => {
562
591
  registerGuard(guard) {
563
592
  assertActive();
564
593
  guards.add(guard);
594
+ if (lockDepth) guard(true);
565
595
  let registered = true;
566
596
  return () => {
567
597
  if (!registered) return;
568
598
  registered = false;
569
599
  guards.delete(guard);
600
+ if (lockDepth) guard(false);
570
601
  };
571
602
  },
572
603
  get active() {
573
- return !disposed;
604
+ return lifecycle !== "disposed" && phase !== "release";
574
605
  },
575
606
  addSource(source) {
576
607
  assertIdle();
@@ -584,7 +615,7 @@ const createScheduler = (onError) => {
584
615
  releaseProducer,
585
616
  nextId: () => sequence++,
586
617
  acceptInput(callback) {
587
- assertIdle();
618
+ assertCommand();
588
619
  phase = "input";
589
620
  lock(true);
590
621
  try {
@@ -600,38 +631,39 @@ const createScheduler = (onError) => {
600
631
  },
601
632
  batch,
602
633
  batchContext: () => activeBatch,
603
- dispose() {
604
- if (disposed) return;
605
- assertIdle();
606
- disposed = true;
634
+ dispose(releaseConsumers, releaseSources) {
635
+ if (lifecycle === "disposed") return;
636
+ if (lifecycle === "active") beginClose();
607
637
  const failures = [];
608
- processors.forEach((processor) => {
609
- processor.disposed = true;
610
- try {
611
- processor.release();
612
- } catch (error) {
613
- failures.push(error);
614
- }
615
- });
616
- sources.forEach((source) => {
617
- source.disposed = true;
638
+ const attempt = (run) => {
618
639
  try {
619
- source.release();
640
+ run();
620
641
  } catch (error) {
621
642
  failures.push(error);
622
643
  }
623
- });
624
- processors.clear();
625
- sources.clear();
626
- pendingSources.clear();
627
- dirty.clear();
628
- queued.clear();
629
- completed.clear();
630
- emissions.clear();
631
- forced.clear();
632
- guards.clear();
633
- errors.length = 0;
634
- reportingFailures = [];
644
+ };
645
+ try {
646
+ withPhase("release", () => {
647
+ attempt(releaseConsumers);
648
+ for (const processor of [...processors].reverse()) attempt(() => releaseProducer(processor));
649
+ for (const source of [...sources].reverse()) attempt(() => releaseProducer(source));
650
+ attempt(releaseSources);
651
+ });
652
+ } finally {
653
+ lifecycle = "disposed";
654
+ processors.clear();
655
+ sources.clear();
656
+ pendingSources.clear();
657
+ dirty.clear();
658
+ queued.clear();
659
+ completed.clear();
660
+ emissions.clear();
661
+ forced.clear();
662
+ guards.clear();
663
+ errors.length = 0;
664
+ reportingFailures = [];
665
+ }
666
+ if (failures.length === 1) throw failures[0];
635
667
  if (failures.length) throw new AggregateError(failures, "Projection cleanup failed.");
636
668
  }
637
669
  };
@@ -261,7 +261,8 @@ var ProjectionDisposedError = class extends Error {
261
261
  //#endregion
262
262
  //#region core/src/projection/graph/scheduler.ts
263
263
  const createScheduler = (onError) => {
264
- let disposed = false;
264
+ let lifecycle = "active";
265
+ let lockDepth = 0;
265
266
  let depth = 0;
266
267
  let batchSequence = 0;
267
268
  let activeBatch;
@@ -279,13 +280,39 @@ const createScheduler = (onError) => {
279
280
  let reportingFailures = [];
280
281
  const guards = /* @__PURE__ */ new Set();
281
282
  const assertActive = () => {
282
- if (disposed) throw new ProjectionDisposedError();
283
+ if (lifecycle === "disposed" || phase === "release") throw new ProjectionDisposedError();
283
284
  };
284
285
  const assertIdle = () => {
285
286
  assertActive();
286
- if (phase !== "idle") throw new Error("Projection cannot be re-entered during input callbacks, processing or notification.");
287
+ if (phase !== "idle" && phase !== "cleanup") throw new Error("Projection cannot be re-entered during input callbacks, processing or notification.");
288
+ };
289
+ const assertCommand = () => {
290
+ assertActive();
291
+ if (lifecycle === "closing" || phase === "cleanup") throw new Error("Projection cleanup only permits reads and unsubscribe.");
292
+ assertIdle();
293
+ };
294
+ const lock = (value) => {
295
+ const previous = lockDepth;
296
+ lockDepth += value ? 1 : -1;
297
+ if (!previous || !lockDepth) guards.forEach((guard) => guard(value));
298
+ };
299
+ const beginClose = () => {
300
+ if (lifecycle !== "active") return false;
301
+ assertCommand();
302
+ lifecycle = "closing";
303
+ return true;
304
+ };
305
+ const withPhase = (next, run) => {
306
+ const previous = phase;
307
+ phase = next;
308
+ lock(true);
309
+ try {
310
+ return run();
311
+ } finally {
312
+ lock(false);
313
+ phase = previous;
314
+ }
287
315
  };
288
- const lock = (value) => guards.forEach((guard) => guard(value));
289
316
  const markDirty = (producer) => {
290
317
  const pending = [producer];
291
318
  for (let index = 0; index < pending.length; index++) {
@@ -302,7 +329,7 @@ const createScheduler = (onError) => {
302
329
  };
303
330
  const errorFor = (producer, cause, kind = producer.kind === "source" ? "source" : "processor") => new ProjectionError(kind, cause);
304
331
  const capture = (source) => {
305
- if (disposed) return;
332
+ if (lifecycle === "disposed") return;
306
333
  if (phase !== "idle") {
307
334
  source.fault = errorFor(source, /* @__PURE__ */ new Error("Source changed while projection was running."), "source");
308
335
  errors.push(source.fault);
@@ -422,18 +449,9 @@ const createScheduler = (onError) => {
422
449
  }
423
450
  }
424
451
  };
425
- const compute = (run) => {
426
- phase = "compute";
427
- lock(true);
428
- try {
429
- return run();
430
- } finally {
431
- lock(false);
432
- phase = "idle";
433
- }
434
- };
452
+ const compute = (run) => withPhase("compute", run);
435
453
  const settle = () => {
436
- if (disposed || depth || phase !== "idle") return;
454
+ if (lifecycle !== "active" || depth || phase !== "idle") return;
437
455
  const cause = settleCause();
438
456
  compute(() => {
439
457
  for (const producer of dirty) advance(producer, cause);
@@ -449,7 +467,7 @@ const createScheduler = (onError) => {
449
467
  }
450
468
  };
451
469
  const flush = () => {
452
- if (disposed || depth) return [];
470
+ if (lifecycle !== "active" || depth || phase === "cleanup" || phase === "release") return [];
453
471
  if (emissions.size || completed.size) profile.projection("flushes");
454
472
  phase = "notify";
455
473
  lock(true);
@@ -497,7 +515,7 @@ const createScheduler = (onError) => {
497
515
  return result;
498
516
  };
499
517
  const batch = (callback, options) => {
500
- assertIdle();
518
+ assertCommand();
501
519
  if (depth === 0) activeBatch = Object.freeze({
502
520
  id: ++batchSequence,
503
521
  ...options?.cause === void 0 ? {} : { cause: options.cause }
@@ -524,7 +542,8 @@ const createScheduler = (onError) => {
524
542
  }
525
543
  };
526
544
  const releaseProducer = (producer) => {
527
- assertIdle();
545
+ if (producer.disposed) return;
546
+ if (phase !== "release") assertIdle();
528
547
  if (producer.outputs.some((output) => output.hasConsumers())) throw new Error("Projection producer is still used by another processor.");
529
548
  producer.disposed = true;
530
549
  dirty.delete(producer);
@@ -554,6 +573,16 @@ const createScheduler = (onError) => {
554
573
  return {
555
574
  assertActive,
556
575
  assertIdle,
576
+ assertCommand,
577
+ beginClose,
578
+ cleanup(run) {
579
+ assertIdle();
580
+ return withPhase("cleanup", run);
581
+ },
582
+ releaseOwned(run) {
583
+ assertIdle();
584
+ return withPhase("release", run);
585
+ },
557
586
  capture,
558
587
  settle,
559
588
  ensureCurrent,
@@ -562,15 +591,17 @@ const createScheduler = (onError) => {
562
591
  registerGuard(guard) {
563
592
  assertActive();
564
593
  guards.add(guard);
594
+ if (lockDepth) guard(true);
565
595
  let registered = true;
566
596
  return () => {
567
597
  if (!registered) return;
568
598
  registered = false;
569
599
  guards.delete(guard);
600
+ if (lockDepth) guard(false);
570
601
  };
571
602
  },
572
603
  get active() {
573
- return !disposed;
604
+ return lifecycle !== "disposed" && phase !== "release";
574
605
  },
575
606
  addSource(source) {
576
607
  assertIdle();
@@ -584,7 +615,7 @@ const createScheduler = (onError) => {
584
615
  releaseProducer,
585
616
  nextId: () => sequence++,
586
617
  acceptInput(callback) {
587
- assertIdle();
618
+ assertCommand();
588
619
  phase = "input";
589
620
  lock(true);
590
621
  try {
@@ -600,38 +631,39 @@ const createScheduler = (onError) => {
600
631
  },
601
632
  batch,
602
633
  batchContext: () => activeBatch,
603
- dispose() {
604
- if (disposed) return;
605
- assertIdle();
606
- disposed = true;
634
+ dispose(releaseConsumers, releaseSources) {
635
+ if (lifecycle === "disposed") return;
636
+ if (lifecycle === "active") beginClose();
607
637
  const failures = [];
608
- processors.forEach((processor) => {
609
- processor.disposed = true;
610
- try {
611
- processor.release();
612
- } catch (error) {
613
- failures.push(error);
614
- }
615
- });
616
- sources.forEach((source) => {
617
- source.disposed = true;
638
+ const attempt = (run) => {
618
639
  try {
619
- source.release();
640
+ run();
620
641
  } catch (error) {
621
642
  failures.push(error);
622
643
  }
623
- });
624
- processors.clear();
625
- sources.clear();
626
- pendingSources.clear();
627
- dirty.clear();
628
- queued.clear();
629
- completed.clear();
630
- emissions.clear();
631
- forced.clear();
632
- guards.clear();
633
- errors.length = 0;
634
- reportingFailures = [];
644
+ };
645
+ try {
646
+ withPhase("release", () => {
647
+ attempt(releaseConsumers);
648
+ for (const processor of [...processors].reverse()) attempt(() => releaseProducer(processor));
649
+ for (const source of [...sources].reverse()) attempt(() => releaseProducer(source));
650
+ attempt(releaseSources);
651
+ });
652
+ } finally {
653
+ lifecycle = "disposed";
654
+ processors.clear();
655
+ sources.clear();
656
+ pendingSources.clear();
657
+ dirty.clear();
658
+ queued.clear();
659
+ completed.clear();
660
+ emissions.clear();
661
+ forced.clear();
662
+ guards.clear();
663
+ errors.length = 0;
664
+ reportingFailures = [];
665
+ }
666
+ if (failures.length === 1) throw failures[0];
635
667
  if (failures.length) throw new AggregateError(failures, "Projection cleanup failed.");
636
668
  }
637
669
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doxum",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Doxum is a typed runtime for complex mutable documents.",
@@ -88,16 +88,47 @@ React consumes Doxum through `doxum/react` only. Do not duplicate Runtime subscr
88
88
  ### Projection provider
89
89
 
90
90
  ```tsx
91
- const runtime = useMemo(() => createProjectionRuntime({ onError: report }), []);
91
+ // runtime is created and disposed by the application/service outside rendering.
92
+ function ApplicationView({ runtime }: { runtime: ProjectionRuntime }) {
93
+ return (
94
+ <ProjectionProvider runtime={runtime} fallback={null}>
95
+ <App />
96
+ </ProjectionProvider>
97
+ );
98
+ }
99
+ ```
100
+
101
+ `runtime` mode owns a child scope, not the supplied Runtime. Scope acquisition happens
102
+ after commit; first render and SSR show `fallback` (default `null`). Every Effect setup
103
+ uses a fresh scope, including StrictMode replay. A runtime change replaces the scope
104
+ and remounts the child subtree with fresh local definitions/state. Abandoned renders
105
+ acquire no scope. Do not create a disposable scope or Runtime in `useMemo` and rely on
106
+ an Effect to clean up that render-created resource.
92
107
 
93
- return (
94
- <ProjectionProvider value={runtime}>
95
- <App />
96
- </ProjectionProvider>
97
- );
108
+ For synchronous SSR or externally owned lifetimes, inject the owner explicitly:
109
+ `<ProjectionProvider value={runtimeOrScope}>`. This mode never disposes the supplied
110
+ owner and takes no fallback. Never combine `runtime` and `value`.
111
+
112
+ `useProjectionScope()` returns an existing context scope. It works inside a managed
113
+ Provider or `value={scope}`, and throws for a missing Provider or `value={runtime}`.
114
+ It does not accept a Runtime argument or allocate another scope. To declare local
115
+ state, memoize a definition owned by the current scope, with the scope as a dependency:
116
+
117
+ ```tsx
118
+ const scope = useProjectionScope();
119
+ const mode = useMemo(() => scope.own(input('all')), [scope]);
120
+ const [value, update] = useInput(mode);
98
121
  ```
99
122
 
100
- The provider accepts a root `ProjectionRuntime` or `ProjectionScope`. The component/service that creates it owns disposal. Do not create a new Runtime on every render.
123
+ A scope does not adopt root projections merely because a component reads them. Use
124
+ `scope.onDispose` for synchronous finalization that needs projection reads; do not rely
125
+ on React parent/child cleanup order. Cleanup may read and unsubscribe, but may not write,
126
+ batch, subscribe or register more resources. Perform domain commands before disposal.
127
+ Registration returns an idempotent cancellation function suitable for Effect cleanup.
128
+ If work must run on scope shutdown, leave its registration active until that shutdown;
129
+ cancelling it in an earlier component cleanup intentionally prevents it from running.
130
+ Parent Runtime disposal ends its scopes; subsequent React cleanup and unsubscribe are
131
+ safe no-ops. Ordinary reads after disposal still throw.
101
132
 
102
133
  ### Reading projections
103
134
 
@@ -71,6 +71,19 @@ This file is maintainer-only. Application/library consumers should use [Public A
71
71
  Sorting changed keys is internal lookup work, never formal order. Count node
72
72
  allocations and sorting separately from selector calls; this is not an O(delta)
73
73
  guarantee for all collection maintenance.
74
+ Scheduler owns Runtime active/closing/disposed state and cleanup/read/command
75
+ admission; scope owns only its status and local resource membership. Cleanup stacks
76
+ are cancellable and LIFO, child callbacks precede parent callbacks, and graph teardown
77
+ starts only after all callbacks. Cleanup permits demanded/lazy reads but no new public
78
+ resources or writes. Restore the caller phase after nested computation and balance
79
+ document locks for connections registered/detached while a lock is held. Release
80
+ dependents before dependencies; close all resources even after failures. Disposed
81
+ reads still throw; only repeated dispose/unsubscribe/cancellation are no-ops.
82
+ Simple listener sets share subscription.ts registration identity/removal semantics;
83
+ notification snapshot order/error policy remains with each notification owner.
84
+ React managed scope acquisition happens after commit, never in render/useMemo;
85
+ borrowed owner injection never takes disposal ownership. Fresh Effect setups own
86
+ fresh scopes/subtrees. Keep the managed first-render/SSR fallback explicit.
74
87
  10. Local sync uses Web Lock leadership and contiguous durable sequence. Writes precede
75
88
  asynchronous persistence. Version 5 / format 3 rejects old databases without editing
76
89
  them. External replace and remote apply are prohibited while attached.
@@ -459,6 +459,28 @@ Rules:
459
459
  - the same definition cannot belong to two scopes;
460
460
  - `scope.items` has the same membership/readable semantics as `runtime.items` and ends with scope disposal.
461
461
 
462
+ `scope.onDispose(cleanup)` and `runtime.onDispose(cleanup)` register synchronous LIFO
463
+ callbacks; the returned function cancels that one registration without executing it.
464
+ Runtime shutdown runs scope callbacks in reverse creation order, then root callbacks,
465
+ while the graph remains readable. All callbacks finish before teardown. Scope-only
466
+ disposal leaves root materialization alive. Read/Readable.current retain normal demand,
467
+ lazy initialization, ownership and fault semantics during cleanup; commands, new
468
+ subscriptions/resources and cleanup registrations are rejected. Connected document
469
+ writes are locked. Cleanup does not flush projection notifications. Business writes
470
+ belong before disposal. Existing source unsubscribe/processor release are teardown,
471
+ not read-capable cleanup callbacks.
472
+
473
+ All callbacks/releases are attempted even on failure; errors escape after terminal
474
+ closure. `dispose` can be called repeatedly, after an earlier cleanup failure, or on a
475
+ scope already ended by its parent. Unsubscribe and cleanup-registration cancellation
476
+ are idempotent. Each subscription owns a distinct registration even if callbacks are
477
+ identical; old unsubscribe functions cannot detach a newer registration. Normal reads,
478
+ commands, resource creation and existing Readable access still throw after disposal.
479
+
480
+ For React, use the managed `ProjectionProvider runtime={runtime}`; acquire the current
481
+ scope with `useProjectionScope()`. Read [React ownership and SSR](integrations.md#projection-provider)
482
+ before choosing managed versus externally owned Provider mode.
483
+
462
484
  ## Batching
463
485
 
464
486
  ```ts
@@ -420,6 +420,7 @@ type ProjectionRuntime = {
420
420
  update<K,V,R>(input: CollectionInput<K,V>, run: (draft: CollectionInputDraft<K,V>) => Synchronous<R>): void;
421
421
  batch<T>(run: () => Synchronous<T>, options?: { cause?: unknown }): T;
422
422
  scope(): ProjectionScope;
423
+ onDispose<R>(cleanup: () => Synchronous<R>): Unsubscribe;
423
424
  dispose(): void;
424
425
  };
425
426
 
@@ -600,25 +601,36 @@ incremental.group(
600
601
 
601
602
  Every declared output must be returned from the static output tree exactly once.
602
603
 
604
+ Runtime/scope `onDispose` registers synchronous, cancellable LIFO cleanup. Child scope
605
+ callbacks precede parent Runtime callbacks; projections remain readable until all
606
+ callbacks finish. Cleanup rejects commands/new registrations. Failures do not interrupt
607
+ remaining teardown and are thrown only after closure. `dispose` and unsubscribe are
608
+ idempotent; all normal projection access still throws after disposal. See
609
+ [cleanup and scope ownership](projections.md#projectionscope).
610
+
603
611
  ## `doxum/react`
604
612
 
605
613
  ### Public symbol inventory
606
614
 
607
615
  <!-- exports:doxum/react:start -->
608
616
 
609
- | Public export | Role |
610
- | --------------------- | -------------------------------------------------- |
611
- | `ProjectionProvider` | Provide a `ProjectionRuntime` or `ProjectionScope` |
612
- | `useDocumentSelector` | React adapter for Core document `select` |
613
- | `useReadable` | Subscribe to any Doxum `Readable` |
614
- | `useHistory` | Read history state and expose undo/redo callbacks |
615
- | `useProjection` | Read projection or projection selector |
616
- | `useInput` | Read/update scalar or collection projection input |
617
+ | Public export | Role |
618
+ | ------------------------- | --------------------------------------------------------- |
619
+ | `ProjectionProvider` | Manage a scope from a Runtime or borrow an external owner |
620
+ | `ProjectionProviderProps` | Mutually exclusive managed/borrowed Provider props |
621
+ | `useProjectionScope` | Read the current scope from context; never allocate one |
622
+ | `useDocumentSelector` | React adapter for Core document `select` |
623
+ | `useReadable` | Subscribe to any Doxum `Readable` |
624
+ | `useHistory` | Read history state and expose undo/redo callbacks |
625
+ | `useProjection` | Read projection or projection selector |
626
+ | `useInput` | Read/update scalar or collection projection input |
617
627
 
618
628
  <!-- exports:doxum/react:end -->
619
629
 
620
630
  ```ts
631
+ <ProjectionProvider runtime={runtime} fallback={null}>...</ProjectionProvider>
621
632
  <ProjectionProvider value={runtimeOrScope}>...</ProjectionProvider>
633
+ useProjectionScope(): ProjectionScope
622
634
 
623
635
  useProjection(projection): T
624
636
  useProjection(projection, selector, equality?): R
@@ -629,6 +641,13 @@ useReadable(readable): T
629
641
  useHistory(history): HistoryState & { undo(): OperationResult<C>; redo(): OperationResult<C> }
630
642
  ```
631
643
 
644
+ Managed `runtime` mode owns only a child scope, acquired after commit. Initial render
645
+ and SSR use `fallback` (default `null`); StrictMode setup replay and runtime replacement
646
+ create a fresh scope generation and remount its subtree. Borrowed `value` mode injects
647
+ synchronously and never disposes the supplied owner. Use it for synchronous SSR with
648
+ request-owned resources. `useProjectionScope()` requires a scope in context, including
649
+ an explicitly injected scope; an injected root Runtime does not implicitly create one.
650
+
632
651
  ## `doxum/local-sync`
633
652
 
634
653
  ### Public symbol inventory