@tschk/moonshine 0.4.0 → 0.4.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 +9 -2
- package/package.json +18 -9
- package/src/index.ts +0 -2
- package/src/resource.ts +25 -12
- package/src/router.ts +2 -0
- package/src/server.ts +11 -2
- package/src/signal.ts +569 -102
- package/src/tracking.ts +10 -0
package/README.md
CHANGED
|
@@ -11,8 +11,15 @@ import { state, derived, effect } from "@tschk/moonshine/runes";
|
|
|
11
11
|
The default export contains only signals, memos, stores, and resources. Router,
|
|
12
12
|
server, compiler, and deployment packages are separate imports.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
Installing the kernel installs no runtime dependencies. Compatibility subpaths
|
|
15
|
+
use optional peers: install `@tschk/moonshine-react`, `@tschk/moonshine-router`,
|
|
16
|
+
`@tschk/moonshine-server`, or `@tschk/moonshine-shaders` when using its matching
|
|
17
|
+
subpath. `/runes` needs no extra package. The React bridge also needs `react` and
|
|
18
|
+
`react-dom`.
|
|
19
|
+
|
|
20
|
+
The opt-in `/tracking` entry exposes dependency collection for native schedulers.
|
|
21
|
+
It can observe first-read versions and preserve an enclosing memo's dependencies;
|
|
22
|
+
it adds no scheduling or rendering behavior to the default kernel.
|
|
16
23
|
|
|
17
24
|
See [Moonshine README](../../README.md), [DESIGN.md](../../DESIGN.md), and
|
|
18
25
|
[docs/MANIFEST.md](../../docs/MANIFEST.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Hyperminimal signal runtime — Bun-first; import /react for TSX apps",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
@@ -11,18 +11,13 @@
|
|
|
11
11
|
"./react": "./src/react.ts",
|
|
12
12
|
"./host-react": "./src/host-react.ts",
|
|
13
13
|
"./runes": "./src/runes.ts",
|
|
14
|
+
"./tracking": "./src/tracking.ts",
|
|
14
15
|
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
15
16
|
"./jsx-dev-runtime": "./src/jsx-runtime.ts",
|
|
16
17
|
"./router": "./src/router.ts",
|
|
17
18
|
"./server": "./src/server.ts",
|
|
18
19
|
"./shaders": "./src/shaders.ts"
|
|
19
20
|
},
|
|
20
|
-
"dependencies": {
|
|
21
|
-
"@tschk/moonshine-react": "^0.3.6",
|
|
22
|
-
"@tschk/moonshine-router": "^0.3.6",
|
|
23
|
-
"@tschk/moonshine-server": "^0.3.6",
|
|
24
|
-
"@tschk/moonshine-shaders": "^0.3.6"
|
|
25
|
-
},
|
|
26
21
|
"scripts": {
|
|
27
22
|
"test": "bun test",
|
|
28
23
|
"typecheck": "bunx tsc --noEmit",
|
|
@@ -30,10 +25,26 @@
|
|
|
30
25
|
"lint": "bunx oxlint ."
|
|
31
26
|
},
|
|
32
27
|
"peerDependencies": {
|
|
28
|
+
"@tschk/moonshine-react": "^0.4.4",
|
|
29
|
+
"@tschk/moonshine-router": "^0.4.4",
|
|
30
|
+
"@tschk/moonshine-server": "^0.4.4",
|
|
31
|
+
"@tschk/moonshine-shaders": "^0.4.4",
|
|
33
32
|
"react": ">=18",
|
|
34
33
|
"react-dom": ">=18"
|
|
35
34
|
},
|
|
36
35
|
"peerDependenciesMeta": {
|
|
36
|
+
"@tschk/moonshine-react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"@tschk/moonshine-router": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"@tschk/moonshine-server": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@tschk/moonshine-shaders": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
37
48
|
"react": {
|
|
38
49
|
"optional": true
|
|
39
50
|
},
|
|
@@ -45,8 +56,6 @@
|
|
|
45
56
|
"@types/bun": "1.3.14",
|
|
46
57
|
"@types/react": "^19.1.0",
|
|
47
58
|
"@types/react-dom": "^19.1.0",
|
|
48
|
-
"react": "19.1.0",
|
|
49
|
-
"react-dom": "19.1.0",
|
|
50
59
|
"typescript": "7.0.2"
|
|
51
60
|
},
|
|
52
61
|
"files": [
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
* import { createApp, useSignal } from "@tschk/moonshine/react"
|
|
7
7
|
* import { state, derived, effect } from "@tschk/moonshine/runes"
|
|
8
8
|
* import { MoonshineRouter } from "@tschk/moonshine/router"
|
|
9
|
-
* import { createMoonshineServer } from "@tschk/moonshine/server"
|
|
10
|
-
* import { useFragmentShader } from "@tschk/moonshine/shaders"
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
11
|
export {
|
package/src/resource.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSignal, type Signal } from "./signal";
|
|
1
|
+
import { batch, createSignal, type Signal } from "./signal";
|
|
2
2
|
|
|
3
3
|
export type ResourceStatus = "pending" | "ready" | "errored";
|
|
4
4
|
|
|
@@ -47,24 +47,37 @@ export function createResource<T>(
|
|
|
47
47
|
options.initial !== undefined ? "ready" : "pending",
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
+
let generation = 0;
|
|
51
|
+
|
|
50
52
|
const refetch = async (): Promise<T | undefined> => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const current = ++generation;
|
|
54
|
+
batch(() => {
|
|
55
|
+
loading.set(true);
|
|
56
|
+
status.set("pending");
|
|
57
|
+
error.set(undefined);
|
|
58
|
+
});
|
|
59
|
+
let value: T;
|
|
54
60
|
try {
|
|
55
|
-
|
|
56
|
-
data.set(() => value);
|
|
57
|
-
status.set("ready");
|
|
58
|
-
return value;
|
|
61
|
+
value = await fetcher();
|
|
59
62
|
} catch (e) {
|
|
63
|
+
if (current !== generation) return data.peek();
|
|
60
64
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
batch(() => {
|
|
66
|
+
error.set(err);
|
|
67
|
+
status.set("errored");
|
|
68
|
+
loading.set(false);
|
|
69
|
+
});
|
|
63
70
|
options.onError?.(err);
|
|
64
71
|
return undefined;
|
|
65
|
-
} finally {
|
|
66
|
-
loading.set(false);
|
|
67
72
|
}
|
|
73
|
+
if (current !== generation) return data.peek();
|
|
74
|
+
// Subscriber failures reject refetch without reclassifying a successful fetch.
|
|
75
|
+
batch(() => {
|
|
76
|
+
data.set(() => value);
|
|
77
|
+
status.set("ready");
|
|
78
|
+
loading.set(false);
|
|
79
|
+
});
|
|
80
|
+
return value;
|
|
68
81
|
};
|
|
69
82
|
|
|
70
83
|
if (options.immediate !== false) {
|
package/src/router.ts
CHANGED
package/src/server.ts
CHANGED
|
@@ -100,8 +100,17 @@ export async function handleMoonshineRequest(
|
|
|
100
100
|
): Promise<Response> {
|
|
101
101
|
const req = toMoonshineRequest(request);
|
|
102
102
|
|
|
103
|
-
if (
|
|
104
|
-
|
|
103
|
+
if (
|
|
104
|
+
options.staticDir &&
|
|
105
|
+
(req.method === "GET" || req.method === "HEAD") &&
|
|
106
|
+
req.pathname !== "/"
|
|
107
|
+
) {
|
|
108
|
+
const staticRes = await tryServeStatic(
|
|
109
|
+
options.staticDir,
|
|
110
|
+
req.pathname,
|
|
111
|
+
undefined,
|
|
112
|
+
request.method,
|
|
113
|
+
);
|
|
105
114
|
if (staticRes) return staticRes;
|
|
106
115
|
}
|
|
107
116
|
|
package/src/signal.ts
CHANGED
|
@@ -3,6 +3,7 @@ type Listener = () => void;
|
|
|
3
3
|
export type Trackable = {
|
|
4
4
|
subscribe: (listener: Listener) => () => void;
|
|
5
5
|
version: number;
|
|
6
|
+
validate?: () => void;
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,6 +16,7 @@ export type Trackable = {
|
|
|
15
16
|
*/
|
|
16
17
|
type Source = Trackable & {
|
|
17
18
|
validate: () => void;
|
|
19
|
+
subscribe: (listener: Listener, request?: InvalidationRequest) => () => void;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -32,16 +34,41 @@ let currentTracker: ((dep: Source) => void) | null = null;
|
|
|
32
34
|
/** Coalesce nested writes into one subscriber flush. */
|
|
33
35
|
export function batch(fn: () => void): void {
|
|
34
36
|
batchDepth++;
|
|
37
|
+
let failed = false;
|
|
38
|
+
let failure: unknown;
|
|
35
39
|
try {
|
|
36
40
|
fn();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
} catch (error) {
|
|
42
|
+
failed = true;
|
|
43
|
+
failure = error;
|
|
44
|
+
}
|
|
45
|
+
batchDepth--;
|
|
46
|
+
if (batchDepth === 0 && pending.size > 0) {
|
|
47
|
+
const queue = [...pending];
|
|
48
|
+
pending.clear();
|
|
49
|
+
try {
|
|
50
|
+
flushListeners(queue);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// A callback failure takes precedence over notification failures.
|
|
53
|
+
if (!failed) failure = error;
|
|
54
|
+
failed = true;
|
|
43
55
|
}
|
|
44
56
|
}
|
|
57
|
+
if (failed) throw failure;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function flushListeners(listeners: Iterable<Listener>): void {
|
|
61
|
+
let failed = false;
|
|
62
|
+
let failure: unknown;
|
|
63
|
+
for (const listener of listeners) {
|
|
64
|
+
try {
|
|
65
|
+
listener();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!failed) failure = error;
|
|
68
|
+
failed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (failed) throw failure;
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
function notifyListeners(listeners: Set<Listener>): void {
|
|
@@ -49,13 +76,18 @@ function notifyListeners(listeners: Set<Listener>): void {
|
|
|
49
76
|
for (const listener of listeners) pending.add(listener);
|
|
50
77
|
return;
|
|
51
78
|
}
|
|
52
|
-
|
|
79
|
+
flushListeners(Array.from(listeners));
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
function track(dep: Source): void {
|
|
56
83
|
currentTracker?.(dep);
|
|
57
84
|
}
|
|
58
85
|
|
|
86
|
+
/** Whether a synchronous dependency collector is currently active. */
|
|
87
|
+
export function isTracking(): boolean {
|
|
88
|
+
return currentTracker !== null;
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
/** Read without subscribing the enclosing memo or effect to what you touch. */
|
|
60
92
|
export function untrack<T>(fn: () => T): T {
|
|
61
93
|
const previous = currentTracker;
|
|
@@ -69,9 +101,14 @@ export function untrack<T>(fn: () => T): T {
|
|
|
69
101
|
|
|
70
102
|
/**
|
|
71
103
|
* Run `fn` while collecting signal/memo/store deps via the active tracker.
|
|
72
|
-
*
|
|
104
|
+
* Opt-in schedulers observe first-read versions; derived values can retain the
|
|
105
|
+
* enclosing memo's graph by propagating dependencies to its tracker.
|
|
73
106
|
*/
|
|
74
|
-
export function collectDeps(
|
|
107
|
+
export function collectDeps(
|
|
108
|
+
fn: () => void,
|
|
109
|
+
onTrack?: (dependency: Trackable) => void,
|
|
110
|
+
propagate = false,
|
|
111
|
+
): Trackable[] {
|
|
75
112
|
const deps: Trackable[] = [];
|
|
76
113
|
const seen = new Set<Trackable>();
|
|
77
114
|
const previous = currentTracker;
|
|
@@ -79,6 +116,8 @@ export function collectDeps(fn: () => void): Trackable[] {
|
|
|
79
116
|
if (seen.has(dep)) return;
|
|
80
117
|
seen.add(dep);
|
|
81
118
|
deps.push(dep);
|
|
119
|
+
if (propagate) previous?.(dep);
|
|
120
|
+
if (onTrack) untrack(() => onTrack(dep));
|
|
82
121
|
};
|
|
83
122
|
try {
|
|
84
123
|
fn();
|
|
@@ -105,7 +144,8 @@ export function createSignal<T>(initial: T): Signal<T> {
|
|
|
105
144
|
const node: Source = {
|
|
106
145
|
version: ++versionCounter,
|
|
107
146
|
validate: () => {},
|
|
108
|
-
subscribe: (listener) => {
|
|
147
|
+
subscribe: (listener: Listener, request?: InvalidationRequest) => {
|
|
148
|
+
if (request) return request.accept(node, undefined, listeners);
|
|
109
149
|
listeners.add(listener);
|
|
110
150
|
return () => {
|
|
111
151
|
listeners.delete(listener);
|
|
@@ -125,7 +165,11 @@ export function createSignal<T>(initial: T): Signal<T> {
|
|
|
125
165
|
value = resolved;
|
|
126
166
|
node.version = ++versionCounter;
|
|
127
167
|
epoch++;
|
|
128
|
-
|
|
168
|
+
if (invalidationRegistry)
|
|
169
|
+
invalidationRegistry.commit(node, node.version, () =>
|
|
170
|
+
notifyListeners(listeners),
|
|
171
|
+
);
|
|
172
|
+
else notifyListeners(listeners);
|
|
129
173
|
};
|
|
130
174
|
read.peek = () => value;
|
|
131
175
|
read.subscribe = node.subscribe;
|
|
@@ -139,95 +183,83 @@ export type Memo<T> = {
|
|
|
139
183
|
subscribe: (listener: Listener) => () => void;
|
|
140
184
|
};
|
|
141
185
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
let value!: T;
|
|
155
|
-
let hasValue = false;
|
|
156
|
-
let stale = true;
|
|
157
|
-
let checkedAt = -1;
|
|
158
|
-
|
|
159
|
-
const node: Source = {
|
|
160
|
-
version: ++versionCounter,
|
|
161
|
-
validate: () => validate(),
|
|
162
|
-
subscribe: (listener) => {
|
|
163
|
-
// Dependencies are only observed while somebody is listening, so the
|
|
164
|
-
// push path exists solely to serve real subscribers.
|
|
165
|
-
validate();
|
|
166
|
-
listeners.add(listener);
|
|
167
|
-
if (listeners.size === 1) observe();
|
|
168
|
-
return () => {
|
|
169
|
-
listeners.delete(listener);
|
|
170
|
-
if (listeners.size === 0) unobserve();
|
|
171
|
-
};
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
function observe(): void {
|
|
176
|
-
for (const unsub of unsubs.splice(0)) unsub();
|
|
177
|
-
for (const dep of deps) unsubs.push(dep.node.subscribe(onDepChange));
|
|
178
|
-
}
|
|
186
|
+
type MemoState<T> = {
|
|
187
|
+
compute: () => T;
|
|
188
|
+
listeners: Set<Listener>;
|
|
189
|
+
deps: Array<{ node: Source; version: number }>;
|
|
190
|
+
unsubs: Array<() => void>;
|
|
191
|
+
value: T;
|
|
192
|
+
hasValue: boolean;
|
|
193
|
+
stale: boolean;
|
|
194
|
+
checkedAt: number;
|
|
195
|
+
node: Source;
|
|
196
|
+
onDepChange: () => void;
|
|
197
|
+
};
|
|
179
198
|
|
|
180
|
-
|
|
181
|
-
|
|
199
|
+
function memoObserve<T>(state: MemoState<T>): void {
|
|
200
|
+
for (const unsub of state.unsubs) unsub();
|
|
201
|
+
state.unsubs.length = 0;
|
|
202
|
+
for (const dep of state.deps) {
|
|
203
|
+
state.unsubs.push(dep.node.subscribe(state.onDepChange));
|
|
182
204
|
}
|
|
205
|
+
}
|
|
183
206
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (node.version !== before) notifyListeners(listeners);
|
|
189
|
-
}
|
|
207
|
+
function memoUnobserve<T>(state: MemoState<T>): void {
|
|
208
|
+
for (const unsub of state.unsubs) unsub();
|
|
209
|
+
state.unsubs.length = 0;
|
|
210
|
+
}
|
|
190
211
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
seen.add(dep);
|
|
198
|
-
collected.push({ node: dep, version: dep.version });
|
|
199
|
-
};
|
|
212
|
+
function memoOnDepChange<T>(state: MemoState<T>): void {
|
|
213
|
+
const before = state.node.version;
|
|
214
|
+
memoValidate(state);
|
|
215
|
+
// Only wake downstream when the derived value actually moved.
|
|
216
|
+
if (state.node.version !== before) notifyListeners(state.listeners);
|
|
217
|
+
}
|
|
200
218
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
219
|
+
function memoRecompute<T>(state: MemoState<T>): void {
|
|
220
|
+
const collected: Array<{ node: Source; version: number }> = [];
|
|
221
|
+
const seen = new Set<Source>();
|
|
222
|
+
const previous = currentTracker;
|
|
223
|
+
currentTracker = (dep) => {
|
|
224
|
+
if (seen.has(dep)) return;
|
|
225
|
+
seen.add(dep);
|
|
226
|
+
collected.push({ node: dep, version: dep.version });
|
|
227
|
+
};
|
|
207
228
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
229
|
+
let next: T;
|
|
230
|
+
try {
|
|
231
|
+
next = state.compute();
|
|
232
|
+
} finally {
|
|
233
|
+
currentTracker = previous;
|
|
234
|
+
}
|
|
213
235
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
236
|
+
// Re-subscribing tears down and rebuilds the whole upstream chain, so only
|
|
237
|
+
// do it when the dependency set actually moved.
|
|
238
|
+
const sameDeps =
|
|
239
|
+
collected.length === state.deps.length &&
|
|
240
|
+
collected.every((entry, index) => entry.node === state.deps[index]!.node);
|
|
241
|
+
|
|
242
|
+
state.deps = collected;
|
|
243
|
+
invalidationRegistry?.dependencies(state.node, collected);
|
|
244
|
+
state.stale = false;
|
|
245
|
+
if (state.listeners.size > 0 && (!sameDeps || state.unsubs.length === 0)) {
|
|
246
|
+
memoObserve(state);
|
|
247
|
+
}
|
|
217
248
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
249
|
+
if (!state.hasValue || !Object.is(next, state.value)) {
|
|
250
|
+
state.value = next;
|
|
251
|
+
state.hasValue = true;
|
|
252
|
+
state.node.version = ++versionCounter;
|
|
223
253
|
}
|
|
254
|
+
}
|
|
224
255
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
256
|
+
function memoValidate<T>(state: MemoState<T>): void {
|
|
257
|
+
if (!state.stale && state.checkedAt === epoch) return;
|
|
258
|
+
state.checkedAt = epoch;
|
|
259
|
+
try {
|
|
260
|
+
if (!state.stale) {
|
|
229
261
|
let changed = false;
|
|
230
|
-
for (const dep of deps) {
|
|
262
|
+
for (const dep of state.deps) {
|
|
231
263
|
dep.node.validate();
|
|
232
264
|
if (dep.node.version !== dep.version) {
|
|
233
265
|
changed = true;
|
|
@@ -236,22 +268,66 @@ export function createMemo<T>(compute: () => T): Memo<T> {
|
|
|
236
268
|
}
|
|
237
269
|
if (!changed) return;
|
|
238
270
|
}
|
|
239
|
-
|
|
271
|
+
memoRecompute(state);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// Failed validation must retry, never serve the old value at this epoch.
|
|
274
|
+
state.checkedAt = -1;
|
|
275
|
+
throw error;
|
|
240
276
|
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Derived value that recomputes when tracked dependencies change.
|
|
281
|
+
* Any `createSignal` / `createMemo` read during `compute` is tracked.
|
|
282
|
+
*
|
|
283
|
+
* Recomputation is pull-based: an unread memo with no subscribers costs nothing
|
|
284
|
+
* when its sources change, and a memo validates its whole dependency chain
|
|
285
|
+
* before answering, so it never reports a half-updated view of the graph.
|
|
286
|
+
*/
|
|
287
|
+
export function createMemo<T>(compute: () => T): Memo<T> {
|
|
288
|
+
const state = {
|
|
289
|
+
compute,
|
|
290
|
+
listeners: new Set<Listener>(),
|
|
291
|
+
deps: [],
|
|
292
|
+
unsubs: [],
|
|
293
|
+
value: undefined as unknown as T,
|
|
294
|
+
hasValue: false,
|
|
295
|
+
stale: true,
|
|
296
|
+
checkedAt: -1,
|
|
297
|
+
} as unknown as MemoState<T>;
|
|
298
|
+
|
|
299
|
+
state.onDepChange = () => memoOnDepChange(state);
|
|
300
|
+
|
|
301
|
+
state.node = {
|
|
302
|
+
version: ++versionCounter,
|
|
303
|
+
validate: () => memoValidate(state),
|
|
304
|
+
subscribe: (listener: Listener, request?: InvalidationRequest) => {
|
|
305
|
+
if (request) return request.accept(state.node, state, state.listeners);
|
|
306
|
+
// Dependencies are only observed while somebody is listening, so the
|
|
307
|
+
// push path exists solely to serve real subscribers.
|
|
308
|
+
memoValidate(state);
|
|
309
|
+
state.listeners.add(listener);
|
|
310
|
+
if (state.listeners.size === 1) memoObserve(state);
|
|
311
|
+
return () => {
|
|
312
|
+
state.listeners.delete(listener);
|
|
313
|
+
if (state.listeners.size === 0) memoUnobserve(state);
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
};
|
|
241
317
|
|
|
242
318
|
const read = (() => {
|
|
243
319
|
// Validate before tracking, so the enclosing memo records the version this
|
|
244
320
|
// node settles on rather than the one it is about to leave behind.
|
|
245
|
-
|
|
246
|
-
track(node);
|
|
247
|
-
return value;
|
|
321
|
+
memoValidate(state);
|
|
322
|
+
track(state.node);
|
|
323
|
+
return state.value;
|
|
248
324
|
}) as Memo<T>;
|
|
249
325
|
|
|
250
326
|
read.peek = () => {
|
|
251
|
-
|
|
252
|
-
return value;
|
|
327
|
+
memoValidate(state);
|
|
328
|
+
return state.value;
|
|
253
329
|
};
|
|
254
|
-
read.subscribe = node.subscribe;
|
|
330
|
+
read.subscribe = state.node.subscribe;
|
|
255
331
|
|
|
256
332
|
return read;
|
|
257
333
|
}
|
|
@@ -286,8 +362,14 @@ function isWrappable(value: unknown): value is object {
|
|
|
286
362
|
function clone<V>(value: V): V {
|
|
287
363
|
if (Array.isArray(value)) return value.map((entry) => clone(entry)) as V;
|
|
288
364
|
if (isWrappable(value)) {
|
|
289
|
-
const out
|
|
290
|
-
for (const
|
|
365
|
+
const out = Object.create(Object.getPrototypeOf(value));
|
|
366
|
+
for (const key of Object.keys(value))
|
|
367
|
+
Object.defineProperty(out, key, {
|
|
368
|
+
value: clone(Reflect.get(value, key)),
|
|
369
|
+
writable: true,
|
|
370
|
+
enumerable: true,
|
|
371
|
+
configurable: true,
|
|
372
|
+
});
|
|
291
373
|
return out as V;
|
|
292
374
|
}
|
|
293
375
|
return value;
|
|
@@ -304,7 +386,8 @@ export function createStore<T extends object>(initial: T): [T, StoreSetter<T>] {
|
|
|
304
386
|
const node: Source = {
|
|
305
387
|
version: ++versionCounter,
|
|
306
388
|
validate: () => {},
|
|
307
|
-
subscribe: (listener) => {
|
|
389
|
+
subscribe: (listener: Listener, request?: InvalidationRequest) => {
|
|
390
|
+
if (request) return request.accept(node, undefined, listeners);
|
|
308
391
|
listeners.add(listener);
|
|
309
392
|
return () => {
|
|
310
393
|
listeners.delete(listener);
|
|
@@ -315,7 +398,11 @@ export function createStore<T extends object>(initial: T): [T, StoreSetter<T>] {
|
|
|
315
398
|
const notify = () => {
|
|
316
399
|
node.version = ++versionCounter;
|
|
317
400
|
epoch++;
|
|
318
|
-
|
|
401
|
+
if (invalidationRegistry)
|
|
402
|
+
invalidationRegistry.commit(node, node.version, () =>
|
|
403
|
+
notifyListeners(listeners),
|
|
404
|
+
);
|
|
405
|
+
else notifyListeners(listeners);
|
|
319
406
|
};
|
|
320
407
|
|
|
321
408
|
// Cached so `store.user === store.user`, which React dependency arrays and
|
|
@@ -334,8 +421,17 @@ export function createStore<T extends object>(initial: T): [T, StoreSetter<T>] {
|
|
|
334
421
|
},
|
|
335
422
|
set(obj, prop, value, receiver) {
|
|
336
423
|
const prev = Reflect.get(obj, prop, receiver);
|
|
337
|
-
|
|
338
|
-
|
|
424
|
+
const own = Object.hasOwn(obj, prop);
|
|
425
|
+
if (own && Object.is(prev, value)) return true;
|
|
426
|
+
const ok =
|
|
427
|
+
prop === "__proto__" && !own
|
|
428
|
+
? Reflect.defineProperty(obj, prop, {
|
|
429
|
+
value,
|
|
430
|
+
writable: true,
|
|
431
|
+
enumerable: true,
|
|
432
|
+
configurable: true,
|
|
433
|
+
})
|
|
434
|
+
: Reflect.set(obj, prop, value, receiver);
|
|
339
435
|
if (ok) notify();
|
|
340
436
|
return ok;
|
|
341
437
|
},
|
|
@@ -361,3 +457,374 @@ export function createStore<T extends object>(initial: T): [T, StoreSetter<T>] {
|
|
|
361
457
|
|
|
362
458
|
return [proxy, setStore];
|
|
363
459
|
}
|
|
460
|
+
|
|
461
|
+
export type InvalidationObserver = {
|
|
462
|
+
invalidate(origin: Trackable, revision: number): void;
|
|
463
|
+
notify(): void;
|
|
464
|
+
demand?: boolean;
|
|
465
|
+
};
|
|
466
|
+
type InvalidationRequest = {
|
|
467
|
+
accepted: boolean;
|
|
468
|
+
accept(
|
|
469
|
+
node: Source,
|
|
470
|
+
memo?: MemoState<unknown>,
|
|
471
|
+
listeners?: ReadonlySet<Listener>,
|
|
472
|
+
): () => void;
|
|
473
|
+
};
|
|
474
|
+
type InvalidationRegistry = {
|
|
475
|
+
subscribe(node: Trackable, observer: InvalidationObserver): () => void;
|
|
476
|
+
dependencies(node: Source, deps: readonly { node: Source }[]): void;
|
|
477
|
+
demand(node: Source): boolean;
|
|
478
|
+
commit(node: Source, revision: number, notify: () => void): void;
|
|
479
|
+
};
|
|
480
|
+
let invalidationRegistry: InvalidationRegistry | undefined;
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Observe input writes without turning memo observation into value demand.
|
|
484
|
+
* Initial observation may validate once. Mark callbacks precede wake/value callbacks;
|
|
485
|
+
* nested writes finish draining by the outermost observed source setter return.
|
|
486
|
+
*/
|
|
487
|
+
export function subscribeInvalidation(
|
|
488
|
+
node: Trackable,
|
|
489
|
+
observer: InvalidationObserver,
|
|
490
|
+
): () => void {
|
|
491
|
+
return (invalidationRegistry ??= createInvalidationRegistry()).subscribe(
|
|
492
|
+
node,
|
|
493
|
+
observer,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function releaseInvalidationStops(stops: Iterable<() => void>): void {
|
|
498
|
+
let bad = false,
|
|
499
|
+
error: unknown;
|
|
500
|
+
for (const stop of stops)
|
|
501
|
+
try {
|
|
502
|
+
stop();
|
|
503
|
+
} catch (next) {
|
|
504
|
+
if (!bad) error = next;
|
|
505
|
+
bad = true;
|
|
506
|
+
}
|
|
507
|
+
if (bad) throw error;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function createInvalidationRegistry(): InvalidationRegistry {
|
|
511
|
+
type Registration = { active: boolean; observer: InvalidationObserver };
|
|
512
|
+
type Entry = {
|
|
513
|
+
node: Source;
|
|
514
|
+
memo?: MemoState<unknown>;
|
|
515
|
+
listeners?: ReadonlySet<Listener>;
|
|
516
|
+
active: boolean;
|
|
517
|
+
observers: Set<Registration>;
|
|
518
|
+
parents: Set<Entry>;
|
|
519
|
+
stops: Map<Source, () => void>;
|
|
520
|
+
};
|
|
521
|
+
const entries = new WeakMap<Source, Entry>();
|
|
522
|
+
const origins: Array<{
|
|
523
|
+
node: Source;
|
|
524
|
+
revision: number;
|
|
525
|
+
notify: () => void;
|
|
526
|
+
generation: number;
|
|
527
|
+
}> = [];
|
|
528
|
+
const wakes = new Map<Registration, number>();
|
|
529
|
+
const values: Array<{ notify: () => void; generation: number }> = [];
|
|
530
|
+
// Sibling writes from one callback share a generation. Only a later callback
|
|
531
|
+
// advances causal depth, so wide graphs are not mistaken for feedback.
|
|
532
|
+
let currentGeneration = 0;
|
|
533
|
+
let running = false,
|
|
534
|
+
marking = false,
|
|
535
|
+
failed = false;
|
|
536
|
+
let failure: unknown;
|
|
537
|
+
function capture(fn: () => void, generation = currentGeneration): void {
|
|
538
|
+
const previous = currentGeneration;
|
|
539
|
+
currentGeneration = generation;
|
|
540
|
+
try {
|
|
541
|
+
fn();
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (!failed) failure = error;
|
|
544
|
+
failed = true;
|
|
545
|
+
} finally {
|
|
546
|
+
currentGeneration = previous;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function inactive(entry: Entry): void {
|
|
550
|
+
if (entry.observers.size || entry.parents.size || !entry.active) return;
|
|
551
|
+
entry.active = false;
|
|
552
|
+
const stops = entry.stops;
|
|
553
|
+
entry.stops = new Map();
|
|
554
|
+
releaseInvalidationStops(stops.values());
|
|
555
|
+
}
|
|
556
|
+
function entryFor(
|
|
557
|
+
node: Source,
|
|
558
|
+
memo?: MemoState<unknown>,
|
|
559
|
+
listeners?: ReadonlySet<Listener>,
|
|
560
|
+
): Entry {
|
|
561
|
+
let entry = entries.get(node);
|
|
562
|
+
if (!entry)
|
|
563
|
+
entries.set(
|
|
564
|
+
node,
|
|
565
|
+
(entry = {
|
|
566
|
+
node,
|
|
567
|
+
memo,
|
|
568
|
+
listeners,
|
|
569
|
+
active: false,
|
|
570
|
+
observers: new Set(),
|
|
571
|
+
parents: new Set(),
|
|
572
|
+
stops: new Map(),
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
return entry;
|
|
576
|
+
}
|
|
577
|
+
function invoke(
|
|
578
|
+
node: Trackable,
|
|
579
|
+
accept: InvalidationRequest["accept"],
|
|
580
|
+
): () => void {
|
|
581
|
+
const request: InvalidationRequest = {
|
|
582
|
+
accepted: false,
|
|
583
|
+
accept(source, memo, listeners) {
|
|
584
|
+
request.accepted = true;
|
|
585
|
+
return accept(source, memo, listeners);
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
const stop = (node as Source).subscribe(() => {}, request);
|
|
589
|
+
if (!request.accepted) {
|
|
590
|
+
const error = new Error(
|
|
591
|
+
"Trackable does not support invalidation observation",
|
|
592
|
+
);
|
|
593
|
+
try {
|
|
594
|
+
stop();
|
|
595
|
+
} catch {
|
|
596
|
+
/* The unsupported protocol error remains primary. */
|
|
597
|
+
}
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
return stop;
|
|
601
|
+
}
|
|
602
|
+
function activate(entry: Entry): void {
|
|
603
|
+
if (entry.active) return;
|
|
604
|
+
entry.active = true;
|
|
605
|
+
try {
|
|
606
|
+
if (entry.memo) sync(entry, entry.memo.deps);
|
|
607
|
+
} catch (error) {
|
|
608
|
+
entry.active = false;
|
|
609
|
+
const stops = entry.stops;
|
|
610
|
+
entry.stops = new Map();
|
|
611
|
+
try {
|
|
612
|
+
releaseInvalidationStops(stops.values());
|
|
613
|
+
} catch {
|
|
614
|
+
/* Preserve the activation error. */
|
|
615
|
+
}
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function link(node: Source, parent: Entry): () => void {
|
|
620
|
+
return invoke(node, (source, memo, listeners) => {
|
|
621
|
+
if (memo && !memo.hasValue) memoValidate(memo);
|
|
622
|
+
const entry = entryFor(source, memo, listeners);
|
|
623
|
+
entry.parents.add(parent);
|
|
624
|
+
try {
|
|
625
|
+
activate(entry);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
entry.parents.delete(parent);
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
let active = true;
|
|
631
|
+
return () => {
|
|
632
|
+
if (!active) return;
|
|
633
|
+
active = false;
|
|
634
|
+
entry.parents.delete(parent);
|
|
635
|
+
inactive(entry);
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
function sync(entry: Entry, deps: readonly { node: Source }[]): void {
|
|
640
|
+
const next = new Map<Source, () => void>();
|
|
641
|
+
try {
|
|
642
|
+
for (const dep of deps)
|
|
643
|
+
if (!next.has(dep.node))
|
|
644
|
+
next.set(
|
|
645
|
+
dep.node,
|
|
646
|
+
entry.stops.get(dep.node) ?? link(dep.node, entry),
|
|
647
|
+
);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
try {
|
|
650
|
+
releaseInvalidationStops(
|
|
651
|
+
[...next]
|
|
652
|
+
.filter(([node]) => !entry.stops.has(node))
|
|
653
|
+
.map(([, stop]) => stop),
|
|
654
|
+
);
|
|
655
|
+
} catch {
|
|
656
|
+
/* Preserve the connection error. */
|
|
657
|
+
}
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
const old = entry.stops;
|
|
661
|
+
entry.stops = next;
|
|
662
|
+
releaseInvalidationStops(
|
|
663
|
+
[...old].filter(([node]) => !next.has(node)).map(([, stop]) => stop),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
function mark(): void {
|
|
667
|
+
if (marking) return;
|
|
668
|
+
marking = true;
|
|
669
|
+
try {
|
|
670
|
+
while (origins.length) {
|
|
671
|
+
const origin = origins.shift()!;
|
|
672
|
+
values.push({ notify: origin.notify, generation: origin.generation });
|
|
673
|
+
const first = entries.get(origin.node);
|
|
674
|
+
if (!first) continue;
|
|
675
|
+
const frontier = [first],
|
|
676
|
+
visited = new Set<Entry>();
|
|
677
|
+
while (frontier.length) {
|
|
678
|
+
const entry = frontier.pop()!;
|
|
679
|
+
if (visited.has(entry) || !entry.active) continue;
|
|
680
|
+
visited.add(entry);
|
|
681
|
+
frontier.push(...entry.parents);
|
|
682
|
+
// Snapshot registrations: callbacks may dispose or add observations.
|
|
683
|
+
const registrations = Array.from(entry.observers);
|
|
684
|
+
for (const registration of registrations)
|
|
685
|
+
if (registration.active) {
|
|
686
|
+
capture(
|
|
687
|
+
() =>
|
|
688
|
+
registration.observer.invalidate(
|
|
689
|
+
origin.node,
|
|
690
|
+
origin.revision,
|
|
691
|
+
),
|
|
692
|
+
origin.generation,
|
|
693
|
+
);
|
|
694
|
+
if (registration.active)
|
|
695
|
+
wakes.set(
|
|
696
|
+
registration,
|
|
697
|
+
Math.max(wakes.get(registration) ?? 0, origin.generation),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} finally {
|
|
703
|
+
marking = false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
subscribe(node, observer) {
|
|
708
|
+
return invoke(node, (source, memo, listeners) => {
|
|
709
|
+
if (memo) memoValidate(memo);
|
|
710
|
+
const entry = entryFor(source, memo, listeners);
|
|
711
|
+
const registration: Registration = { active: true, observer };
|
|
712
|
+
entry.observers.add(registration);
|
|
713
|
+
try {
|
|
714
|
+
activate(entry);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
entry.observers.delete(registration);
|
|
717
|
+
registration.active = false;
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
return () => {
|
|
721
|
+
if (!registration.active) return;
|
|
722
|
+
registration.active = false;
|
|
723
|
+
entry.observers.delete(registration);
|
|
724
|
+
wakes.delete(registration);
|
|
725
|
+
inactive(entry);
|
|
726
|
+
};
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
demand(node) {
|
|
730
|
+
const entry = entries.get(node);
|
|
731
|
+
if (!entry) return false;
|
|
732
|
+
const frontier = [entry],
|
|
733
|
+
visited = new Set<Entry>();
|
|
734
|
+
while (frontier.length) {
|
|
735
|
+
const current = frontier.pop()!;
|
|
736
|
+
if (visited.has(current) || !current.active) continue;
|
|
737
|
+
visited.add(current);
|
|
738
|
+
if (
|
|
739
|
+
current.listeners?.size ||
|
|
740
|
+
[...current.observers].some(
|
|
741
|
+
({ active, observer }) => active && observer.demand !== false,
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
return true;
|
|
745
|
+
frontier.push(...current.parents);
|
|
746
|
+
}
|
|
747
|
+
return false;
|
|
748
|
+
},
|
|
749
|
+
dependencies(node, deps) {
|
|
750
|
+
const entry = entries.get(node);
|
|
751
|
+
if (entry?.active) sync(entry, deps);
|
|
752
|
+
},
|
|
753
|
+
commit(node, revision, notify) {
|
|
754
|
+
if (!running && !entries.get(node)?.active) {
|
|
755
|
+
notify();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const generation = running ? currentGeneration + 1 : 1;
|
|
759
|
+
if (generation > 1000) {
|
|
760
|
+
capture(() => {
|
|
761
|
+
throw new Error("Invalidation update limit exceeded");
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
origins.push({ node, revision, notify, generation });
|
|
766
|
+
if (running) {
|
|
767
|
+
mark();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
running = true;
|
|
771
|
+
failed = false;
|
|
772
|
+
failure = undefined;
|
|
773
|
+
try {
|
|
774
|
+
while (origins.length || wakes.size || values.length) {
|
|
775
|
+
mark();
|
|
776
|
+
const wake = wakes.entries().next().value as
|
|
777
|
+
[Registration, number] | undefined;
|
|
778
|
+
if (wake) {
|
|
779
|
+
const [registration, wakeGeneration] = wake;
|
|
780
|
+
wakes.delete(registration);
|
|
781
|
+
if (registration.active)
|
|
782
|
+
capture(() => registration.observer.notify(), wakeGeneration);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const value = values.shift();
|
|
786
|
+
if (value) capture(value.notify, value.generation);
|
|
787
|
+
}
|
|
788
|
+
} catch (error) {
|
|
789
|
+
capture(() => {
|
|
790
|
+
throw error;
|
|
791
|
+
});
|
|
792
|
+
} finally {
|
|
793
|
+
running = false;
|
|
794
|
+
origins.length = values.length = 0;
|
|
795
|
+
wakes.clear();
|
|
796
|
+
currentGeneration = 0;
|
|
797
|
+
}
|
|
798
|
+
if (failed) throw failure;
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export { track as trackDependency };
|
|
804
|
+
|
|
805
|
+
/** Query native value-consumer reachability without validating or registering an observer. */
|
|
806
|
+
export function hasValueDemand(node: Trackable): boolean {
|
|
807
|
+
let demand = false;
|
|
808
|
+
const request: InvalidationRequest = {
|
|
809
|
+
accepted: false,
|
|
810
|
+
accept(source, memo, listeners) {
|
|
811
|
+
request.accepted = true;
|
|
812
|
+
demand =
|
|
813
|
+
!!listeners?.size ||
|
|
814
|
+
!!memo?.listeners.size ||
|
|
815
|
+
(invalidationRegistry?.demand(source) ?? false);
|
|
816
|
+
return () => {};
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
const stop = (node as Source).subscribe(() => {}, request);
|
|
820
|
+
if (!request.accepted) {
|
|
821
|
+
const error = new Error("Trackable does not support demand inspection");
|
|
822
|
+
try {
|
|
823
|
+
stop();
|
|
824
|
+
} catch {
|
|
825
|
+
/* The unsupported protocol error remains primary. */
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
return demand;
|
|
830
|
+
}
|
package/src/tracking.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Dependency collection for opt-in schedulers; no renderer or scheduling policy. */
|
|
2
|
+
export { collectDeps, isTracking } from "./signal";
|
|
3
|
+
export type { Trackable } from "./signal";
|
|
4
|
+
|
|
5
|
+
export { subscribeInvalidation } from "./signal";
|
|
6
|
+
export type { InvalidationObserver } from "./signal";
|
|
7
|
+
|
|
8
|
+
export { trackDependency } from "./signal";
|
|
9
|
+
|
|
10
|
+
export { hasValueDemand } from "./signal";
|