@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 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
- Optional compatibility subpaths (`/react`, `/runes`, `/router`, `/server`,
15
- `/shaders`) remain available during the `0.3.x` line.
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.0",
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
- loading.set(true);
52
- status.set("pending");
53
- error.set(undefined);
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
- const value = await fetcher();
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
- error.set(err);
62
- status.set("errored");
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
@@ -7,6 +7,7 @@ import {
7
7
  navigate,
8
8
  getLocation,
9
9
  useLocation,
10
+ useHistory,
10
11
  useParams,
11
12
  useNavigate,
12
13
  useRouter,
@@ -66,6 +67,7 @@ export {
66
67
  navigate,
67
68
  getLocation,
68
69
  useLocation,
70
+ useHistory,
69
71
  useParams,
70
72
  useNavigate,
71
73
  useRouter,
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 (options.staticDir && req.method === "GET" && req.pathname !== "/") {
104
- const staticRes = await tryServeStatic(options.staticDir, req.pathname);
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
- } finally {
38
- batchDepth--;
39
- if (batchDepth === 0 && pending.size > 0) {
40
- const queue = [...pending];
41
- pending.clear();
42
- for (const listener of queue) listener();
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
- for (const listener of Array.from(listeners)) listener();
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
- * Used by `@tschk/moonshine/runes` `effect`.
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(fn: () => void): Trackable[] {
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
- notifyListeners(listeners);
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
- * Derived value that recomputes when tracked dependencies change.
144
- * Any `createSignal` / `createMemo` read during `compute` is tracked.
145
- *
146
- * Recomputation is pull-based: an unread memo with no subscribers costs nothing
147
- * when its sources change, and a memo validates its whole dependency chain
148
- * before answering, so it never reports a half-updated view of the graph.
149
- */
150
- export function createMemo<T>(compute: () => T): Memo<T> {
151
- const listeners = new Set<Listener>();
152
- let deps: Array<{ node: Source; version: number }> = [];
153
- const unsubs: Array<() => void> = [];
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
- function unobserve(): void {
181
- for (const unsub of unsubs.splice(0)) unsub();
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
- function onDepChange(): void {
185
- const before = node.version;
186
- validate();
187
- // Only wake downstream when the derived value actually moved.
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
- function recompute(): void {
192
- const collected: Array<{ node: Source; version: number }> = [];
193
- const seen = new Set<Source>();
194
- const previous = currentTracker;
195
- currentTracker = (dep) => {
196
- if (seen.has(dep)) return;
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
- let next: T;
202
- try {
203
- next = compute();
204
- } finally {
205
- currentTracker = previous;
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
- // Re-subscribing tears down and rebuilds the whole upstream chain, so only
209
- // do it when the dependency set actually moved.
210
- const sameDeps =
211
- collected.length === deps.length &&
212
- collected.every((entry, index) => entry.node === deps[index]!.node);
229
+ let next: T;
230
+ try {
231
+ next = state.compute();
232
+ } finally {
233
+ currentTracker = previous;
234
+ }
213
235
 
214
- deps = collected;
215
- stale = false;
216
- if (listeners.size > 0 && (!sameDeps || unsubs.length === 0)) observe();
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
- if (!hasValue || !Object.is(next, value)) {
219
- value = next;
220
- hasValue = true;
221
- node.version = ++versionCounter;
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
- function validate(): void {
226
- if (!stale && checkedAt === epoch) return;
227
- checkedAt = epoch;
228
- if (!stale) {
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
- recompute();
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
- validate();
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
- validate();
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: Record<string, unknown> = {};
290
- for (const [key, entry] of Object.entries(value)) out[key] = clone(entry);
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
- notifyListeners(listeners);
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
- if (Object.is(prev, value)) return true;
338
- const ok = Reflect.set(obj, prop, value, receiver);
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
+ }
@@ -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";