@praxisjs/core 2.1.0 → 2.3.0

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/component/internals.d.ts +12 -2
  3. package/dist/component/internals.d.ts.map +1 -1
  4. package/dist/component/internals.js +25 -7
  5. package/dist/component/internals.js.map +1 -1
  6. package/dist/internal.d.ts +3 -2
  7. package/dist/internal.d.ts.map +1 -1
  8. package/dist/internal.js +3 -2
  9. package/dist/internal.js.map +1 -1
  10. package/dist/reactive/history.d.ts +2 -0
  11. package/dist/reactive/history.d.ts.map +1 -1
  12. package/dist/reactive/history.js +2 -1
  13. package/dist/reactive/history.js.map +1 -1
  14. package/dist/signal/batch.d.ts +1 -3
  15. package/dist/signal/batch.d.ts.map +1 -1
  16. package/dist/signal/batch.js +4 -16
  17. package/dist/signal/batch.js.map +1 -1
  18. package/dist/signal/computed.d.ts.map +1 -1
  19. package/dist/signal/computed.js +19 -61
  20. package/dist/signal/computed.js.map +1 -1
  21. package/dist/signal/effect.d.ts +6 -12
  22. package/dist/signal/effect.d.ts.map +1 -1
  23. package/dist/signal/effect.js +11 -74
  24. package/dist/signal/effect.js.map +1 -1
  25. package/dist/signal/graph.d.ts +111 -0
  26. package/dist/signal/graph.d.ts.map +1 -0
  27. package/dist/signal/graph.js +513 -0
  28. package/dist/signal/graph.js.map +1 -0
  29. package/dist/signal/index.d.ts +2 -1
  30. package/dist/signal/index.d.ts.map +1 -1
  31. package/dist/signal/index.js +1 -0
  32. package/dist/signal/index.js.map +1 -1
  33. package/dist/signal/peek.d.ts.map +1 -1
  34. package/dist/signal/peek.js +2 -9
  35. package/dist/signal/peek.js.map +1 -1
  36. package/dist/signal/persisted.d.ts +11 -1
  37. package/dist/signal/persisted.d.ts.map +1 -1
  38. package/dist/signal/persisted.js +39 -5
  39. package/dist/signal/persisted.js.map +1 -1
  40. package/dist/signal/selector.d.ts +5 -0
  41. package/dist/signal/selector.d.ts.map +1 -0
  42. package/dist/signal/selector.js +128 -0
  43. package/dist/signal/selector.js.map +1 -0
  44. package/dist/signal/signal.d.ts +0 -5
  45. package/dist/signal/signal.d.ts.map +1 -1
  46. package/dist/signal/signal.js +11 -81
  47. package/dist/signal/signal.js.map +1 -1
  48. package/package.json +13 -4
  49. package/src/component/internals.ts +35 -10
  50. package/src/internal.ts +27 -0
  51. package/src/reactive/history.ts +4 -1
  52. package/src/signal/batch.ts +5 -19
  53. package/src/signal/computed.ts +20 -63
  54. package/src/signal/effect.ts +12 -83
  55. package/src/signal/graph.ts +562 -0
  56. package/src/signal/index.ts +2 -1
  57. package/src/signal/peek.ts +2 -8
  58. package/src/signal/persisted.ts +54 -5
  59. package/src/signal/selector.ts +140 -0
  60. package/src/signal/signal.ts +11 -79
  61. package/dist/__tests__/batch.test.d.ts +0 -2
  62. package/dist/__tests__/batch.test.d.ts.map +0 -1
  63. package/dist/__tests__/batch.test.js +0 -73
  64. package/dist/__tests__/batch.test.js.map +0 -1
  65. package/dist/__tests__/component.test.d.ts +0 -2
  66. package/dist/__tests__/component.test.d.ts.map +0 -1
  67. package/dist/__tests__/component.test.js +0 -124
  68. package/dist/__tests__/component.test.js.map +0 -1
  69. package/dist/__tests__/computed.test.d.ts +0 -2
  70. package/dist/__tests__/computed.test.d.ts.map +0 -1
  71. package/dist/__tests__/computed.test.js +0 -252
  72. package/dist/__tests__/computed.test.js.map +0 -1
  73. package/dist/__tests__/effect.test.d.ts +0 -2
  74. package/dist/__tests__/effect.test.d.ts.map +0 -1
  75. package/dist/__tests__/effect.test.js +0 -189
  76. package/dist/__tests__/effect.test.js.map +0 -1
  77. package/dist/__tests__/peek.test.d.ts +0 -2
  78. package/dist/__tests__/peek.test.d.ts.map +0 -1
  79. package/dist/__tests__/peek.test.js +0 -26
  80. package/dist/__tests__/peek.test.js.map +0 -1
  81. package/dist/__tests__/persisted-signal.test.d.ts +0 -2
  82. package/dist/__tests__/persisted-signal.test.d.ts.map +0 -1
  83. package/dist/__tests__/persisted-signal.test.js +0 -156
  84. package/dist/__tests__/persisted-signal.test.js.map +0 -1
  85. package/dist/__tests__/reactive.test.d.ts +0 -2
  86. package/dist/__tests__/reactive.test.d.ts.map +0 -1
  87. package/dist/__tests__/reactive.test.js +0 -264
  88. package/dist/__tests__/reactive.test.js.map +0 -1
  89. package/dist/__tests__/resource-focus.test.d.ts +0 -2
  90. package/dist/__tests__/resource-focus.test.d.ts.map +0 -1
  91. package/dist/__tests__/resource-focus.test.js +0 -50
  92. package/dist/__tests__/resource-focus.test.js.map +0 -1
  93. package/dist/__tests__/resource-server-pass.test.d.ts +0 -2
  94. package/dist/__tests__/resource-server-pass.test.d.ts.map +0 -1
  95. package/dist/__tests__/resource-server-pass.test.js +0 -78
  96. package/dist/__tests__/resource-server-pass.test.js.map +0 -1
  97. package/dist/__tests__/resource.test.d.ts +0 -2
  98. package/dist/__tests__/resource.test.d.ts.map +0 -1
  99. package/dist/__tests__/resource.test.js +0 -336
  100. package/dist/__tests__/resource.test.js.map +0 -1
  101. package/dist/__tests__/signal.test.d.ts +0 -2
  102. package/dist/__tests__/signal.test.d.ts.map +0 -1
  103. package/dist/__tests__/signal.test.js +0 -186
  104. package/dist/__tests__/signal.test.js.map +0 -1
  105. package/dist/__tests__/synced.test.d.ts +0 -2
  106. package/dist/__tests__/synced.test.d.ts.map +0 -1
  107. package/dist/__tests__/synced.test.js +0 -142
  108. package/dist/__tests__/synced.test.js.map +0 -1
  109. package/dist/__tests__/track.test.d.ts +0 -2
  110. package/dist/__tests__/track.test.d.ts.map +0 -1
  111. package/dist/__tests__/track.test.js +0 -54
  112. package/dist/__tests__/track.test.js.map +0 -1
  113. package/src/__tests__/batch.test.ts +0 -87
  114. package/src/__tests__/component.test.ts +0 -165
  115. package/src/__tests__/computed.test.ts +0 -290
  116. package/src/__tests__/effect.test.ts +0 -226
  117. package/src/__tests__/peek.test.ts +0 -31
  118. package/src/__tests__/persisted-signal.test.ts +0 -194
  119. package/src/__tests__/reactive.test.ts +0 -305
  120. package/src/__tests__/resource-focus.test.ts +0 -61
  121. package/src/__tests__/resource-server-pass.test.ts +0 -90
  122. package/src/__tests__/resource.test.ts +0 -421
  123. package/src/__tests__/signal.test.ts +0 -213
  124. package/src/__tests__/synced.test.ts +0 -173
  125. package/src/__tests__/track.test.ts +0 -61
  126. package/tsconfig.json +0 -8
@@ -0,0 +1,562 @@
1
+ // Push-pull reactive graph. Sources (signals, computeds, selector buckets) keep a doubly linked
2
+ // list of subscriber links, subscribers (computeds, effects) keep a singly linked list of the
3
+ // same links in read order. A re-run walks its previous links in order and reuses every link
4
+ // whose source is read again in the same position, so a binding that reads the same sources on
5
+ // every run allocates nothing and never unsubscribes. Writes push DIRTY to direct subscribers
6
+ // and PENDING further down; reads pull, recomputing a computed only when one of its sources
7
+ // actually changed (tracked with per-node versions).
8
+
9
+ export const DIRTY = 1;
10
+ export const PENDING = 2;
11
+ export const QUEUED = 4;
12
+ export const RUNNING = 8;
13
+ export const DISPOSED = 16;
14
+ export const COMPUTED = 32;
15
+ export const EFFECT = 64;
16
+ export const HAS_ERROR = 128;
17
+ /** The node is a WatchedNode: it wants to know when its last subscriber goes away. */
18
+ export const WATCHED = 256;
19
+ /** An effect waiting for the deferred pass (see `defer`). */
20
+ export const DEFERRED = 512;
21
+
22
+ // Fields are declared with `declare` and assigned in the constructors: class field initializers
23
+ // define each property one by one, which makes these objects (created by the thousand per render)
24
+ // about twice as slow to allocate.
25
+
26
+ export class Link {
27
+ declare dep: ReactiveNode;
28
+ declare sub: ReactiveNode;
29
+ declare version: number;
30
+ declare nextDep: Link | undefined;
31
+ declare prevSub: Link | undefined;
32
+ declare nextSub: Link | undefined;
33
+
34
+ constructor(dep: ReactiveNode, sub: ReactiveNode, nextDep: Link | undefined, prevSub: Link | undefined) {
35
+ this.dep = dep;
36
+ this.sub = sub;
37
+ this.version = dep.version;
38
+ this.nextDep = nextDep;
39
+ this.prevSub = prevSub;
40
+ this.nextSub = undefined;
41
+ }
42
+ }
43
+
44
+ export class ReactiveNode {
45
+ declare flags: number;
46
+ declare version: number;
47
+ declare subs: Link | undefined;
48
+ declare subsTail: Link | undefined;
49
+ declare deps: Link | undefined;
50
+ declare depsTail: Link | undefined;
51
+
52
+ constructor(flags: number) {
53
+ this.flags = flags;
54
+ this.version = 0;
55
+ this.subs = undefined;
56
+ this.subsTail = undefined;
57
+ this.deps = undefined;
58
+ this.depsTail = undefined;
59
+ }
60
+ }
61
+
62
+ export abstract class WatchedNode extends ReactiveNode {
63
+ constructor() {
64
+ super(WATCHED);
65
+ }
66
+
67
+ abstract unwatched(): void;
68
+ }
69
+
70
+ export class SignalNode<T = unknown> extends ReactiveNode {
71
+ declare value: T;
72
+
73
+ constructor(value: T) {
74
+ super(0);
75
+ this.value = value;
76
+ }
77
+ }
78
+
79
+ export class ComputedNode<T = unknown> extends ReactiveNode {
80
+ declare value: T | undefined;
81
+ declare error: unknown;
82
+ declare getter: () => T;
83
+
84
+ constructor(getter: () => T) {
85
+ super(COMPUTED | DIRTY);
86
+ this.value = undefined;
87
+ this.error = undefined;
88
+ this.getter = getter;
89
+ }
90
+ }
91
+
92
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
93
+ export type EffectCleanup = (() => void) | void;
94
+
95
+ // Method syntax keeps `this` bivariant, so an EffectNode<Ctx> is still an EffectNode to code that
96
+ // handles effects of every kind (disposal, scheduling).
97
+ type EffectFn<Ctx> = { run(this: EffectNode<Ctx>): EffectCleanup }["run"];
98
+
99
+ export class EffectNode<Ctx = unknown> extends ReactiveNode {
100
+ /** Called as a method, so `this` is the node: a function shared by many effects reads `ctx`. */
101
+ declare fn: EffectFn<Ctx>;
102
+ declare cleanup: EffectCleanup;
103
+ /** What was rendering when the effect was created (see setCurrentOwner), for tooling. */
104
+ declare owner: object | undefined;
105
+ /** State of an effect whose `fn` is shared, kept here instead of in a closure per effect. */
106
+ declare ctx: Ctx;
107
+
108
+ constructor(fn: EffectFn<Ctx>, owner?: object, ctx?: Ctx) {
109
+ super(EFFECT);
110
+ this.fn = fn;
111
+ this.cleanup = undefined;
112
+ this.owner = owner;
113
+ this.ctx = ctx as Ctx;
114
+ }
115
+ }
116
+
117
+ // ── Shape keepers ───────────────────────────────────────────────────────────
118
+
119
+ // V8 holds map transitions weakly: once every instance of a class is collected (a list cleared
120
+ // right before a full GC), the hidden class its constructor builds dies with them, and the
121
+ // optimized code and inline caches that embedded it are thrown away, so the next render runs cold.
122
+ // One inert instance of each hot class, never linked into a graph, keeps those shapes alive. Its
123
+ // fields get the same assignments, in the same order, as real instances; values a user may supply
124
+ // start as undefined, which no later value forces off the shared map (a number would, for one).
125
+ // Stored with Object.defineProperty because bundlers drop a module-level instance nothing reads.
126
+
127
+ function noop(): void {
128
+ // The keepers never run: their functions only fill the field.
129
+ }
130
+
131
+ const signalKeeper = new SignalNode(undefined);
132
+ const effectKeeper = new EffectNode(noop);
133
+ Object.defineProperty(SignalNode, "shapeKeeper", { value: signalKeeper });
134
+ Object.defineProperty(ComputedNode, "shapeKeeper", { value: new ComputedNode(noop) });
135
+ Object.defineProperty(EffectNode, "shapeKeeper", { value: effectKeeper });
136
+ Object.defineProperty(Link, "shapeKeeper", { value: new Link(signalKeeper, effectKeeper, undefined, undefined) });
137
+
138
+ export let activeSub: ReactiveNode | undefined = undefined;
139
+
140
+ // ── Instrumentation ─────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Receives what the graph does, for tooling (devtools profiling). While none is set the graph pays
144
+ * one flag check per write, computed run and effect run, and production bundles of an app that
145
+ * never sets one none at all.
146
+ */
147
+ export interface ReactiveObserver {
148
+ /** A signal (or selector bucket) was written and its subscribers marked. */
149
+ write?(node: ReactiveNode): void;
150
+ /** A computed recomputed, taking `ms`. */
151
+ compute?(node: ComputedNode, ms: number): void;
152
+ /** An effect ran, taking `ms`; `owner` is what was rendering when it was created, if known. */
153
+ effect?(node: EffectNode, ms: number, owner: object | undefined): void;
154
+ }
155
+
156
+ let observer: ReactiveObserver | null = null;
157
+ // The checks read this flag, not `observer`: minifiers fold a boolean that nothing ever sets to true
158
+ // (no observer is installed in an app without devtools), which drops the instrumentation from
159
+ // production bundles. A null check is not folded.
160
+ let observing = false;
161
+ let currentOwner: object | undefined = undefined;
162
+
163
+ /** Installs an observer (null removes it) and returns the previous one. */
164
+ export function setReactiveObserver(next: ReactiveObserver | null): ReactiveObserver | null {
165
+ const prev = observer;
166
+ observer = next;
167
+ observing = next !== null;
168
+ return prev;
169
+ }
170
+
171
+ /**
172
+ * Marks what is being rendered, so effects created meanwhile are attributed to it, whether or not
173
+ * an observer is installed yet. Returns the previous owner, to restore afterwards.
174
+ */
175
+ export function setCurrentOwner(owner: object | undefined): object | undefined {
176
+ const prev = currentOwner;
177
+ currentOwner = owner;
178
+ return prev;
179
+ }
180
+
181
+ export function getEffectOwner(node: EffectNode): object | undefined {
182
+ return node.owner;
183
+ }
184
+
185
+ export function setActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
186
+ const prev = activeSub;
187
+ activeSub = sub;
188
+ return prev;
189
+ }
190
+
191
+ /** Stops dependency tracking until `resumeTracking(prev)`; the closure-free form of untrack(). */
192
+ export function pauseTracking(): ReactiveNode | undefined {
193
+ return setActiveSub(undefined);
194
+ }
195
+
196
+ export function resumeTracking(prev: ReactiveNode | undefined): void {
197
+ activeSub = prev;
198
+ }
199
+
200
+ // ── Tracking ────────────────────────────────────────────────────────────────
201
+
202
+ export function track(dep: ReactiveNode): void {
203
+ const sub = activeSub;
204
+ if (sub === undefined) return;
205
+ const prevDep = sub.depsTail;
206
+ if (prevDep?.dep === dep) {
207
+ prevDep.version = dep.version;
208
+ return;
209
+ }
210
+ const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps;
211
+ if (nextDep?.dep === dep) {
212
+ nextDep.version = dep.version;
213
+ sub.depsTail = nextDep;
214
+ return;
215
+ }
216
+ // Read again out of order within the same run: the link already exists among the ones kept
217
+ // so far, so another one would only notify the subscriber twice.
218
+ const depsTail = dep.subsTail;
219
+ if (depsTail?.sub === sub && isTrackedLink(depsTail, sub)) {
220
+ depsTail.version = dep.version;
221
+ return;
222
+ }
223
+ const link = new Link(dep, sub, nextDep, dep.subsTail);
224
+ if (prevDep !== undefined) prevDep.nextDep = link;
225
+ else sub.deps = link;
226
+ sub.depsTail = link;
227
+ if (dep.subsTail !== undefined) dep.subsTail.nextSub = link;
228
+ else dep.subs = link;
229
+ dep.subsTail = link;
230
+ }
231
+
232
+ // Whether `link` is among the ones `sub` has read so far in its current run (deps up to depsTail).
233
+ function isTrackedLink(link: Link, sub: ReactiveNode): boolean {
234
+ const tail = sub.depsTail;
235
+ if (tail === undefined) return false;
236
+ let current = sub.deps as Link;
237
+ for (;;) {
238
+ if (current === link) return true;
239
+ if (current === tail) return false;
240
+ current = current.nextDep as Link;
241
+ }
242
+ }
243
+
244
+ export function startTracking(sub: ReactiveNode): ReactiveNode | undefined {
245
+ sub.depsTail = undefined;
246
+ sub.flags = (sub.flags & ~(DIRTY | PENDING)) | RUNNING;
247
+ return setActiveSub(sub);
248
+ }
249
+
250
+ export function endTracking(sub: ReactiveNode, prev: ReactiveNode | undefined): void {
251
+ activeSub = prev;
252
+ sub.flags &= ~RUNNING;
253
+ // Disposed during its own run (its scope went away): whatever it read after that must not keep it.
254
+ if ((sub.flags & DISPOSED) !== 0) {
255
+ unlinkDeps(sub);
256
+ return;
257
+ }
258
+ const tail = sub.depsTail;
259
+ let stale = tail !== undefined ? tail.nextDep : sub.deps;
260
+ if (stale !== undefined) {
261
+ if (tail !== undefined) tail.nextDep = undefined;
262
+ else sub.deps = undefined;
263
+ while (stale !== undefined) stale = unlink(stale);
264
+ }
265
+ }
266
+
267
+ /** Removes every dependency of `sub`. */
268
+ export function unlinkDeps(sub: ReactiveNode): void {
269
+ let link = sub.deps;
270
+ sub.deps = undefined;
271
+ sub.depsTail = undefined;
272
+ while (link !== undefined) link = unlink(link);
273
+ }
274
+
275
+ function unlink(link: Link): Link | undefined {
276
+ const dep = link.dep;
277
+ const next = link.nextDep;
278
+ const prevSub = link.prevSub;
279
+ const nextSub = link.nextSub;
280
+ if (prevSub !== undefined) prevSub.nextSub = nextSub;
281
+ else dep.subs = nextSub;
282
+ if (nextSub !== undefined) nextSub.prevSub = prevSub;
283
+ else dep.subsTail = prevSub;
284
+ if (dep.subs === undefined && (dep.flags & WATCHED) !== 0) (dep as WatchedNode).unwatched();
285
+ return next;
286
+ }
287
+
288
+ // ── Propagation ─────────────────────────────────────────────────────────────
289
+
290
+ let batchDepth = 0;
291
+ let flushing = false;
292
+ const queue: EffectNode[] = [];
293
+ let queueIndex = 0;
294
+
295
+ export function isBatching(): boolean {
296
+ return batchDepth > 0;
297
+ }
298
+
299
+ export function startBatch(): void {
300
+ batchDepth++;
301
+ }
302
+
303
+ export function endBatch(): void {
304
+ if (--batchDepth === 0) flush();
305
+ }
306
+
307
+ /** Marks the direct subscribers of a changed source and runs the effects that became stale. */
308
+ export function notify(dep: ReactiveNode): void {
309
+ let link = dep.subs;
310
+ if (link === undefined) return;
311
+ do {
312
+ mark(link, DIRTY);
313
+ link = link.nextSub;
314
+ } while (link !== undefined);
315
+ if (batchDepth === 0) flush();
316
+ }
317
+
318
+ function mark(link: Link, flag: number): void {
319
+ const node = link.sub;
320
+ const flags = node.flags;
321
+ // A subscriber that is running only depends on what it has read so far in this run: a write to
322
+ // a source it has not read again yet does not make the run stale.
323
+ if ((flags & RUNNING) !== 0 && !isTrackedLink(link, node)) return;
324
+ if ((flags & (DIRTY | PENDING)) !== 0) {
325
+ // Already stale: only an upgrade from PENDING to DIRTY changes anything, and its
326
+ // subscribers were marked when it first went stale. An effect upgraded this way read a signal
327
+ // that changed, so it joins the synchronous pass instead of waiting for the deferred one.
328
+ if (flag === DIRTY) {
329
+ node.flags = flags | DIRTY;
330
+ if ((flags & EFFECT) !== 0 && (flags & (QUEUED | RUNNING)) === 0) enqueue(node as EffectNode);
331
+ }
332
+ return;
333
+ }
334
+ node.flags = flags | flag;
335
+ if ((flags & EFFECT) !== 0) {
336
+ // A running effect that invalidates itself is queued again once it finishes.
337
+ if ((flags & (QUEUED | RUNNING)) !== 0) return;
338
+ if (flag === DIRTY) enqueue(node as EffectNode);
339
+ else defer(node as EffectNode);
340
+ return;
341
+ }
342
+ let sub = node.subs;
343
+ while (sub !== undefined) {
344
+ mark(sub, PENDING);
345
+ sub = sub.nextSub;
346
+ }
347
+ }
348
+
349
+ function enqueue(node: EffectNode): void {
350
+ node.flags |= QUEUED;
351
+ queue.push(node);
352
+ }
353
+
354
+ // Effects that only depend on a change through a computed run in a microtask, so every write of the
355
+ // same synchronous block reaches them as one update with the final values. They pull fresh values
356
+ // when they run, and one that already re-ran for a direct change in the meantime is skipped.
357
+ const deferred: EffectNode[] = [];
358
+ let deferredScheduled = false;
359
+
360
+ function defer(node: EffectNode): void {
361
+ if ((node.flags & DEFERRED) !== 0) return;
362
+ node.flags |= DEFERRED;
363
+ deferred.push(node);
364
+ if (deferredScheduled) return;
365
+ deferredScheduled = true;
366
+ queueMicrotask(flushDeferred);
367
+ }
368
+
369
+ function flushDeferred(): void {
370
+ deferredScheduled = false;
371
+ // The synchronous queue always drains before a microtask, so none of these is queued already.
372
+ for (const node of deferred) {
373
+ node.flags &= ~DEFERRED;
374
+ enqueue(node);
375
+ }
376
+ deferred.length = 0;
377
+ flush();
378
+ }
379
+
380
+ export function flush(): void {
381
+ if (flushing) return;
382
+ flushing = true;
383
+ let error: unknown;
384
+ let hasError = false;
385
+ try {
386
+ while (queueIndex < queue.length) {
387
+ const node = queue[queueIndex++];
388
+ node.flags &= ~QUEUED;
389
+ try {
390
+ if (isStale(node)) runEffect(node);
391
+ } catch (e) {
392
+ error = e;
393
+ hasError = true;
394
+ }
395
+ }
396
+ } finally {
397
+ queue.length = 0;
398
+ queueIndex = 0;
399
+ flushing = false;
400
+ }
401
+ if (hasError) throw error;
402
+ }
403
+
404
+ // ── Pull ────────────────────────────────────────────────────────────────────
405
+
406
+ /** Whether a subscriber has to re-run: DIRTY, or PENDING with a source that really changed. */
407
+ export function isStale(node: ReactiveNode): boolean {
408
+ const flags = node.flags;
409
+ if ((flags & DISPOSED) !== 0) return false;
410
+ if ((flags & (DIRTY | HAS_ERROR)) !== 0) return true;
411
+ if ((flags & PENDING) === 0) return false;
412
+ let link = node.deps;
413
+ while (link !== undefined) {
414
+ const dep = link.dep;
415
+ if ((dep.flags & COMPUTED) !== 0) refreshComputed(dep as ComputedNode);
416
+ if (link.version !== dep.version) {
417
+ node.flags |= DIRTY;
418
+ return true;
419
+ }
420
+ link = link.nextDep;
421
+ }
422
+ node.flags &= ~PENDING;
423
+ return false;
424
+ }
425
+
426
+ export function refreshComputed(node: ComputedNode): void {
427
+ if ((node.flags & RUNNING) !== 0) throw new Error("[PraxisJS] Cycle detected while computing a value");
428
+ if (!isStale(node)) return;
429
+ const prev = startTracking(node);
430
+ let value: unknown;
431
+ let threw = false;
432
+ const start = observing ? performance.now() : 0;
433
+ try {
434
+ value = node.getter();
435
+ } catch (e) {
436
+ value = e;
437
+ threw = true;
438
+ } finally {
439
+ endTracking(node, prev);
440
+ if (observing) (observer as ReactiveObserver).compute?.(node, performance.now() - start);
441
+ }
442
+ if (threw) {
443
+ // HAS_ERROR keeps it stale so the next read retries, without the DIRTY flag that would stop
444
+ // later source changes from reaching its subscribers. The error is rethrown to every reader.
445
+ node.flags |= HAS_ERROR;
446
+ node.error = value;
447
+ node.version++;
448
+ return;
449
+ }
450
+ const hadError = (node.flags & HAS_ERROR) !== 0;
451
+ node.flags &= ~HAS_ERROR;
452
+ node.error = undefined;
453
+ if (hadError || node.version === 0 || !Object.is(node.value, value)) {
454
+ node.value = value;
455
+ node.version++;
456
+ }
457
+ }
458
+
459
+ export function readComputed<T>(node: ComputedNode<T>): T {
460
+ refreshComputed(node);
461
+ track(node);
462
+ if ((node.flags & HAS_ERROR) !== 0) throw node.error;
463
+ return node.value as T;
464
+ }
465
+
466
+ // ── Effects ─────────────────────────────────────────────────────────────────
467
+
468
+ export function runEffect(node: EffectNode): void {
469
+ const cleanup = node.cleanup;
470
+ if (cleanup !== undefined) {
471
+ node.cleanup = undefined;
472
+ const prevSub = setActiveSub(undefined);
473
+ try {
474
+ cleanup();
475
+ } finally {
476
+ activeSub = prevSub;
477
+ }
478
+ }
479
+ const prev = startTracking(node);
480
+ // Effects created while this one runs (a reactive child rendering its content) inherit its owner.
481
+ const prevOwner = currentOwner;
482
+ if (node.owner !== undefined) currentOwner = node.owner;
483
+ try {
484
+ if (!observing) node.cleanup = node.fn();
485
+ else runObserved(node);
486
+ } finally {
487
+ currentOwner = prevOwner;
488
+ endTracking(node, prev);
489
+ // Invalidated while it ran: run again with the pass its invalidation calls for.
490
+ const flags = node.flags;
491
+ if ((flags & (DISPOSED | QUEUED)) === 0) {
492
+ if ((flags & DIRTY) !== 0) {
493
+ enqueue(node);
494
+ if (batchDepth === 0) flush();
495
+ } else if ((flags & PENDING) !== 0) {
496
+ defer(node);
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ function runObserved(node: EffectNode): void {
503
+ const start = performance.now();
504
+ try {
505
+ node.cleanup = node.fn();
506
+ } finally {
507
+ (observer as ReactiveObserver).effect?.(node, performance.now() - start, node.owner);
508
+ }
509
+ }
510
+
511
+ export function disposeEffect(node: EffectNode): void {
512
+ if ((node.flags & DISPOSED) !== 0) return;
513
+ node.flags |= DISPOSED;
514
+ unlinkDeps(node);
515
+ const cleanup = node.cleanup;
516
+ node.cleanup = undefined;
517
+ if (cleanup !== undefined) {
518
+ const prevSub = setActiveSub(undefined);
519
+ try {
520
+ cleanup();
521
+ } finally {
522
+ activeSub = prevSub;
523
+ }
524
+ }
525
+ }
526
+
527
+ export function createEffect(fn: () => EffectCleanup): EffectNode {
528
+ const node = new EffectNode(fn, currentOwner);
529
+ runEffect(node);
530
+ return node;
531
+ }
532
+
533
+ /**
534
+ * Creates and runs an effect whose `fn` reads its state from `this.ctx`: one function can serve
535
+ * every effect of a kind (each DOM binding of a render), and each effect allocates no closure.
536
+ */
537
+ export function createContextEffect<Ctx>(fn: (this: EffectNode<Ctx>) => EffectCleanup, ctx: Ctx): EffectNode<Ctx> {
538
+ const node = new EffectNode(fn, currentOwner, ctx);
539
+ runEffect(node);
540
+ return node;
541
+ }
542
+
543
+ // ── Signals ─────────────────────────────────────────────────────────────────
544
+
545
+ export function readSignal<T>(node: SignalNode<T>): T {
546
+ if (activeSub !== undefined) track(node);
547
+ return node.value;
548
+ }
549
+
550
+ export function writeSignal<T>(node: SignalNode<T>, value: T): void {
551
+ const prev = node.value;
552
+ // Object.is, inline: writes are hot and often run before the graph code is optimized, where a
553
+ // builtin call costs more than these comparisons.
554
+ if (prev === value ? value !== 0 || 1 / (value as number) === 1 / (prev as number) : value !== value && prev !== prev) {
555
+ return;
556
+ }
557
+ node.value = value;
558
+ node.version++;
559
+ if (observing) (observer as ReactiveObserver).write?.(node);
560
+ // A signal nothing reads yet (a model being built) needs no notification pass.
561
+ if (node.subs !== undefined) notify(node);
562
+ }
@@ -2,6 +2,7 @@ export { batch } from "./batch";
2
2
  export { peek } from "./peek";
3
3
  export { signal } from "./signal";
4
4
  export { computed, writableComputed } from "./computed";
5
- export { type PersistedSignalOptions, persistedSignal } from "./persisted";
5
+ export { type PersistedSignalOptions, type PersistedSignal, persistedSignal } from "./persisted";
6
6
  export { type SyncedSignal, syncedSignal } from "./synced";
7
7
  export { effect, untrack } from "./effect";
8
+ export { type Selector, type SelectorEquals, selector } from "./selector";
@@ -1,13 +1,7 @@
1
1
  import type { Computed, Signal } from "@praxisjs/shared";
2
2
 
3
- import { activeEffect, runEffect } from "./effect";
3
+ import { untrack } from "./effect";
4
4
 
5
5
  export function peek<T>(source: Signal<T> | Computed<T>): T {
6
- const prev = activeEffect;
7
- runEffect(null);
8
- try {
9
- return source();
10
- } finally {
11
- runEffect(prev);
12
- }
6
+ return untrack(source);
13
7
  }
@@ -6,6 +6,17 @@ export interface PersistedSignalOptions<T> {
6
6
  serialize?: (value: T) => string;
7
7
  deserialize?: (value: string) => T;
8
8
  syncTabs?: boolean;
9
+ /**
10
+ * Milliseconds to hold back writes to `localStorage`. Writes within the window are coalesced
11
+ * into one, using the latest value; pending data is flushed on `pagehide` and `close()`.
12
+ * `0` (default) writes on every `set`.
13
+ */
14
+ writeDelay?: number;
15
+ }
16
+
17
+ export interface PersistedSignal<T> extends Signal<T> {
18
+ /** Stops listening for `storage` events from other tabs. The signal keeps working locally. */
19
+ close(): void;
9
20
  }
10
21
 
11
22
  export function persistedSignal<T>(
@@ -17,6 +28,7 @@ export function persistedSignal<T>(
17
28
  serialize = JSON.stringify,
18
29
  deserialize = JSON.parse as (value: string) => T,
19
30
  syncTabs = true,
31
+ writeDelay = 0,
20
32
  } = options;
21
33
 
22
34
  function getStoredValue(): T {
@@ -41,6 +53,29 @@ export function persistedSignal<T>(
41
53
  }
42
54
  }
43
55
 
56
+ let pendingWrite: { value: T } | undefined;
57
+ let writeTimer: ReturnType<typeof setTimeout> | undefined;
58
+
59
+ function flushWrite(): void {
60
+ clearTimeout(writeTimer);
61
+ writeTimer = undefined;
62
+ if (!pendingWrite) return;
63
+ const { value } = pendingWrite;
64
+ pendingWrite = undefined;
65
+ setStoredValue(value);
66
+ }
67
+
68
+ // The timer starts at the first write of a burst (throttle, not debounce) so a value that keeps
69
+ // changing still reaches storage within `writeDelay`.
70
+ function scheduleWrite(value: T): void {
71
+ if (writeDelay <= 0) {
72
+ setStoredValue(value);
73
+ return;
74
+ }
75
+ pendingWrite = { value };
76
+ writeTimer ??= setTimeout(flushWrite, writeDelay);
77
+ }
78
+
44
79
  const inner = signal(getStoredValue());
45
80
 
46
81
  function read() {
@@ -48,19 +83,23 @@ export function persistedSignal<T>(
48
83
  }
49
84
 
50
85
  function set(value: T) {
51
- setStoredValue(value);
86
+ scheduleWrite(value);
52
87
  inner.set(value);
53
88
  }
54
89
 
55
90
  function update(fh: (prev: T) => T) {
56
91
  const newValue = fh(inner());
57
- setStoredValue(newValue);
92
+ scheduleWrite(newValue);
58
93
  inner.set(newValue);
59
94
  }
60
95
 
96
+ let removeStorageListener: (() => void) | undefined;
97
+
61
98
  if (syncTabs) {
62
- window.addEventListener("storage", (event) => {
99
+ const onStorage = (event: StorageEvent): void => {
63
100
  if (event.key !== key || event.storageArea !== localStorage) return;
101
+ // Another tab's value wins; a still-pending local write would overwrite it later.
102
+ pendingWrite = undefined;
64
103
  try {
65
104
  const newValue = event.newValue
66
105
  ? deserialize(event.newValue)
@@ -73,14 +112,24 @@ export function persistedSignal<T>(
73
112
  );
74
113
  inner.set(initialValue);
75
114
  }
76
- });
115
+ };
116
+ window.addEventListener("storage", onStorage);
117
+ removeStorageListener = () => { window.removeEventListener("storage", onStorage); };
77
118
  }
78
119
 
79
- const source = read as Signal<T>;
120
+ if (writeDelay > 0) window.addEventListener("pagehide", flushWrite);
121
+
122
+ const source = read as PersistedSignal<T>;
80
123
  source.set = set;
81
124
  source.update = update;
82
125
  source.subscribe = inner.subscribe.bind(inner);
83
126
  source.__isSignal = true;
127
+ source.close = () => {
128
+ removeStorageListener?.();
129
+ removeStorageListener = undefined;
130
+ window.removeEventListener("pagehide", flushWrite);
131
+ flushWrite();
132
+ };
84
133
 
85
134
  return source;
86
135
  }