@praxisjs/core 2.2.0 → 2.3.1

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 (116) hide show
  1. package/CHANGELOG.md +24 -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/signal/batch.d.ts +1 -3
  11. package/dist/signal/batch.d.ts.map +1 -1
  12. package/dist/signal/batch.js +4 -30
  13. package/dist/signal/batch.js.map +1 -1
  14. package/dist/signal/computed.d.ts.map +1 -1
  15. package/dist/signal/computed.js +13 -66
  16. package/dist/signal/computed.js.map +1 -1
  17. package/dist/signal/effect.d.ts +6 -14
  18. package/dist/signal/effect.d.ts.map +1 -1
  19. package/dist/signal/effect.js +11 -83
  20. package/dist/signal/effect.js.map +1 -1
  21. package/dist/signal/graph.d.ts +111 -0
  22. package/dist/signal/graph.d.ts.map +1 -0
  23. package/dist/signal/graph.js +513 -0
  24. package/dist/signal/graph.js.map +1 -0
  25. package/dist/signal/index.d.ts +1 -0
  26. package/dist/signal/index.d.ts.map +1 -1
  27. package/dist/signal/index.js +1 -0
  28. package/dist/signal/index.js.map +1 -1
  29. package/dist/signal/peek.d.ts.map +1 -1
  30. package/dist/signal/peek.js +2 -9
  31. package/dist/signal/peek.js.map +1 -1
  32. package/dist/signal/selector.d.ts +5 -0
  33. package/dist/signal/selector.d.ts.map +1 -0
  34. package/dist/signal/selector.js +128 -0
  35. package/dist/signal/selector.js.map +1 -0
  36. package/dist/signal/signal.d.ts +0 -5
  37. package/dist/signal/signal.d.ts.map +1 -1
  38. package/dist/signal/signal.js +11 -70
  39. package/dist/signal/signal.js.map +1 -1
  40. package/package.json +13 -4
  41. package/src/component/internals.ts +35 -10
  42. package/src/internal.ts +26 -0
  43. package/src/signal/batch.ts +5 -33
  44. package/src/signal/computed.ts +13 -66
  45. package/src/signal/effect.ts +12 -92
  46. package/src/signal/graph.ts +562 -0
  47. package/src/signal/index.ts +1 -0
  48. package/src/signal/peek.ts +2 -8
  49. package/src/signal/selector.ts +140 -0
  50. package/src/signal/signal.ts +11 -74
  51. package/dist/__tests__/batch.test.d.ts +0 -2
  52. package/dist/__tests__/batch.test.d.ts.map +0 -1
  53. package/dist/__tests__/batch.test.js +0 -124
  54. package/dist/__tests__/batch.test.js.map +0 -1
  55. package/dist/__tests__/component.test.d.ts +0 -2
  56. package/dist/__tests__/component.test.d.ts.map +0 -1
  57. package/dist/__tests__/component.test.js +0 -124
  58. package/dist/__tests__/component.test.js.map +0 -1
  59. package/dist/__tests__/computed.test.d.ts +0 -2
  60. package/dist/__tests__/computed.test.d.ts.map +0 -1
  61. package/dist/__tests__/computed.test.js +0 -289
  62. package/dist/__tests__/computed.test.js.map +0 -1
  63. package/dist/__tests__/effect.test.d.ts +0 -2
  64. package/dist/__tests__/effect.test.d.ts.map +0 -1
  65. package/dist/__tests__/effect.test.js +0 -200
  66. package/dist/__tests__/effect.test.js.map +0 -1
  67. package/dist/__tests__/peek.test.d.ts +0 -2
  68. package/dist/__tests__/peek.test.d.ts.map +0 -1
  69. package/dist/__tests__/peek.test.js +0 -26
  70. package/dist/__tests__/peek.test.js.map +0 -1
  71. package/dist/__tests__/persisted-signal.test.d.ts +0 -2
  72. package/dist/__tests__/persisted-signal.test.d.ts.map +0 -1
  73. package/dist/__tests__/persisted-signal.test.js +0 -252
  74. package/dist/__tests__/persisted-signal.test.js.map +0 -1
  75. package/dist/__tests__/reactive.test.d.ts +0 -2
  76. package/dist/__tests__/reactive.test.d.ts.map +0 -1
  77. package/dist/__tests__/reactive.test.js +0 -299
  78. package/dist/__tests__/reactive.test.js.map +0 -1
  79. package/dist/__tests__/resource-focus.test.d.ts +0 -2
  80. package/dist/__tests__/resource-focus.test.d.ts.map +0 -1
  81. package/dist/__tests__/resource-focus.test.js +0 -50
  82. package/dist/__tests__/resource-focus.test.js.map +0 -1
  83. package/dist/__tests__/resource-server-pass.test.d.ts +0 -2
  84. package/dist/__tests__/resource-server-pass.test.d.ts.map +0 -1
  85. package/dist/__tests__/resource-server-pass.test.js +0 -78
  86. package/dist/__tests__/resource-server-pass.test.js.map +0 -1
  87. package/dist/__tests__/resource.test.d.ts +0 -2
  88. package/dist/__tests__/resource.test.d.ts.map +0 -1
  89. package/dist/__tests__/resource.test.js +0 -336
  90. package/dist/__tests__/resource.test.js.map +0 -1
  91. package/dist/__tests__/signal.test.d.ts +0 -2
  92. package/dist/__tests__/signal.test.d.ts.map +0 -1
  93. package/dist/__tests__/signal.test.js +0 -254
  94. package/dist/__tests__/signal.test.js.map +0 -1
  95. package/dist/__tests__/synced.test.d.ts +0 -2
  96. package/dist/__tests__/synced.test.d.ts.map +0 -1
  97. package/dist/__tests__/synced.test.js +0 -142
  98. package/dist/__tests__/synced.test.js.map +0 -1
  99. package/dist/__tests__/track.test.d.ts +0 -2
  100. package/dist/__tests__/track.test.d.ts.map +0 -1
  101. package/dist/__tests__/track.test.js +0 -54
  102. package/dist/__tests__/track.test.js.map +0 -1
  103. package/src/__tests__/batch.test.ts +0 -138
  104. package/src/__tests__/component.test.ts +0 -165
  105. package/src/__tests__/computed.test.ts +0 -333
  106. package/src/__tests__/effect.test.ts +0 -241
  107. package/src/__tests__/peek.test.ts +0 -31
  108. package/src/__tests__/persisted-signal.test.ts +0 -305
  109. package/src/__tests__/reactive.test.ts +0 -343
  110. package/src/__tests__/resource-focus.test.ts +0 -61
  111. package/src/__tests__/resource-server-pass.test.ts +0 -90
  112. package/src/__tests__/resource.test.ts +0 -421
  113. package/src/__tests__/signal.test.ts +0 -291
  114. package/src/__tests__/synced.test.ts +0 -173
  115. package/src/__tests__/track.test.ts +0 -61
  116. package/tsconfig.json +0 -8
@@ -1,102 +1,22 @@
1
- export type Effect = () => void;
2
- export type SubList = Effect | Effect[] | Set<Effect> | null;
3
- export interface SubscriberHolder {
4
- subs: SubList;
5
- }
6
-
7
- // Past this many subscribers an array makes unsubscribing O(n) per effect re-run, which turns one
8
- // signal with thousands of bindings into a quadratic update; a Set keeps removal O(1).
9
- export const SUBS_SET_THRESHOLD = 16;
10
-
11
- type TrackedEffect = Effect & { __deps?: Set<SubscriberHolder> };
12
-
13
- export let activeEffect: Effect | null = null;
14
- const effectStack: Effect[] = [];
15
-
16
- export function removeSub(holder: SubscriberHolder, eff: Effect): void {
17
- const subs = holder.subs;
18
- if (subs === null) return;
19
- if (typeof subs === "function") {
20
- if (subs === eff) holder.subs = null;
21
- return;
22
- }
23
- if (subs instanceof Set) {
24
- subs.delete(eff);
25
- if (subs.size === 0) holder.subs = null;
26
- return;
27
- }
28
- const idx = subs.indexOf(eff);
29
- if (idx >= 0) subs.splice(idx, 1);
30
- if (subs.length === 0) holder.subs = null;
31
- }
32
-
33
- export function recordDependency(eff: Effect, holder: SubscriberHolder): void {
34
- const tracked = eff as TrackedEffect;
35
- tracked.__deps ??= new Set();
36
- tracked.__deps.add(holder);
37
- }
38
-
39
- export function cleanupEffectDeps(eff: Effect): void {
40
- const tracked = eff as TrackedEffect;
41
- const deps = tracked.__deps;
42
- if (!deps) return;
43
- for (const holder of deps) removeSub(holder, eff);
44
- deps.clear();
45
- }
1
+ import { activeSub, createEffect, disposeEffect, setActiveSub, type EffectCleanup } from "./graph";
46
2
 
47
- export function track(effect: Effect) {
48
- cleanupEffectDeps(effect);
49
- effectStack.push(effect);
50
- activeEffect = effect;
51
- try {
52
- effect();
53
- } finally {
54
- effectStack.pop();
55
- activeEffect = effectStack[effectStack.length - 1] ?? null;
56
- }
57
- }
58
-
59
- export function runEffect(effect: Effect | null) {
60
- activeEffect = effect;
61
- }
3
+ export type Effect = () => void;
62
4
 
63
5
  export function untrack<T>(fn: () => T): T {
64
- const prev = activeEffect;
65
- activeEffect = null;
6
+ if (activeSub === undefined) return fn();
7
+ const prev = setActiveSub(undefined);
66
8
  try {
67
9
  return fn();
68
10
  } finally {
69
- activeEffect = prev;
11
+ setActiveSub(prev);
70
12
  }
71
13
  }
72
14
 
73
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
74
- type Cleanup = (() => void) | void;
75
-
76
- export function effect(fn: () => Cleanup) {
77
- let cleanup: Cleanup;
78
- let stopped = false;
79
-
80
- function run(): void {
81
- if (stopped) return;
82
- cleanupEffectDeps(run);
83
- cleanup?.();
84
- const prev = activeEffect;
85
- activeEffect = run;
86
- try {
87
- cleanup = fn();
88
- } finally {
89
- activeEffect = prev;
90
- }
91
- }
92
-
93
- run();
94
-
95
- return function stop(): void {
96
- if (stopped) return;
97
- stopped = true;
98
- cleanupEffectDeps(run);
99
- cleanup?.();
100
- cleanup = undefined;
101
- };
15
+ /**
16
+ * Runs `fn` now and again whenever a signal or computed it read changes. A returned function is
17
+ * called before each re-run and on stop. Returns the stop function.
18
+ */
19
+ export function effect(fn: () => EffectCleanup): () => void {
20
+ const node = createEffect(fn);
21
+ return () => { disposeEffect(node); };
102
22
  }
@@ -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
+ }
@@ -5,3 +5,4 @@ export { computed, writableComputed } from "./computed";
5
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
  }