@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.
- package/CHANGELOG.md +30 -0
- package/dist/component/internals.d.ts +12 -2
- package/dist/component/internals.d.ts.map +1 -1
- package/dist/component/internals.js +25 -7
- package/dist/component/internals.js.map +1 -1
- package/dist/internal.d.ts +3 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -2
- package/dist/internal.js.map +1 -1
- package/dist/reactive/history.d.ts +2 -0
- package/dist/reactive/history.d.ts.map +1 -1
- package/dist/reactive/history.js +2 -1
- package/dist/reactive/history.js.map +1 -1
- package/dist/signal/batch.d.ts +1 -3
- package/dist/signal/batch.d.ts.map +1 -1
- package/dist/signal/batch.js +4 -16
- package/dist/signal/batch.js.map +1 -1
- package/dist/signal/computed.d.ts.map +1 -1
- package/dist/signal/computed.js +19 -61
- package/dist/signal/computed.js.map +1 -1
- package/dist/signal/effect.d.ts +6 -12
- package/dist/signal/effect.d.ts.map +1 -1
- package/dist/signal/effect.js +11 -74
- package/dist/signal/effect.js.map +1 -1
- package/dist/signal/graph.d.ts +111 -0
- package/dist/signal/graph.d.ts.map +1 -0
- package/dist/signal/graph.js +513 -0
- package/dist/signal/graph.js.map +1 -0
- package/dist/signal/index.d.ts +2 -1
- package/dist/signal/index.d.ts.map +1 -1
- package/dist/signal/index.js +1 -0
- package/dist/signal/index.js.map +1 -1
- package/dist/signal/peek.d.ts.map +1 -1
- package/dist/signal/peek.js +2 -9
- package/dist/signal/peek.js.map +1 -1
- package/dist/signal/persisted.d.ts +11 -1
- package/dist/signal/persisted.d.ts.map +1 -1
- package/dist/signal/persisted.js +39 -5
- package/dist/signal/persisted.js.map +1 -1
- package/dist/signal/selector.d.ts +5 -0
- package/dist/signal/selector.d.ts.map +1 -0
- package/dist/signal/selector.js +128 -0
- package/dist/signal/selector.js.map +1 -0
- package/dist/signal/signal.d.ts +0 -5
- package/dist/signal/signal.d.ts.map +1 -1
- package/dist/signal/signal.js +11 -81
- package/dist/signal/signal.js.map +1 -1
- package/package.json +13 -4
- package/src/component/internals.ts +35 -10
- package/src/internal.ts +27 -0
- package/src/reactive/history.ts +4 -1
- package/src/signal/batch.ts +5 -19
- package/src/signal/computed.ts +20 -63
- package/src/signal/effect.ts +12 -83
- package/src/signal/graph.ts +562 -0
- package/src/signal/index.ts +2 -1
- package/src/signal/peek.ts +2 -8
- package/src/signal/persisted.ts +54 -5
- package/src/signal/selector.ts +140 -0
- package/src/signal/signal.ts +11 -79
- package/dist/__tests__/batch.test.d.ts +0 -2
- package/dist/__tests__/batch.test.d.ts.map +0 -1
- package/dist/__tests__/batch.test.js +0 -73
- package/dist/__tests__/batch.test.js.map +0 -1
- package/dist/__tests__/component.test.d.ts +0 -2
- package/dist/__tests__/component.test.d.ts.map +0 -1
- package/dist/__tests__/component.test.js +0 -124
- package/dist/__tests__/component.test.js.map +0 -1
- package/dist/__tests__/computed.test.d.ts +0 -2
- package/dist/__tests__/computed.test.d.ts.map +0 -1
- package/dist/__tests__/computed.test.js +0 -252
- package/dist/__tests__/computed.test.js.map +0 -1
- package/dist/__tests__/effect.test.d.ts +0 -2
- package/dist/__tests__/effect.test.d.ts.map +0 -1
- package/dist/__tests__/effect.test.js +0 -189
- package/dist/__tests__/effect.test.js.map +0 -1
- package/dist/__tests__/peek.test.d.ts +0 -2
- package/dist/__tests__/peek.test.d.ts.map +0 -1
- package/dist/__tests__/peek.test.js +0 -26
- package/dist/__tests__/peek.test.js.map +0 -1
- package/dist/__tests__/persisted-signal.test.d.ts +0 -2
- package/dist/__tests__/persisted-signal.test.d.ts.map +0 -1
- package/dist/__tests__/persisted-signal.test.js +0 -156
- package/dist/__tests__/persisted-signal.test.js.map +0 -1
- package/dist/__tests__/reactive.test.d.ts +0 -2
- package/dist/__tests__/reactive.test.d.ts.map +0 -1
- package/dist/__tests__/reactive.test.js +0 -264
- package/dist/__tests__/reactive.test.js.map +0 -1
- package/dist/__tests__/resource-focus.test.d.ts +0 -2
- package/dist/__tests__/resource-focus.test.d.ts.map +0 -1
- package/dist/__tests__/resource-focus.test.js +0 -50
- package/dist/__tests__/resource-focus.test.js.map +0 -1
- package/dist/__tests__/resource-server-pass.test.d.ts +0 -2
- package/dist/__tests__/resource-server-pass.test.d.ts.map +0 -1
- package/dist/__tests__/resource-server-pass.test.js +0 -78
- package/dist/__tests__/resource-server-pass.test.js.map +0 -1
- package/dist/__tests__/resource.test.d.ts +0 -2
- package/dist/__tests__/resource.test.d.ts.map +0 -1
- package/dist/__tests__/resource.test.js +0 -336
- package/dist/__tests__/resource.test.js.map +0 -1
- package/dist/__tests__/signal.test.d.ts +0 -2
- package/dist/__tests__/signal.test.d.ts.map +0 -1
- package/dist/__tests__/signal.test.js +0 -186
- package/dist/__tests__/signal.test.js.map +0 -1
- package/dist/__tests__/synced.test.d.ts +0 -2
- package/dist/__tests__/synced.test.d.ts.map +0 -1
- package/dist/__tests__/synced.test.js +0 -142
- package/dist/__tests__/synced.test.js.map +0 -1
- package/dist/__tests__/track.test.d.ts +0 -2
- package/dist/__tests__/track.test.d.ts.map +0 -1
- package/dist/__tests__/track.test.js +0 -54
- package/dist/__tests__/track.test.js.map +0 -1
- package/src/__tests__/batch.test.ts +0 -87
- package/src/__tests__/component.test.ts +0 -165
- package/src/__tests__/computed.test.ts +0 -290
- package/src/__tests__/effect.test.ts +0 -226
- package/src/__tests__/peek.test.ts +0 -31
- package/src/__tests__/persisted-signal.test.ts +0 -194
- package/src/__tests__/reactive.test.ts +0 -305
- package/src/__tests__/resource-focus.test.ts +0 -61
- package/src/__tests__/resource-server-pass.test.ts +0 -90
- package/src/__tests__/resource.test.ts +0 -421
- package/src/__tests__/signal.test.ts +0 -213
- package/src/__tests__/synced.test.ts +0 -173
- package/src/__tests__/track.test.ts +0 -61
- 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
|
+
}
|
package/src/signal/index.ts
CHANGED
|
@@ -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";
|
package/src/signal/peek.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import type { Computed, Signal } from "@praxisjs/shared";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { untrack } from "./effect";
|
|
4
4
|
|
|
5
5
|
export function peek<T>(source: Signal<T> | Computed<T>): T {
|
|
6
|
-
|
|
7
|
-
runEffect(null);
|
|
8
|
-
try {
|
|
9
|
-
return source();
|
|
10
|
-
} finally {
|
|
11
|
-
runEffect(prev);
|
|
12
|
-
}
|
|
6
|
+
return untrack(source);
|
|
13
7
|
}
|
package/src/signal/persisted.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
92
|
+
scheduleWrite(newValue);
|
|
58
93
|
inner.set(newValue);
|
|
59
94
|
}
|
|
60
95
|
|
|
96
|
+
let removeStorageListener: (() => void) | undefined;
|
|
97
|
+
|
|
61
98
|
if (syncTabs) {
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|