@praxisjs/core 0.4.0 → 0.4.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.
- package/CHANGELOG.md +10 -0
- package/dist/__tests__/batch.test.d.ts +2 -0
- package/dist/__tests__/batch.test.d.ts.map +1 -0
- package/dist/__tests__/batch.test.js +26 -0
- package/dist/__tests__/batch.test.js.map +1 -0
- package/dist/__tests__/component.test.d.ts +2 -0
- package/dist/__tests__/component.test.d.ts.map +1 -0
- package/dist/__tests__/component.test.js +48 -0
- package/dist/__tests__/component.test.js.map +1 -0
- package/dist/__tests__/computed.test.d.ts +2 -0
- package/dist/__tests__/computed.test.d.ts.map +1 -0
- package/dist/__tests__/computed.test.js +79 -0
- package/dist/__tests__/computed.test.js.map +1 -0
- package/dist/__tests__/effect.test.d.ts +2 -0
- package/dist/__tests__/effect.test.d.ts.map +1 -0
- package/dist/__tests__/effect.test.js +49 -0
- package/dist/__tests__/effect.test.js.map +1 -0
- package/dist/__tests__/peek.test.d.ts +2 -0
- package/dist/__tests__/peek.test.d.ts.map +1 -0
- package/dist/__tests__/peek.test.js +26 -0
- package/dist/__tests__/peek.test.js.map +1 -0
- package/dist/__tests__/persisted-signal.test.d.ts +2 -0
- package/dist/__tests__/persisted-signal.test.d.ts.map +1 -0
- package/dist/__tests__/persisted-signal.test.js +74 -0
- package/dist/__tests__/persisted-signal.test.js.map +1 -0
- package/dist/__tests__/reactive.test.d.ts +2 -0
- package/dist/__tests__/reactive.test.d.ts.map +1 -0
- package/dist/__tests__/reactive.test.js +181 -0
- package/dist/__tests__/reactive.test.js.map +1 -0
- package/dist/__tests__/resource.test.d.ts +2 -0
- package/dist/__tests__/resource.test.d.ts.map +1 -0
- package/dist/__tests__/resource.test.js +84 -0
- package/dist/__tests__/resource.test.js.map +1 -0
- package/dist/__tests__/signal.test.d.ts +2 -0
- package/dist/__tests__/signal.test.d.ts.map +1 -0
- package/dist/__tests__/signal.test.js +64 -0
- package/dist/__tests__/signal.test.js.map +1 -0
- package/dist/__tests__/track.test.d.ts +2 -0
- package/dist/__tests__/track.test.d.ts.map +1 -0
- package/dist/__tests__/track.test.js +54 -0
- package/dist/__tests__/track.test.js.map +1 -0
- package/dist/reactive/reactive.d.ts.map +1 -1
- package/dist/reactive/reactive.js +8 -1
- package/dist/reactive/reactive.js.map +1 -1
- package/dist/signal/persisted.d.ts.map +1 -1
- package/dist/signal/persisted.js +10 -12
- package/dist/signal/persisted.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/batch.test.ts +29 -0
- package/src/__tests__/component.test.ts +58 -0
- package/src/__tests__/computed.test.ts +89 -0
- package/src/__tests__/effect.test.ts +54 -0
- package/src/__tests__/peek.test.ts +31 -0
- package/src/__tests__/persisted-signal.test.ts +87 -0
- package/src/__tests__/reactive.test.ts +210 -0
- package/src/__tests__/resource.test.ts +97 -0
- package/src/__tests__/signal.test.ts +74 -0
- package/src/__tests__/track.test.ts +61 -0
- package/src/reactive/reactive.ts +9 -1
- package/src/signal/persisted.ts +12 -14
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, beforeEach, 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
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { history } from "../reactive/history";
|
|
4
|
+
import { when, until, debounced } from "../reactive/reactive";
|
|
5
|
+
import { computed } from "../signal/computed";
|
|
6
|
+
import { signal } from "../signal/signal";
|
|
7
|
+
|
|
8
|
+
// ---------- when ----------
|
|
9
|
+
|
|
10
|
+
describe("when", () => {
|
|
11
|
+
it("calls fn immediately when source is already truthy", () => {
|
|
12
|
+
const s = signal<number>(5);
|
|
13
|
+
const fn = vi.fn();
|
|
14
|
+
when(s, fn);
|
|
15
|
+
expect(fn).toHaveBeenCalledWith(5);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("calls fn once the source becomes truthy", () => {
|
|
19
|
+
const s = signal<number>(0);
|
|
20
|
+
const fn = vi.fn();
|
|
21
|
+
when(s, fn);
|
|
22
|
+
expect(fn).not.toHaveBeenCalled();
|
|
23
|
+
s.set(1);
|
|
24
|
+
expect(fn).toHaveBeenCalledWith(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("only fires once, even if source changes again", () => {
|
|
28
|
+
const s = signal(0);
|
|
29
|
+
const fn = vi.fn();
|
|
30
|
+
when(s, fn);
|
|
31
|
+
s.set(1);
|
|
32
|
+
s.set(2);
|
|
33
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not re-fire after immediate truthy source changes again", () => {
|
|
37
|
+
// Regression: when source is truthy on first run, stop?.() was a no-op
|
|
38
|
+
// because `stop` hadn't been assigned yet. The effect must be cancelled
|
|
39
|
+
// after firing so further source changes do not re-trigger fn.
|
|
40
|
+
const s = signal(1); // immediately truthy
|
|
41
|
+
const fn = vi.fn();
|
|
42
|
+
when(s, fn);
|
|
43
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
44
|
+
s.set(2);
|
|
45
|
+
s.set(3);
|
|
46
|
+
expect(fn).toHaveBeenCalledTimes(1); // must still be 1
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not call fn when source stays falsy", () => {
|
|
50
|
+
const s = signal<number>(0);
|
|
51
|
+
const fn = vi.fn();
|
|
52
|
+
when(s, fn);
|
|
53
|
+
s.set(0);
|
|
54
|
+
expect(fn).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------- until ----------
|
|
59
|
+
|
|
60
|
+
describe("until", () => {
|
|
61
|
+
it("resolves immediately when source is already truthy", async () => {
|
|
62
|
+
const s = signal(42);
|
|
63
|
+
const value = await until(s);
|
|
64
|
+
expect(value).toBe(42);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("resolves when source becomes truthy", async () => {
|
|
68
|
+
const s = signal<number>(0);
|
|
69
|
+
const promise = until(s);
|
|
70
|
+
s.set(7);
|
|
71
|
+
expect(await promise).toBe(7);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------- debounced ----------
|
|
76
|
+
|
|
77
|
+
describe("debounced", () => {
|
|
78
|
+
it("returns initial value immediately", () => {
|
|
79
|
+
vi.useFakeTimers();
|
|
80
|
+
const s = signal(1);
|
|
81
|
+
const d = debounced(s, 100);
|
|
82
|
+
expect(d()).toBe(1);
|
|
83
|
+
vi.useRealTimers();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("debounces rapid updates", () => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
const s = signal(1);
|
|
89
|
+
const d = debounced(s, 100);
|
|
90
|
+
|
|
91
|
+
s.set(2);
|
|
92
|
+
s.set(3);
|
|
93
|
+
s.set(4);
|
|
94
|
+
|
|
95
|
+
expect(d()).toBe(1); // not yet updated
|
|
96
|
+
|
|
97
|
+
vi.advanceTimersByTime(100);
|
|
98
|
+
expect(d()).toBe(4); // settled on last value
|
|
99
|
+
vi.useRealTimers();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("emits each value when updates are spaced apart", () => {
|
|
103
|
+
vi.useFakeTimers();
|
|
104
|
+
const s = signal(0);
|
|
105
|
+
const d = debounced(s, 50);
|
|
106
|
+
|
|
107
|
+
s.set(1);
|
|
108
|
+
vi.advanceTimersByTime(50);
|
|
109
|
+
expect(d()).toBe(1);
|
|
110
|
+
|
|
111
|
+
s.set(2);
|
|
112
|
+
vi.advanceTimersByTime(50);
|
|
113
|
+
expect(d()).toBe(2);
|
|
114
|
+
vi.useRealTimers();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------- history ----------
|
|
119
|
+
|
|
120
|
+
describe("history", () => {
|
|
121
|
+
it("current reflects signal value", () => {
|
|
122
|
+
const s = signal("a");
|
|
123
|
+
const h = history(s);
|
|
124
|
+
expect(h.current()).toBe("a");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("canUndo is false initially", () => {
|
|
128
|
+
const s = signal(0);
|
|
129
|
+
const h = history(s);
|
|
130
|
+
expect(h.canUndo()).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("canUndo becomes true after a change", () => {
|
|
134
|
+
const s = signal(0);
|
|
135
|
+
const h = history(s);
|
|
136
|
+
s.set(1);
|
|
137
|
+
expect(h.canUndo()).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("undo reverts to previous value", () => {
|
|
141
|
+
const s = signal(0);
|
|
142
|
+
const h = history(s);
|
|
143
|
+
s.set(1);
|
|
144
|
+
s.set(2);
|
|
145
|
+
h.undo();
|
|
146
|
+
expect(h.current()).toBe(1);
|
|
147
|
+
expect(s()).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("redo re-applies reverted value", () => {
|
|
151
|
+
const s = signal(0);
|
|
152
|
+
const h = history(s);
|
|
153
|
+
s.set(1);
|
|
154
|
+
h.undo();
|
|
155
|
+
expect(h.canRedo()).toBe(true);
|
|
156
|
+
h.redo();
|
|
157
|
+
expect(h.current()).toBe(1);
|
|
158
|
+
expect(s()).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("values() returns full timeline", () => {
|
|
162
|
+
const s = signal("a");
|
|
163
|
+
const h = history(s);
|
|
164
|
+
s.set("b");
|
|
165
|
+
s.set("c");
|
|
166
|
+
expect(h.values()).toEqual(["a", "b", "c"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("clear removes past and future", () => {
|
|
170
|
+
const s = signal(0);
|
|
171
|
+
const h = history(s);
|
|
172
|
+
s.set(1);
|
|
173
|
+
s.set(2);
|
|
174
|
+
h.clear();
|
|
175
|
+
expect(h.canUndo()).toBe(false);
|
|
176
|
+
expect(h.canRedo()).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("undo does nothing when there is no history", () => {
|
|
180
|
+
const s = signal(5);
|
|
181
|
+
const h = history(s);
|
|
182
|
+
h.undo();
|
|
183
|
+
expect(h.current()).toBe(5);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("redo does nothing when there is no future", () => {
|
|
187
|
+
const s = signal(5);
|
|
188
|
+
const h = history(s);
|
|
189
|
+
s.set(10);
|
|
190
|
+
h.redo();
|
|
191
|
+
expect(h.current()).toBe(10);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("works with computed source (read-only — no signal.set)", () => {
|
|
195
|
+
const s = signal(1);
|
|
196
|
+
const c = computed(() => s() * 2);
|
|
197
|
+
const h = history(c);
|
|
198
|
+
s.set(2);
|
|
199
|
+
expect(h.current()).toBe(4);
|
|
200
|
+
expect(h.canUndo()).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("respects limit — oldest entries are dropped", () => {
|
|
204
|
+
const s = signal(0);
|
|
205
|
+
const h = history(s, 3);
|
|
206
|
+
for (let i = 1; i <= 5; i++) s.set(i);
|
|
207
|
+
// past holds at most 3, plus current = 4 values total
|
|
208
|
+
expect(h.values().length).toBeLessThanOrEqual(4);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { resource, createResource } from "../async/resource";
|
|
4
|
+
import { signal } from "../signal/signal";
|
|
5
|
+
|
|
6
|
+
describe("resource", () => {
|
|
7
|
+
it("starts as pending and resolves to success", async () => {
|
|
8
|
+
const r = resource(() => Promise.resolve("hello"));
|
|
9
|
+
expect(r.status()).toBe("pending");
|
|
10
|
+
await vi.waitFor(() => r.status() === "success");
|
|
11
|
+
expect(r.data()).toBe("hello");
|
|
12
|
+
expect(r.pending()).toBe(false);
|
|
13
|
+
expect(r.error()).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("transitions to error on rejection", async () => {
|
|
17
|
+
const r = resource(() => Promise.reject(new Error("oops")));
|
|
18
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
19
|
+
expect(r.status()).toBe("error");
|
|
20
|
+
expect((r.error() as Error).message).toBe("oops");
|
|
21
|
+
expect(r.data()).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("wraps non-Error rejections in Error", async () => {
|
|
25
|
+
const r = resource(() => Promise.reject("plain string"));
|
|
26
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
27
|
+
expect(r.status()).toBe("error");
|
|
28
|
+
expect(r.error()).toBeInstanceOf(Error);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("immediate=false does not fetch on creation", () => {
|
|
32
|
+
const fetcher = vi.fn(() => Promise.resolve(1));
|
|
33
|
+
const r = resource(fetcher, { immediate: false });
|
|
34
|
+
expect(fetcher).not.toHaveBeenCalled();
|
|
35
|
+
expect(r.status()).toBe("idle");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("refetch() re-runs the fetcher", async () => {
|
|
39
|
+
let count = 0;
|
|
40
|
+
const r = resource(() => Promise.resolve(++count));
|
|
41
|
+
await vi.waitFor(() => r.status() === "success");
|
|
42
|
+
expect(r.data()).toBe(1);
|
|
43
|
+
r.refetch();
|
|
44
|
+
await vi.waitFor(() => r.data() === 2);
|
|
45
|
+
expect(r.data()).toBe(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("cancel() sets status back to idle and ignores in-flight result", async () => {
|
|
49
|
+
let resolve!: (v: number) => void;
|
|
50
|
+
const r = resource(() => new Promise<number>((res) => { resolve = res; }));
|
|
51
|
+
r.cancel();
|
|
52
|
+
expect(r.status()).toBe("idle");
|
|
53
|
+
resolve(99);
|
|
54
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
55
|
+
expect(r.data()).toBeNull(); // stale result ignored
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("mutate() sets data directly", async () => {
|
|
59
|
+
const r = resource(() => Promise.resolve(1));
|
|
60
|
+
await vi.waitFor(() => r.status() === "success");
|
|
61
|
+
r.mutate(999);
|
|
62
|
+
expect(r.data()).toBe(999);
|
|
63
|
+
expect(r.status()).toBe("success");
|
|
64
|
+
expect(r.error()).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("initialData is visible when immediate=false", () => {
|
|
68
|
+
const r = resource(
|
|
69
|
+
() => new Promise<string>(() => {}),
|
|
70
|
+
{ initialData: "cached", immediate: false },
|
|
71
|
+
);
|
|
72
|
+
expect(r.data()).toBe("cached");
|
|
73
|
+
expect(r.status()).toBe("idle");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keepPreviousData=true preserves old data during refetch", async () => {
|
|
77
|
+
let call = 0;
|
|
78
|
+
const r = resource(() => Promise.resolve(++call), { keepPreviousData: true });
|
|
79
|
+
await vi.waitFor(() => r.data() === 1);
|
|
80
|
+
r.refetch();
|
|
81
|
+
// data should still be 1 while pending
|
|
82
|
+
expect(r.data()).toBe(1);
|
|
83
|
+
await vi.waitFor(() => r.data() === 2);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("createResource", () => {
|
|
88
|
+
it("re-fetches when the param signal changes", async () => {
|
|
89
|
+
const id = signal(1);
|
|
90
|
+
const fetcher = vi.fn((n: number) => Promise.resolve(n * 10));
|
|
91
|
+
const r = createResource(id, fetcher);
|
|
92
|
+
await vi.waitFor(() => r.data() === 10);
|
|
93
|
+
id.set(2);
|
|
94
|
+
await vi.waitFor(() => r.data() === 20);
|
|
95
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { signal } from "../signal/signal";
|
|
4
|
+
|
|
5
|
+
describe("signal", () => {
|
|
6
|
+
it("returns initial value", () => {
|
|
7
|
+
const s = signal(42);
|
|
8
|
+
expect(s()).toBe(42);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("updates value with set", () => {
|
|
12
|
+
const s = signal(0);
|
|
13
|
+
s.set(10);
|
|
14
|
+
expect(s()).toBe(10);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("updates value with update fn", () => {
|
|
18
|
+
const s = signal(5);
|
|
19
|
+
s.update((v) => v * 2);
|
|
20
|
+
expect(s()).toBe(10);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("skips update when value is identical", () => {
|
|
24
|
+
const s = signal(1);
|
|
25
|
+
const calls: number[] = [];
|
|
26
|
+
s.subscribe((v) => calls.push(v));
|
|
27
|
+
const before = calls.length;
|
|
28
|
+
s.set(1); // same value
|
|
29
|
+
expect(calls.length).toBe(before);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("subscribe fires immediately with current value", () => {
|
|
33
|
+
const s = signal("hello");
|
|
34
|
+
const received: string[] = [];
|
|
35
|
+
s.subscribe((v) => received.push(v));
|
|
36
|
+
expect(received).toEqual(["hello"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("subscribe fires on subsequent updates", () => {
|
|
40
|
+
const s = signal(0);
|
|
41
|
+
const received: number[] = [];
|
|
42
|
+
s.subscribe((v) => received.push(v));
|
|
43
|
+
s.set(1);
|
|
44
|
+
s.set(2);
|
|
45
|
+
expect(received).toEqual([0, 1, 2]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("unsubscribe stops receiving updates", () => {
|
|
49
|
+
const s = signal(0);
|
|
50
|
+
const received: number[] = [];
|
|
51
|
+
const unsub = s.subscribe((v) => received.push(v));
|
|
52
|
+
unsub();
|
|
53
|
+
s.set(99);
|
|
54
|
+
expect(received).toEqual([0]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("marks __isSignal = true", () => {
|
|
58
|
+
const s = signal(0);
|
|
59
|
+
expect(s.__isSignal).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("supports object values", () => {
|
|
63
|
+
const s = signal<{ name: string }>({ name: "Alice" });
|
|
64
|
+
s.set({ name: "Bob" });
|
|
65
|
+
expect(s().name).toBe("Bob");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("supports null and undefined", () => {
|
|
69
|
+
const s = signal<null | number>(null);
|
|
70
|
+
expect(s()).toBeNull();
|
|
71
|
+
s.set(5);
|
|
72
|
+
expect(s()).toBe(5);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { track, runEffect, activeEffect } from "../signal/effect";
|
|
4
|
+
import { signal } from "../signal/signal";
|
|
5
|
+
|
|
6
|
+
describe("track", () => {
|
|
7
|
+
it("runs the effect immediately", () => {
|
|
8
|
+
const fn = vi.fn();
|
|
9
|
+
track(fn);
|
|
10
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("sets activeEffect during execution, resets after", () => {
|
|
14
|
+
let during: unknown = "not-set";
|
|
15
|
+
track(() => {
|
|
16
|
+
during = activeEffect;
|
|
17
|
+
});
|
|
18
|
+
expect(during).toBeTypeOf("function");
|
|
19
|
+
expect(activeEffect).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("supports nested track calls — restores outer effect", () => {
|
|
23
|
+
const outer = vi.fn();
|
|
24
|
+
const inner = vi.fn();
|
|
25
|
+
let outerActive: unknown;
|
|
26
|
+
let innerActive: unknown;
|
|
27
|
+
|
|
28
|
+
track(() => {
|
|
29
|
+
outer();
|
|
30
|
+
outerActive = activeEffect;
|
|
31
|
+
track(() => {
|
|
32
|
+
inner();
|
|
33
|
+
innerActive = activeEffect;
|
|
34
|
+
});
|
|
35
|
+
// after inner track, activeEffect should be back to outer
|
|
36
|
+
expect(activeEffect).toBe(outerActive);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(outer).toHaveBeenCalledOnce();
|
|
40
|
+
expect(inner).toHaveBeenCalledOnce();
|
|
41
|
+
expect(innerActive).not.toBe(outerActive);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("subscribes the tracked effect to signals read inside it", () => {
|
|
45
|
+
const s = signal(1);
|
|
46
|
+
const values: number[] = [];
|
|
47
|
+
track(() => values.push(s()));
|
|
48
|
+
s.set(2);
|
|
49
|
+
expect(values).toEqual([1, 2]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("runEffect", () => {
|
|
54
|
+
it("sets activeEffect to the given value", () => {
|
|
55
|
+
const fn = () => {};
|
|
56
|
+
runEffect(fn);
|
|
57
|
+
expect(activeEffect).toBe(fn);
|
|
58
|
+
runEffect(null);
|
|
59
|
+
expect(activeEffect).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/reactive/reactive.ts
CHANGED
|
@@ -8,16 +8,24 @@ export function when<T>(
|
|
|
8
8
|
fn: (value: NonNullable<T>) => void,
|
|
9
9
|
) {
|
|
10
10
|
let disposed = false;
|
|
11
|
+
const ref = { cancel: undefined as (() => void) | undefined };
|
|
11
12
|
|
|
12
13
|
const stop = effect(() => {
|
|
13
14
|
const value = source();
|
|
14
15
|
if (!value || disposed) return;
|
|
15
16
|
|
|
16
17
|
disposed = true;
|
|
17
|
-
|
|
18
|
+
ref.cancel?.();
|
|
18
19
|
fn(value);
|
|
19
20
|
});
|
|
20
21
|
|
|
22
|
+
ref.cancel = stop;
|
|
23
|
+
|
|
24
|
+
// If source was truthy on the first synchronous run, ref.cancel?.() above
|
|
25
|
+
// was a no-op because it hadn't been assigned yet. Cancel the effect now.
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
27
|
+
if (disposed) stop();
|
|
28
|
+
|
|
21
29
|
return stop;
|
|
22
30
|
}
|
|
23
31
|
|
package/src/signal/persisted.ts
CHANGED
|
@@ -60,20 +60,18 @@ export function persistedSignal<T>(
|
|
|
60
60
|
|
|
61
61
|
if (syncTabs) {
|
|
62
62
|
window.addEventListener("storage", (event) => {
|
|
63
|
-
if (event.key
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
inner.set(initialValue);
|
|
76
|
-
}
|
|
63
|
+
if (event.key !== key || event.storageArea !== localStorage) return;
|
|
64
|
+
try {
|
|
65
|
+
const newValue = event.newValue
|
|
66
|
+
? deserialize(event.newValue)
|
|
67
|
+
: initialValue;
|
|
68
|
+
inner.set(newValue);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`Failed to deserialize value for key "${key}" from storage event:`,
|
|
72
|
+
e,
|
|
73
|
+
);
|
|
74
|
+
inner.set(initialValue);
|
|
77
75
|
}
|
|
78
76
|
});
|
|
79
77
|
}
|