@praxisjs/core 2.2.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 +18 -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/signal/batch.d.ts +1 -3
- package/dist/signal/batch.d.ts.map +1 -1
- package/dist/signal/batch.js +4 -30
- package/dist/signal/batch.js.map +1 -1
- package/dist/signal/computed.d.ts.map +1 -1
- package/dist/signal/computed.js +13 -66
- package/dist/signal/computed.js.map +1 -1
- package/dist/signal/effect.d.ts +6 -14
- package/dist/signal/effect.d.ts.map +1 -1
- package/dist/signal/effect.js +11 -83
- 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 +1 -0
- 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/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 -70
- package/dist/signal/signal.js.map +1 -1
- package/package.json +13 -4
- package/src/component/internals.ts +35 -10
- package/src/internal.ts +26 -0
- package/src/signal/batch.ts +5 -33
- package/src/signal/computed.ts +13 -66
- package/src/signal/effect.ts +12 -92
- package/src/signal/graph.ts +562 -0
- package/src/signal/index.ts +1 -0
- package/src/signal/peek.ts +2 -8
- package/src/signal/selector.ts +140 -0
- package/src/signal/signal.ts +11 -74
- 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 -124
- 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 -289
- 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 -200
- 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 -252
- 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 -299
- 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 -254
- 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 -138
- package/src/__tests__/component.test.ts +0 -165
- package/src/__tests__/computed.test.ts +0 -333
- package/src/__tests__/effect.test.ts +0 -241
- package/src/__tests__/peek.test.ts +0 -31
- package/src/__tests__/persisted-signal.test.ts +0 -305
- package/src/__tests__/reactive.test.ts +0 -343
- 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 -291
- package/src/__tests__/synced.test.ts +0 -173
- package/src/__tests__/track.test.ts +0 -61
- package/tsconfig.json +0 -8
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
effect,
|
|
5
|
-
track,
|
|
6
|
-
activeEffect,
|
|
7
|
-
cleanupEffectDeps,
|
|
8
|
-
recordDependency,
|
|
9
|
-
type SubscriberHolder,
|
|
10
|
-
} from "../signal/effect";
|
|
11
|
-
import { batch } from "../signal/batch";
|
|
12
|
-
import { signal } from "../signal/signal";
|
|
13
|
-
|
|
14
|
-
describe("effect", () => {
|
|
15
|
-
it("runs immediately on creation", () => {
|
|
16
|
-
const ran = vi.fn();
|
|
17
|
-
effect(ran);
|
|
18
|
-
expect(ran).toHaveBeenCalledOnce();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("re-runs when a tracked signal changes", () => {
|
|
22
|
-
const s = signal(1);
|
|
23
|
-
const values: number[] = [];
|
|
24
|
-
effect(() => {
|
|
25
|
-
values.push(s());
|
|
26
|
-
});
|
|
27
|
-
s.set(2);
|
|
28
|
-
s.set(3);
|
|
29
|
-
expect(values).toEqual([1, 2, 3]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("stop() calls the cleanup function", () => {
|
|
33
|
-
const cleanupFn = vi.fn();
|
|
34
|
-
const stop = effect(() => cleanupFn);
|
|
35
|
-
stop();
|
|
36
|
-
expect(cleanupFn).toHaveBeenCalledOnce();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("calls cleanup function before re-run", () => {
|
|
40
|
-
const s = signal(0);
|
|
41
|
-
const order: string[] = [];
|
|
42
|
-
effect(() => {
|
|
43
|
-
const v = s();
|
|
44
|
-
order.push(`run:${v}`);
|
|
45
|
-
return () => order.push(`cleanup`);
|
|
46
|
-
});
|
|
47
|
-
s.set(1);
|
|
48
|
-
expect(order).toEqual(["run:0", "cleanup", "run:1"]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("tracks multiple signals", () => {
|
|
52
|
-
const a = signal(1);
|
|
53
|
-
const b = signal(10);
|
|
54
|
-
const sums: number[] = [];
|
|
55
|
-
effect(() => {
|
|
56
|
-
sums.push(a() + b());
|
|
57
|
-
});
|
|
58
|
-
a.set(2);
|
|
59
|
-
b.set(20);
|
|
60
|
-
expect(sums).toEqual([11, 12, 22]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("stop() prevents future re-runs", () => {
|
|
64
|
-
const s = signal(0);
|
|
65
|
-
const values: number[] = [];
|
|
66
|
-
const stop = effect(() => {
|
|
67
|
-
values.push(s());
|
|
68
|
-
});
|
|
69
|
-
stop();
|
|
70
|
-
s.set(99);
|
|
71
|
-
expect(values).toEqual([0]); // only initial run
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("drops dependencies that are no longer read", () => {
|
|
75
|
-
const useA = signal(true);
|
|
76
|
-
const a = signal(1);
|
|
77
|
-
const b = signal(10);
|
|
78
|
-
const values: number[] = [];
|
|
79
|
-
|
|
80
|
-
effect(() => {
|
|
81
|
-
values.push(useA() ? a() : b());
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
useA.set(false);
|
|
85
|
-
expect(values).toEqual([1, 10]);
|
|
86
|
-
|
|
87
|
-
a.set(2);
|
|
88
|
-
expect(values).toEqual([1, 10]);
|
|
89
|
-
|
|
90
|
-
b.set(20);
|
|
91
|
-
expect(values).toEqual([1, 10, 20]);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("does not skip other subscribers when one effect changes subscriptions while notifying", () => {
|
|
95
|
-
const useOther = signal(false);
|
|
96
|
-
const source = signal(0);
|
|
97
|
-
const other = signal(100);
|
|
98
|
-
const first = vi.fn();
|
|
99
|
-
const second = vi.fn();
|
|
100
|
-
|
|
101
|
-
effect(() => {
|
|
102
|
-
first(useOther() ? other() : source());
|
|
103
|
-
});
|
|
104
|
-
effect(() => {
|
|
105
|
-
second(source());
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
useOther.set(true);
|
|
109
|
-
first.mockClear();
|
|
110
|
-
second.mockClear();
|
|
111
|
-
|
|
112
|
-
source.set(1);
|
|
113
|
-
|
|
114
|
-
expect(first).not.toHaveBeenCalled();
|
|
115
|
-
expect(second).toHaveBeenCalledWith(1);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("effect with no reactive dependencies runs once only", () => {
|
|
119
|
-
const ran = vi.fn();
|
|
120
|
-
effect(() => {
|
|
121
|
-
ran(); // no signal reads
|
|
122
|
-
});
|
|
123
|
-
expect(ran).toHaveBeenCalledTimes(1);
|
|
124
|
-
// No signal changes possible — just verify it doesn't re-run spontaneously
|
|
125
|
-
expect(ran).toHaveBeenCalledTimes(1);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("stop() with no cleanup function does not throw", () => {
|
|
129
|
-
const stop = effect(() => { /* no return value */ });
|
|
130
|
-
expect(() => stop()).not.toThrow();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("cleanup is re-invoked before each re-run", () => {
|
|
134
|
-
const s = signal(0);
|
|
135
|
-
const cleanupCount = { n: 0 };
|
|
136
|
-
effect(() => {
|
|
137
|
-
s(); // track
|
|
138
|
-
return () => { cleanupCount.n++; };
|
|
139
|
-
});
|
|
140
|
-
s.set(1);
|
|
141
|
-
s.set(2);
|
|
142
|
-
// cleanup called once before second run, once before third run
|
|
143
|
-
expect(cleanupCount.n).toBe(2);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("stop() called multiple times does not crash or double-clean", () => {
|
|
147
|
-
const cleanupFn = vi.fn();
|
|
148
|
-
const stop = effect(() => cleanupFn);
|
|
149
|
-
expect(() => {
|
|
150
|
-
stop();
|
|
151
|
-
stop();
|
|
152
|
-
stop();
|
|
153
|
-
}).not.toThrow();
|
|
154
|
-
// cleanup is only called once (on first stop)
|
|
155
|
-
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("ignores a queued run after the effect was stopped", () => {
|
|
159
|
-
const s = signal(0);
|
|
160
|
-
const values: number[] = [];
|
|
161
|
-
const stop = effect(() => {
|
|
162
|
-
values.push(s());
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
batch(() => {
|
|
166
|
-
s.set(1);
|
|
167
|
-
stop();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
expect(values).toEqual([0]);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("cleanupEffectDeps handles empty, single, and array dependency holders", () => {
|
|
174
|
-
const tracked = vi.fn();
|
|
175
|
-
const other = vi.fn();
|
|
176
|
-
const emptyHolder: SubscriberHolder = { subs: null };
|
|
177
|
-
const singleHolder: SubscriberHolder = { subs: tracked };
|
|
178
|
-
const arrayHolder: SubscriberHolder = { subs: [tracked, other] };
|
|
179
|
-
const unmatchedSingleHolder: SubscriberHolder = { subs: other };
|
|
180
|
-
const unmatchedArrayHolder: SubscriberHolder = { subs: [other] };
|
|
181
|
-
|
|
182
|
-
recordDependency(tracked, emptyHolder);
|
|
183
|
-
recordDependency(tracked, singleHolder);
|
|
184
|
-
recordDependency(tracked, arrayHolder);
|
|
185
|
-
recordDependency(tracked, unmatchedSingleHolder);
|
|
186
|
-
recordDependency(tracked, unmatchedArrayHolder);
|
|
187
|
-
|
|
188
|
-
cleanupEffectDeps(tracked);
|
|
189
|
-
|
|
190
|
-
expect(emptyHolder.subs).toBeNull();
|
|
191
|
-
expect(singleHolder.subs).toBeNull();
|
|
192
|
-
expect(arrayHolder.subs).toEqual([other]);
|
|
193
|
-
expect(unmatchedSingleHolder.subs).toBe(other);
|
|
194
|
-
expect(unmatchedArrayHolder.subs).toEqual([other]);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("cleanupEffectDeps handles Set dependency holders, emptying them to null", () => {
|
|
198
|
-
const tracked = vi.fn();
|
|
199
|
-
const other = vi.fn();
|
|
200
|
-
const sharedHolder: SubscriberHolder = { subs: new Set([tracked, other]) };
|
|
201
|
-
const soleHolder: SubscriberHolder = { subs: new Set([tracked]) };
|
|
202
|
-
|
|
203
|
-
recordDependency(tracked, sharedHolder);
|
|
204
|
-
recordDependency(tracked, soleHolder);
|
|
205
|
-
|
|
206
|
-
cleanupEffectDeps(tracked);
|
|
207
|
-
|
|
208
|
-
expect([...(sharedHolder.subs as Set<() => void>)]).toEqual([other]);
|
|
209
|
-
expect(soleHolder.subs).toBeNull();
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("exception inside nested track() restores outer activeEffect", () => {
|
|
213
|
-
const s = signal(0);
|
|
214
|
-
const outer = vi.fn();
|
|
215
|
-
let outerEffectRef: unknown;
|
|
216
|
-
|
|
217
|
-
effect(() => {
|
|
218
|
-
outer();
|
|
219
|
-
s(); // track
|
|
220
|
-
outerEffectRef = activeEffect;
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// Now do a track that throws — outer activeEffect must be restored
|
|
224
|
-
try {
|
|
225
|
-
track(() => { throw new Error("inner throws"); });
|
|
226
|
-
} catch {
|
|
227
|
-
// expected
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
expect(activeEffect).toBeNull();
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("activeEffect is null after a track() that throws", () => {
|
|
234
|
-
try {
|
|
235
|
-
track(() => { throw new Error("boom"); });
|
|
236
|
-
} catch {
|
|
237
|
-
// expected
|
|
238
|
-
}
|
|
239
|
-
expect(activeEffect).toBeNull();
|
|
240
|
-
});
|
|
241
|
-
});
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { computed } from "../signal/computed";
|
|
4
|
-
import { effect } from "../signal/effect";
|
|
5
|
-
import { peek } from "../signal/peek";
|
|
6
|
-
import { signal } from "../signal/signal";
|
|
7
|
-
|
|
8
|
-
describe("peek", () => {
|
|
9
|
-
it("returns the current value without tracking", () => {
|
|
10
|
-
const s = signal(42);
|
|
11
|
-
expect(peek(s)).toBe(42);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("does not subscribe the active effect when peeked", () => {
|
|
15
|
-
const s = signal(1);
|
|
16
|
-
const runs: number[] = [];
|
|
17
|
-
|
|
18
|
-
effect(() => {
|
|
19
|
-
runs.push(peek(s)); // peek — should not subscribe
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
s.set(99); // should NOT trigger re-run
|
|
23
|
-
expect(runs).toEqual([1]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("works with computed values", () => {
|
|
27
|
-
const s = signal(5);
|
|
28
|
-
const c = computed(() => s() * 2);
|
|
29
|
-
expect(peek(c)).toBe(10);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { persistedSignal } from "../signal/persisted";
|
|
5
|
-
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
localStorage.clear();
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
describe("persistedSignal", () => {
|
|
11
|
-
it("returns initialValue when nothing is stored", () => {
|
|
12
|
-
const s = persistedSignal("key1", 42);
|
|
13
|
-
expect(s()).toBe(42);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("reads existing value from localStorage", () => {
|
|
17
|
-
localStorage.setItem("key2", "99");
|
|
18
|
-
const s = persistedSignal("key2", 0);
|
|
19
|
-
expect(s()).toBe(99);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("persists set() to localStorage", () => {
|
|
23
|
-
const s = persistedSignal("key3", 0);
|
|
24
|
-
s.set(7);
|
|
25
|
-
expect(localStorage.getItem("key3")).toBe("7");
|
|
26
|
-
expect(s()).toBe(7);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("persists update() to localStorage", () => {
|
|
30
|
-
const s = persistedSignal("key4", 10);
|
|
31
|
-
s.update((v) => v + 5);
|
|
32
|
-
expect(localStorage.getItem("key4")).toBe("15");
|
|
33
|
-
expect(s()).toBe(15);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("removes the key when set to null", () => {
|
|
37
|
-
const s = persistedSignal<string | null>("key5", "hello");
|
|
38
|
-
s.set(null);
|
|
39
|
-
expect(localStorage.getItem("key5")).toBeNull();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("marks __isSignal = true", () => {
|
|
43
|
-
const s = persistedSignal("key6", 0);
|
|
44
|
-
expect(s.__isSignal).toBe(true);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("supports custom serialize/deserialize", () => {
|
|
48
|
-
const s = persistedSignal("key7", { x: 1 }, {
|
|
49
|
-
serialize: (v) => JSON.stringify(v),
|
|
50
|
-
deserialize: (raw) => JSON.parse(raw) as { x: number },
|
|
51
|
-
});
|
|
52
|
-
s.set({ x: 99 });
|
|
53
|
-
expect(localStorage.getItem("key7")).toBe('{"x":99}');
|
|
54
|
-
const s2 = persistedSignal("key7", { x: 0 }, {
|
|
55
|
-
serialize: JSON.stringify,
|
|
56
|
-
deserialize: (raw) => JSON.parse(raw) as { x: number },
|
|
57
|
-
});
|
|
58
|
-
expect(s2().x).toBe(99);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("falls back to initialValue when deserialization fails", () => {
|
|
62
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
63
|
-
localStorage.setItem("key8", "{ bad json");
|
|
64
|
-
const s = persistedSignal("key8", 42);
|
|
65
|
-
expect(s()).toBe(42);
|
|
66
|
-
warn.mockRestore();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("syncs from storage event on other tab", () => {
|
|
70
|
-
const s = persistedSignal("key9", 1);
|
|
71
|
-
window.dispatchEvent(
|
|
72
|
-
new StorageEvent("storage", {
|
|
73
|
-
key: "key9",
|
|
74
|
-
newValue: "55",
|
|
75
|
-
storageArea: localStorage,
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
expect(s()).toBe(55);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("syncTabs=false does not add storage listener", () => {
|
|
82
|
-
const s = persistedSignal("key10", 1, { syncTabs: false });
|
|
83
|
-
s.set(5);
|
|
84
|
-
// Just verify it works without errors
|
|
85
|
-
expect(s()).toBe(5);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("warns and falls back to initialValue when serialize throws on set", () => {
|
|
89
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
90
|
-
const s = persistedSignal("key11", { x: 1 }, {
|
|
91
|
-
serialize: () => { throw new Error("serialize fail"); },
|
|
92
|
-
deserialize: JSON.parse as (v: string) => { x: number },
|
|
93
|
-
});
|
|
94
|
-
expect(() => s.set({ x: 2 })).not.toThrow();
|
|
95
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Failed to serialize"), expect.any(Error));
|
|
96
|
-
warn.mockRestore();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("syncs to initialValue when storage event has newValue=null (key removed)", () => {
|
|
100
|
-
const s = persistedSignal("key12", 42);
|
|
101
|
-
s.set(99);
|
|
102
|
-
|
|
103
|
-
window.dispatchEvent(
|
|
104
|
-
new StorageEvent("storage", {
|
|
105
|
-
key: "key12",
|
|
106
|
-
newValue: null, // key removed in another tab
|
|
107
|
-
storageArea: localStorage,
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
expect(s()).toBe(42); // falls back to initialValue
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("warns and falls back to initialValue when storage event deserialization fails", () => {
|
|
114
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
115
|
-
const s = persistedSignal("key13", 0, {
|
|
116
|
-
deserialize: () => { throw new Error("bad"); },
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
window.dispatchEvent(
|
|
120
|
-
new StorageEvent("storage", {
|
|
121
|
-
key: "key13",
|
|
122
|
-
newValue: "invalid",
|
|
123
|
-
storageArea: localStorage,
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
expect(s()).toBe(0);
|
|
127
|
-
expect(warn).toHaveBeenCalledWith(
|
|
128
|
-
expect.stringContaining("Failed to deserialize"),
|
|
129
|
-
expect.any(Error),
|
|
130
|
-
);
|
|
131
|
-
warn.mockRestore();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("ignores storage events from other keys", () => {
|
|
135
|
-
const s = persistedSignal("key14", 1);
|
|
136
|
-
window.dispatchEvent(
|
|
137
|
-
new StorageEvent("storage", {
|
|
138
|
-
key: "other-key",
|
|
139
|
-
newValue: "999",
|
|
140
|
-
storageArea: localStorage,
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
expect(s()).toBe(1);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("set(undefined) removes the key from localStorage", () => {
|
|
147
|
-
const s = persistedSignal<number | undefined>("key15", 5);
|
|
148
|
-
s.set(undefined);
|
|
149
|
-
expect(localStorage.getItem("key15")).toBeNull();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("localStorage.setItem throws QuotaExceededError — signal state is updated but no crash", () => {
|
|
153
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
154
|
-
const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
155
|
-
const err = new DOMException("QuotaExceededError", "QuotaExceededError");
|
|
156
|
-
throw err;
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const s = persistedSignal("key16", 0);
|
|
160
|
-
expect(() => s.set(42)).not.toThrow();
|
|
161
|
-
// The in-memory signal value is still updated despite storage failure
|
|
162
|
-
expect(s()).toBe(42);
|
|
163
|
-
|
|
164
|
-
setItemSpy.mockRestore();
|
|
165
|
-
warn.mockRestore();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("syncTabs=true — storage event with empty string newValue is handled", () => {
|
|
169
|
-
const s = persistedSignal("key17", 99);
|
|
170
|
-
window.dispatchEvent(
|
|
171
|
-
new StorageEvent("storage", {
|
|
172
|
-
key: "key17",
|
|
173
|
-
newValue: "",
|
|
174
|
-
storageArea: localStorage,
|
|
175
|
-
}),
|
|
176
|
-
);
|
|
177
|
-
// Empty string is falsy, so falls back to initialValue
|
|
178
|
-
expect(s()).toBe(99);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("syncTabs=true — storage event from sessionStorage is ignored", () => {
|
|
182
|
-
const s = persistedSignal("key18", 10);
|
|
183
|
-
s.set(20);
|
|
184
|
-
window.dispatchEvent(
|
|
185
|
-
new StorageEvent("storage", {
|
|
186
|
-
key: "key18",
|
|
187
|
-
newValue: "999",
|
|
188
|
-
storageArea: sessionStorage,
|
|
189
|
-
}),
|
|
190
|
-
);
|
|
191
|
-
// Should stay at 20 since the event is from sessionStorage, not localStorage
|
|
192
|
-
expect(s()).toBe(20);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("close() stops storage-event syncing but keeps the signal working locally", () => {
|
|
196
|
-
const s = persistedSignal("close-key", 0);
|
|
197
|
-
s.close();
|
|
198
|
-
window.dispatchEvent(
|
|
199
|
-
new StorageEvent("storage", { key: "close-key", newValue: "5", storageArea: localStorage }),
|
|
200
|
-
);
|
|
201
|
-
expect(s()).toBe(0);
|
|
202
|
-
s.set(3);
|
|
203
|
-
expect(s()).toBe(3);
|
|
204
|
-
expect(localStorage.getItem("close-key")).toBe("3");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("close() removes the window listener and is safe to call twice", () => {
|
|
208
|
-
const remove = vi.spyOn(window, "removeEventListener");
|
|
209
|
-
const s = persistedSignal("close-twice", 0);
|
|
210
|
-
s.close();
|
|
211
|
-
s.close();
|
|
212
|
-
expect(remove.mock.calls.filter(([type]) => type === "storage")).toHaveLength(1);
|
|
213
|
-
remove.mockRestore();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("close() is a no-op when syncTabs is false", () => {
|
|
217
|
-
const remove = vi.spyOn(window, "removeEventListener");
|
|
218
|
-
const s = persistedSignal("close-nosync", 0, { syncTabs: false });
|
|
219
|
-
expect(() => { s.close(); }).not.toThrow();
|
|
220
|
-
expect(remove.mock.calls.filter(([type]) => type === "storage")).toHaveLength(0);
|
|
221
|
-
remove.mockRestore();
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
describe("persistedSignal writeDelay", () => {
|
|
226
|
-
afterEach(() => {
|
|
227
|
-
vi.useRealTimers();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("writes immediately by default", () => {
|
|
231
|
-
const s = persistedSignal("wd-default", 0);
|
|
232
|
-
s.set(1);
|
|
233
|
-
expect(localStorage.getItem("wd-default")).toBe("1");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("holds writes back and flushes the latest value once the delay elapses", () => {
|
|
237
|
-
vi.useFakeTimers();
|
|
238
|
-
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
|
239
|
-
const s = persistedSignal("wd-burst", 0, { writeDelay: 100 });
|
|
240
|
-
s.set(1);
|
|
241
|
-
s.set(2);
|
|
242
|
-
s.update((n) => n + 1);
|
|
243
|
-
expect(s()).toBe(3);
|
|
244
|
-
expect(localStorage.getItem("wd-burst")).toBeNull();
|
|
245
|
-
vi.advanceTimersByTime(100);
|
|
246
|
-
expect(localStorage.getItem("wd-burst")).toBe("3");
|
|
247
|
-
expect(setItem.mock.calls.filter(([k]) => k === "wd-burst")).toHaveLength(1);
|
|
248
|
-
setItem.mockRestore();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("starts a new window for writes after a flush", () => {
|
|
252
|
-
vi.useFakeTimers();
|
|
253
|
-
const s = persistedSignal("wd-next", 0, { writeDelay: 50 });
|
|
254
|
-
s.set(1);
|
|
255
|
-
vi.advanceTimersByTime(50);
|
|
256
|
-
s.set(2);
|
|
257
|
-
expect(localStorage.getItem("wd-next")).toBe("1");
|
|
258
|
-
vi.advanceTimersByTime(50);
|
|
259
|
-
expect(localStorage.getItem("wd-next")).toBe("2");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("flushes pending data on pagehide", () => {
|
|
263
|
-
vi.useFakeTimers();
|
|
264
|
-
const s = persistedSignal("wd-hide", 0, { writeDelay: 1000 });
|
|
265
|
-
s.set(5);
|
|
266
|
-
window.dispatchEvent(new Event("pagehide"));
|
|
267
|
-
expect(localStorage.getItem("wd-hide")).toBe("5");
|
|
268
|
-
vi.advanceTimersByTime(1000);
|
|
269
|
-
expect(localStorage.getItem("wd-hide")).toBe("5");
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it("close() flushes pending data and stops listening for pagehide", () => {
|
|
273
|
-
vi.useFakeTimers();
|
|
274
|
-
const s = persistedSignal("wd-close", 0, { writeDelay: 1000 });
|
|
275
|
-
s.set(7);
|
|
276
|
-
s.close();
|
|
277
|
-
expect(localStorage.getItem("wd-close")).toBe("7");
|
|
278
|
-
s.set(8);
|
|
279
|
-
window.dispatchEvent(new Event("pagehide"));
|
|
280
|
-
expect(localStorage.getItem("wd-close")).toBe("7");
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("a value arriving from another tab discards the pending local write", () => {
|
|
284
|
-
vi.useFakeTimers();
|
|
285
|
-
const s = persistedSignal("wd-remote", 0, { writeDelay: 100 });
|
|
286
|
-
s.set(1);
|
|
287
|
-
localStorage.setItem("wd-remote", "9");
|
|
288
|
-
window.dispatchEvent(
|
|
289
|
-
new StorageEvent("storage", { key: "wd-remote", newValue: "9", storageArea: localStorage }),
|
|
290
|
-
);
|
|
291
|
-
vi.advanceTimersByTime(100);
|
|
292
|
-
expect(s()).toBe(9);
|
|
293
|
-
expect(localStorage.getItem("wd-remote")).toBe("9");
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("removes null and undefined values when the write is flushed", () => {
|
|
297
|
-
vi.useFakeTimers();
|
|
298
|
-
const s = persistedSignal<number | null>("wd-null", 1, { writeDelay: 10 });
|
|
299
|
-
s.set(2);
|
|
300
|
-
vi.advanceTimersByTime(10);
|
|
301
|
-
s.set(null);
|
|
302
|
-
vi.advanceTimersByTime(10);
|
|
303
|
-
expect(localStorage.getItem("wd-null")).toBeNull();
|
|
304
|
-
});
|
|
305
|
-
});
|