@praxisjs/core 2.0.0 → 2.2.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 +22 -0
- package/dist/__tests__/batch.test.js +52 -1
- package/dist/__tests__/batch.test.js.map +1 -1
- package/dist/__tests__/computed.test.js +37 -0
- package/dist/__tests__/computed.test.js.map +1 -1
- package/dist/__tests__/effect.test.js +11 -0
- package/dist/__tests__/effect.test.js.map +1 -1
- package/dist/__tests__/persisted-signal.test.js +97 -1
- package/dist/__tests__/persisted-signal.test.js.map +1 -1
- package/dist/__tests__/reactive.test.js +35 -0
- package/dist/__tests__/reactive.test.js.map +1 -1
- package/dist/__tests__/resource-server-pass.test.d.ts +2 -0
- package/dist/__tests__/resource-server-pass.test.d.ts.map +1 -0
- package/dist/__tests__/resource-server-pass.test.js +78 -0
- package/dist/__tests__/resource-server-pass.test.js.map +1 -0
- package/dist/__tests__/signal.test.js +68 -0
- package/dist/__tests__/signal.test.js.map +1 -1
- package/dist/__tests__/synced.test.js +6 -7
- package/dist/__tests__/synced.test.js.map +1 -1
- package/dist/async/resource-cache.d.ts +7 -0
- package/dist/async/resource-cache.d.ts.map +1 -1
- package/dist/async/resource-cache.js +18 -0
- package/dist/async/resource-cache.js.map +1 -1
- package/dist/async/resource.d.ts +7 -0
- package/dist/async/resource.d.ts.map +1 -1
- package/dist/async/resource.js +16 -1
- package/dist/async/resource.js.map +1 -1
- package/dist/async/server-mode.d.ts +10 -0
- package/dist/async/server-mode.d.ts.map +1 -0
- package/dist/async/server-mode.js +15 -0
- package/dist/async/server-mode.js.map +1 -0
- package/dist/internal.d.ts +4 -3
- 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.map +1 -1
- package/dist/signal/batch.js +22 -8
- package/dist/signal/batch.js.map +1 -1
- package/dist/signal/computed.d.ts.map +1 -1
- package/dist/signal/computed.js +11 -0
- package/dist/signal/computed.js.map +1 -1
- package/dist/signal/effect.d.ts +3 -1
- package/dist/signal/effect.d.ts.map +1 -1
- package/dist/signal/effect.js +11 -2
- package/dist/signal/effect.js.map +1 -1
- package/dist/signal/index.d.ts +1 -1
- package/dist/signal/index.d.ts.map +1 -1
- package/dist/signal/index.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/signal.d.ts +2 -2
- package/dist/signal/signal.d.ts.map +1 -1
- package/dist/signal/signal.js +9 -20
- package/dist/signal/signal.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/batch.test.ts +51 -0
- package/src/__tests__/computed.test.ts +43 -0
- package/src/__tests__/effect.test.ts +15 -0
- package/src/__tests__/persisted-signal.test.ts +112 -1
- package/src/__tests__/reactive.test.ts +38 -0
- package/src/__tests__/resource-server-pass.test.ts +90 -0
- package/src/__tests__/signal.test.ts +78 -0
- package/src/async/resource-cache.ts +20 -0
- package/src/async/resource.ts +17 -0
- package/src/async/server-mode.ts +16 -0
- package/src/internal.ts +4 -1
- package/src/reactive/history.ts +4 -1
- package/src/signal/batch.ts +20 -6
- package/src/signal/computed.ts +11 -1
- package/src/signal/effect.ts +12 -3
- package/src/signal/index.ts +1 -1
- package/src/signal/persisted.ts +54 -5
- package/src/signal/signal.ts +8 -13
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { flushPendingResources, resource } from "../async/resource";
|
|
4
|
+
import { _clearCache, getPendingResources } from "../async/resource-cache";
|
|
5
|
+
import { setServerRenderPass, isServerRenderPass } from "../async/server-mode";
|
|
6
|
+
|
|
7
|
+
describe("server render pass — flushPendingResources", () => {
|
|
8
|
+
beforeEach(() => { _clearCache(); });
|
|
9
|
+
afterEach(() => { setServerRenderPass(false); });
|
|
10
|
+
|
|
11
|
+
it("is a no-op when no server pass is active", async () => {
|
|
12
|
+
setServerRenderPass(false);
|
|
13
|
+
const r = resource(() => new Promise<string>(() => {}));
|
|
14
|
+
expect(getPendingResources().size).toBe(0);
|
|
15
|
+
await flushPendingResources();
|
|
16
|
+
// Untouched — resource() never settled and flush didn't wait for it.
|
|
17
|
+
expect(r.status()).toBe("pending");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("resolves immediately when nothing is pending", async () => {
|
|
21
|
+
setServerRenderPass(true);
|
|
22
|
+
await expect(flushPendingResources()).resolves.toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("tracks isServerRenderPass() state via the setter", () => {
|
|
26
|
+
expect(isServerRenderPass()).toBe(false);
|
|
27
|
+
setServerRenderPass(true);
|
|
28
|
+
expect(isServerRenderPass()).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("awaits an unkeyed resource() (e.g. @Collection) before returning", async () => {
|
|
32
|
+
setServerRenderPass(true);
|
|
33
|
+
const r = resource(() => new Promise<string>((res) => { setTimeout(() => { res("content"); }, 5); }));
|
|
34
|
+
await flushPendingResources();
|
|
35
|
+
expect(r.status()).toBe("success");
|
|
36
|
+
expect(r.data()).toBe("content");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("awaits a keyed resource() too", async () => {
|
|
40
|
+
setServerRenderPass(true);
|
|
41
|
+
const r = resource(() => Promise.resolve("keyed-value"), { key: "ssg-key" });
|
|
42
|
+
await flushPendingResources();
|
|
43
|
+
expect(r.status()).toBe("success");
|
|
44
|
+
expect(r.data()).toBe("keyed-value");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("awaits multiple concurrently pending resources", async () => {
|
|
48
|
+
setServerRenderPass(true);
|
|
49
|
+
const r1 = resource(() => new Promise<number>((res) => { setTimeout(() => { res(1); }, 5); }));
|
|
50
|
+
const r2 = resource(() => new Promise<number>((res) => { setTimeout(() => { res(2); }, 10); }));
|
|
51
|
+
await flushPendingResources();
|
|
52
|
+
expect(r1.data()).toBe(1);
|
|
53
|
+
expect(r2.data()).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fixed-point loop: awaits a resource created while an earlier one settles", async () => {
|
|
57
|
+
setServerRenderPass(true);
|
|
58
|
+
let second: ReturnType<typeof resource> | undefined;
|
|
59
|
+
|
|
60
|
+
// Simulates a component that, upon its first resource resolving, creates a
|
|
61
|
+
// second resource (e.g. a detail page fetching related content) — the new
|
|
62
|
+
// promise must still be picked up even though it didn't exist yet when
|
|
63
|
+
// flushPendingResources() started its first pass.
|
|
64
|
+
const first = resource(() => new Promise<string>((res) => {
|
|
65
|
+
setTimeout(() => {
|
|
66
|
+
res("first");
|
|
67
|
+
second = resource(() => new Promise<string>((res2) => { setTimeout(() => { res2("second"); }, 5); }));
|
|
68
|
+
}, 5);
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
await flushPendingResources();
|
|
72
|
+
expect(first.status()).toBe("success");
|
|
73
|
+
expect(second?.status()).toBe("success");
|
|
74
|
+
expect(second?.data()).toBe("second");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not leave stale entries after resources settle", async () => {
|
|
78
|
+
setServerRenderPass(true);
|
|
79
|
+
resource(() => Promise.resolve("done"));
|
|
80
|
+
await flushPendingResources();
|
|
81
|
+
expect(getPendingResources().size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("a rejected resource does not hang the flush", async () => {
|
|
85
|
+
setServerRenderPass(true);
|
|
86
|
+
const r = resource(() => Promise.reject(new Error("boom")));
|
|
87
|
+
await expect(flushPendingResources()).resolves.toBeUndefined();
|
|
88
|
+
expect(r.status()).toBe("error");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { batch } from "../signal/batch";
|
|
4
|
+
import { SUBS_SET_THRESHOLD } from "../signal/effect";
|
|
5
|
+
import { effect } from "../signal/effect";
|
|
4
6
|
import { signal, addSub, removeSub, type SubList } from "../signal/signal";
|
|
5
7
|
import type { Effect } from "../signal/effect";
|
|
6
8
|
|
|
@@ -211,3 +213,79 @@ describe("signal", () => {
|
|
|
211
213
|
expect(calls.length).toBe(before);
|
|
212
214
|
});
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
describe("large subscriber lists", () => {
|
|
218
|
+
function fns(count: number): Array<() => void> {
|
|
219
|
+
return Array.from({ length: count }, () => () => {});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
it("keeps an array up to the threshold and promotes to a Set past it, preserving order", () => {
|
|
223
|
+
const holder: { subs: SubList } = { subs: null };
|
|
224
|
+
const list = fns(SUBS_SET_THRESHOLD);
|
|
225
|
+
for (const fn of list) addSub(holder, fn);
|
|
226
|
+
expect(Array.isArray(holder.subs)).toBe(true);
|
|
227
|
+
|
|
228
|
+
const extra = () => {};
|
|
229
|
+
addSub(holder, extra);
|
|
230
|
+
expect(holder.subs).toBeInstanceOf(Set);
|
|
231
|
+
expect([...(holder.subs as Set<() => void>)]).toEqual([...list, extra]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("addSub on a Set holder adds new subscribers and ignores duplicates", () => {
|
|
235
|
+
const list = fns(SUBS_SET_THRESHOLD + 1);
|
|
236
|
+
const holder: { subs: SubList } = { subs: new Set(list) };
|
|
237
|
+
addSub(holder, list[0]);
|
|
238
|
+
expect((holder.subs as Set<() => void>).size).toBe(list.length);
|
|
239
|
+
const extra = () => {};
|
|
240
|
+
addSub(holder, extra);
|
|
241
|
+
expect((holder.subs as Set<() => void>).size).toBe(list.length + 1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("removeSub on a Set holder removes one subscriber, ignores unknown ones, and normalizes to null when emptied", () => {
|
|
245
|
+
const [a, b, stranger] = fns(3);
|
|
246
|
+
const holder: { subs: SubList } = { subs: new Set([a, b]) };
|
|
247
|
+
removeSub(holder, stranger);
|
|
248
|
+
expect((holder.subs as Set<() => void>).size).toBe(2);
|
|
249
|
+
removeSub(holder, a);
|
|
250
|
+
expect([...(holder.subs as Set<() => void>)]).toEqual([b]);
|
|
251
|
+
removeSub(holder, b);
|
|
252
|
+
expect(holder.subs).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("many effects on one signal all re-run once per change, in subscription order", () => {
|
|
256
|
+
const count = SUBS_SET_THRESHOLD * 3;
|
|
257
|
+
const s = signal(0);
|
|
258
|
+
const order: number[] = [];
|
|
259
|
+
const stops = Array.from({ length: count }, (_, i) =>
|
|
260
|
+
effect(() => {
|
|
261
|
+
void s();
|
|
262
|
+
order.push(i);
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
order.length = 0;
|
|
266
|
+
s.set(1);
|
|
267
|
+
expect(order).toEqual(Array.from({ length: count }, (_, i) => i));
|
|
268
|
+
order.length = 0;
|
|
269
|
+
s.set(2);
|
|
270
|
+
expect(order).toEqual(Array.from({ length: count }, (_, i) => i));
|
|
271
|
+
stops.forEach((stop) => { stop(); });
|
|
272
|
+
order.length = 0;
|
|
273
|
+
s.set(3);
|
|
274
|
+
expect(order).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("an effect stopped while the signal holds a Set no longer runs, the others still do", () => {
|
|
278
|
+
const s = signal(0);
|
|
279
|
+
const runs = new Map<number, number>();
|
|
280
|
+
const stops = Array.from({ length: SUBS_SET_THRESHOLD + 4 }, (_, i) =>
|
|
281
|
+
effect(() => {
|
|
282
|
+
void s();
|
|
283
|
+
runs.set(i, (runs.get(i) ?? 0) + 1);
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
stops[2]();
|
|
287
|
+
s.set(1);
|
|
288
|
+
expect(runs.get(2)).toBe(1);
|
|
289
|
+
expect(runs.get(3)).toBe(2);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isServerRenderPass } from "./server-mode";
|
|
2
|
+
|
|
1
3
|
interface CacheEntry {
|
|
2
4
|
data: unknown;
|
|
3
5
|
timestamp: number;
|
|
@@ -6,6 +8,7 @@ interface CacheEntry {
|
|
|
6
8
|
const _cache = new Map<string, CacheEntry>();
|
|
7
9
|
const _inflight = new Map<string, Promise<unknown>>();
|
|
8
10
|
const _registry = new Map<string, Set<() => void>>();
|
|
11
|
+
const _pendingResources = new Set<Promise<unknown>>();
|
|
9
12
|
|
|
10
13
|
export function getCacheEntry(key: string): CacheEntry | undefined {
|
|
11
14
|
return _cache.get(key);
|
|
@@ -54,9 +57,26 @@ export function invalidateResource(key: string): void {
|
|
|
54
57
|
_registry.get(key)?.forEach((fn) => { fn(); });
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Registers `promise` as pending while a server render pass is active (no-op otherwise,
|
|
62
|
+
* so client-side resource() calls never pay this cost). Self-removes on settle.
|
|
63
|
+
*/
|
|
64
|
+
export function trackPendingResource(promise: Promise<unknown>): void {
|
|
65
|
+
if (!isServerRenderPass()) return;
|
|
66
|
+
_pendingResources.add(promise);
|
|
67
|
+
const cleanup = (): void => { _pendingResources.delete(promise); };
|
|
68
|
+
promise.then(cleanup, cleanup);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Snapshot of currently in-flight resource promises tracked during a server render pass. */
|
|
72
|
+
export function getPendingResources(): ReadonlySet<Promise<unknown>> {
|
|
73
|
+
return _pendingResources;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/** Resets all cache state. Intended for use in tests only. */
|
|
58
77
|
export function _clearCache(): void {
|
|
59
78
|
_cache.clear();
|
|
60
79
|
_inflight.clear();
|
|
61
80
|
_registry.clear();
|
|
81
|
+
_pendingResources.clear();
|
|
62
82
|
}
|
package/src/async/resource.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
setInflight,
|
|
10
10
|
registerResource,
|
|
11
11
|
unregisterResource,
|
|
12
|
+
trackPendingResource,
|
|
13
|
+
getPendingResources,
|
|
12
14
|
} from "./resource-cache";
|
|
13
15
|
import { effect, untrack } from "../signal/effect";
|
|
14
16
|
|
|
@@ -58,6 +60,7 @@ export function resource<T>(
|
|
|
58
60
|
let _runId = 0;
|
|
59
61
|
|
|
60
62
|
function _execute(fn: Promise<T>): void {
|
|
63
|
+
trackPendingResource(fn);
|
|
61
64
|
const currentRunId = ++_runId;
|
|
62
65
|
|
|
63
66
|
function clearInflight(): void {
|
|
@@ -194,3 +197,17 @@ export function createResource<P, T>(
|
|
|
194
197
|
): Resource<T> {
|
|
195
198
|
return resource(() => fetcher(param()), options);
|
|
196
199
|
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Awaits every resource fetch tracked during a server render pass (see
|
|
203
|
+
* `setServerRenderPass`), including ones triggered by resources that themselves
|
|
204
|
+
* settle while waiting (fixed-point loop). No-op — resolves immediately — when
|
|
205
|
+
* nothing is pending or tracking isn't active.
|
|
206
|
+
*/
|
|
207
|
+
export async function flushPendingResources(): Promise<void> {
|
|
208
|
+
for (;;) {
|
|
209
|
+
const pending = Array.from(getPendingResources());
|
|
210
|
+
if (pending.length === 0) return;
|
|
211
|
+
await Promise.allSettled(pending);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
let _serverRenderPass = false;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Marks whether the current process is doing a build-time server/SSG render pass
|
|
5
|
+
* (as opposed to a normal browser render). Consumed by `resource-cache` (gates
|
|
6
|
+
* pending-promise tracking for `flushPendingResources`) and by `@praxisjs/runtime`'s
|
|
7
|
+
* `Portal`, which skips mounting entirely during a server pass so its content
|
|
8
|
+
* never gets baked into static HTML (it mounts normally once on the client).
|
|
9
|
+
*/
|
|
10
|
+
export function setServerRenderPass(active: boolean): void {
|
|
11
|
+
_serverRenderPass = active;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isServerRenderPass(): boolean {
|
|
15
|
+
return _serverRenderPass;
|
|
16
|
+
}
|
package/src/internal.ts
CHANGED
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
effect,
|
|
17
17
|
untrack,
|
|
18
18
|
type PersistedSignalOptions,
|
|
19
|
+
type PersistedSignal,
|
|
19
20
|
type SyncedSignal,
|
|
20
21
|
} from "./signal";
|
|
21
22
|
export { RootComponent } from "./component";
|
|
@@ -38,8 +39,10 @@ export {
|
|
|
38
39
|
export {
|
|
39
40
|
resource,
|
|
40
41
|
createResource,
|
|
42
|
+
flushPendingResources,
|
|
41
43
|
type ResourceStatus,
|
|
42
44
|
type Resource,
|
|
43
45
|
type ResourceOptions,
|
|
44
46
|
} from "./async/resource";
|
|
45
|
-
export { invalidateResource, _clearCache } from "./async/resource-cache";
|
|
47
|
+
export { invalidateResource, trackPendingResource, _clearCache } from "./async/resource-cache";
|
|
48
|
+
export { setServerRenderPass, isServerRenderPass } from "./async/server-mode";
|
package/src/reactive/history.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface HistoryElement<T> {
|
|
|
12
12
|
undo(): void;
|
|
13
13
|
redo(): void;
|
|
14
14
|
clear(): void;
|
|
15
|
+
/** Stops tracking the source. Existing entries stay readable; no new ones are recorded. */
|
|
16
|
+
destroy(): void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export function history<T>(
|
|
@@ -25,7 +27,7 @@ export function history<T>(
|
|
|
25
27
|
let _ignoreNext = false;
|
|
26
28
|
let _initialized = false;
|
|
27
29
|
|
|
28
|
-
effect(() => {
|
|
30
|
+
const stop = effect(() => {
|
|
29
31
|
const value = source();
|
|
30
32
|
|
|
31
33
|
if (!_initialized) {
|
|
@@ -83,5 +85,6 @@ export function history<T>(
|
|
|
83
85
|
_past.set([]);
|
|
84
86
|
_future.set([]);
|
|
85
87
|
},
|
|
88
|
+
destroy: stop,
|
|
86
89
|
};
|
|
87
90
|
}
|
package/src/signal/batch.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Effect } from "./effect";
|
|
2
2
|
|
|
3
|
-
const batchEffects
|
|
3
|
+
const batchEffects = new Set<Effect>();
|
|
4
4
|
let batchDepth = 0;
|
|
5
5
|
|
|
6
6
|
export function isBatching(): boolean {
|
|
@@ -8,7 +8,7 @@ export function isBatching(): boolean {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function enqueueEffect(effect: Effect): void {
|
|
11
|
-
|
|
11
|
+
batchEffects.add(effect);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function batch(fn: () => void) {
|
|
@@ -16,10 +16,24 @@ export function batch(fn: () => void) {
|
|
|
16
16
|
try {
|
|
17
17
|
fn();
|
|
18
18
|
} finally {
|
|
19
|
-
if (--batchDepth === 0)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
if (--batchDepth === 0) flush();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The queue is detached before running so a throwing effect can't leave stale entries behind,
|
|
24
|
+
// and every queued effect still runs — matching how notifySubs treats errors outside a batch.
|
|
25
|
+
function flush(): void {
|
|
26
|
+
const queued = [...batchEffects];
|
|
27
|
+
batchEffects.clear();
|
|
28
|
+
let lastError: unknown;
|
|
29
|
+
let hasError = false;
|
|
30
|
+
for (const effect of queued) {
|
|
31
|
+
try {
|
|
32
|
+
effect();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
lastError = e;
|
|
35
|
+
hasError = true;
|
|
23
36
|
}
|
|
24
37
|
}
|
|
38
|
+
if (hasError) throw lastError;
|
|
25
39
|
}
|
package/src/signal/computed.ts
CHANGED
|
@@ -60,7 +60,17 @@ export function computed<T>(getter: () => T): Computed<T> {
|
|
|
60
60
|
return cachedValue as T;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const c = read as Computed<T
|
|
63
|
+
const c = read as Computed<T> & { dispose(): void };
|
|
64
|
+
|
|
65
|
+
// Drops this computed's subscriptions to its sources so an unreachable one (e.g. an evicted
|
|
66
|
+
// @Memo entry) isn't kept alive by the signals it read. Does nothing while anything still
|
|
67
|
+
// depends on it: detaching then would leave those dependents silently stale.
|
|
68
|
+
c.dispose = () => {
|
|
69
|
+
if (leafHolder.subs !== null || computedHolder.subs !== null) return;
|
|
70
|
+
if (recompute !== null) cleanupEffectDeps(recompute);
|
|
71
|
+
dirty = true;
|
|
72
|
+
cachedValue = undefined;
|
|
73
|
+
};
|
|
64
74
|
|
|
65
75
|
c.subscribe = (fn: (value: T) => void) => {
|
|
66
76
|
const wrapped: Effect = () => { fn(read()); };
|
package/src/signal/effect.ts
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
export type Effect = () => void;
|
|
2
|
-
export type SubList = Effect | Effect[] | null;
|
|
2
|
+
export type SubList = Effect | Effect[] | Set<Effect> | null;
|
|
3
3
|
export interface SubscriberHolder {
|
|
4
4
|
subs: SubList;
|
|
5
5
|
}
|
|
6
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
|
+
|
|
7
11
|
type TrackedEffect = Effect & { __deps?: Set<SubscriberHolder> };
|
|
8
12
|
|
|
9
13
|
export let activeEffect: Effect | null = null;
|
|
10
14
|
const effectStack: Effect[] = [];
|
|
11
15
|
|
|
12
|
-
function
|
|
16
|
+
export function removeSub(holder: SubscriberHolder, eff: Effect): void {
|
|
13
17
|
const subs = holder.subs;
|
|
14
18
|
if (subs === null) return;
|
|
15
19
|
if (typeof subs === "function") {
|
|
16
20
|
if (subs === eff) holder.subs = null;
|
|
17
21
|
return;
|
|
18
22
|
}
|
|
23
|
+
if (subs instanceof Set) {
|
|
24
|
+
subs.delete(eff);
|
|
25
|
+
if (subs.size === 0) holder.subs = null;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
19
28
|
const idx = subs.indexOf(eff);
|
|
20
29
|
if (idx >= 0) subs.splice(idx, 1);
|
|
21
30
|
if (subs.length === 0) holder.subs = null;
|
|
@@ -31,7 +40,7 @@ export function cleanupEffectDeps(eff: Effect): void {
|
|
|
31
40
|
const tracked = eff as TrackedEffect;
|
|
32
41
|
const deps = tracked.__deps;
|
|
33
42
|
if (!deps) return;
|
|
34
|
-
for (const holder of deps)
|
|
43
|
+
for (const holder of deps) removeSub(holder, eff);
|
|
35
44
|
deps.clear();
|
|
36
45
|
}
|
|
37
46
|
|
package/src/signal/index.ts
CHANGED
|
@@ -2,6 +2,6 @@ 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";
|
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
|
}
|
package/src/signal/signal.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { isBatching, enqueueEffect } from "./batch";
|
|
|
4
4
|
import {
|
|
5
5
|
activeEffect,
|
|
6
6
|
recordDependency,
|
|
7
|
+
removeSub,
|
|
8
|
+
SUBS_SET_THRESHOLD,
|
|
7
9
|
type Effect,
|
|
8
10
|
type SubList,
|
|
9
11
|
type SubscriberHolder,
|
|
@@ -17,23 +19,16 @@ export function addSub(holder: SubscriberHolder, eff: Effect): void {
|
|
|
17
19
|
holder.subs = eff;
|
|
18
20
|
} else if (typeof subs === "function") {
|
|
19
21
|
if (subs !== eff) holder.subs = [subs, eff];
|
|
20
|
-
} else {
|
|
21
|
-
|
|
22
|
+
} else if (subs instanceof Set) {
|
|
23
|
+
subs.add(eff);
|
|
24
|
+
} else if (!subs.includes(eff)) {
|
|
25
|
+
subs.push(eff);
|
|
26
|
+
if (subs.length > SUBS_SET_THRESHOLD) holder.subs = new Set(subs);
|
|
22
27
|
}
|
|
23
28
|
recordDependency(eff, holder);
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
export
|
|
27
|
-
const subs = holder.subs;
|
|
28
|
-
if (subs === null) return;
|
|
29
|
-
if (typeof subs === "function") {
|
|
30
|
-
if (subs === eff) holder.subs = null;
|
|
31
|
-
} else {
|
|
32
|
-
const idx = subs.indexOf(eff);
|
|
33
|
-
if (idx >= 0) subs.splice(idx, 1);
|
|
34
|
-
if (subs.length === 0) holder.subs = null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
31
|
+
export { removeSub };
|
|
37
32
|
|
|
38
33
|
export function notifySubs(subs: Exclude<SubList, null>, batching: boolean): void {
|
|
39
34
|
if (typeof subs === "function") {
|