solid-deps 0.0.2 → 0.0.4

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
@@ -1,202 +1,198 @@
1
1
  # solid-deps
2
2
 
3
- Attach additional reactive dependencies to an existing **Solid 2 effect**—even after an `await`—without re-running the effect at the moment of attachment.
3
+ Track additional dependencies for an existing **Solid 2 computation**, including reads performed after an `await`.
4
4
 
5
- `solid-deps` is an experimental, browser-oriented utility for Solid 2. It lets a synchronous callback read signals, memos, or store properties and registers those reads as dependencies of an existing tracked computation. When an attached dependency changes, Solid can rerun that computation normally.
5
+ `solid-deps` adds subscriptions to the original effect without immediately re-executing it. When those sources change later, Solid schedules the computation normally.
6
6
 
7
- ## Installation
7
+ ## Install
8
8
 
9
9
  ```sh
10
- npm install solid-deps@0.0.2 solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
10
+ npm install solid-deps solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
11
11
  ```
12
12
 
13
- With Bun:
13
+ Or with Bun:
14
14
 
15
15
  ```sh
16
- bun add solid-deps@0.0.2 solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
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:** This release targets **Solid 2.0.0-rc.17** (`solid-js` and `@solidjs/signals`). Both the app and `solid-deps` must resolve to the **same instance** of `@solidjs/signals`. Server-side rendering is not supported. See [Limitations](#limitations).
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
- ## Quick start
21
+ ## Quick start: `createScope()`
22
22
 
23
- Capture the computation before `await`, then attach the reads after the asynchronous work finishes.
23
+ Capture a dependency scope inside the tracked part of your computation. Call it after `await` to attach signal reads to that same computation:
24
24
 
25
25
  ```tsx
26
- import { createEffect, createSignal, getOwner } from "solid-js";
27
- import { attachRead, captureObserverPass } from "solid-deps";
26
+ import { createEffect, createSignal } from 'solid-js';
27
+ import { createScope, StaleScopeError, DisposedScopeError } from 'solid-deps';
28
28
 
29
29
  export default function Counter() {
30
30
  const [count, setCount] = createSignal(0);
31
31
 
32
- createEffect(
33
- async () => {
34
- // Capture the current effect pass while it is still tracking.
35
- const pass = captureObserverPass(getOwner());
32
+ createEffect(async () => {
33
+ const scope = createScope(); // Capture the current observer cycle BEFORE await
36
34
 
37
- await Promise.resolve();
35
+ await Promise.resolve();
38
36
 
39
- // count() becomes a dependency of this effect.
40
- const [value, result] = attachRead(pass, () => count());
41
-
42
- // An older async run may finish after a newer one, or after disposal.
43
- if (result.stale || result.disposed) return;
44
-
45
- console.log("Count:", value);
46
- },
47
- () => {}
48
- );
37
+ try {
38
+ const value = scope(() => count()); // Returns the count directly
39
+ console.log('Count:', value);
40
+ } catch (error) {
41
+ // An older async invocation must not attach reads to a newer cycle.
42
+ if (error instanceof StaleScopeError || error instanceof DisposedScopeError) return;
43
+ throw error;
44
+ }
45
+ }, () => {});
49
46
 
50
47
  return (
51
- <button type="button" onClick={() => setCount(value => value + 1)}>
52
- Increment: {count()}
48
+ <button onClick={() => setCount(v => v + 1)}>
49
+ Count: {count()}
53
50
  </button>
54
51
  );
55
52
  }
56
53
  ```
57
54
 
58
- The first late read adds `count` as a dependency. Later changes to `count` can rerun the effect; each new pass captures its own token and reattaches the read after `await`.
55
+ `scope(callback)` is a **callable function** whose result is exactly the callback's return value. It tracks every reactive source synchronously read by the callback. It does **not** execute the callback under the captured effect's original owner/context: reads run under a temporary tracking observer and their subscriptions are transferred.
59
56
 
60
- ## API
57
+ A scope can be called multiple times while its captured observer cycle is still current:
58
+
59
+ ```ts
60
+ const scope = createScope();
61
+
62
+ await loadData();
63
+
64
+ const name = scope(() => userName());
65
+ const summary = scope(() => ({ count: count(), enabled: enabled() }));
66
+ ```
61
67
 
62
- The package exports `attachRead`, `captureObserverPass`, and `inspectDeps` from its main entry point.
68
+ The callback must be synchronous. Await asynchronous work **before** calling `scope()`, not inside it.
63
69
 
64
- ### `attachRead(observerOrPass, read)`
70
+ ## API
71
+
72
+ ### `createScope(owner?)`
65
73
 
66
- Runs a **synchronous** reader and attaches the reactive sources it accesses to the specified existing computation.
74
+ Create a callable scope that captures an observer and its current dependency generation.
67
75
 
68
76
  ```ts
69
- const [retValue, result] = attachRead(pass, () => {
70
- const countValue = count();
71
- const nameValue = name();
72
- return { countValue, nameValue };
73
- });
77
+ const scope = createScope(); // Capture current tracked observer implicitly
78
+ const scope2 = createScope(owner); // Explicit existing computation owner
79
+
80
+ const value = scope(() => signal());
74
81
  ```
75
82
 
76
- The return value is always a tuple:
83
+ The implicit form must be invoked while an observer is actively tracking, generally at the start of the effect's computation, before `await`. The explicit owner must be a tracking computation, not an arbitrary Solid root/component owner.
77
84
 
78
- - **`retValue`** — the exact value returned by `read()`, or `undefined` if the read was skipped.
79
- - **`result`** — an `AttachReadResult` object describing the attachment.
85
+ A scope call:
80
86
 
81
- | Result field | Type | Meaning |
82
- | --- | --- | --- |
83
- | `added` | `number` | Newly attached or revalidated dependencies, including promoted ones. |
84
- | `duplicate` | `number` | Dependencies already valid for the current effect pass. |
85
- | `promoted` | `number` | Previously attached dependencies reused for this pass; included in `added`. |
86
- | `disposed` | `boolean` | Target computation was disposed; `read()` was not called. |
87
- | `stale` | `boolean` | Captured pass has been superseded; `read()` was not called. |
87
+ - Returns the exact synchronous callback value, including `0`, `false`, `null`, `undefined`, or an object reference.
88
+ - Throws `StaleScopeError` if its captured computation has begun a newer cycle. The callback is not executed.
89
+ - Throws `DisposedScopeError` if the computation has been disposed. The callback is not executed.
90
+ - Propagates errors thrown by the callback or the graph-validation mechanism.
88
91
 
89
- A disposed or stale pass returns `[undefined, result]` without executing `read()`. Errors thrown by the reader propagate to the caller. A reader returning a Promise or thenable is rejected with a `TypeError`; awaiting work **before** calling `attachRead()` is supported, but passing an `async` reader is not.
92
+ ### `captureOwnerCycle(owner?)`
90
93
 
91
- You may also pass a captured computation directly:
94
+ Capture an opaque owner-cycle token with the same implicit-or-explicit owner behavior as `createScope`:
92
95
 
93
96
  ```ts
94
- import { getOwner } from "solid-js";
97
+ import { captureOwnerCycle, attachRead } from 'solid-deps';
95
98
 
96
- // `owner` must have been captured inside a tracked computation.
97
- const [value, result] = attachRead(owner, () => count());
99
+ const cycle = captureOwnerCycle();
100
+ await something();
101
+ const result = attachRead(cycle, () => count());
98
102
  ```
99
103
 
100
- For async effects, prefer a pass token so an older continuation cannot attach dependencies to a newer execution.
104
+ Tokens retain the observer identity and dependency generation at capture time. They do not create a new reactive computation.
101
105
 
102
- ### `captureObserverPass(observer)`
106
+ ### `attachRead(target, callback)`
103
107
 
104
- Captures a token identifying the current execution of a tracked computation. Call it **during the computation's tracked phase, before an `await`**.
108
+ The lower-level API runs a synchronous callback and returns a **single object** containing both the callback value and attachment metadata.
105
109
 
106
110
  ```ts
107
- const pass = captureObserverPass(getOwner());
111
+ const result = attachRead(cycle, () => {
112
+ const first = firstName();
113
+ const last = lastName();
114
+ return `${first} ${last}`;
115
+ });
116
+
117
+ console.log(result.value);
118
+ console.log(result.added, result.duplicate, result.promoted);
108
119
  ```
109
120
 
110
- Pass the token to `attachRead()`. If a newer execution has started, the older token produces `result.stale === true` and the reader is skipped.
121
+ `target` may be a captured `OwnerCycle` or a raw, validated effect/computation owner. A raw owner is not generation-guarded; use a captured cycle after `await` when executions can overlap.
122
+
123
+ | Field | Type | Description |
124
+ | --- | --- | --- |
125
+ | `value` | `T \| undefined` | Exact callback return; `undefined` when skipped |
126
+ | `added` | `number` | Newly validated/attached sources, including promotions |
127
+ | `duplicate` | `number` | Already-valid sources encountered again |
128
+ | `promoted` | `number` | Reused links from the previous generation (included in `added`) |
129
+ | `stale` | `boolean` | Captured cycle superseded; callback skipped |
130
+ | `disposed` | `boolean` | Target disposed; callback skipped |
111
131
 
112
- `getOwner()` must refer to an actual tracking computation. An ordinary component/root owner is not a suitable attachment target.
132
+ `attachRead()` returns status rather than throwing for stale/disposed targets. Reader exceptions are propagated; a Promise/thenable callback result is rejected.
113
133
 
114
- ### `inspectDeps(observer)`
134
+ ### `inspectDeps(owner)`
115
135
 
116
- Returns an array of the current dependency **source objects** for the supplied computation:
136
+ Return the dependency source **node objects** currently linked to the supplied computation, for diagnostics:
117
137
 
118
138
  ```ts
119
- const sources = inspectDeps(owner);
120
- console.log(sources.length);
139
+ const nodes = inspectDeps(owner);
140
+ console.log(nodes.length);
121
141
  ```
122
142
 
123
- This is a diagnostic helper, not an accessor-value API. Treat returned source objects as read-only.
143
+ Do not mutate these private nodes.
124
144
 
125
145
  ## TypeScript
126
146
 
127
- Type declarations are included. No separate `@types` package is needed.
128
-
129
- The package exports the following types and typed function signatures:
147
+ Declarations ship with the package; no external `@types` dependency is needed.
130
148
 
131
149
  ```ts
132
- import type { Owner } from "@solidjs/signals";
150
+ import type { Owner } from '@solidjs/signals';
133
151
 
134
- export interface ObserverPass {
135
- readonly __solidDepsObserverPass: unique symbol;
152
+ export interface OwnerCycle {
153
+ readonly __solidDepsOwnerCycle: unique symbol;
136
154
  }
137
155
 
138
- export interface AttachReadResult {
156
+ export type Scope = <T>(callback: () => T) => T;
157
+
158
+ export interface AttachReadResult<T> {
159
+ readonly value: T | undefined;
139
160
  readonly added: number;
140
161
  readonly duplicate: number;
141
162
  readonly promoted: number;
142
- readonly disposed: boolean;
143
163
  readonly stale: boolean;
164
+ readonly disposed: boolean;
144
165
  }
145
166
 
146
- export declare function captureObserverPass(observer: Owner | null): ObserverPass;
147
-
167
+ export declare function createScope(owner?: Owner | null): Scope;
168
+ export declare function captureOwnerCycle(owner?: Owner | null): OwnerCycle;
148
169
  export declare function attachRead<T>(
149
- observerOrPass: Owner | ObserverPass | null,
150
- read: () => T
151
- ): [retValue: T | undefined, result: AttachReadResult];
152
-
153
- export declare function inspectDeps(observer: Owner): unknown[];
154
- ```
155
-
156
- For example, TypeScript preserves the reader's return type:
157
-
158
- ```ts
159
- import { createEffect, createSignal, getOwner } from "solid-js";
160
- import { attachRead, captureObserverPass } from "solid-deps";
161
- import type { AttachReadResult, ObserverPass } from "solid-deps";
162
-
163
- const [count] = createSignal(0);
164
-
165
- createEffect(async () => {
166
- const pass: ObserverPass = captureObserverPass(getOwner());
167
- await Promise.resolve();
168
-
169
- const [value, result] = attachRead(pass, () => count());
170
- // value: number | undefined
171
- // result: AttachReadResult
172
-
173
- if (!result.stale && !result.disposed && value !== undefined) {
174
- console.log(value.toFixed(0));
175
- }
176
- }, () => {});
170
+ target: Owner | OwnerCycle | null,
171
+ callback: () => T
172
+ ): AttachReadResult<T>;
173
+ export declare function inspectDeps(owner: Owner): unknown[];
174
+ export declare class StaleScopeError extends Error {}
175
+ export declare class DisposedScopeError extends Error {}
177
176
  ```
178
177
 
179
- `ObserverPass` is an opaque type. Obtain one from `captureObserverPass()` rather than constructing it yourself.
180
-
181
- ## Dependency lifetime
182
-
183
- - Reads are attached **without immediately re-running** the target computation.
184
- - Source changes that occurred **before** attachment are not replayed.
185
- - The attached dependencies participate in subsequent normal updates. On the next computation pass, Solid may remove dependencies that are not read or reattached for that pass.
186
- - Use `captureObserverPass()` for async work that may overlap with newer effect executions.
187
- - Disposed computations cannot receive new dependencies.
188
-
189
- ## Limitations
190
-
191
- `solid-deps` is experimental and restricted to Solid **2.0.0-rc.17** in browser-oriented applications. Dependency registration requires an existing **tracked computation**, not simply any owner.
178
+ With `createScope()`, a callback returning `number` gives a `number` directly. With `attachRead()`, `result.value` is `number | undefined` because a captured cycle can be stale or disposed.
192
179
 
193
- Keep `read()` synchronous and limited to settled reactive reads, such as signals, memos, or store properties. Do not use it to create computations, install cleanups, write signals, or perform side effects. It does not run in the target's original reactive context. The library rejects reader callbacks that create owned children or register cleanups, and rejects sources owned by its temporary observer, to prevent subscriptions to disposed computations.
180
+ ## Behavior and limitations
194
181
 
195
- Pending async memo reads can throw `NotReadyError`; a failed read does not attach dependencies. A memo with a `loadingValue` can return that fallback while pending, but `attachRead` does not restore the target observer's original suspension or transition context. Treat resources, loading boundaries, held transactions, and optimistic state as **unsupported beyond ordinary settled reads**.
182
+ - An attached read affects **future** invalidations. Writes that occurred before registration are not replayed.
183
+ - 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.
185
+ - A callback that allocates owned children/cleanups, or reads a newly allocated probe-owned memo, is rejected. Access existing independently owned memos instead.
186
+ - 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.
189
+ - 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
+ - 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
+ - Other Solid versions, SSR, and multiple separately instantiated signal runtimes are not guaranteed.
196
192
 
197
- An `attachRead()` subscription is prospective: signal updates that happen before attachment are not replayed. Grafted dependencies can be removed by the target's next normal recomputation. For async effects, reattach on each pass using `captureObserverPass()`.
193
+ ## Online Solid playground check
198
194
 
199
- Reads that create computations/cleanups, write signals, or depend on `getOwner()`/`getObserver()` seeing the original effect are unsupported, because the callback executes under a temporary observer. Server rendering and independently bundled copies of the signals runtime are not supported. Do not assume compatibility with other Solid versions.
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.
200
196
 
201
197
  ## License
202
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-deps",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Experimental direct dependency attachment for Solid 2 reactive observers after async boundaries",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  "types": "./types/index.d.mts",
17
17
  "files": [
18
18
  "src/attach-read.mjs",
19
+ "src/discover.mjs",
19
20
  "src/index.mjs",
20
21
  "types/index.d.mts",
21
22
  "README.md",
@@ -23,7 +24,7 @@
23
24
  ],
24
25
  "sideEffects": false,
25
26
  "peerDependencies": {
26
- "@solidjs/signals": "2.0.0-rc.17"
27
+ "@solidjs/signals": ">=2.0.0-rc.17 <3.0.0"
27
28
  },
28
29
  "keywords": [
29
30
  "solid",
@@ -36,14 +37,17 @@
36
37
  ],
37
38
  "scripts": {
38
39
  "setup": "node scripts/setup.mjs",
39
- "test": "node --conditions=browser --test test/test.mjs test/test-async.mjs test/test-tuple.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",
40
- "test:dev": "node --conditions=browser --conditions=development --test test/test.mjs test/test-async.mjs test/test-tuple.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",
41
- "test:observe": "node --conditions=browser --conditions=observe --test test/test.mjs test/test-async.mjs test/test-tuple.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",
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",
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",
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",
42
43
  "test:all": "node scripts/run-tests.mjs",
43
- "test:package": "node scripts/smoke-package.mjs"
44
+ "test:package": "node scripts/smoke-package.mjs",
45
+ "test:browser": "node --test test/browser/playwright.test.mjs",
46
+ "test:browser:preflight": "node scripts/browser-preflight.mjs"
44
47
  },
45
48
  "devDependencies": {
46
49
  "@solidjs/signals": "2.0.0-rc.17",
47
- "solid-js": "2.0.0-rc.17"
50
+ "solid-js": "2.0.0-rc.17",
51
+ "playwright-core": "^1.56.0"
48
52
  }
49
53
  }
@@ -7,22 +7,13 @@
7
7
  */
8
8
  import { createRoot, createReaction, getObserver, isDisposed } from '@solidjs/signals';
9
9
 
10
- const layouts = {
11
- dev: { deps: '_deps', tail: '_depsTail', gen: '_depGen', flags: '_flags', dep: '_dep', sub: '_sub', next: '_nextDep', linkGen: '_gen', child: '_firstChild', disposal: '_disposal' },
12
- prod: { deps: 'C', tail: '_', gen: 'Ne', flags: 'R', dep: 'S', sub: 'u', next: 'G', linkGen: 'Te', child: 'De', disposal: 'He' },
13
- observe: { deps: 'C', tail: '_', gen: 'Ne', flags: 'R', dep: 'S', sub: 'u', next: 'G', linkGen: 'Te', child: 'ze', disposal: 'rt' }
14
- };
15
- function layoutOf(node) {
16
- const shape = node && (('_deps' in node) ? layouts.dev : ('C' in node) ? ('ze' in node ? layouts.observe : layouts.prod) : null);
17
- if (!shape || typeof node[shape.gen] !== 'number' || !(shape.tail in node))
18
- throw new TypeError('Expected a tracking Computed from @solidjs/signals rc.17, not a plain Owner');
19
- return shape;
20
- }
10
+ import { layoutOf } from './discover.mjs';
11
+
21
12
  // Probe-owned sources disappear when the temporary tracking root is disposed.
22
13
  // Their dependency edges must never be transferred to a longer-lived target.
23
14
  function ownedBy(node, ancestor) {
24
15
  const seen = new Set();
25
- for (let owner = node; owner != null; owner = owner._parent) {
16
+ for (let owner = node; owner != null; owner = owner[layoutOf(ancestor).parent]) {
26
17
  if (owner === ancestor) return true;
27
18
  if (seen.has(owner)) throw new Error('Cyclic owner ancestry');
28
19
  seen.add(owner);
@@ -43,28 +34,29 @@ function links(owner, layout) {
43
34
  }
44
35
  // Capture a generation before an await, so a superseded async invocation
45
36
  // cannot attach subscriptions to a more recent pass of the same effect.
46
- const observerPasses = new WeakMap();
47
- export function captureObserverPass(observer) {
37
+ const ownerCycles = new WeakMap();
38
+ export function captureOwnerCycle(owner = getObserver()) {
39
+ const observer = owner;
48
40
  const shape = layoutOf(observer);
49
41
  const token = Object.freeze({});
50
- observerPasses.set(token, { observer, generation: observer[shape.gen] });
42
+ ownerCycles.set(token, { observer, generation: observer[shape.gen] });
51
43
  return token;
52
44
  }
53
45
 
54
46
  /**
55
47
  * Attach dependencies read by `read` onto `target` without re-running target.
56
- * Returns [retValue, result] where result describes the graft.
48
+ * Returns an object { value, added, duplicate, promoted, stale, disposed }.
57
49
  * They remain until target recomputes and performs normal dependency reconciliation.
58
50
  */
59
- export function attachRead(observerOrPass, read) {
60
- const pass = observerOrPass && observerPasses.get(observerOrPass);
61
- const target = pass ? pass.observer : observerOrPass;
51
+ export function attachRead(observerOrCycle, read) {
52
+ const pass = observerOrCycle && ownerCycles.get(observerOrCycle);
53
+ const target = pass ? pass.observer : observerOrCycle;
62
54
  const s = layoutOf(target);
63
55
  if (typeof read !== 'function') throw new TypeError('read must be a function');
64
- if (isDisposed(target)) return [undefined, { added: 0, duplicate: 0, promoted: 0, disposed: true, stale: false }];
56
+ if (isDisposed(target)) return { value: undefined, added: 0, duplicate: 0, promoted: 0, disposed: true, stale: false };
65
57
  if (pass && target[s.gen] !== pass.generation)
66
- return [undefined, { added: 0, duplicate: 0, promoted: 0, disposed: false, stale: true }];
67
- if (target[s.flags] & 4) throw new Error('Target observer is currently recomputing');
58
+ return { value: undefined, added: 0, duplicate: 0, promoted: 0, disposed: false, stale: true };
59
+ if (target[s.flags] & s.recomputingBit) throw new Error('Target observer is currently recomputing');
68
60
  const existing = links(target, s);
69
61
  const beforeTail = target[s.tail];
70
62
  const beforeGen = target[s.gen];
@@ -93,9 +85,9 @@ export function attachRead(observerOrPass, read) {
93
85
  throw new TypeError('attachRead requires synchronous reads, not an async callback');
94
86
  if (!probe) throw new Error('No temporary observer was captured');
95
87
  const p = layoutOf(probe);
96
- if (p !== s) throw new Error('Temporary observer and target use different Solid builds');
97
- if (isDisposed(target)) return [undefined, { added: 0, duplicate: 0, promoted: 0, disposed: true, stale: false }];
98
- if ((target[s.flags] & 4) || target[s.tail] !== beforeTail || target[s.gen] !== beforeGen ||
88
+ if (p !== s) throw new Error('Temporary observer and target use different Solid runtimes');
89
+ if (isDisposed(target)) return { value: undefined, added: 0, duplicate: 0, promoted: 0, disposed: true, stale: false };
90
+ if ((target[s.flags] & s.recomputingBit) || target[s.tail] !== beforeTail || target[s.gen] !== beforeGen ||
99
91
  (target[s.deps] ?? null) !== (existing[0] ?? null))
100
92
  throw new Error('Target changed during capture; refusing unsafe graft');
101
93
  const after = links(target, s);
@@ -158,13 +150,13 @@ export function attachRead(observerOrPass, read) {
158
150
  nextList[i][s.next] = nextList[i + 1] ?? null;
159
151
  target[s.deps] = nextList[0] ?? null;
160
152
  target[s.tail] = validated.at(-1) ?? null;
161
- return [retValue, {
153
+ return { value: retValue,
162
154
  added: additions.length,
163
155
  duplicate: kept.length - promoted.length,
164
156
  promoted: promoted.length,
165
157
  disposed: false,
166
158
  stale: false
167
- }];
159
+ };
168
160
  } finally {
169
161
  disposeRoot?.();
170
162
  }
@@ -173,3 +165,34 @@ export function inspectDeps(owner) {
173
165
  const s = layoutOf(owner);
174
166
  return links(owner, s).map(link => link[s.dep]);
175
167
  }
168
+
169
+ /** The captured observer has been superseded by a newer tracking cycle. */
170
+ export class StaleScopeError extends Error {
171
+ constructor() {
172
+ super('The scope belongs to a superseded observer cycle');
173
+ this.name = 'StaleScopeError';
174
+ }
175
+ }
176
+
177
+ /** The computation owning a captured scope has been disposed. */
178
+ export class DisposedScopeError extends Error {
179
+ constructor() {
180
+ super('The scope owner has been disposed');
181
+ this.name = 'DisposedScopeError';
182
+ }
183
+ }
184
+
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);
192
+ return function scope(read) {
193
+ const result = attachRead(cycle, read);
194
+ if (result.disposed) throw new DisposedScopeError();
195
+ if (result.stale) throw new StaleScopeError();
196
+ return result.value;
197
+ };
198
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Discover the private linked dependency graph from controlled, disposable
3
+ * Solid computations. Only recognizable descriptive development property names
4
+ * are encoded; all unknown layouts use the name-independent inference.
5
+ *
6
+ * This is topology inference, not a guarantee of compatibility with a new
7
+ * algorithm or a property-mangling transform that changes graph semantics.
8
+ */
9
+ import {
10
+ createRoot, createReaction, createSignal, createMemo,
11
+ getObserver, onCleanup, runWithOwner
12
+ } from '@solidjs/signals';
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
+
120
+ const own = (obj) => obj && typeof obj === 'object'
121
+ ? Reflect.ownKeys(obj).filter(key => Object.prototype.propertyIsEnumerable.call(obj, key)) : [];
122
+ const entries = obj => own(obj).map(key => [key, obj[key]]);
123
+ const obj = value => value !== null && typeof value === 'object';
124
+ const numeric = node => new Map(entries(node).filter(([, value]) => typeof value === 'number'));
125
+ function unique(candidates, role) {
126
+ if (candidates.length !== 1)
127
+ throw new Error(`solid-deps: unable to infer ${role} (found ${candidates.length} candidates); refusing graph mutation`);
128
+ return candidates[0];
129
+ }
130
+
131
+ /** Pure, name-independent inference used by both calibration and synthetic tests. */
132
+ export function inferGraphTopology(probe) {
133
+ const targets = entries(probe).filter(([, value]) => obj(value));
134
+ const candidates = [];
135
+ for (const [deps, first] of targets) {
136
+ for (const [tail, last] of targets) {
137
+ if (deps === tail || first === last) continue;
138
+ for (const [next, nextValue] of entries(first)) {
139
+ if (nextValue !== last) continue;
140
+ for (const [sub, target] of entries(first)) {
141
+ if (target !== probe || last[sub] !== probe) continue;
142
+ for (const [dep, source1] of entries(first)) {
143
+ const source2 = last[dep];
144
+ if (!obj(source1) || !obj(source2) || source1 === source2 ||
145
+ source1 === probe || source2 === probe) continue;
146
+ // Both source objects contain an edge pointer. This excludes
147
+ // coincidental owner or linked-list references.
148
+ if (!entries(source1).some(([, v]) => v === first) ||
149
+ !entries(source2).some(([, v]) => v === last)) continue;
150
+ candidates.push({ deps, tail, next, sub, dep, first, last });
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return unique(candidates, 'dependency head/tail, edge and source links');
157
+ }
158
+
159
+ function discover() {
160
+ let dispose;
161
+ try {
162
+ // runWithOwner(null) prevents calibration from becoming an owned child of
163
+ // the user's current computation, including during its first render.
164
+ return runWithOwner(null, () => createRoot(d => {
165
+ dispose = d;
166
+ const [a, setA] = createSignal(1);
167
+ const [b] = createSignal(2);
168
+ let probe;
169
+ let during;
170
+ const track = createReaction(() => {});
171
+ track(() => {
172
+ probe = getObserver();
173
+ during = numeric(probe);
174
+ a();
175
+ b();
176
+ });
177
+ if (!obj(probe)) throw new Error('solid-deps: no controlled observer');
178
+ const after = numeric(probe);
179
+
180
+ // Two sources, two edges and one subscriber. Prefer structural identity,
181
+ // not own-key order, and require reciprocation from each source to edge.
182
+ const shape = inferGraphTopology(probe);
183
+ const genCandidates = [];
184
+ for (const [linkGen, val] of numeric(shape.first)) {
185
+ if (shape.last[linkGen] !== val) continue;
186
+ for (const [gen, candidateValue] of after) {
187
+ if (candidateValue === val && candidateValue > 0 &&
188
+ during.get(gen) === candidateValue) genCandidates.push({ gen, linkGen });
189
+ }
190
+ }
191
+ // Distinct unrelated numeric state may coincidentally equal the link
192
+ // generation. Prefer unique relational evidence; if ambiguous, the
193
+ // generation field immediately following the dependency-tail slot is a
194
+ // secondary structural hint in the currently inspected engine family.
195
+ // Never guess if that hint is also ambiguous.
196
+ const ordered = own(probe);
197
+ const tailOffset = ordered.indexOf(shape.tail);
198
+ const adjacent = genCandidates.filter(candidate =>
199
+ ordered.indexOf(candidate.gen) === tailOffset + 1);
200
+ const { gen, linkGen } = unique(
201
+ genCandidates.length === 1 ? genCandidates : adjacent,
202
+ 'dependency generation');
203
+
204
+ // An invalidated reaction changes its scheduler flags without entering
205
+ // its tracking callback. Other numeric fields can mirror the initial
206
+ // recomputing bit; this extra observation disambiguates them.
207
+ runWithOwner(null, () => setA(3));
208
+ const queued = numeric(probe);
209
+ const flagsCandidates = [];
210
+ for (const [key, duringValue] of during) {
211
+ const idle = after.get(key);
212
+ const invalidated = queued.get(key);
213
+ if (!Number.isInteger(duringValue) || !Number.isInteger(idle) ||
214
+ !Number.isInteger(invalidated)) continue;
215
+ const bit = duringValue & ~idle;
216
+ if (bit && invalidated !== idle && key !== gen) flagsCandidates.push({ flags: key, recomputingBit: bit });
217
+ }
218
+ const { flags, recomputingBit } = unique(flagsCandidates, 'recomputing flag');
219
+
220
+ // A fresh owned memo exposes the parent's child slot and the child's
221
+ // owner pointer. A separate cleanup changes the disposal slot.
222
+ let parent;
223
+ let child;
224
+ const lifecycle = createReaction(() => {});
225
+ lifecycle(() => {
226
+ parent = getObserver();
227
+ createMemo(() => { child = getObserver(); return b(); })();
228
+ });
229
+ if (!obj(child)) throw new Error('solid-deps: failed to create owned memo probe');
230
+ const parentKeys = own(child).filter(key => child[key] === parent);
231
+ const parentKey = unique(parentKeys, 'owner parent pointer');
232
+ const childKeys = own(parent).filter(key => parent[key] === child);
233
+ const childKey = unique(childKeys, 'first owned child pointer');
234
+
235
+ let cleanupOwner;
236
+ let disposal;
237
+ const cleanupProbe = createReaction(() => {});
238
+ cleanupProbe(() => {
239
+ cleanupOwner = getObserver();
240
+ const before = new Map(entries(cleanupOwner));
241
+ const marker = () => {};
242
+ onCleanup(marker);
243
+ // Cleanup slot is newly assigned to marker for a fresh reaction.
244
+ disposal = unique(own(cleanupOwner).filter(key =>
245
+ before.get(key) !== cleanupOwner[key] && cleanupOwner[key] === marker), 'disposal callback slot');
246
+ });
247
+ const layout = Object.freeze({
248
+ deps: shape.deps, tail: shape.tail, next: shape.next,
249
+ sub: shape.sub, dep: shape.dep, gen, linkGen, flags,
250
+ recomputingBit, parent: parentKey, child: childKey, disposal
251
+ });
252
+ // Calibration verifies reciprocal links, child ownership, and that
253
+ // numeric flags/generations are observable at the expected stages.
254
+ if (shape.first[layout.dep] === shape.last[layout.dep] ||
255
+ child[layout.parent] !== parent || parent[layout.child] !== child ||
256
+ probe[layout.gen] !== shape.first[layout.linkGen]) {
257
+ throw new Error('solid-deps: inferred graph failed post-discovery validation');
258
+ }
259
+ return layout;
260
+ }));
261
+ } finally {
262
+ dispose?.();
263
+ }
264
+ }
265
+
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;
280
+ }
281
+
282
+ export function layoutOf(node) {
283
+ const shape = internalLayout(node);
284
+ if (!obj(node) || typeof node[shape.gen] !== 'number' ||
285
+ !(shape.deps in node) || !(shape.tail in node) ||
286
+ typeof node[shape.flags] !== 'number' || !(shape.child in node) ||
287
+ !(shape.disposal in node)) {
288
+ throw new TypeError('Expected a tracking Computed from the current @solidjs/signals runtime, not a plain Owner');
289
+ }
290
+ return shape;
291
+ }
package/src/index.mjs CHANGED
@@ -1 +1 @@
1
- export { attachRead, captureObserverPass, inspectDeps } from './attach-read.mjs';
1
+ export { attachRead, captureOwnerCycle, createScope, inspectDeps, StaleScopeError, DisposedScopeError } from './attach-read.mjs';
package/types/index.d.mts CHANGED
@@ -1,37 +1,60 @@
1
1
  import type { Owner } from '@solidjs/signals';
2
2
 
3
- /** Opaque identity of a specific observer recomputation, captured before await. */
4
- export interface ObserverPass {
5
- readonly __solidDepsObserverPass: unique symbol;
3
+ /** Opaque snapshot of a tracked observer's dependency generation. */
4
+ export interface OwnerCycle {
5
+ readonly __solidDepsOwnerCycle: unique symbol;
6
6
  }
7
7
 
8
- /** Metadata about a synchronous dependency graft. */
9
- export interface AttachReadResult {
10
- /** Newly attached/validated sources, INCLUDING promotions. */
8
+ /** Result of a synchronous dependency graft. */
9
+ export interface AttachReadResult<T> {
10
+ /** The exact callback result, or undefined if the callback was skipped. */
11
+ readonly value: T | undefined;
12
+ /** Sources newly attached or revalidated, including promotions. */
11
13
  readonly added: number;
12
- /** Sources already attached as current validated dependencies. */
14
+ /** Reads already covered by current validated subscriptions. */
13
15
  readonly duplicate: number;
14
- /** Prior-generation links reused; a subset of added. */
16
+ /** Previous-generation links reused; included in added. */
15
17
  readonly promoted: number;
16
- /** Observer already disposed; callback was not invoked. */
18
+ /** Observer disposed; the callback was not run. */
17
19
  readonly disposed: boolean;
18
- /** A newer recomputation superseded the pass; callback was not invoked. */
20
+ /** Observer reran after capture; the callback was not run. */
19
21
  readonly stale: boolean;
20
22
  }
21
23
 
24
+ /** Execute synchronous reads against the captured cycle and return the value. */
25
+ export type Scope = <T>(read: () => T) => T;
26
+
27
+ /** Thrown by a scope when its observer executed again. */
28
+ export declare class StaleScopeError extends Error {
29
+ constructor();
30
+ }
31
+
32
+ /** Thrown by a scope when its observer has been disposed. */
33
+ export declare class DisposedScopeError extends Error {
34
+ constructor();
35
+ }
36
+
37
+ /**
38
+ * Capture the current tracked observer and generation.
39
+ * Explicit owner permits capture outside a tracking callback.
40
+ * Without an owner, requires an active observer (before await).
41
+ */
42
+ export declare function captureOwnerCycle(owner?: Owner | null): OwnerCycle;
43
+
44
+ /**
45
+ * Create a callable dependency scope for a tracked observer cycle.
46
+ * Throws StaleScopeError / DisposedScopeError rather than returning a silent undefined.
47
+ */
48
+ export declare function createScope(owner?: Owner | null): Scope;
49
+
22
50
  /**
23
- * Add dependencies read by a synchronous callback to an existing tracking observer.
24
- * Returns `[retValue, result]` (not `[result, retValue]`).
25
- * If skipped for stale/disposed target, retValue is undefined.
26
- * EXPERIMENTAL: accesses private Solid 2.0.0-rc.17 dependency graph layouts.
51
+ * Graft dependencies read synchronously by `read` onto an existing observer.
52
+ * The callback is skipped when the target is stale or disposed.
27
53
  */
28
54
  export declare function attachRead<T>(
29
- observerOrPass: Owner | ObserverPass | null,
55
+ observerOrCycle: Owner | OwnerCycle | null,
30
56
  read: () => T
31
- ): [retValue: T | undefined, result: AttachReadResult];
32
-
33
- /** Capture a specific recomputation inside a tracking callback (before await). */
34
- export declare function captureObserverPass(observer: Owner | null): ObserverPass;
57
+ ): AttachReadResult<T>;
35
58
 
36
- /** Return source node objects in an observer's dependency chain. Diagnostic only. */
59
+ /** Diagnostic only: get the raw dependency-source nodes for a computation. */
37
60
  export declare function inspectDeps(observer: Owner): unknown[];