solid-deps 0.0.2 → 0.0.3
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 +115 -119
- package/package.json +11 -7
- package/src/attach-read.mjs +50 -27
- package/src/discover.mjs +173 -0
- package/src/index.mjs +1 -1
- package/types/index.d.mts +43 -20
package/README.md
CHANGED
|
@@ -1,202 +1,198 @@
|
|
|
1
1
|
# solid-deps
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Track additional dependencies for an existing **Solid 2 computation**, including reads performed after an `await`.
|
|
4
4
|
|
|
5
|
-
`solid-deps`
|
|
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
|
-
##
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install solid-deps
|
|
10
|
+
npm install solid-deps solid-js@2.0.0-rc.17 @solidjs/signals@2.0.0-rc.17
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Or with Bun:
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
bun add solid-deps
|
|
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:**
|
|
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.
|
|
20
20
|
|
|
21
|
-
## Quick start
|
|
21
|
+
## Quick start: `createScope()`
|
|
22
22
|
|
|
23
|
-
Capture the computation
|
|
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
|
|
27
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
await Promise.resolve();
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
52
|
-
|
|
48
|
+
<button onClick={() => setCount(v => v + 1)}>
|
|
49
|
+
Count: {count()}
|
|
53
50
|
</button>
|
|
54
51
|
);
|
|
55
52
|
}
|
|
56
53
|
```
|
|
57
54
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
68
|
+
The callback must be synchronous. Await asynchronous work **before** calling `scope()`, not inside it.
|
|
63
69
|
|
|
64
|
-
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `createScope(owner?)`
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
Create a callable scope that captures an observer and its current dependency generation.
|
|
67
75
|
|
|
68
76
|
```ts
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
- **`result`** — an `AttachReadResult` object describing the attachment.
|
|
85
|
+
A scope call:
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
+
### `captureOwnerCycle(owner?)`
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
Capture an opaque owner-cycle token with the same implicit-or-explicit owner behavior as `createScope`:
|
|
92
95
|
|
|
93
96
|
```ts
|
|
94
|
-
import {
|
|
97
|
+
import { captureOwnerCycle, attachRead } from 'solid-deps';
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
const cycle = captureOwnerCycle();
|
|
100
|
+
await something();
|
|
101
|
+
const result = attachRead(cycle, () => count());
|
|
98
102
|
```
|
|
99
103
|
|
|
100
|
-
|
|
104
|
+
Tokens retain the observer identity and dependency generation at capture time. They do not create a new reactive computation.
|
|
101
105
|
|
|
102
|
-
### `
|
|
106
|
+
### `attachRead(target, callback)`
|
|
103
107
|
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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(
|
|
134
|
+
### `inspectDeps(owner)`
|
|
115
135
|
|
|
116
|
-
|
|
136
|
+
Return the dependency source **node objects** currently linked to the supplied computation, for diagnostics:
|
|
117
137
|
|
|
118
138
|
```ts
|
|
119
|
-
const
|
|
120
|
-
console.log(
|
|
139
|
+
const nodes = inspectDeps(owner);
|
|
140
|
+
console.log(nodes.length);
|
|
121
141
|
```
|
|
122
142
|
|
|
123
|
-
|
|
143
|
+
Do not mutate these private nodes.
|
|
124
144
|
|
|
125
145
|
## TypeScript
|
|
126
146
|
|
|
127
|
-
|
|
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
|
|
150
|
+
import type { Owner } from '@solidjs/signals';
|
|
133
151
|
|
|
134
|
-
export interface
|
|
135
|
-
readonly
|
|
152
|
+
export interface OwnerCycle {
|
|
153
|
+
readonly __solidDepsOwnerCycle: unique symbol;
|
|
136
154
|
}
|
|
137
155
|
|
|
138
|
-
export
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
):
|
|
152
|
-
|
|
153
|
-
export declare
|
|
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
|
-
`
|
|
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
|
-
|
|
180
|
+
## Behavior and limitations
|
|
194
181
|
|
|
195
|
-
|
|
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
|
-
|
|
193
|
+
## Online Solid playground check
|
|
198
194
|
|
|
199
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.3",
|
|
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-
|
|
40
|
-
"test:dev": "node --conditions=browser --conditions=development --test test/test.mjs test/test-async.mjs test/test-
|
|
41
|
-
"test:observe": "node --conditions=browser --conditions=observe --test test/test.mjs test/test-async.mjs test/test-
|
|
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",
|
|
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
|
}
|
package/src/attach-read.mjs
CHANGED
|
@@ -7,22 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createRoot, createReaction, getObserver, isDisposed } from '@solidjs/signals';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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
|
|
47
|
-
export function
|
|
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
|
-
|
|
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
|
|
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(
|
|
60
|
-
const pass =
|
|
61
|
-
const target = pass ? pass.observer :
|
|
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
|
|
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
|
|
67
|
-
if (target[s.flags] &
|
|
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
|
|
97
|
-
if (isDisposed(target)) return
|
|
98
|
-
if ((target[s.flags] &
|
|
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
|
|
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
|
+
}
|
package/src/discover.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover the private linked dependency graph from controlled, disposable
|
|
3
|
+
* Solid computations. No minified or development property names are encoded.
|
|
4
|
+
*
|
|
5
|
+
* This is topology inference, not a guarantee of compatibility with a new
|
|
6
|
+
* algorithm or a property-mangling transform that changes graph semantics.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createRoot, createReaction, createSignal, createMemo,
|
|
10
|
+
getObserver, onCleanup, runWithOwner
|
|
11
|
+
} from '@solidjs/signals';
|
|
12
|
+
|
|
13
|
+
let inferred;
|
|
14
|
+
const own = (obj) => obj && typeof obj === 'object'
|
|
15
|
+
? Reflect.ownKeys(obj).filter(key => Object.prototype.propertyIsEnumerable.call(obj, key)) : [];
|
|
16
|
+
const entries = obj => own(obj).map(key => [key, obj[key]]);
|
|
17
|
+
const obj = value => value !== null && typeof value === 'object';
|
|
18
|
+
const numeric = node => new Map(entries(node).filter(([, value]) => typeof value === 'number'));
|
|
19
|
+
function unique(candidates, role) {
|
|
20
|
+
if (candidates.length !== 1)
|
|
21
|
+
throw new Error(`solid-deps: unable to infer ${role} (found ${candidates.length} candidates); refusing graph mutation`);
|
|
22
|
+
return candidates[0];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pure, name-independent inference used by both calibration and synthetic tests. */
|
|
26
|
+
export function inferGraphTopology(probe) {
|
|
27
|
+
const targets = entries(probe).filter(([, value]) => obj(value));
|
|
28
|
+
const candidates = [];
|
|
29
|
+
for (const [deps, first] of targets) {
|
|
30
|
+
for (const [tail, last] of targets) {
|
|
31
|
+
if (deps === tail || first === last) continue;
|
|
32
|
+
for (const [next, nextValue] of entries(first)) {
|
|
33
|
+
if (nextValue !== last) continue;
|
|
34
|
+
for (const [sub, target] of entries(first)) {
|
|
35
|
+
if (target !== probe || last[sub] !== probe) continue;
|
|
36
|
+
for (const [dep, source1] of entries(first)) {
|
|
37
|
+
const source2 = last[dep];
|
|
38
|
+
if (!obj(source1) || !obj(source2) || source1 === source2 ||
|
|
39
|
+
source1 === probe || source2 === probe) continue;
|
|
40
|
+
// Both source objects contain an edge pointer. This excludes
|
|
41
|
+
// coincidental owner or linked-list references.
|
|
42
|
+
if (!entries(source1).some(([, v]) => v === first) ||
|
|
43
|
+
!entries(source2).some(([, v]) => v === last)) continue;
|
|
44
|
+
candidates.push({ deps, tail, next, sub, dep, first, last });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return unique(candidates, 'dependency head/tail, edge and source links');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function discover() {
|
|
54
|
+
let dispose;
|
|
55
|
+
try {
|
|
56
|
+
// runWithOwner(null) prevents calibration from becoming an owned child of
|
|
57
|
+
// the user's current computation, including during its first render.
|
|
58
|
+
return runWithOwner(null, () => createRoot(d => {
|
|
59
|
+
dispose = d;
|
|
60
|
+
const [a, setA] = createSignal(1);
|
|
61
|
+
const [b] = createSignal(2);
|
|
62
|
+
let probe;
|
|
63
|
+
let during;
|
|
64
|
+
const track = createReaction(() => {});
|
|
65
|
+
track(() => {
|
|
66
|
+
probe = getObserver();
|
|
67
|
+
during = numeric(probe);
|
|
68
|
+
a();
|
|
69
|
+
b();
|
|
70
|
+
});
|
|
71
|
+
if (!obj(probe)) throw new Error('solid-deps: no controlled observer');
|
|
72
|
+
const after = numeric(probe);
|
|
73
|
+
|
|
74
|
+
// Two sources, two edges and one subscriber. Prefer structural identity,
|
|
75
|
+
// not own-key order, and require reciprocation from each source to edge.
|
|
76
|
+
const shape = inferGraphTopology(probe);
|
|
77
|
+
const genCandidates = [];
|
|
78
|
+
for (const [linkGen, val] of numeric(shape.first)) {
|
|
79
|
+
if (shape.last[linkGen] !== val) continue;
|
|
80
|
+
for (const [gen, candidateValue] of after) {
|
|
81
|
+
if (candidateValue === val && candidateValue > 0 &&
|
|
82
|
+
during.get(gen) === candidateValue) genCandidates.push({ gen, linkGen });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Distinct unrelated numeric state may coincidentally equal the link
|
|
86
|
+
// generation. Prefer unique relational evidence; if ambiguous, the
|
|
87
|
+
// generation field immediately following the dependency-tail slot is a
|
|
88
|
+
// secondary structural hint in the currently inspected engine family.
|
|
89
|
+
// Never guess if that hint is also ambiguous.
|
|
90
|
+
const ordered = own(probe);
|
|
91
|
+
const tailOffset = ordered.indexOf(shape.tail);
|
|
92
|
+
const adjacent = genCandidates.filter(candidate =>
|
|
93
|
+
ordered.indexOf(candidate.gen) === tailOffset + 1);
|
|
94
|
+
const { gen, linkGen } = unique(
|
|
95
|
+
genCandidates.length === 1 ? genCandidates : adjacent,
|
|
96
|
+
'dependency generation');
|
|
97
|
+
|
|
98
|
+
// An invalidated reaction changes its scheduler flags without entering
|
|
99
|
+
// its tracking callback. Other numeric fields can mirror the initial
|
|
100
|
+
// recomputing bit; this extra observation disambiguates them.
|
|
101
|
+
runWithOwner(null, () => setA(3));
|
|
102
|
+
const queued = numeric(probe);
|
|
103
|
+
const flagsCandidates = [];
|
|
104
|
+
for (const [key, duringValue] of during) {
|
|
105
|
+
const idle = after.get(key);
|
|
106
|
+
const invalidated = queued.get(key);
|
|
107
|
+
if (!Number.isInteger(duringValue) || !Number.isInteger(idle) ||
|
|
108
|
+
!Number.isInteger(invalidated)) continue;
|
|
109
|
+
const bit = duringValue & ~idle;
|
|
110
|
+
if (bit && invalidated !== idle && key !== gen) flagsCandidates.push({ flags: key, recomputingBit: bit });
|
|
111
|
+
}
|
|
112
|
+
const { flags, recomputingBit } = unique(flagsCandidates, 'recomputing flag');
|
|
113
|
+
|
|
114
|
+
// A fresh owned memo exposes the parent's child slot and the child's
|
|
115
|
+
// owner pointer. A separate cleanup changes the disposal slot.
|
|
116
|
+
let parent;
|
|
117
|
+
let child;
|
|
118
|
+
const lifecycle = createReaction(() => {});
|
|
119
|
+
lifecycle(() => {
|
|
120
|
+
parent = getObserver();
|
|
121
|
+
createMemo(() => { child = getObserver(); return b(); })();
|
|
122
|
+
});
|
|
123
|
+
if (!obj(child)) throw new Error('solid-deps: failed to create owned memo probe');
|
|
124
|
+
const parentKeys = own(child).filter(key => child[key] === parent);
|
|
125
|
+
const parentKey = unique(parentKeys, 'owner parent pointer');
|
|
126
|
+
const childKeys = own(parent).filter(key => parent[key] === child);
|
|
127
|
+
const childKey = unique(childKeys, 'first owned child pointer');
|
|
128
|
+
|
|
129
|
+
let cleanupOwner;
|
|
130
|
+
let disposal;
|
|
131
|
+
const cleanupProbe = createReaction(() => {});
|
|
132
|
+
cleanupProbe(() => {
|
|
133
|
+
cleanupOwner = getObserver();
|
|
134
|
+
const before = new Map(entries(cleanupOwner));
|
|
135
|
+
const marker = () => {};
|
|
136
|
+
onCleanup(marker);
|
|
137
|
+
// Cleanup slot is newly assigned to marker for a fresh reaction.
|
|
138
|
+
disposal = unique(own(cleanupOwner).filter(key =>
|
|
139
|
+
before.get(key) !== cleanupOwner[key] && cleanupOwner[key] === marker), 'disposal callback slot');
|
|
140
|
+
});
|
|
141
|
+
const layout = Object.freeze({
|
|
142
|
+
deps: shape.deps, tail: shape.tail, next: shape.next,
|
|
143
|
+
sub: shape.sub, dep: shape.dep, gen, linkGen, flags,
|
|
144
|
+
recomputingBit, parent: parentKey, child: childKey, disposal
|
|
145
|
+
});
|
|
146
|
+
// Calibration verifies reciprocal links, child ownership, and that
|
|
147
|
+
// numeric flags/generations are observable at the expected stages.
|
|
148
|
+
if (shape.first[layout.dep] === shape.last[layout.dep] ||
|
|
149
|
+
child[layout.parent] !== parent || parent[layout.child] !== child ||
|
|
150
|
+
probe[layout.gen] !== shape.first[layout.linkGen]) {
|
|
151
|
+
throw new Error('solid-deps: inferred graph failed post-discovery validation');
|
|
152
|
+
}
|
|
153
|
+
return layout;
|
|
154
|
+
}));
|
|
155
|
+
} finally {
|
|
156
|
+
dispose?.();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function internalLayout() {
|
|
161
|
+
return inferred ??= discover();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function layoutOf(node) {
|
|
165
|
+
const shape = internalLayout();
|
|
166
|
+
if (!obj(node) || typeof node[shape.gen] !== 'number' ||
|
|
167
|
+
!(shape.deps in node) || !(shape.tail in node) ||
|
|
168
|
+
typeof node[shape.flags] !== 'number' || !(shape.child in node) ||
|
|
169
|
+
!(shape.disposal in node)) {
|
|
170
|
+
throw new TypeError('Expected a tracking Computed from the current @solidjs/signals runtime, not a plain Owner');
|
|
171
|
+
}
|
|
172
|
+
return shape;
|
|
173
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { attachRead,
|
|
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
|
|
4
|
-
export interface
|
|
5
|
-
readonly
|
|
3
|
+
/** Opaque snapshot of a tracked observer's dependency generation. */
|
|
4
|
+
export interface OwnerCycle {
|
|
5
|
+
readonly __solidDepsOwnerCycle: unique symbol;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
export interface AttachReadResult {
|
|
10
|
-
/**
|
|
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
|
-
/**
|
|
14
|
+
/** Reads already covered by current validated subscriptions. */
|
|
13
15
|
readonly duplicate: number;
|
|
14
|
-
/**
|
|
16
|
+
/** Previous-generation links reused; included in added. */
|
|
15
17
|
readonly promoted: number;
|
|
16
|
-
/** Observer
|
|
18
|
+
/** Observer disposed; the callback was not run. */
|
|
17
19
|
readonly disposed: boolean;
|
|
18
|
-
/**
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
55
|
+
observerOrCycle: Owner | OwnerCycle | null,
|
|
30
56
|
read: () => T
|
|
31
|
-
):
|
|
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
|
-
/**
|
|
59
|
+
/** Diagnostic only: get the raw dependency-source nodes for a computation. */
|
|
37
60
|
export declare function inspectDeps(observer: Owner): unknown[];
|