solid-deps 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -16,7 +16,7 @@ Or with Bun:
16
16
  bun add solid-deps solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
17
17
  ```
18
18
 
19
- **Compatibility:** Tested with Solid 2.0.0-rc.17 production, development and observe builds. `solid-deps` now discovers observer graph fields at runtime, rather than using fixed field-name mappings. Other Solid 2 versions may work if they retain compatible internal graph semantics, but are not yet verified. Your app and `solid-deps` must share one `@solidjs/signals` runtime instance. SSR is not supported.
19
+ **Compatibility:** Tested with Solid 2.0.0-rc.17 production, development and observe builds. `solid-deps` recognizes validated descriptive development fields directly and discovers unknown or minified graph fields at runtime. Other Solid 2 versions may work if they retain compatible internal graph semantics, but are not yet verified. Your app and `solid-deps` must share one `@solidjs/signals` runtime instance. SSR is not supported.
20
20
 
21
21
  ## Quick start: `createScope()`
22
22
 
@@ -67,6 +67,86 @@ const summary = scope(() => ({ count: count(), enabled: enabled() }));
67
67
 
68
68
  The callback must be synchronous. Await asynchronous work **before** calling `scope()`, not inside it.
69
69
 
70
+ ## Pending async memos and `NotReadyError`
71
+
72
+ A synchronous callback may read an **existing async memo that is not ready yet**. In that case, Solid 2 throws `NotReadyError` as part of its pending-value protocol. `solid-deps` preserves the dependencies read up to that point **before rethrowing** the same error:
73
+
74
+ ```ts
75
+ import { createMemo, createEffect, NotReadyError } from 'solid-js';
76
+ import { createOwnerScope } from 'solid-deps';
77
+
78
+ const memo = createMemo(async () => {
79
+ return await loadData();
80
+ });
81
+
82
+ createEffect(() => {
83
+ const scope = createOwnerScope();
84
+
85
+ // Example: an externally scheduled callback after the effect starts.
86
+ queueMicrotask(() => {
87
+ try {
88
+ console.log(scope(() => memo()));
89
+ } catch (error) {
90
+ if (error instanceof NotReadyError) {
91
+ // memo() has been subscribed to the original effect.
92
+ // Its successful settlement can invalidate that effect.
93
+ return;
94
+ }
95
+ throw error;
96
+ }
97
+ });
98
+
99
+ return 0;
100
+ }, () => {});
101
+ ```
102
+
103
+ **What this means:** the pending memo becomes a direct dependency of the original computation, even when `scope()` cannot return a value. If the memo later fulfills, Solid can schedule the original effect; its next normal recomputation may remove the late subscription if the effect no longer reads it. Only sources actually read **before** `NotReadyError` are attached. Other thrown errors still leave the original dependency graph unchanged.
104
+
105
+ `NotReadyError` is **re-thrown**, not converted to `undefined` or an `AttachReadResult`. `createScope()` and `createOwnerScope()` share this behavior. When using a delayed callback outside Solid's tracked execution, catch or handle the error; simply propagating it does not automatically restore the original `<Loading>` boundary context. Fulfillment-based wakeup and basic suspended-effect behavior have been tested on rc.17; complex Loading/transition semantics and error-state recovery are not guaranteed.
106
+
107
+ ## Alternative: `createOwnerScope()`
108
+
109
+ `createScope()` is **cycle-bound**: when the effect recomputes, an earlier scope becomes stale. Use `createOwnerScope()` when an older asynchronous callback deliberately needs to attach reads to the **same still-live effect**, even after that effect has rerun.
110
+
111
+ ```tsx
112
+ import { createEffect, createSignal } from 'solid-js';
113
+ import { createOwnerScope, DisposedScopeError } from 'solid-deps';
114
+
115
+ const [key, setKey] = createSignal(0);
116
+ const [late, setLate] = createSignal(0);
117
+
118
+ createEffect(() => {
119
+ const ownerScope = createOwnerScope();
120
+ const current = key(); // Native dependency
121
+
122
+ setTimeout(() => {
123
+ try {
124
+ console.log(ownerScope(() => late()));
125
+ } catch (error) {
126
+ if (!(error instanceof DisposedScopeError)) throw error;
127
+ }
128
+ }, 2000);
129
+
130
+ return current;
131
+ }, () => {});
132
+
133
+ setKey(1); // The old ownerScope still targets the same live computation.
134
+ ```
135
+
136
+ This example deliberately leaves the timeout running to demonstrate owner-bound behavior. **For async work that belongs only to a particular effect execution, prefer `createScope()` and cancel obsolete work using the effect's cleanup mechanism.**
137
+
138
+ Comparison:
139
+
140
+ | | `createScope()` | `createOwnerScope()` |
141
+ | --- | --- | --- |
142
+ | Captures | Computation and dependency generation | Computation identity only |
143
+ | Reusable after a normal effect rerun | No — throws `StaleScopeError` | Yes, while the effect is alive |
144
+ | After final disposal | Throws `DisposedScopeError` | Throws `DisposedScopeError` |
145
+ | Callback return | Direct value | Direct value |
146
+ | Extra dependencies survive every rerun | No | No |
147
+
148
+ Owner-bound callbacks do **not** restore Solid ownership context, transfer to a replacement effect, or make attached dependencies permanent. An older callback may alter the currently running effect's dependency graph; use this behavior intentionally.
149
+
70
150
  ## API
71
151
 
72
152
  ### `createScope(owner?)`
@@ -89,6 +169,21 @@ A scope call:
89
169
  - Throws `DisposedScopeError` if the computation has been disposed. The callback is not executed.
90
170
  - Propagates errors thrown by the callback or the graph-validation mechanism.
91
171
 
172
+ ### `createOwnerScope(owner?)`
173
+
174
+ Create a callable function bound to the lifetime of the current tracked computation, rather than its current execution cycle.
175
+
176
+ ```ts
177
+ const scope = createOwnerScope(); // While an observer is tracking
178
+ const other = createOwnerScope(owner); // Explicit existing computation owner
179
+ await loadData();
180
+ const value = scope(() => signal()); // Exact callback value
181
+ ```
182
+
183
+ The implicit form requires an active observer at creation time. An explicit owner must be a tracking computation rather than a plain Solid owner/root. The handle keeps a reference to that **same** computation across normal reruns; it does not discover or switch to any newly created effect. Final disposal causes `DisposedScopeError`, and the callback is skipped. Other reader/graph errors propagate normally.
184
+
185
+ Do not call it to perform tracked reads *inside an effect cleanup callback*: the temporary tracking computation required for grafting can be rejected by Solid in development mode. Use cleanup to cancel external timers or requests instead.
186
+
92
187
  ### `captureOwnerCycle(owner?)`
93
188
 
94
189
  Capture an opaque owner-cycle token with the same implicit-or-explicit owner behavior as `createScope`:
@@ -165,6 +260,7 @@ export interface AttachReadResult<T> {
165
260
  }
166
261
 
167
262
  export declare function createScope(owner?: Owner | null): Scope;
263
+ export declare function createOwnerScope(owner?: Owner | null): Scope;
168
264
  export declare function captureOwnerCycle(owner?: Owner | null): OwnerCycle;
169
265
  export declare function attachRead<T>(
170
266
  target: Owner | OwnerCycle | null,
@@ -181,18 +277,19 @@ With `createScope()`, a callback returning `number` gives a `number` directly. W
181
277
 
182
278
  - An attached read affects **future** invalidations. Writes that occurred before registration are not replayed.
183
279
  - Extra dependencies can be removed on the target's next normal recomputation. Reattach them on each async effect execution when needed.
184
- - `scope()` / `attachRead()` callbacks must only make synchronous, settled reactive reads. Creating owned computations, registering cleanups, or mutating signals from inside them is not supported.
280
+ - `scope()` / `attachRead()` callbacks must be synchronous. They may read existing pending memos: `NotReadyError` is propagated **after** transferring previously read dependencies. Ordinary thrown errors leave the target unchanged. Creating owned computations, registering cleanups, or mutating signals from inside callbacks is not supported.
185
281
  - A callback that allocates owned children/cleanups, or reads a newly allocated probe-owned memo, is rejected. Access existing independently owned memos instead.
186
282
  - Reads happen under a temporary observer; `getOwner()`, `getObserver()`, pending resource behavior, transitions, Loading boundaries, and ownership-sensitive operations are **not equivalent** to running under the original effect.
187
- - Pending reads can throw (such as `NotReadyError`). Advanced suspense/transition and held-transaction behavior is not broadly supported.
188
- - A scope invalidated by a newer run throws rather than silently executing a stale callback. This doesn't cancel async work or its other side effects.
283
+ - A pending memo can throw `NotReadyError`, and the attached observer will keep the dependency for successful settlement. This does not recreate the original Loading boundary or guarantee retry after rejection. Advanced suspense/transition and held-transaction behavior is not broadly supported.
284
+ - Cycle-bound `createScope()` rejects an older execution after rerun; `createOwnerScope()` stays valid for the same live observer, but old callbacks can change the new execution's dependencies. Neither automatically cancels work.
285
+ - Do not graft reads from inside effect-phase cleanup; use cleanup to cancel obsolete external work. Final disposal invalidates both scope types.
189
286
  - Uses private observer graph internals discovered by inspecting disposable runtime probes. This supports field renaming when graph relationships remain compatible, but cannot guarantee future graph algorithms, scheduler semantics, or minifier property restructuring. Discovery fails with an error if required fields are ambiguous or missing.
190
287
  - Calibrates only once per loaded package instance. In an ambiguous numeric-generation case, a field-position clue is used after checking link-generation equality; reordered internal fields may therefore cause calibration to fail or be misidentified. Test on new versions before shipping.
191
288
  - Other Solid versions, SSR, and multiple separately instantiated signal runtimes are not guaranteed.
192
289
 
193
290
  ## Online Solid playground check
194
291
 
195
- A copy-pasteable `main.tsx` is available in the source ZIP at `examples/online-playground/main.tsx`. Install `solid-deps@0.0.3`, ensure the playground runs a Solid 2 browser build with a shared signals runtime, and use **Run runtime checks**. It reports successful automatic layout discovery, async dependency attachment, deduplication, future updates, stale cycles, and disposal.
292
+ A copy-pasteable `main.tsx` is available in the source ZIP at `examples/online-playground/main.tsx`. Install `solid-deps@0.0.5`, ensure the playground runs a Solid 2 browser build with a shared signals runtime, and use **Run runtime checks**. It reports successful automatic layout discovery, async dependency attachment, deduplication, future updates, stale cycles, and disposal.
196
293
 
197
294
  ## License
198
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-deps",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Experimental direct dependency attachment for Solid 2 reactive observers after async boundaries",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,9 +37,9 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "setup": "node scripts/setup.mjs",
40
- "test": "node --conditions=browser --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs",
41
- "test:dev": "node --conditions=browser --conditions=development --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs",
42
- "test:observe": "node --conditions=browser --conditions=observe --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs",
40
+ "test": "node --conditions=browser --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs",
41
+ "test:dev": "node --conditions=browser --conditions=development --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs",
42
+ "test:observe": "node --conditions=browser --conditions=observe --test test/test.mjs test/test-async.mjs test/test-return.mjs test/test-scope.mjs test/test-manifest.mjs test/test-graph-hardening.mjs test/test-async-transitions.mjs test/test-transaction-integrity.mjs test/test-scheduler-lifecycle.mjs test/test-release-hardening.mjs test/test-discovery.mjs test/test-fastpath.mjs test/test-owner-bound-research.mjs test/test-owner-scope.mjs test/test-notready-lifecycle.mjs",
43
43
  "test:all": "node scripts/run-tests.mjs",
44
44
  "test:package": "node scripts/smoke-package.mjs",
45
45
  "test:browser": "node --test test/browser/playwright.test.mjs",
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Experimental RC.17 graph surgery. No changes/forks of Solid.
3
3
  * Target must be captured using getObserver() while its tracking function runs.
4
- * Scope: synchronous, settled signal and memo reads, outside a target recompute.
4
+ * Scope: synchronous reactive reads, including pending memo reads that throw NotReadyError.
5
+ * Reads execute in a temporary observer; pending dependencies can be transferred,
6
+ * but this does not restore the target effect's Suspense/Loading context.
5
7
  * Maintenance/compatibility: see HANDOFF.md and docs/INTERNALS.md.
6
8
  * This is link transfer, NOT restoration of the original observer context.
7
9
  */
8
- import { createRoot, createReaction, getObserver, isDisposed } from '@solidjs/signals';
10
+ import { createRoot, createReaction, getObserver, isDisposed, NotReadyError } from '@solidjs/signals';
9
11
 
10
12
  import { layoutOf } from './discover.mjs';
11
13
 
@@ -45,7 +47,9 @@ export function captureOwnerCycle(owner = getObserver()) {
45
47
 
46
48
  /**
47
49
  * Attach dependencies read by `read` onto `target` without re-running target.
48
- * Returns an object { value, added, duplicate, promoted, stale, disposed }.
50
+ * Returns an object { value, added, duplicate, promoted, stale, disposed }
51
+ * on successful reads. A NotReadyError preserves tracked dependencies before
52
+ * rethrowing so pending memos can wake the target; other exceptions roll back.
49
53
  * They remain until target recomputes and performs normal dependency reconciliation.
50
54
  */
51
55
  export function attachRead(observerOrCycle, read) {
@@ -80,7 +84,13 @@ export function attachRead(observerOrCycle, read) {
80
84
  try { retValue = read(); } catch (err) { caught = [err]; }
81
85
  });
82
86
  });
83
- if (caught) throw caught[0];
87
+ // Solid's normal tracking pass keeps dependencies read before NotReadyError,
88
+ // so pending sources can wake the computation after settling. Preserve the
89
+ // same dependency semantics for a late read, while still propagating the
90
+ // suspension signal to the caller. Other exceptions remain transactional:
91
+ // they must not mutate the target observer's graph.
92
+ const pendingError = caught?.[0] instanceof NotReadyError ? caught[0] : null;
93
+ if (caught && !pendingError) throw caught[0];
84
94
  if (retValue != null && typeof retValue.then === 'function')
85
95
  throw new TypeError('attachRead requires synchronous reads, not an async callback');
86
96
  if (!probe) throw new Error('No temporary observer was captured');
@@ -150,6 +160,9 @@ export function attachRead(observerOrCycle, read) {
150
160
  nextList[i][s.next] = nextList[i + 1] ?? null;
151
161
  target[s.deps] = nextList[0] ?? null;
152
162
  target[s.tail] = validated.at(-1) ?? null;
163
+ // Re-throw only AFTER the graph transfer has succeeded. The temporary
164
+ // probe is disposed in finally; its moved edges now belong to the target.
165
+ if (pendingError) throw pendingError;
153
166
  return { value: retValue,
154
167
  added: additions.length,
155
168
  duplicate: kept.length - promoted.length,
@@ -182,17 +195,34 @@ export class DisposedScopeError extends Error {
182
195
  }
183
196
  }
184
197
 
185
- /**
186
- * Capture the current observer cycle and return a callable dependency scope.
187
- * The callback must be synchronous. Its return value is returned directly.
188
- * Throws instead of returning an ambiguous undefined when the cycle has ended.
189
- */
190
- export function createScope(owner) {
191
- const cycle = arguments.length === 0 ? captureOwnerCycle() : captureOwnerCycle(owner);
198
+ /** Shared callable facade for owner-bound and cycle-bound dependency scopes. */
199
+ function makeScope(target) {
192
200
  return function scope(read) {
193
- const result = attachRead(cycle, read);
201
+ const result = attachRead(target, read);
194
202
  if (result.disposed) throw new DisposedScopeError();
195
203
  if (result.stale) throw new StaleScopeError();
196
204
  return result.value;
197
205
  };
198
206
  }
207
+
208
+ /**
209
+ * Capture the current observer cycle and return a callable dependency scope.
210
+ * The callback must be synchronous; it may throw NotReadyError after grafting
211
+ * its pending dependencies. An earlier cycle becomes stale after rerun.
212
+ */
213
+ export function createScope(owner) {
214
+ return makeScope(arguments.length === 0 ? captureOwnerCycle() : captureOwnerCycle(owner));
215
+ }
216
+
217
+ /**
218
+ * Capture a live computation owner, without binding to its dependency generation.
219
+ * The returned callback remains usable across that computation's normal reruns,
220
+ * but rejects reads after its final disposal. An old callback can therefore
221
+ * attach dependencies to the newest execution of the same computation.
222
+ */
223
+ export function createOwnerScope(owner) {
224
+ const observer = arguments.length === 0 ? getObserver() : owner;
225
+ // Validate at capture time, including the initial empty dependency list.
226
+ layoutOf(observer);
227
+ return makeScope(observer);
228
+ }
package/src/discover.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Discover the private linked dependency graph from controlled, disposable
3
- * Solid computations. No minified or development property names are encoded.
3
+ * Solid computations. Only recognizable descriptive development property names
4
+ * are encoded; all unknown layouts use the name-independent inference.
4
5
  *
5
6
  * This is topology inference, not a guarantee of compatibility with a new
6
7
  * algorithm or a property-mangling transform that changes graph semantics.
@@ -11,6 +12,111 @@ import {
11
12
  } from '@solidjs/signals';
12
13
 
13
14
  let inferred;
15
+ const diagnostics = { knownDev: 0, inferred: 0 };
16
+
17
+ /** Internal test-only counters. Not exported from the package entry point. */
18
+ export function _discoveryDiagnostics() {
19
+ return { ...diagnostics, origin: inferred?.origin ?? null };
20
+ }
21
+
22
+ // Development field names are a candidate, never evidence of graph compatibility.
23
+ // Minified field names are deliberately not recorded here.
24
+ const knownDev = Object.freeze({
25
+ deps: '_deps', tail: '_depsTail', gen: '_depGen', flags: '_flags',
26
+ dep: '_dep', sub: '_sub', next: '_nextDep', linkGen: '_gen',
27
+ parent: '_parent', child: '_firstChild', disposal: '_disposal',
28
+ recomputingBit: 4
29
+ });
30
+ const devFields = ['deps', 'tail', 'gen', 'flags', 'parent', 'child', 'disposal'];
31
+
32
+ // Only inspect own *data* properties to avoid executing an arbitrary getter
33
+ // during compatibility detection. A missing data field is not silently read.
34
+ function data(node, key) {
35
+ if (!obj(node)) return { exists: false };
36
+ const descriptor = Object.getOwnPropertyDescriptor(node, key);
37
+ return descriptor && 'value' in descriptor
38
+ ? { exists: true, value: descriptor.value }
39
+ : { exists: false };
40
+ }
41
+
42
+ /**
43
+ * Pure, non-mutating candidate check; the initial observer may have zero deps,
44
+ * or a validated prefix followed by an older-generation stale suffix.
45
+ * `null` means unknown or partially renamed; corrupted complete layouts throw.
46
+ * Kept as an internal module export for the source-tree regression suite.
47
+ */
48
+ export function tryKnownDevLayout(node) {
49
+ if (!obj(node)) return null;
50
+ const present = devFields.filter(role => Object.hasOwn(node, knownDev[role]));
51
+ if (present.length !== devFields.length) return null;
52
+
53
+ const read = (object, key, role) => {
54
+ const slot = data(object, key);
55
+ if (!slot.exists) throw new Error(`solid-deps: invalid known development ${role}; refusing graph mutation`);
56
+ return slot.value;
57
+ };
58
+ const fail = why => { throw new Error(`solid-deps: invalid known development graph (${why}); refusing graph mutation`); };
59
+ const gen = read(node, knownDev.gen, 'generation');
60
+ const flags = read(node, knownDev.flags, 'flags');
61
+ const head = read(node, knownDev.deps, 'head');
62
+ const tail = read(node, knownDev.tail, 'tail');
63
+ const parent = read(node, knownDev.parent, 'parent');
64
+ const child = read(node, knownDev.child, 'child');
65
+ const disposal = read(node, knownDev.disposal, 'disposal');
66
+
67
+ if (!Number.isSafeInteger(gen) || gen < 0 || !Number.isSafeInteger(flags) || flags < 0)
68
+ fail('generation or flags is not a nonnegative integer');
69
+ // Multiple onCleanup() calls create an array of disposal callbacks.
70
+ // Check their data slots without invoking a potentially shadowed .every().
71
+ let validDisposal = disposal == null || typeof disposal === 'function';
72
+ if (Array.isArray(disposal)) {
73
+ validDisposal = disposal.length > 0;
74
+ for (let i = 0; i < disposal.length; i++) {
75
+ const callback = data(disposal, String(i));
76
+ if (!callback.exists || typeof callback.value !== 'function') validDisposal = false;
77
+ }
78
+ }
79
+ if (head != null && !obj(head) || tail != null && !obj(tail) ||
80
+ parent != null && !obj(parent) || child != null && !obj(child) ||
81
+ !validDisposal)
82
+ fail('invalid dependency or lifecycle value');
83
+ if (head == null && tail != null) fail('tail exists without a head');
84
+
85
+ let edge = head;
86
+ let tailReached = tail == null;
87
+ const visited = new Set();
88
+ while (edge != null) {
89
+ if (!obj(edge) || visited.has(edge)) fail('non-link or cyclic dependency chain');
90
+ visited.add(edge);
91
+ const source = read(edge, knownDev.dep, 'link source');
92
+ const subscriber = read(edge, knownDev.sub, 'link subscriber');
93
+ const linkGen = read(edge, knownDev.linkGen, 'link generation');
94
+ const next = read(edge, knownDev.next, 'next link');
95
+ if (!obj(source) || subscriber !== node || !Number.isSafeInteger(linkGen) ||
96
+ linkGen < 0 || linkGen > gen || next != null && !obj(next))
97
+ fail('inconsistent dependency link');
98
+
99
+ // Check both sides of the source subscription list. In rc.17 dev builds,
100
+ // source._subs and edge._nextSub connect each signal back to its readers.
101
+ // This is a read-only check, never an attempt to repair graph structure.
102
+ let sub = read(source, '_subs', 'source subscriber head');
103
+ const subscribers = new Set();
104
+ let found = false;
105
+ while (sub != null) {
106
+ if (!obj(sub) || subscribers.has(sub)) fail('invalid source subscriber list');
107
+ subscribers.add(sub);
108
+ if (sub === edge) found = true;
109
+ sub = read(sub, '_nextSub', 'subscriber next');
110
+ }
111
+ if (!found) fail('source is not subscribed back to dependency link');
112
+
113
+ if (edge === tail) tailReached = true;
114
+ edge = next;
115
+ }
116
+ if (!tailReached) fail('tail not reachable from dependency head');
117
+ return knownDev;
118
+ }
119
+
14
120
  const own = (obj) => obj && typeof obj === 'object'
15
121
  ? Reflect.ownKeys(obj).filter(key => Object.prototype.propertyIsEnumerable.call(obj, key)) : [];
16
122
  const entries = obj => own(obj).map(key => [key, obj[key]]);
@@ -157,12 +263,24 @@ function discover() {
157
263
  }
158
264
  }
159
265
 
160
- export function internalLayout() {
161
- return inferred ??= discover();
266
+ export function internalLayout(node) {
267
+ if (inferred) return inferred.layout;
268
+ // Called with the real target by layoutOf; keep the optional no-argument
269
+ // form for internal source-tree tests and unknown-layout calibration.
270
+ const dev = node == null ? null : tryKnownDevLayout(node);
271
+ if (dev) {
272
+ diagnostics.knownDev++;
273
+ inferred = { layout: dev, origin: 'known-dev' };
274
+ } else {
275
+ const layout = discover();
276
+ diagnostics.inferred++;
277
+ inferred = { layout, origin: 'inferred' };
278
+ }
279
+ return inferred.layout;
162
280
  }
163
281
 
164
282
  export function layoutOf(node) {
165
- const shape = internalLayout();
283
+ const shape = internalLayout(node);
166
284
  if (!obj(node) || typeof node[shape.gen] !== 'number' ||
167
285
  !(shape.deps in node) || !(shape.tail in node) ||
168
286
  typeof node[shape.flags] !== 'number' || !(shape.child in node) ||
package/src/index.mjs CHANGED
@@ -1 +1 @@
1
- export { attachRead, captureOwnerCycle, createScope, inspectDeps, StaleScopeError, DisposedScopeError } from './attach-read.mjs';
1
+ export { attachRead, captureOwnerCycle, createScope, createOwnerScope, inspectDeps, StaleScopeError, DisposedScopeError } from './attach-read.mjs';
package/types/index.d.mts CHANGED
@@ -47,6 +47,13 @@ export declare function captureOwnerCycle(owner?: Owner | null): OwnerCycle;
47
47
  */
48
48
  export declare function createScope(owner?: Owner | null): Scope;
49
49
 
50
+ /**
51
+ * Create an owner-bound callable scope. Unlike createScope(), it stays valid
52
+ * across normal reruns of the same computation and rejects final disposal.
53
+ * Captured work from an older execution may attach to the current execution.
54
+ */
55
+ export declare function createOwnerScope(owner?: Owner | null): Scope;
56
+
50
57
  /**
51
58
  * Graft dependencies read synchronously by `read` onto an existing observer.
52
59
  * The callback is skipped when the target is stale or disposed.