= {
163 | [P in keyof T as Exclude]: T[P];
164 | };
165 |
166 | export function omit, K extends readonly (keyof T)[]>(
167 | props: T,
168 | ...keys: K
169 | ): Omit {
170 | const blocked = new Set(keys);
171 | if (SUPPORTS_PROXY && $PROXY in props) {
172 | return new Proxy(
173 | {
174 | get(property) {
175 | return blocked.has(property) ? undefined : props[property as any];
176 | },
177 | has(property) {
178 | return !blocked.has(property) && property in props;
179 | },
180 | keys() {
181 | return Object.keys(props).filter(k => !blocked.has(k));
182 | }
183 | },
184 | propTraps
185 | ) as unknown as Omit;
186 | }
187 | const result: Record = {};
188 |
189 | for (const propName of Object.getOwnPropertyNames(props)) {
190 | if (!blocked.has(propName)) {
191 | const desc = Object.getOwnPropertyDescriptor(props, propName)!;
192 | !desc.get && !desc.set && desc.enumerable && desc.writable && desc.configurable
193 | ? (result[propName] = desc.value)
194 | : Object.defineProperty(result, propName, desc);
195 | }
196 | }
197 | return result as any;
198 | }
199 |
--------------------------------------------------------------------------------
/tests/context.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ContextNotFoundError,
3 | createContext,
4 | createRoot,
5 | getContext,
6 | hasContext,
7 | NoOwnerError,
8 | setContext
9 | } from "../src/index.js";
10 |
11 | it("should create context", () => {
12 | const context = createContext(1);
13 |
14 | expect(context.id).toBeDefined();
15 | expect(context.defaultValue).toEqual(1);
16 |
17 | createRoot(() => {
18 | setContext(context);
19 | expect(getContext(context)).toEqual(1);
20 | });
21 | });
22 |
23 | it("should forward context across roots", () => {
24 | const context = createContext(1);
25 | createRoot(() => {
26 | setContext(context, 2);
27 | createRoot(() => {
28 | expect(getContext(context)).toEqual(2);
29 | createRoot(() => {
30 | expect(getContext(context)).toEqual(2);
31 | });
32 | });
33 | });
34 | });
35 |
36 | it("should not expose context on parent when set in child", () => {
37 | const context = createContext(1);
38 | createRoot(() => {
39 | createRoot(() => {
40 | setContext(context, 4);
41 | });
42 |
43 | expect(getContext(context)).toEqual(1);
44 | });
45 | });
46 |
47 | it("should return true if context has been provided", () => {
48 | const context = createContext();
49 | createRoot(() => {
50 | setContext(context, 1);
51 | expect(hasContext(context)).toBeTruthy();
52 | });
53 | });
54 |
55 | it("should return false if context has not been provided", () => {
56 | const context = createContext();
57 | createRoot(() => {
58 | expect(hasContext(context)).toBeFalsy();
59 | });
60 | });
61 |
62 | it("should throw error when trying to get context outside owner", () => {
63 | const context = createContext();
64 | expect(() => getContext(context)).toThrowError(NoOwnerError);
65 | });
66 |
67 | it("should throw error when trying to set context outside owner", () => {
68 | const context = createContext();
69 | expect(() => setContext(context)).toThrowError(NoOwnerError);
70 | });
71 |
72 | it("should throw error when trying to get context without setting it first", () => {
73 | const context = createContext();
74 | expect(() => createRoot(() => getContext(context))).toThrowError(ContextNotFoundError);
75 | });
76 |
--------------------------------------------------------------------------------
/tests/createAsync.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createAsync,
3 | createEffect,
4 | createMemo,
5 | createRoot,
6 | createSignal,
7 | flushSync,
8 | isPending,
9 | latest,
10 | resolve
11 | } from "../src/index.js";
12 |
13 | it("diamond should not cause waterfalls on read", async () => {
14 | //
15 | // s
16 | // / \
17 | // / \
18 | // b c
19 | // \ /
20 | // \ /
21 | // e
22 | //
23 | const [s, set] = createSignal(1);
24 | const effect = vi.fn();
25 | const async1 = vi.fn(() => Promise.resolve(s()));
26 | const async2 = vi.fn(() => Promise.resolve(s()));
27 |
28 | createRoot(() => {
29 | const b = createAsync(async1);
30 | const c = createAsync(async2);
31 | createEffect(
32 | () => [b(), c()],
33 | v => effect(...v)
34 | );
35 | });
36 |
37 | expect(async1).toHaveBeenCalledTimes(1);
38 | expect(async2).toHaveBeenCalledTimes(1);
39 | expect(effect).toHaveBeenCalledTimes(0);
40 | await new Promise(r => setTimeout(r, 0));
41 | expect(async1).toHaveBeenCalledTimes(1);
42 | expect(async2).toHaveBeenCalledTimes(1);
43 | expect(effect).toHaveBeenCalledTimes(1);
44 | expect(effect).toHaveBeenCalledWith(1, 1);
45 | set(2);
46 | expect(async1).toHaveBeenCalledTimes(1);
47 | expect(async2).toHaveBeenCalledTimes(1);
48 | expect(effect).toHaveBeenCalledTimes(1);
49 | flushSync();
50 | expect(async1).toHaveBeenCalledTimes(2);
51 | expect(async2).toHaveBeenCalledTimes(2);
52 | expect(effect).toHaveBeenCalledTimes(1);
53 | await new Promise(r => setTimeout(r, 0));
54 | expect(async1).toHaveBeenCalledTimes(2);
55 | expect(async2).toHaveBeenCalledTimes(2);
56 | expect(effect).toHaveBeenCalledTimes(2);
57 | expect(effect).toHaveBeenCalledWith(2, 2);
58 | });
59 |
60 | it("should waterfall when dependent on another async with shared source", async () => {
61 | //
62 | // s
63 | // /|
64 | // a |
65 | // \|
66 | // b
67 | // |
68 | // e
69 | //
70 | let a;
71 | const [s, set] = createSignal(1);
72 | const effect = vi.fn();
73 | const async1 = vi.fn(() => Promise.resolve(s()));
74 | const async2 = vi.fn(() => Promise.resolve(s() + a()));
75 |
76 | createRoot(() => {
77 | a = createAsync(async1);
78 | const b = createAsync(async2);
79 |
80 | createEffect(
81 | () => b(),
82 | v => effect(v)
83 | );
84 | });
85 |
86 | expect(async1).toHaveBeenCalledTimes(1);
87 | expect(async2).toHaveBeenCalledTimes(1);
88 | expect(effect).toHaveBeenCalledTimes(0);
89 | await new Promise(r => setTimeout(r, 0));
90 | expect(async1).toHaveBeenCalledTimes(1);
91 | expect(async2).toHaveBeenCalledTimes(2);
92 | expect(effect).toHaveBeenCalledTimes(1);
93 | expect(effect).toHaveBeenCalledWith(2);
94 | set(2);
95 | expect(async1).toHaveBeenCalledTimes(1);
96 | expect(async2).toHaveBeenCalledTimes(2);
97 | expect(effect).toHaveBeenCalledTimes(1);
98 | flushSync();
99 | expect(async1).toHaveBeenCalledTimes(2);
100 | expect(async2).toHaveBeenCalledTimes(3);
101 | expect(effect).toHaveBeenCalledTimes(1);
102 | await new Promise(r => setTimeout(r, 0));
103 | expect(async1).toHaveBeenCalledTimes(2);
104 | expect(async2).toHaveBeenCalledTimes(4);
105 | expect(effect).toHaveBeenCalledTimes(2);
106 | expect(effect).toHaveBeenCalledWith(4);
107 | });
108 |
109 | it("should should show stale state with `isPending`", async () => {
110 | const [s, set] = createSignal(1);
111 | const async1 = vi.fn(() => Promise.resolve(s()));
112 | const a = createRoot(() => createAsync(async1));
113 | const b = createMemo(() => (isPending(a) ? "stale" : "not stale"));
114 | expect(b).toThrow();
115 | await new Promise(r => setTimeout(r, 0));
116 | expect(b()).toBe("not stale");
117 | set(2);
118 | expect(b()).toBe("stale");
119 | flushSync();
120 | expect(b()).toBe("stale");
121 | await new Promise(r => setTimeout(r, 0));
122 | expect(b()).toBe("not stale");
123 | });
124 |
125 | it("should get latest value with `latest`", async () => {
126 | const [s, set] = createSignal(1);
127 | const async1 = vi.fn(() => Promise.resolve(s()));
128 | const a = createRoot(() => createAsync(async1));
129 | const b = createMemo(() => latest(a));
130 | expect(b).toThrow();
131 | await new Promise(r => setTimeout(r, 0));
132 | expect(b()).toBe(1);
133 | set(2);
134 | expect(b()).toBe(1);
135 | flushSync();
136 | expect(b()).toBe(1);
137 | await new Promise(r => setTimeout(r, 0));
138 | expect(b()).toBe(2);
139 | });
140 |
141 | it("should resolve to a value with resolveAsync", async () => {
142 | const [s, set] = createSignal(1);
143 | const async1 = vi.fn(() => Promise.resolve(s()));
144 | let value: number | undefined;
145 | createRoot(() => {
146 | const a = createAsync(async1);
147 | createEffect(
148 | () => {},
149 | () => {
150 | (async () => {
151 | value = await resolve(a);
152 | })();
153 | }
154 | );
155 | });
156 | expect(value).toBe(undefined);
157 | await new Promise(r => setTimeout(r, 0));
158 | expect(value).toBe(1);
159 | set(2);
160 | expect(value).toBe(1);
161 | flushSync();
162 | expect(value).toBe(1);
163 | await new Promise(r => setTimeout(r, 0));
164 | // doesn't update because not tracked
165 | expect(value).toBe(1);
166 | });
167 |
168 | it("should handle streams", async () => {
169 | const effect = vi.fn();
170 | createRoot(() => {
171 | const v = createAsync(async function* () {
172 | yield await Promise.resolve(1);
173 | yield await Promise.resolve(2);
174 | yield await Promise.resolve(3);
175 | });
176 | createEffect(v, v => effect(v));
177 | });
178 | flushSync();
179 | expect(effect).toHaveBeenCalledTimes(0);
180 | await Promise.resolve();
181 | await Promise.resolve();
182 | await Promise.resolve();
183 | await Promise.resolve();
184 | expect(effect).toHaveBeenCalledTimes(1);
185 | expect(effect).toHaveBeenCalledWith(1);
186 | await Promise.resolve();
187 | await Promise.resolve();
188 | await Promise.resolve();
189 | expect(effect).toHaveBeenCalledTimes(2);
190 | expect(effect).toHaveBeenCalledWith(2);
191 | await Promise.resolve();
192 | await Promise.resolve();
193 | await Promise.resolve();
194 | expect(effect).toHaveBeenCalledTimes(3);
195 | expect(effect).toHaveBeenCalledWith(3);
196 | });
197 |
--------------------------------------------------------------------------------
/tests/createEffect.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createMemo,
4 | createRenderEffect,
5 | createRoot,
6 | createSignal,
7 | flushSync,
8 | onCleanup
9 | } from "../src/index.js";
10 |
11 | afterEach(() => flushSync());
12 |
13 | it("should run effect", () => {
14 | const [$x, setX] = createSignal(0),
15 | compute = vi.fn($x),
16 | effect = vi.fn();
17 |
18 | createRoot(() => createEffect(compute, effect));
19 | expect(compute).toHaveBeenCalledTimes(1);
20 | expect(effect).toHaveBeenCalledTimes(0);
21 | flushSync();
22 | expect(compute).toHaveBeenCalledTimes(1);
23 | expect(effect).toHaveBeenCalledTimes(1);
24 | expect(effect).toHaveBeenCalledWith(0, undefined);
25 |
26 | setX(1);
27 | flushSync();
28 | expect(compute).toHaveBeenCalledTimes(2);
29 | expect(effect).toHaveBeenCalledTimes(2);
30 | expect(effect).toHaveBeenCalledWith(1, 0);
31 | });
32 |
33 | it("should run effect on change", () => {
34 | const effect = vi.fn();
35 |
36 | const [$x, setX] = createSignal(10);
37 | const [$y, setY] = createSignal(10);
38 |
39 | const $a = createMemo(() => $x() + $y());
40 | const $b = createMemo(() => $a());
41 |
42 | createRoot(() => createEffect($b, effect));
43 |
44 | expect(effect).to.toHaveBeenCalledTimes(0);
45 |
46 | setX(20);
47 | flushSync();
48 | expect(effect).to.toHaveBeenCalledTimes(1);
49 |
50 | setY(20);
51 | flushSync();
52 | expect(effect).to.toHaveBeenCalledTimes(2);
53 |
54 | setX(20);
55 | setY(20);
56 | flushSync();
57 | expect(effect).to.toHaveBeenCalledTimes(2);
58 | });
59 |
60 | it("should handle nested effect", () => {
61 | const [$x, setX] = createSignal(0);
62 | const [$y, setY] = createSignal(0);
63 |
64 | const outerEffect = vi.fn();
65 | const innerEffect = vi.fn();
66 | const innerPureDispose = vi.fn();
67 | const innerEffectDispose = vi.fn();
68 |
69 | const stopEffect = createRoot(dispose => {
70 | createEffect(() => {
71 | $x();
72 | createEffect(
73 | () => {
74 | $y();
75 | onCleanup(innerPureDispose);
76 | },
77 | () => {
78 | innerEffect();
79 | return () => {
80 | innerEffectDispose();
81 | };
82 | }
83 | );
84 | }, outerEffect);
85 |
86 | return dispose;
87 | });
88 |
89 | flushSync();
90 | expect(outerEffect).toHaveBeenCalledTimes(1);
91 | expect(innerEffect).toHaveBeenCalledTimes(1);
92 | expect(innerPureDispose).toHaveBeenCalledTimes(0);
93 | expect(innerEffectDispose).toHaveBeenCalledTimes(0);
94 |
95 | setY(1);
96 | flushSync();
97 | expect(outerEffect).toHaveBeenCalledTimes(1);
98 | expect(innerEffect).toHaveBeenCalledTimes(2);
99 | expect(innerPureDispose).toHaveBeenCalledTimes(1);
100 | expect(innerEffectDispose).toHaveBeenCalledTimes(1);
101 |
102 | setY(2);
103 | flushSync();
104 | expect(outerEffect).toHaveBeenCalledTimes(1);
105 | expect(innerEffect).toHaveBeenCalledTimes(3);
106 | expect(innerPureDispose).toHaveBeenCalledTimes(2);
107 | expect(innerEffectDispose).toHaveBeenCalledTimes(2);
108 |
109 | innerEffect.mockReset();
110 | innerPureDispose.mockReset();
111 | innerEffectDispose.mockReset();
112 |
113 | setX(1);
114 | flushSync();
115 | expect(outerEffect).toHaveBeenCalledTimes(2);
116 | expect(innerEffect).toHaveBeenCalledTimes(1); // new one is created
117 | expect(innerPureDispose).toHaveBeenCalledTimes(1);
118 | expect(innerEffectDispose).toHaveBeenCalledTimes(1);
119 |
120 | setY(3);
121 | flushSync();
122 | expect(outerEffect).toHaveBeenCalledTimes(2);
123 | expect(innerEffect).toHaveBeenCalledTimes(2);
124 | expect(innerPureDispose).toHaveBeenCalledTimes(2);
125 | expect(innerEffectDispose).toHaveBeenCalledTimes(2);
126 |
127 | stopEffect();
128 | setX(10);
129 | setY(10);
130 | expect(outerEffect).toHaveBeenCalledTimes(2);
131 | expect(innerEffect).toHaveBeenCalledTimes(2);
132 | expect(innerPureDispose).toHaveBeenCalledTimes(3);
133 | expect(innerEffectDispose).toHaveBeenCalledTimes(3);
134 | });
135 |
136 | it("should stop effect", () => {
137 | const effect = vi.fn();
138 |
139 | const [$x, setX] = createSignal(10);
140 |
141 | const stopEffect = createRoot(dispose => {
142 | createEffect($x, effect);
143 | return dispose;
144 | });
145 |
146 | stopEffect();
147 |
148 | setX(20);
149 | flushSync();
150 | expect(effect).toHaveBeenCalledTimes(0);
151 | });
152 |
153 | it("should run all disposals before each new run", () => {
154 | const effect = vi.fn();
155 | const disposeA = vi.fn();
156 | const disposeB = vi.fn();
157 | const disposeC = vi.fn();
158 |
159 | function fnA() {
160 | onCleanup(disposeA);
161 | }
162 |
163 | function fnB() {
164 | onCleanup(disposeB);
165 | }
166 |
167 | const [$x, setX] = createSignal(0);
168 |
169 | createRoot(() =>
170 | createEffect(
171 | () => {
172 | fnA(), fnB();
173 | return $x();
174 | },
175 | () => {
176 | effect();
177 | return disposeC;
178 | }
179 | )
180 | );
181 | flushSync();
182 |
183 | expect(effect).toHaveBeenCalledTimes(1);
184 | expect(disposeA).toHaveBeenCalledTimes(0);
185 | expect(disposeB).toHaveBeenCalledTimes(0);
186 | expect(disposeC).toHaveBeenCalledTimes(0);
187 |
188 | for (let i = 1; i <= 3; i += 1) {
189 | setX(i);
190 | flushSync();
191 | expect(effect).toHaveBeenCalledTimes(i + 1);
192 | expect(disposeA).toHaveBeenCalledTimes(i);
193 | expect(disposeB).toHaveBeenCalledTimes(i);
194 | expect(disposeC).toHaveBeenCalledTimes(i);
195 | }
196 | });
197 |
198 | it("should dispose of nested effect", () => {
199 | const [$x, setX] = createSignal(0);
200 | const innerEffect = vi.fn();
201 |
202 | const stopEffect = createRoot(dispose => {
203 | createEffect(
204 | () => {
205 | createEffect($x, innerEffect);
206 | },
207 | () => {}
208 | );
209 |
210 | return dispose;
211 | });
212 |
213 | stopEffect();
214 |
215 | setX(10);
216 | flushSync();
217 | expect(innerEffect).toHaveBeenCalledTimes(0);
218 | expect(innerEffect).not.toHaveBeenCalledWith(10);
219 | });
220 |
221 | it("should conditionally observe", () => {
222 | const [$x, setX] = createSignal(0);
223 | const [$y, setY] = createSignal(0);
224 | const [$condition, setCondition] = createSignal(true);
225 |
226 | const $a = createMemo(() => ($condition() ? $x() : $y()));
227 | const effect = vi.fn();
228 |
229 | createRoot(() => createEffect($a, effect));
230 | flushSync();
231 |
232 | expect(effect).toHaveBeenCalledTimes(1);
233 |
234 | setY(1);
235 | flushSync();
236 | expect(effect).toHaveBeenCalledTimes(1);
237 |
238 | setX(1);
239 | flushSync();
240 | expect(effect).toHaveBeenCalledTimes(2);
241 |
242 | setCondition(false);
243 | flushSync();
244 | expect(effect).toHaveBeenCalledTimes(2);
245 |
246 | setY(2);
247 | flushSync();
248 | expect(effect).toHaveBeenCalledTimes(3);
249 |
250 | setX(3);
251 | flushSync();
252 | expect(effect).toHaveBeenCalledTimes(3);
253 | });
254 |
255 | it("should dispose of nested conditional effect", () => {
256 | const [$condition, setCondition] = createSignal(true);
257 |
258 | const disposeA = vi.fn();
259 | const disposeB = vi.fn();
260 |
261 | function fnA() {
262 | createEffect(
263 | () => {
264 | onCleanup(disposeA);
265 | },
266 | () => {}
267 | );
268 | }
269 |
270 | function fnB() {
271 | createEffect(
272 | () => {
273 | onCleanup(disposeB);
274 | },
275 | () => {}
276 | );
277 | }
278 |
279 | createRoot(() =>
280 | createEffect(
281 | () => ($condition() ? fnA() : fnB()),
282 | () => {}
283 | )
284 | );
285 | flushSync();
286 | setCondition(false);
287 | flushSync();
288 | expect(disposeA).toHaveBeenCalledTimes(1);
289 | });
290 |
291 | // https://github.com/preactjs/signals/issues/152
292 | it("should handle looped effects", () => {
293 | let values: number[] = [],
294 | loop = 2;
295 |
296 | const [$value, setValue] = createSignal(0);
297 |
298 | let x = 0;
299 | createRoot(() =>
300 | createEffect(
301 | () => {
302 | x++;
303 | values.push($value());
304 | for (let i = 0; i < loop; i++) {
305 | createEffect(
306 | () => {
307 | values.push($value() + i);
308 | },
309 | () => {}
310 | );
311 | }
312 | },
313 | () => {}
314 | )
315 | );
316 |
317 | flushSync();
318 |
319 | expect(values).toHaveLength(3);
320 | expect(values.join(",")).toBe("0,0,1");
321 |
322 | loop = 1;
323 | values = [];
324 | setValue(1);
325 | flushSync();
326 |
327 | expect(values).toHaveLength(2);
328 | expect(values.join(",")).toBe("1,1");
329 |
330 | values = [];
331 | setValue(2);
332 | flushSync();
333 |
334 | expect(values).toHaveLength(2);
335 | expect(values.join(",")).toBe("2,2");
336 | });
337 |
338 | it("should apply changes in effect in same flush", async () => {
339 | const [$x, setX] = createSignal(0),
340 | [$y, setY] = createSignal(0);
341 |
342 | const $a = createMemo(() => {
343 | return $x() + 1;
344 | }),
345 | $b = createMemo(() => {
346 | return $a() + 2;
347 | });
348 |
349 | createRoot(() =>
350 | createEffect($y, () => {
351 | setX(n => n + 1);
352 | })
353 | );
354 | flushSync();
355 |
356 | expect($x()).toBe(1);
357 | expect($b()).toBe(4);
358 | expect($a()).toBe(2);
359 |
360 | setY(1);
361 |
362 | flushSync();
363 |
364 | expect($x()).toBe(2);
365 | expect($b()).toBe(5);
366 | expect($a()).toBe(3);
367 |
368 | setY(2);
369 |
370 | flushSync();
371 |
372 | expect($x()).toBe(3);
373 | expect($b()).toBe(6);
374 | expect($a()).toBe(4);
375 | });
376 |
377 | it("should run parent effect before child effect", () => {
378 | const [$x, setX] = createSignal(0);
379 | const $condition = createMemo(() => $x());
380 |
381 | let calls = 0;
382 |
383 | createRoot(() =>
384 | createEffect(
385 | () => {
386 | createEffect(
387 | () => {
388 | $x();
389 | calls++;
390 | },
391 | () => {}
392 | );
393 |
394 | $condition();
395 | },
396 | () => {}
397 | )
398 | );
399 |
400 | setX(1);
401 | flushSync();
402 | expect(calls).toBe(2);
403 | });
404 |
405 | it("should run render effect before user effects", () => {
406 | const [$x, setX] = createSignal(0);
407 |
408 | let mark = "";
409 | createRoot(() => {
410 | createEffect($x, () => {
411 | mark += "b";
412 | });
413 | createRenderEffect($x, () => {
414 | mark += "a";
415 | });
416 | });
417 |
418 | flushSync();
419 | expect(mark).toBe("ab");
420 | setX(1);
421 | flushSync();
422 | expect(mark).toBe("abab");
423 | });
424 |
425 | it("should defer user effects with the defer option", () => {
426 | let mark = "";
427 | const [$x, setX] = createSignal(0);
428 | createRoot(() => {
429 | createEffect(
430 | $x,
431 | () => {
432 | mark += "b";
433 | },
434 | undefined,
435 | undefined,
436 | { defer: true }
437 | );
438 | });
439 | flushSync();
440 | expect(mark).toBe("");
441 | setX(1);
442 | flushSync();
443 | expect(mark).toBe("b");
444 | });
445 |
--------------------------------------------------------------------------------
/tests/createErrorBoundary.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createErrorBoundary,
3 | createMemo,
4 | createRenderEffect,
5 | createRoot,
6 | createSignal,
7 | flushSync
8 | } from "../src/index.js";
9 |
10 | it("should let errors bubble up when not handled", () => {
11 | const error = new Error();
12 | expect(() => {
13 | createRoot(() => {
14 | createRenderEffect(
15 | () => {
16 | throw error;
17 | },
18 | () => {}
19 | );
20 | });
21 | flushSync();
22 | }).toThrowError(error.cause as Error);
23 | });
24 |
25 | it("should handle error", () => {
26 | const error = new Error();
27 |
28 | const b = createRoot(() =>
29 | createErrorBoundary(
30 | () => {
31 | throw error;
32 | },
33 | () => "errored"
34 | )
35 | );
36 |
37 | expect(b()).toBe("errored");
38 | });
39 |
40 | it("should forward error to another handler", () => {
41 | const error = new Error();
42 |
43 | const b = createRoot(() =>
44 | createErrorBoundary(
45 | () => {
46 | const inner = createErrorBoundary(
47 | () => {
48 | throw error;
49 | },
50 | e => {
51 | expect(e).toBe(error);
52 | throw e;
53 | }
54 | );
55 | createRenderEffect(inner, () => {});
56 | },
57 | () => "errored"
58 | )
59 | );
60 |
61 | expect(b()).toBe("errored");
62 | });
63 |
64 | it("should not duplicate error handler", () => {
65 | const error = new Error(),
66 | handler = vi.fn();
67 |
68 | let [$x, setX] = createSignal(0),
69 | shouldThrow = false;
70 |
71 | createRoot(() => {
72 | const b = createErrorBoundary(() => {
73 | $x();
74 | if (shouldThrow) throw error;
75 | }, handler);
76 | createRenderEffect(b, () => {});
77 | });
78 |
79 | setX(1);
80 | flushSync();
81 |
82 | shouldThrow = true;
83 | setX(2);
84 | flushSync();
85 | expect(handler).toHaveBeenCalledTimes(1);
86 | });
87 |
88 | it("should not trigger wrong handler", () => {
89 | const error = new Error(),
90 | rootHandler = vi.fn(),
91 | handler = vi.fn();
92 |
93 | let [$x, setX] = createSignal(0),
94 | shouldThrow = false;
95 |
96 | createRoot(() => {
97 | const b = createErrorBoundary(() => {
98 | createRenderEffect(
99 | () => {
100 | $x();
101 | if (shouldThrow) throw error;
102 | },
103 | () => {}
104 | );
105 |
106 | const b2 = createErrorBoundary(() => {
107 | // no-op
108 | }, handler);
109 | createRenderEffect(b2, () => {});
110 | }, rootHandler);
111 | createRenderEffect(b, () => {});
112 | });
113 |
114 | expect(rootHandler).toHaveBeenCalledTimes(0);
115 | shouldThrow = true;
116 | setX(1);
117 | flushSync();
118 |
119 | expect(rootHandler).toHaveBeenCalledTimes(1);
120 | expect(handler).not.toHaveBeenCalledWith(error);
121 | });
122 |
123 | it("should throw error if there are no handlers left", () => {
124 | const error = new Error(),
125 | handler = vi.fn(e => {
126 | throw e;
127 | });
128 |
129 | expect(() => {
130 | createErrorBoundary(() => {
131 | createErrorBoundary(() => {
132 | throw error;
133 | }, handler)();
134 | }, handler)();
135 | }).toThrow(error);
136 |
137 | expect(handler).toHaveBeenCalledTimes(2);
138 | });
139 |
140 | it("should handle errors when the effect is on the outside", async () => {
141 | const error = new Error(),
142 | rootHandler = vi.fn();
143 |
144 | const [$x, setX] = createSignal(0);
145 |
146 | createRoot(() => {
147 | const b = createErrorBoundary(
148 | () => {
149 | if ($x()) throw error;
150 | createErrorBoundary(
151 | () => {
152 | throw error;
153 | },
154 | e => {
155 | expect(e).toBe(error);
156 | }
157 | );
158 | },
159 | err => rootHandler(err)
160 | );
161 | createRenderEffect(
162 | () => b()?.(),
163 | () => {}
164 | );
165 | });
166 | expect(rootHandler).toHaveBeenCalledTimes(0);
167 | setX(1);
168 | flushSync();
169 | expect(rootHandler).toHaveBeenCalledWith(error);
170 | expect(rootHandler).toHaveBeenCalledTimes(1);
171 | });
172 |
173 | it("should handle errors when the effect is on the outside and memo in the middle", async () => {
174 | const error = new Error(),
175 | rootHandler = vi.fn();
176 |
177 | createRoot(() => {
178 | const b = createErrorBoundary(
179 | () =>
180 | createMemo(() => {
181 | throw error;
182 | }),
183 | rootHandler
184 | );
185 | createRenderEffect(b, () => {});
186 | });
187 | expect(rootHandler).toHaveBeenCalledTimes(1);
188 | });
189 |
--------------------------------------------------------------------------------
/tests/createMemo.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createErrorBoundary,
4 | createMemo,
5 | createRoot,
6 | createSignal,
7 | flushSync,
8 | hasUpdated
9 | } from "../src/index.js";
10 |
11 | afterEach(() => flushSync());
12 |
13 | it("should store and return value on read", () => {
14 | const [$x] = createSignal(1);
15 | const [$y] = createSignal(1);
16 |
17 | const $a = createMemo(() => $x() + $y());
18 |
19 | expect($a()).toBe(2);
20 | flushSync();
21 |
22 | // Try again to ensure state is maintained.
23 | expect($a()).toBe(2);
24 | });
25 |
26 | it("should update when dependency is updated", () => {
27 | const [$x, setX] = createSignal(1);
28 | const [$y, setY] = createSignal(1);
29 |
30 | const $a = createMemo(() => $x() + $y());
31 |
32 | setX(2);
33 | expect($a()).toBe(3);
34 |
35 | setY(2);
36 | expect($a()).toBe(4);
37 | });
38 |
39 | it("should update when deep dependency is updated", () => {
40 | const [$x, setX] = createSignal(1);
41 | const [$y] = createSignal(1);
42 |
43 | const $a = createMemo(() => $x() + $y());
44 | const $b = createMemo(() => $a());
45 |
46 | setX(2);
47 | expect($b()).toBe(3);
48 | });
49 |
50 | it("should update when deep computed dependency is updated", () => {
51 | const [$x, setX] = createSignal(10);
52 | const [$y] = createSignal(10);
53 |
54 | const $a = createMemo(() => $x() + $y());
55 | const $b = createMemo(() => $a());
56 | const $c = createMemo(() => $b());
57 |
58 | setX(20);
59 | expect($c()).toBe(30);
60 | });
61 |
62 | it("should only re-compute when needed", () => {
63 | const computed = vi.fn();
64 |
65 | const [$x, setX] = createSignal(10);
66 | const [$y, setY] = createSignal(10);
67 |
68 | const $a = createMemo(() => computed($x() + $y()));
69 |
70 | expect(computed).not.toHaveBeenCalled();
71 |
72 | $a();
73 | expect(computed).toHaveBeenCalledTimes(1);
74 | expect(computed).toHaveBeenCalledWith(20);
75 |
76 | $a();
77 | expect(computed).toHaveBeenCalledTimes(1);
78 |
79 | setX(20);
80 | $a();
81 | expect(computed).toHaveBeenCalledTimes(2);
82 |
83 | setY(20);
84 | $a();
85 | expect(computed).toHaveBeenCalledTimes(3);
86 |
87 | $a();
88 | expect(computed).toHaveBeenCalledTimes(3);
89 | });
90 |
91 | it("should only re-compute whats needed", () => {
92 | const memoA = vi.fn(n => n);
93 | const memoB = vi.fn(n => n);
94 |
95 | const [$x, setX] = createSignal(10);
96 | const [$y, setY] = createSignal(10);
97 |
98 | const $a = createMemo(() => memoA($x()));
99 | const $b = createMemo(() => memoB($y()));
100 | const $c = createMemo(() => $a() + $b());
101 |
102 | expect(memoA).not.toHaveBeenCalled();
103 | expect(memoB).not.toHaveBeenCalled();
104 |
105 | $c();
106 | expect(memoA).toHaveBeenCalledTimes(1);
107 | expect(memoB).toHaveBeenCalledTimes(1);
108 | expect($c()).toBe(20);
109 |
110 | setX(20);
111 | flushSync();
112 |
113 | $c();
114 | expect(memoA).toHaveBeenCalledTimes(2);
115 | expect(memoB).toHaveBeenCalledTimes(1);
116 | expect($c()).toBe(30);
117 |
118 | setY(20);
119 | flushSync();
120 |
121 | $c();
122 | expect(memoA).toHaveBeenCalledTimes(2);
123 | expect(memoB).toHaveBeenCalledTimes(2);
124 | expect($c()).toBe(40);
125 | });
126 |
127 | it("should discover new dependencies", () => {
128 | const [$x, setX] = createSignal(1);
129 | const [$y, setY] = createSignal(0);
130 |
131 | const $c = createMemo(() => {
132 | if ($x()) {
133 | return $x();
134 | } else {
135 | return $y();
136 | }
137 | });
138 |
139 | expect($c()).toBe(1);
140 |
141 | setX(0);
142 | flushSync();
143 | expect($c()).toBe(0);
144 |
145 | setY(10);
146 | flushSync();
147 | expect($c()).toBe(10);
148 | });
149 |
150 | it("should accept equals option", () => {
151 | const [$x, setX] = createSignal(0);
152 |
153 | const $a = createMemo(() => $x(), 0, {
154 | // Skip even numbers.
155 | equals: (prev, next) => prev + 1 === next
156 | });
157 |
158 | const effectA = vi.fn();
159 | createRoot(() => createEffect($a, effectA));
160 | flushSync();
161 |
162 | expect($a()).toBe(0);
163 | expect(effectA).toHaveBeenCalledTimes(1);
164 |
165 | setX(2);
166 | flushSync();
167 | expect($a()).toBe(2);
168 | expect(effectA).toHaveBeenCalledTimes(2);
169 |
170 | // no-change
171 | setX(3);
172 | flushSync();
173 | expect($a()).toBe(2);
174 | expect(effectA).toHaveBeenCalledTimes(2);
175 | });
176 |
177 | it("should use fallback if error is thrown during init", () => {
178 | createRoot(() => {
179 | createErrorBoundary(
180 | () => {
181 | const $a = createMemo(() => {
182 | if (1) throw Error();
183 | return "";
184 | }, "foo");
185 |
186 | expect($a()).toBe("foo");
187 | },
188 | () => {}
189 | )();
190 | });
191 | });
192 |
193 | it("should detect which signal triggered it", () => {
194 | const [$x, setX] = createSignal(0);
195 | const [$y, setY] = createSignal(0);
196 |
197 | const $a = createMemo(() => {
198 | const uX = hasUpdated($x);
199 | const uY = hasUpdated($y);
200 | return uX && uY ? "both" : uX ? "x" : uY ? "y" : "neither";
201 | });
202 | createRoot(() => createEffect($a, () => {}));
203 | expect($a()).toBe("neither");
204 | flushSync();
205 | expect($a()).toBe("neither");
206 |
207 | setY(1);
208 | flushSync();
209 | expect($a()).toBe("y");
210 |
211 | setX(1);
212 | flushSync();
213 | expect($a()).toBe("x");
214 |
215 | setY(2);
216 | flushSync();
217 | expect($a()).toBe("y");
218 |
219 | setX(2);
220 | setY(3);
221 | flushSync();
222 | expect($a()).toBe("both");
223 | });
224 |
--------------------------------------------------------------------------------
/tests/createRoot.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Computation,
3 | createEffect,
4 | createMemo,
5 | createRenderEffect,
6 | createRoot,
7 | createSignal,
8 | flushSync,
9 | getOwner,
10 | onCleanup,
11 | Owner,
12 | type Accessor,
13 | type Signal
14 | } from "../src/index.js";
15 |
16 | afterEach(() => flushSync());
17 |
18 | it("should dispose of inner computations", () => {
19 | let $x: Signal;
20 | let $y: Accessor;
21 |
22 | const memo = vi.fn(() => $x[0]() + 10);
23 |
24 | createRoot(dispose => {
25 | $x = createSignal(10);
26 | $y = createMemo(memo);
27 | $y();
28 | dispose();
29 | });
30 |
31 | // expect($y!).toThrow();
32 | expect(memo).toHaveBeenCalledTimes(1);
33 |
34 | flushSync();
35 |
36 | $x;
37 | flushSync();
38 |
39 | // expect($y!).toThrow();
40 | expect(memo).toHaveBeenCalledTimes(1);
41 | });
42 |
43 | it("should return result", () => {
44 | const result = createRoot(dispose => {
45 | dispose();
46 | return 10;
47 | });
48 |
49 | expect(result).toBe(10);
50 | });
51 |
52 | it("should create new tracking scope", () => {
53 | const [$x, setX] = createSignal(0);
54 | const effect = vi.fn();
55 |
56 | const stopEffect = createRoot(dispose => {
57 | createEffect(
58 | () => {
59 | $x();
60 | createRoot(() => void createEffect($x, effect));
61 | },
62 | () => {}
63 | );
64 |
65 | return dispose;
66 | });
67 | flushSync();
68 |
69 | expect(effect).toHaveBeenCalledWith(0, undefined);
70 | expect(effect).toHaveBeenCalledTimes(1);
71 |
72 | stopEffect();
73 |
74 | setX(10);
75 | flushSync();
76 | expect(effect).not.toHaveBeenCalledWith(10);
77 | expect(effect).toHaveBeenCalledTimes(1);
78 | });
79 |
80 | it("should not be reactive", () => {
81 | let $x: Signal;
82 |
83 | const root = vi.fn();
84 |
85 | createRoot(() => {
86 | $x = createSignal(0);
87 | $x[0]();
88 | root();
89 | });
90 |
91 | expect(root).toHaveBeenCalledTimes(1);
92 |
93 | $x;
94 | flushSync();
95 | expect(root).toHaveBeenCalledTimes(1);
96 | });
97 |
98 | it("should hold parent tracking", () => {
99 | createRoot(() => {
100 | const parent = getOwner();
101 | createRoot(() => {
102 | expect(getOwner()!._parent).toBe(parent);
103 | });
104 | });
105 | });
106 |
107 | it("should not observe", () => {
108 | const [$x] = createSignal(0);
109 | createRoot(() => {
110 | $x();
111 | const owner = getOwner() as Computation;
112 | expect(owner._sources).toBeUndefined();
113 | expect(owner._observers).toBeUndefined();
114 | });
115 | });
116 |
117 | it("should not throw if dispose called during active disposal process", () => {
118 | createRoot(dispose => {
119 | onCleanup(() => dispose());
120 | dispose();
121 | });
122 | });
123 |
124 | it("should not generate ids if no id is provided", () => {
125 | let o: Owner | null;
126 | let m: Owner | null;
127 |
128 | createRoot(() => {
129 | o = getOwner();
130 | const c = createMemo(() => {
131 | m = getOwner();
132 | });
133 | c();
134 | });
135 |
136 | expect(o!.id).toEqual(null);
137 | expect(m!.id).toEqual(null);
138 | });
139 |
140 | it("should generate ids if id is provided", () => {
141 | let o: Owner | null;
142 | let m: Owner | null;
143 | let m2: Owner | null;
144 | let c: string;
145 | let c2: string;
146 | let c3: string;
147 | let r: Owner | null;
148 |
149 | createRoot(
150 | () => {
151 | o = getOwner();
152 | const memo = createMemo(() => {
153 | m = getOwner()!;
154 | c = m.getNextChildId();
155 | return createMemo(() => {
156 | m2 = getOwner()!;
157 | c2 = m2.getNextChildId();
158 | c3 = m2.getNextChildId();
159 | });
160 | });
161 | createRenderEffect(
162 | () => {
163 | r = getOwner();
164 | memo()();
165 | },
166 | () => {}
167 | );
168 | },
169 | { id: "" }
170 | );
171 |
172 | expect(o!.id).toEqual("");
173 | expect(m!.id).toEqual("0");
174 | expect(c!).toEqual("00");
175 | expect(m2!.id).toEqual("01");
176 | expect(r!.id).toEqual("1");
177 | expect(c2!).toEqual("010");
178 | expect(c3!).toEqual("011");
179 | });
180 |
--------------------------------------------------------------------------------
/tests/createSignal.test.ts:
--------------------------------------------------------------------------------
1 | import { createSignal, flushSync } from "../src/index.js";
2 |
3 | afterEach(() => flushSync());
4 |
5 | it("should store and return value on read", () => {
6 | const [$x] = createSignal(1);
7 | expect($x).toBeInstanceOf(Function);
8 | expect($x()).toBe(1);
9 | });
10 |
11 | it("should update signal via setter", () => {
12 | const [$x, setX] = createSignal(1);
13 | setX(2);
14 | expect($x()).toBe(2);
15 | });
16 |
17 | it("should update signal via update function", () => {
18 | const [$x, setX] = createSignal(1);
19 | setX(n => n + 1);
20 | expect($x()).toBe(2);
21 | });
22 |
23 | it("should accept equals option", () => {
24 | const [$x, setX] = createSignal(1, {
25 | // Skip even numbers.
26 | equals: (prev, next) => prev + 1 === next
27 | });
28 |
29 | setX(11);
30 | expect($x()).toBe(11);
31 |
32 | setX(12);
33 | expect($x()).toBe(11);
34 |
35 | setX(13);
36 | expect($x()).toBe(13);
37 |
38 | setX(14);
39 | expect($x()).toBe(13);
40 | });
41 |
42 | it("should update signal with functional value", () => {
43 | const [$x, setX] = createSignal<() => number>(() => () => 10);
44 | expect($x()()).toBe(10);
45 | setX(() => () => 20);
46 | expect($x()()).toBe(20);
47 | });
48 |
49 | it("should create signal derived from another signal", () => {
50 | const [$x, setX] = createSignal(1);
51 | const [$y, setY] = createSignal(() => $x() + 1);
52 | expect($y()).toBe(2);
53 | setY(1);
54 | expect($y()).toBe(1);
55 | setX(2);
56 | expect($y()).toBe(3);
57 | });
58 |
--------------------------------------------------------------------------------
/tests/flushSync.test.ts:
--------------------------------------------------------------------------------
1 | import { createEffect, createRoot, createSignal, flushSync } from "../src/index.js";
2 |
3 | afterEach(() => flushSync());
4 |
5 | it("should batch updates", () => {
6 | const [$x, setX] = createSignal(10);
7 | const effect = vi.fn();
8 |
9 | createRoot(() => createEffect($x, effect));
10 | flushSync();
11 |
12 | setX(20);
13 | setX(30);
14 | setX(40);
15 |
16 | expect(effect).to.toHaveBeenCalledTimes(1);
17 | flushSync();
18 | expect(effect).to.toHaveBeenCalledTimes(2);
19 | });
20 |
21 | it("should wait for queue to flush", () => {
22 | const [$x, setX] = createSignal(10);
23 | const $effect = vi.fn();
24 |
25 | createRoot(() => createEffect($x, $effect));
26 | flushSync();
27 |
28 | expect($effect).to.toHaveBeenCalledTimes(1);
29 |
30 | setX(20);
31 | flushSync();
32 | expect($effect).to.toHaveBeenCalledTimes(2);
33 |
34 | setX(30);
35 | flushSync();
36 | expect($effect).to.toHaveBeenCalledTimes(3);
37 | });
38 |
39 | it("should not fail if called while flushing", () => {
40 | const [$a, setA] = createSignal(10);
41 |
42 | const effect = vi.fn(() => {
43 | flushSync();
44 | });
45 |
46 | createRoot(() => createEffect($a, effect));
47 | flushSync();
48 |
49 | expect(effect).to.toHaveBeenCalledTimes(1);
50 |
51 | setA(20);
52 | flushSync();
53 | expect(effect).to.toHaveBeenCalledTimes(2);
54 | });
55 |
--------------------------------------------------------------------------------
/tests/gc.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createMemo,
4 | createRoot,
5 | createSignal,
6 | flushSync,
7 | getOwner
8 | } from "../src/index.js";
9 |
10 | function gc() {
11 | return new Promise(resolve =>
12 | setTimeout(async () => {
13 | flushSync(); // flush call stack (holds a reference)
14 | global.gc!();
15 | resolve(void 0);
16 | }, 0)
17 | );
18 | }
19 |
20 | if (global.gc) {
21 | it("should gc computed if there are no observers", async () => {
22 | const [$x] = createSignal(0),
23 | ref = new WeakRef(createMemo(() => $x()));
24 |
25 | await gc();
26 | expect(ref.deref()).toBeUndefined();
27 | });
28 |
29 | it("should _not_ gc computed if there are observers", async () => {
30 | let [$x] = createSignal(0),
31 | pointer;
32 |
33 | const ref = new WeakRef((pointer = createMemo(() => $x())));
34 |
35 | ref.deref()!();
36 |
37 | await gc();
38 | expect(ref.deref()).toBeDefined();
39 |
40 | pointer = undefined;
41 | await gc();
42 | expect(ref.deref()).toBeUndefined();
43 | });
44 |
45 | it("should gc root if disposed", async () => {
46 | let [$x] = createSignal(0),
47 | ref!: WeakRef,
48 | pointer;
49 |
50 | const dispose = createRoot(dispose => {
51 | ref = new WeakRef(
52 | (pointer = createMemo(() => {
53 | $x();
54 | }))
55 | );
56 |
57 | return dispose;
58 | });
59 |
60 | await gc();
61 | expect(ref.deref()).toBeDefined();
62 |
63 | dispose();
64 | await gc();
65 | expect(ref.deref()).toBeDefined();
66 |
67 | pointer = undefined;
68 | await gc();
69 | expect(ref.deref()).toBeUndefined();
70 | });
71 |
72 | it("should gc effect lazily", async () => {
73 | let [$x, setX] = createSignal(0),
74 | ref!: WeakRef;
75 |
76 | const dispose = createRoot(dispose => {
77 | createEffect($x, () => {
78 | ref = new WeakRef(getOwner()!);
79 | });
80 |
81 | return dispose;
82 | });
83 |
84 | await gc();
85 | expect(ref.deref()).toBeDefined();
86 |
87 | dispose();
88 | setX(1);
89 |
90 | await gc();
91 | expect(ref.deref()).toBeUndefined();
92 | });
93 | } else {
94 | it("", () => {});
95 | }
96 |
--------------------------------------------------------------------------------
/tests/getOwner.test.ts:
--------------------------------------------------------------------------------
1 | import { createEffect, createRoot, getOwner, untrack } from "../src/index.js";
2 |
3 | it("should return current owner", () => {
4 | createRoot(() => {
5 | const owner = getOwner();
6 | expect(owner).toBeDefined();
7 | createEffect(
8 | () => {
9 | expect(getOwner()).toBeDefined();
10 | expect(getOwner()).not.toBe(owner);
11 | },
12 | () => {}
13 | );
14 | });
15 | });
16 |
17 | it("should return parent scope from inside untrack", () => {
18 | createRoot(() => {
19 | untrack(() => {
20 | expect(getOwner()).toBeDefined();
21 | });
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/tests/graph.test.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/preactjs/signals/blob/17c0155997f47f4bb81e5715b46a55d1fafa22a2/packages/core/test/signal.test.tsx#L1383
2 |
3 | import { createMemo, createSignal } from "../src/index.js";
4 |
5 | it("should drop X->B->X updates", () => {
6 | // X
7 | // / |
8 | // A | <- Looks like a flag doesn't it? :D
9 | // \ |
10 | // B
11 | // |
12 | // C
13 |
14 | const [$x, setX] = createSignal(2);
15 |
16 | const $a = createMemo(() => $x() - 1);
17 | const $b = createMemo(() => $x() + $a());
18 |
19 | const compute = vi.fn(() => "c: " + $b());
20 | const $c = createMemo(compute);
21 |
22 | expect($c()).toBe("c: 3");
23 | expect(compute).toHaveBeenCalledTimes(1);
24 | compute.mockReset();
25 |
26 | setX(4);
27 | $c();
28 | expect(compute).toHaveBeenCalledTimes(1);
29 | });
30 |
31 | it("should only update every signal once (diamond graph)", () => {
32 | // In this scenario "C" should only update once when "X" receive an update. This is sometimes
33 | // referred to as the "diamond" scenario.
34 | // X
35 | // / \
36 | // A B
37 | // \ /
38 | // C
39 |
40 | const [$x, setX] = createSignal("a");
41 | const $a = createMemo(() => $x());
42 | const $b = createMemo(() => $x());
43 |
44 | const spy = vi.fn(() => $a() + " " + $b());
45 | const $c = createMemo(spy);
46 |
47 | expect($c()).toBe("a a");
48 | expect(spy).toHaveBeenCalledTimes(1);
49 |
50 | setX("aa");
51 | expect($c()).toBe("aa aa");
52 | expect(spy).toHaveBeenCalledTimes(2);
53 | });
54 |
55 | it("should only update every signal once (diamond graph + tail)", () => {
56 | // "D" will be likely updated twice if our mark+sweep logic is buggy.
57 | // X
58 | // / \
59 | // A B
60 | // \ /
61 | // C
62 | // |
63 | // D
64 |
65 | const [$x, setX] = createSignal("a");
66 |
67 | const $a = createMemo(() => $x());
68 | const $b = createMemo(() => $x());
69 | const $c = createMemo(() => $a() + " " + $b());
70 |
71 | const spy = vi.fn(() => $c());
72 | const $d = createMemo(spy);
73 |
74 | expect($d()).toBe("a a");
75 | expect(spy).toHaveBeenCalledTimes(1);
76 |
77 | setX("aa");
78 | expect($d()).toBe("aa aa");
79 | expect(spy).toHaveBeenCalledTimes(2);
80 | });
81 |
82 | it("should bail out if result is the same", () => {
83 | // Bail out if value of "A" never changes
84 | // X->A->B
85 |
86 | const [$x, setX] = createSignal("a");
87 |
88 | const $a = createMemo(() => {
89 | $x();
90 | return "foo";
91 | });
92 |
93 | const spy = vi.fn(() => $a());
94 | const $b = createMemo(spy);
95 |
96 | expect($b()).toBe("foo");
97 | expect(spy).toHaveBeenCalledTimes(1);
98 |
99 | setX("aa");
100 | expect($b()).toBe("foo");
101 | expect(spy).toHaveBeenCalledTimes(1);
102 | });
103 |
104 | it("should only update every signal once (jagged diamond graph + tails)", () => {
105 | // "E" and "F" will be likely updated >3 if our mark+sweep logic is buggy.
106 | // X
107 | // / \
108 | // A B
109 | // | |
110 | // | C
111 | // \ /
112 | // D
113 | // / \
114 | // E F
115 |
116 | const [$x, setX] = createSignal("a");
117 |
118 | const $a = createMemo(() => $x());
119 | const $b = createMemo(() => $x());
120 | const $c = createMemo(() => $b());
121 |
122 | const dSpy = vi.fn(() => $a() + " " + $c());
123 | const $d = createMemo(dSpy);
124 |
125 | const eSpy = vi.fn(() => $d());
126 | const $e = createMemo(eSpy);
127 | const fSpy = vi.fn(() => $d());
128 | const $f = createMemo(fSpy);
129 |
130 | expect($e()).toBe("a a");
131 | expect(eSpy).toHaveBeenCalledTimes(1);
132 |
133 | expect($f()).toBe("a a");
134 | expect(fSpy).toHaveBeenCalledTimes(1);
135 |
136 | setX("b");
137 |
138 | expect($d()).toBe("b b");
139 | expect(dSpy).toHaveBeenCalledTimes(2);
140 |
141 | expect($e()).toBe("b b");
142 | expect(eSpy).toHaveBeenCalledTimes(2);
143 |
144 | expect($f()).toBe("b b");
145 | expect(fSpy).toHaveBeenCalledTimes(2);
146 |
147 | setX("c");
148 |
149 | expect($d()).toBe("c c");
150 | expect(dSpy).toHaveBeenCalledTimes(3);
151 |
152 | expect($e()).toBe("c c");
153 | expect(eSpy).toHaveBeenCalledTimes(3);
154 |
155 | expect($f()).toBe("c c");
156 | expect(fSpy).toHaveBeenCalledTimes(3);
157 | });
158 |
159 | it("should ensure subs update even if one dep is static", () => {
160 | // X
161 | // / \
162 | // A *B <- returns same value every time
163 | // \ /
164 | // C
165 |
166 | const [$x, setX] = createSignal("a");
167 |
168 | const $a = createMemo(() => $x());
169 | const $b = createMemo(() => {
170 | $x();
171 | return "c";
172 | });
173 |
174 | const spy = vi.fn(() => $a() + " " + $b());
175 | const $c = createMemo(spy);
176 |
177 | expect($c()).toBe("a c");
178 |
179 | setX("aa");
180 |
181 | expect($c()).toBe("aa c");
182 | expect(spy).toHaveBeenCalledTimes(2);
183 | });
184 |
185 | it("should ensure subs update even if two deps mark it clean", () => {
186 | // In this scenario both "B" and "C" always return the same value. But "D" must still update
187 | // because "X" marked it. If "D" isn't updated, then we have a bug.
188 | // X
189 | // / | \
190 | // A *B *C
191 | // \ | /
192 | // D
193 |
194 | const [$x, setX] = createSignal("a");
195 |
196 | const $b = createMemo(() => $x());
197 | const $c = createMemo(() => {
198 | $x();
199 | return "c";
200 | });
201 | const $d = createMemo(() => {
202 | $x();
203 | return "d";
204 | });
205 |
206 | const spy = vi.fn(() => $b() + " " + $c() + " " + $d());
207 | const $e = createMemo(spy);
208 |
209 | expect($e()).toBe("a c d");
210 |
211 | setX("aa");
212 |
213 | expect($e()).toBe("aa c d");
214 | expect(spy).toHaveBeenCalledTimes(2);
215 | });
216 |
217 | it("propagates in topological order", () => {
218 | //
219 | // c1
220 | // / \
221 | // / \
222 | // b1 b2
223 | // \ /
224 | // \ /
225 | // a1
226 | //
227 | var seq = "",
228 | [a1, setA1] = createSignal(false),
229 | b1 = createMemo(
230 | () => {
231 | a1();
232 | seq += "b1";
233 | },
234 | undefined,
235 | { equals: false }
236 | ),
237 | b2 = createMemo(
238 | () => {
239 | a1();
240 | seq += "b2";
241 | },
242 | undefined,
243 | { equals: false }
244 | ),
245 | c1 = createMemo(
246 | () => {
247 | b1(), b2();
248 | seq += "c1";
249 | },
250 | undefined,
251 | { equals: false }
252 | );
253 |
254 | c1();
255 | seq = "";
256 | setA1(true);
257 | c1();
258 | expect(seq).toBe("b1b2c1");
259 | });
260 |
261 | it("only propagates once with linear convergences", () => {
262 | // d
263 | // |
264 | // +---+---+---+---+
265 | // v v v v v
266 | // f1 f2 f3 f4 f5
267 | // | | | | |
268 | // +---+---+---+---+
269 | // v
270 | // g
271 | var [d, setD] = createSignal(0),
272 | f1 = createMemo(() => d()),
273 | f2 = createMemo(() => d()),
274 | f3 = createMemo(() => d()),
275 | f4 = createMemo(() => d()),
276 | f5 = createMemo(() => d()),
277 | gcount = 0,
278 | g = createMemo(() => {
279 | gcount++;
280 | return f1() + f2() + f3() + f4() + f5();
281 | });
282 |
283 | g();
284 | gcount = 0;
285 | setD(1);
286 | g();
287 | expect(gcount).toBe(1);
288 | });
289 |
290 | it("only propagates once with exponential convergence", () => {
291 | // d
292 | // |
293 | // +---+---+
294 | // v v v
295 | // f1 f2 f3
296 | // \ | /
297 | // O
298 | // / | \
299 | // v v v
300 | // g1 g2 g3
301 | // +---+---+
302 | // v
303 | // h
304 | var [d, setD] = createSignal(0),
305 | f1 = createMemo(() => {
306 | return d();
307 | }),
308 | f2 = createMemo(() => {
309 | return d();
310 | }),
311 | f3 = createMemo(() => {
312 | return d();
313 | }),
314 | g1 = createMemo(() => {
315 | return f1() + f2() + f3();
316 | }),
317 | g2 = createMemo(() => {
318 | return f1() + f2() + f3();
319 | }),
320 | g3 = createMemo(() => {
321 | return f1() + f2() + f3();
322 | }),
323 | hcount = 0,
324 | h = createMemo(() => {
325 | hcount++;
326 | return g1() + g2() + g3();
327 | });
328 | h();
329 | hcount = 0;
330 | setD(1);
331 | h();
332 | expect(hcount).toBe(1);
333 | });
334 |
335 | it("does not trigger downstream computations unless changed", () => {
336 | const [s1, set] = createSignal(1, { equals: false });
337 | let order = "";
338 | const t1 = createMemo(() => {
339 | order += "t1";
340 | return s1();
341 | });
342 | const t2 = createMemo(() => {
343 | order += "c1";
344 | t1();
345 | });
346 | t2();
347 | expect(order).toBe("c1t1");
348 | order = "";
349 | set(1);
350 | t2();
351 | expect(order).toBe("t1");
352 | order = "";
353 | set(2);
354 | t2();
355 | expect(order).toBe("t1c1");
356 | });
357 |
358 | it("applies updates to changed dependees in same order as createMemo", () => {
359 | const [s1, set] = createSignal(0);
360 | let order = "";
361 | const t1 = createMemo(() => {
362 | order += "t1";
363 | return s1() === 0;
364 | });
365 | const t2 = createMemo(() => {
366 | order += "c1";
367 | return s1();
368 | });
369 | const t3 = createMemo(() => {
370 | order += "c2";
371 | return t1();
372 | });
373 | t2();
374 | t3();
375 | expect(order).toBe("c1c2t1");
376 | order = "";
377 | set(1);
378 | t2();
379 | t3();
380 | expect(order).toBe("c1t1c2");
381 | });
382 |
383 | it("updates downstream pending computations", () => {
384 | const [s1, set] = createSignal(0);
385 | const [s2] = createSignal(0);
386 | let order = "";
387 | const t1 = createMemo(() => {
388 | order += "t1";
389 | return s1() === 0;
390 | });
391 | const t2 = createMemo(() => {
392 | order += "c1";
393 | return s1();
394 | });
395 | const t3 = createMemo(() => {
396 | order += "c2";
397 | t1();
398 | return createMemo(() => {
399 | order += "c2_1";
400 | return s2();
401 | });
402 | });
403 | order = "";
404 | set(1);
405 | t2();
406 | t3()();
407 | expect(order).toBe("c1c2t1c2_1");
408 | });
409 |
410 | describe("with changing dependencies", () => {
411 | let i: () => boolean, setI: (v: boolean) => void;
412 | let t: () => number, setT: (v: number) => void;
413 | let e: () => number, setE: (v: number) => void;
414 | let fevals: number;
415 | let f: () => number;
416 |
417 | function init() {
418 | [i, setI] = createSignal(true);
419 | [t, setT] = createSignal(1);
420 | [e, setE] = createSignal(2);
421 | fevals = 0;
422 | f = createMemo(() => {
423 | fevals++;
424 | return i() ? t() : e();
425 | });
426 | f();
427 | fevals = 0;
428 | }
429 |
430 | it("updates on active dependencies", () => {
431 | init();
432 | setT(5);
433 | expect(f()).toBe(5);
434 | expect(fevals).toBe(1);
435 | });
436 |
437 | it("does not update on inactive dependencies", () => {
438 | init();
439 | setE(5);
440 | expect(f()).toBe(1);
441 | expect(fevals).toBe(0);
442 | });
443 |
444 | it("deactivates obsolete dependencies", () => {
445 | init();
446 | setI(false);
447 | f();
448 | fevals = 0;
449 | setT(5);
450 | f();
451 | expect(fevals).toBe(0);
452 | });
453 |
454 | it("activates new dependencies", () => {
455 | init();
456 | setI(false);
457 | fevals = 0;
458 | setE(5);
459 | f();
460 | expect(fevals).toBe(1);
461 | });
462 |
463 | it("ensures that new dependencies are updated before dependee", () => {
464 | var order = "",
465 | [a, setA] = createSignal(0),
466 | b = createMemo(() => {
467 | order += "b";
468 | return a() + 1;
469 | }),
470 | c = createMemo(() => {
471 | order += "c";
472 | const check = b();
473 | if (check) {
474 | return check;
475 | }
476 | return e();
477 | }),
478 | d = createMemo(() => {
479 | return a();
480 | }),
481 | e = createMemo(() => {
482 | order += "d";
483 | return d() + 10;
484 | });
485 |
486 | c();
487 | e();
488 | expect(order).toBe("cbd");
489 |
490 | order = "";
491 | setA(-1);
492 | c();
493 | e();
494 |
495 | expect(order).toBe("bcd");
496 | expect(c()).toBe(9);
497 |
498 | order = "";
499 | setA(0);
500 | c();
501 | e();
502 | expect(order).toBe("bcd");
503 | expect(c()).toBe(1);
504 | });
505 | });
506 |
507 | it("does not update subsequent pending computations after stale invocations", () => {
508 | const [s1, set1] = createSignal(1);
509 | const [s2, set2] = createSignal(false);
510 | let count = 0;
511 | /*
512 | s1
513 | |
514 | +---+---+
515 | t1 t2 c1 t3
516 | \ /
517 | c3
518 | [PN,PN,STL,void]
519 | */
520 | const t1 = createMemo(() => s1() > 0);
521 | const t2 = createMemo(() => s1() > 0);
522 | const c1 = createMemo(() => s1());
523 | const t3 = createMemo(() => {
524 | const a = s1();
525 | const b = s2();
526 | return a && b;
527 | });
528 | const c3 = createMemo(() => {
529 | t1();
530 | t2();
531 | c1();
532 | t3();
533 | count++;
534 | });
535 | c3();
536 | set2(true);
537 | c3();
538 | expect(count).toBe(2);
539 | set1(2);
540 | c3();
541 | expect(count).toBe(3);
542 | });
543 |
544 | it("evaluates stale computations before dependees when trackers stay unchanged", () => {
545 | let [s1, set] = createSignal(1, { equals: false });
546 | let order = "";
547 | let t1 = createMemo(() => {
548 | order += "t1";
549 | return s1() > 2;
550 | });
551 | let t2 = createMemo(() => {
552 | order += "t2";
553 | return s1() > 2;
554 | });
555 | let c1 = createMemo(
556 | () => {
557 | order += "c1";
558 | s1();
559 | },
560 | undefined,
561 | { equals: false }
562 | );
563 | const c2 = createMemo(() => {
564 | order += "c2";
565 | t1();
566 | t2();
567 | c1();
568 | });
569 | c2();
570 | order = "";
571 | set(1);
572 | c2();
573 | expect(order).toBe("t1t2c1c2");
574 | order = "";
575 | set(3);
576 | c2();
577 | expect(order).toBe("t1c2t2c1");
578 | });
579 |
580 | it("correctly marks downstream computations as stale on change", () => {
581 | const [s1, set] = createSignal(1);
582 | let order = "";
583 | const t1 = createMemo(() => {
584 | order += "t1";
585 | return s1();
586 | });
587 | const c1 = createMemo(() => {
588 | order += "c1";
589 | return t1();
590 | });
591 | const c2 = createMemo(() => {
592 | order += "c2";
593 | return c1();
594 | });
595 | const c3 = createMemo(() => {
596 | order += "c3";
597 | return c2();
598 | });
599 | c3();
600 | order = "";
601 | set(2);
602 | c3();
603 | expect(order).toBe("t1c1c2c3");
604 | });
605 |
--------------------------------------------------------------------------------
/tests/mapArray.test.ts:
--------------------------------------------------------------------------------
1 | import { createEffect, createRoot, createSignal, flushSync, mapArray } from "../src/index.js";
2 |
3 | it("should compute keyed map", () => {
4 | const [$source, setSource] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }]);
5 |
6 | const computed = vi.fn();
7 |
8 | const map = mapArray($source, (value, index) => {
9 | computed();
10 | return {
11 | get id() {
12 | return value().id;
13 | },
14 | get index() {
15 | return index();
16 | }
17 | };
18 | });
19 |
20 | const [a, b, c] = map();
21 | expect(a.id).toBe("a");
22 | expect(a.index).toBe(0);
23 | expect(b.id).toBe("b");
24 | expect(b.index).toBe(1);
25 | expect(c.id).toBe("c");
26 | expect(c.index).toBe(2);
27 | expect(computed).toHaveBeenCalledTimes(3);
28 |
29 | // Move values around
30 | setSource(p => {
31 | const tmp = p[1];
32 | p[1] = p[0];
33 | p[0] = tmp;
34 | return [...p];
35 | });
36 |
37 | const [a2, b2, c2] = map();
38 | expect(a2.id).toBe("b");
39 | expect(a === b2).toBeTruthy();
40 | expect(a2.index).toBe(0);
41 | expect(b2.id).toBe("a");
42 | expect(b2.index).toBe(1);
43 | expect(b === a2).toBeTruthy();
44 | expect(c2.id).toBe("c");
45 | expect(c2.index).toBe(2);
46 | expect(c === c2).toBeTruthy();
47 | expect(computed).toHaveBeenCalledTimes(3);
48 |
49 | // Add new value
50 | setSource(p => [...p, { id: "d" }]);
51 |
52 | expect(map().length).toBe(4);
53 | expect(map()[map().length - 1].id).toBe("d");
54 | expect(map()[map().length - 1].index).toBe(3);
55 | expect(computed).toHaveBeenCalledTimes(4);
56 |
57 | // Remove value
58 | setSource(p => p.slice(1));
59 |
60 | expect(map().length).toBe(3);
61 | expect(map()[0].id).toBe("a");
62 | expect(map()[0] === b2 && map()[0] === a).toBeTruthy();
63 | expect(computed).toHaveBeenCalledTimes(4);
64 |
65 | // Empty
66 | setSource([]);
67 |
68 | expect(map().length).toBe(0);
69 | expect(computed).toHaveBeenCalledTimes(4);
70 | });
71 |
72 | it("should notify observer", () => {
73 | const [$source, setSource] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }]);
74 |
75 | const map = mapArray($source, value => {
76 | return {
77 | get id() {
78 | return value().id;
79 | }
80 | };
81 | });
82 |
83 | const effect = vi.fn();
84 | createRoot(() => createEffect(map, effect));
85 | flushSync();
86 |
87 | setSource(prev => prev.slice(1));
88 | flushSync();
89 | expect(effect).toHaveBeenCalledTimes(2);
90 | });
91 |
92 | it("should compute map when key by index", () => {
93 | const [$source, setSource] = createSignal([1, 2, 3]);
94 |
95 | const computed = vi.fn();
96 | const map = mapArray(
97 | $source,
98 | (value, index) => {
99 | computed();
100 | return {
101 | get id() {
102 | return value() * 2;
103 | },
104 | get index() {
105 | return index();
106 | }
107 | };
108 | },
109 | { keyed: false }
110 | );
111 |
112 | const [a, b, c] = map();
113 | expect(a.index).toBe(0);
114 | expect(a.id).toBe(2);
115 | expect(b.index).toBe(1);
116 | expect(b.id).toBe(4);
117 | expect(c.index).toBe(2);
118 | expect(c.id).toBe(6);
119 | expect(computed).toHaveBeenCalledTimes(3);
120 |
121 | // Move values around
122 | setSource([3, 2, 1]);
123 |
124 | const [a2, b2, c2] = map();
125 | expect(a2.index).toBe(0);
126 | expect(a2.id).toBe(6);
127 | expect(a === a2).toBeTruthy();
128 | expect(b2.index).toBe(1);
129 | expect(b2.id).toBe(4);
130 | expect(b === b2).toBeTruthy();
131 | expect(c2.index).toBe(2);
132 | expect(c2.id).toBe(2);
133 | expect(c === c2).toBeTruthy();
134 | expect(computed).toHaveBeenCalledTimes(3);
135 |
136 | // Add new value
137 | setSource([3, 2, 1, 4]);
138 |
139 | expect(map().length).toBe(4);
140 | expect(map()[map().length - 1].index).toBe(3);
141 | expect(map()[map().length - 1].id).toBe(8);
142 | expect(computed).toHaveBeenCalledTimes(4);
143 |
144 | // Remove value
145 | setSource([2, 1, 4]);
146 |
147 | expect(map().length).toBe(3);
148 | expect(map()[0].id).toBe(4);
149 |
150 | // Empty
151 | setSource([]);
152 |
153 | expect(map().length).toBe(0);
154 | expect(computed).toHaveBeenCalledTimes(4);
155 | });
156 |
157 | it("should compute custom keyed map", () => {
158 | const [$source, setSource] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }]);
159 |
160 | const computed = vi.fn();
161 |
162 | const map = mapArray(
163 | $source,
164 | (value, index) => {
165 | computed();
166 | return {
167 | get id() {
168 | return value().id;
169 | },
170 | get index() {
171 | return index();
172 | }
173 | };
174 | },
175 | {
176 | keyed: item => item.id
177 | }
178 | );
179 |
180 | const [a, b, c] = map();
181 | expect(a.id).toBe("a");
182 | expect(a.index).toBe(0);
183 | expect(b.id).toBe("b");
184 | expect(b.index).toBe(1);
185 | expect(c.id).toBe("c");
186 | expect(c.index).toBe(2);
187 | expect(computed).toHaveBeenCalledTimes(3);
188 |
189 | // Move values around
190 | setSource(p => {
191 | const tmp = p[1];
192 | p[1] = p[0];
193 | p[0] = tmp;
194 | return [...p];
195 | });
196 |
197 | const [a2, b2, c2] = map();
198 | expect(a2.id).toBe("b");
199 | expect(a === b2).toBeTruthy();
200 | expect(a2.index).toBe(0);
201 | expect(b2.id).toBe("a");
202 | expect(b2.index).toBe(1);
203 | expect(b === a2).toBeTruthy();
204 | expect(c2.id).toBe("c");
205 | expect(c2.index).toBe(2);
206 | expect(c === c2).toBeTruthy();
207 | expect(computed).toHaveBeenCalledTimes(3);
208 |
209 | // Add new value
210 | setSource(p => [...p, { id: "d" }]);
211 |
212 | expect(map().length).toBe(4);
213 | expect(map()[map().length - 1].id).toBe("d");
214 | expect(map()[map().length - 1].index).toBe(3);
215 | expect(computed).toHaveBeenCalledTimes(4);
216 |
217 | // Remove value
218 | setSource(p => p.slice(1));
219 |
220 | expect(map().length).toBe(3);
221 | expect(map()[0].id).toBe("a");
222 | expect(map()[0] === b2 && map()[0] === a).toBeTruthy();
223 | expect(computed).toHaveBeenCalledTimes(4);
224 |
225 | // Empty
226 | setSource([]);
227 |
228 | expect(map().length).toBe(0);
229 | expect(computed).toHaveBeenCalledTimes(4);
230 | });
231 |
--------------------------------------------------------------------------------
/tests/onCleanup.test.ts:
--------------------------------------------------------------------------------
1 | import { createEffect, createRoot, flushSync, onCleanup } from "../src/index.js";
2 |
3 | afterEach(() => flushSync());
4 |
5 | it("should be invoked when computation is disposed", () => {
6 | const disposeA = vi.fn();
7 | const disposeB = vi.fn();
8 | const disposeC = vi.fn();
9 |
10 | const stopEffect = createRoot(dispose => {
11 | createEffect(
12 | () => {
13 | onCleanup(disposeA);
14 | onCleanup(disposeB);
15 | onCleanup(disposeC);
16 | },
17 | () => {}
18 | );
19 |
20 | return dispose;
21 | });
22 | flushSync();
23 |
24 | stopEffect();
25 |
26 | expect(disposeA).toHaveBeenCalled();
27 | expect(disposeB).toHaveBeenCalled();
28 | expect(disposeC).toHaveBeenCalled();
29 | });
30 |
31 | it("should not trigger wrong onCleanup", () => {
32 | const dispose = vi.fn();
33 |
34 | createRoot(() => {
35 | createEffect(
36 | () => {
37 | onCleanup(dispose);
38 | },
39 | () => {}
40 | );
41 |
42 | const stopEffect = createRoot(dispose => {
43 | createEffect(
44 | () => {},
45 | () => {}
46 | );
47 | return dispose;
48 | });
49 |
50 | stopEffect();
51 | flushSync();
52 |
53 | expect(dispose).toHaveBeenCalledTimes(0);
54 | });
55 | });
56 |
57 | it("should clean up in reverse order", () => {
58 | const disposeParent = vi.fn();
59 | const disposeA = vi.fn();
60 | const disposeB = vi.fn();
61 |
62 | let calls = 0;
63 |
64 | const stopEffect = createRoot(dispose => {
65 | createEffect(
66 | () => {
67 | onCleanup(() => disposeParent(++calls));
68 |
69 | createEffect(
70 | () => {
71 | onCleanup(() => disposeA(++calls));
72 | },
73 | () => {}
74 | );
75 |
76 | createEffect(
77 | () => {
78 | onCleanup(() => disposeB(++calls));
79 | },
80 | () => {}
81 | );
82 | },
83 | () => {}
84 | );
85 |
86 | return dispose;
87 | });
88 | flushSync();
89 |
90 | stopEffect();
91 |
92 | expect(disposeB).toHaveBeenCalled();
93 | expect(disposeA).toHaveBeenCalled();
94 | expect(disposeParent).toHaveBeenCalled();
95 |
96 | expect(disposeB).toHaveBeenCalledWith(1);
97 | expect(disposeA).toHaveBeenCalledWith(2);
98 | expect(disposeParent).toHaveBeenCalledWith(3);
99 | });
100 |
101 | it("should dispose all roots", () => {
102 | const disposals: string[] = [];
103 |
104 | const dispose = createRoot(dispose => {
105 | createRoot(() => {
106 | onCleanup(() => disposals.push("SUBTREE 1"));
107 | createEffect(
108 | () => onCleanup(() => disposals.push("+A1")),
109 | () => {}
110 | );
111 | createEffect(
112 | () => onCleanup(() => disposals.push("+B1")),
113 | () => {}
114 | );
115 | createEffect(
116 | () => onCleanup(() => disposals.push("+C1")),
117 | () => {}
118 | );
119 | });
120 |
121 | createRoot(() => {
122 | onCleanup(() => disposals.push("SUBTREE 2"));
123 | createEffect(
124 | () => onCleanup(() => disposals.push("+A2")),
125 | () => {}
126 | );
127 | createEffect(
128 | () => onCleanup(() => disposals.push("+B2")),
129 | () => {}
130 | );
131 | createEffect(
132 | () => onCleanup(() => disposals.push("+C2")),
133 | () => {}
134 | );
135 | });
136 |
137 | onCleanup(() => disposals.push("ROOT"));
138 |
139 | return dispose;
140 | });
141 |
142 | flushSync();
143 | dispose();
144 |
145 | expect(disposals).toMatchInlineSnapshot(`
146 | [
147 | "+C2",
148 | "+B2",
149 | "+A2",
150 | "SUBTREE 2",
151 | "+C1",
152 | "+B1",
153 | "+A1",
154 | "SUBTREE 1",
155 | "ROOT",
156 | ]
157 | `);
158 | });
159 |
--------------------------------------------------------------------------------
/tests/repeat.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createRoot,
4 | createSignal,
5 | createStore,
6 | flushSync,
7 | repeat
8 | } from "../src/index.js";
9 |
10 | it("should compute keyed map", () => {
11 | const [source, setSource] = createStore>([
12 | { id: "a" },
13 | { id: "b" },
14 | { id: "c" }
15 | ]);
16 |
17 | const computed = vi.fn();
18 |
19 | const map = repeat(
20 | () => source.length,
21 | index => {
22 | computed();
23 | return {
24 | get id() {
25 | return source[index].id;
26 | },
27 | get index() {
28 | return index;
29 | }
30 | };
31 | }
32 | );
33 |
34 | const [a, b, c] = map();
35 | expect(a.id).toBe("a");
36 | expect(a.index).toBe(0);
37 | expect(b.id).toBe("b");
38 | expect(b.index).toBe(1);
39 | expect(c.id).toBe("c");
40 | expect(c.index).toBe(2);
41 | expect(computed).toHaveBeenCalledTimes(3);
42 |
43 | // Move values around
44 | setSource(p => {
45 | [p[0], p[1]] = [p[1], p[0]];
46 | });
47 |
48 | const [a2, b2, c2] = map();
49 | expect(a2.id).toBe("b");
50 | expect(a === a2).toBeTruthy();
51 | expect(a2.index).toBe(0);
52 | expect(b2.id).toBe("a");
53 | expect(b2.index).toBe(1);
54 | expect(b === b2).toBeTruthy();
55 | expect(c2.id).toBe("c");
56 | expect(c2.index).toBe(2);
57 | expect(c === c2).toBeTruthy();
58 | expect(computed).toHaveBeenCalledTimes(3);
59 |
60 | // Add new value
61 | setSource(p => p.push({ id: "d" }));
62 |
63 | expect(map().length).toBe(4);
64 | expect(map()[map().length - 1].id).toBe("d");
65 | expect(map()[map().length - 1].index).toBe(3);
66 | expect(computed).toHaveBeenCalledTimes(4);
67 |
68 | // Remove value
69 | setSource(p => p.pop());
70 |
71 | expect(map().length).toBe(3);
72 | expect(map()[0].id).toBe("b");
73 | expect(map()[0] === a2 && map()[0] === a).toBeTruthy();
74 | expect(computed).toHaveBeenCalledTimes(4);
75 |
76 | // Empty
77 | setSource(p => (p.length = 0));
78 |
79 | expect(map().length).toBe(0);
80 | expect(computed).toHaveBeenCalledTimes(4);
81 | });
82 |
83 | it("should notify observer", () => {
84 | const [source, setSource] = createStore([{ id: "a" }, { id: "b" }, { id: "c" }]);
85 |
86 | const map = repeat(
87 | () => source.length,
88 | index => {
89 | return {
90 | get id() {
91 | return source[index].id;
92 | }
93 | };
94 | }
95 | );
96 |
97 | const effect = vi.fn();
98 | createRoot(() => createEffect(map, effect));
99 | flushSync();
100 |
101 | setSource(prev => prev.pop());
102 | flushSync();
103 | expect(effect).toHaveBeenCalledTimes(2);
104 | });
105 |
106 | it("should compute map when key by index", () => {
107 | const [source, setSource] = createSignal([1, 2, 3]);
108 |
109 | const computed = vi.fn();
110 | const map = repeat(
111 | () => source().length,
112 | index => {
113 | computed();
114 | return {
115 | get id() {
116 | return source()[index] * 2;
117 | },
118 | get index() {
119 | return index;
120 | }
121 | };
122 | }
123 | );
124 |
125 | const [a, b, c] = map();
126 | expect(a.index).toBe(0);
127 | expect(a.id).toBe(2);
128 | expect(b.index).toBe(1);
129 | expect(b.id).toBe(4);
130 | expect(c.index).toBe(2);
131 | expect(c.id).toBe(6);
132 | expect(computed).toHaveBeenCalledTimes(3);
133 |
134 | // Move values around
135 | setSource([3, 2, 1]);
136 |
137 | const [a2, b2, c2] = map();
138 | expect(a2.index).toBe(0);
139 | expect(a2.id).toBe(6);
140 | expect(a === a2).toBeTruthy();
141 | expect(b2.index).toBe(1);
142 | expect(b2.id).toBe(4);
143 | expect(b === b2).toBeTruthy();
144 | expect(c2.index).toBe(2);
145 | expect(c2.id).toBe(2);
146 | expect(c === c2).toBeTruthy();
147 | expect(computed).toHaveBeenCalledTimes(3);
148 |
149 | // Add new value
150 | setSource([3, 2, 1, 4]);
151 |
152 | expect(map().length).toBe(4);
153 | expect(map()[map().length - 1].index).toBe(3);
154 | expect(map()[map().length - 1].id).toBe(8);
155 | expect(computed).toHaveBeenCalledTimes(4);
156 |
157 | // Remove value
158 | setSource([2, 1, 4]);
159 |
160 | expect(map().length).toBe(3);
161 | expect(map()[0].id).toBe(4);
162 |
163 | // Empty
164 | setSource([]);
165 |
166 | expect(map().length).toBe(0);
167 | expect(computed).toHaveBeenCalledTimes(4);
168 | });
169 |
170 | it("should retain instances when only `offset` changes", () => {
171 | const [source] = createStore>([
172 | { id: "a" },
173 | { id: "b" },
174 | { id: "c" },
175 | { id: "d" },
176 | { id: "e" }
177 | ]);
178 | const [count, setCount] = createSignal(3);
179 | const [from, setFrom] = createSignal(0);
180 |
181 | const computed = vi.fn();
182 |
183 | const map = repeat(
184 | count,
185 | index => {
186 | computed();
187 | return {
188 | get id() {
189 | return source[index].id;
190 | },
191 | get index() {
192 | return index;
193 | }
194 | };
195 | },
196 | { from }
197 | );
198 |
199 | const [a, b, c, d] = map();
200 | expect(a.id).toBe("a");
201 | expect(a.index).toBe(0);
202 | expect(b.id).toBe("b");
203 | expect(b.index).toBe(1);
204 | expect(c.id).toBe("c");
205 | expect(c.index).toBe(2);
206 | expect(d).toBeUndefined();
207 | expect(computed).toHaveBeenCalledTimes(3);
208 |
209 | setFrom(2);
210 | const [c2, d2, e2] = map();
211 | expect(c2.id).toBe("c");
212 | expect(c2.index).toBe(2);
213 | expect(d2.id).toBe("d");
214 | expect(d2.index).toBe(3);
215 | expect(e2.id).toBe("e");
216 | expect(e2.index).toBe(4);
217 | expect(computed).toHaveBeenCalledTimes(5);
218 |
219 | setFrom(1);
220 | const [b3, c3, d3, e3] = map();
221 | expect(b3.id).toBe("b");
222 | expect(b3.index).toBe(1);
223 | expect(c3.id).toBe("c");
224 | expect(c3.index).toBe(2);
225 | expect(d3.id).toBe("d");
226 | expect(d3.index).toBe(3);
227 | expect(e3).toBeUndefined();
228 | expect(computed).toHaveBeenCalledTimes(6);
229 |
230 | setCount(4);
231 | const [b4, c4, d4, e4] = map();
232 | expect(b4.id).toBe("b");
233 | expect(b4.index).toBe(1);
234 | expect(c4.id).toBe("c");
235 | expect(c4.index).toBe(2);
236 | expect(d4.id).toBe("d");
237 | expect(d4.index).toBe(3);
238 | expect(e4.id).toBe("e");
239 | expect(e4.index).toBe(4);
240 | expect(computed).toHaveBeenCalledTimes(7);
241 | });
242 |
--------------------------------------------------------------------------------
/tests/runWithObserver.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createRoot,
4 | createSignal,
5 | flushSync,
6 | getObserver,
7 | runWithObserver,
8 | type Computation
9 | } from "../src/index.js";
10 |
11 | it("should return value", () => {
12 | let observer!: Computation | null;
13 |
14 | createRoot(() => {
15 | createEffect(
16 | () => {
17 | observer = getObserver()!;
18 | },
19 | () => {}
20 | );
21 | });
22 | expect(runWithObserver(observer!, () => 100)).toBe(100);
23 | });
24 |
25 | it("should add dependencies to no deps", () => {
26 | let count = 0;
27 |
28 | const [a, setA] = createSignal(0);
29 | createRoot(() => {
30 | createEffect(
31 | () => getObserver()!,
32 | o => {
33 | runWithObserver(o, () => {
34 | a();
35 | count++;
36 | });
37 | }
38 | );
39 | });
40 | expect(count).toBe(0);
41 | flushSync();
42 | expect(count).toBe(1);
43 | setA(1);
44 | flushSync();
45 | expect(count).toBe(2);
46 | });
47 |
48 | it("should add dependencies to existing deps", () => {
49 | let count = 0;
50 |
51 | const [a, setA] = createSignal(0);
52 | const [b, setB] = createSignal(0);
53 | createRoot(() => {
54 | createEffect(
55 | () => (a(), getObserver()!),
56 | o => {
57 | runWithObserver(o, () => {
58 | b();
59 | count++;
60 | });
61 | }
62 | );
63 | });
64 | expect(count).toBe(0);
65 | flushSync();
66 | expect(count).toBe(1);
67 | setB(1);
68 | flushSync();
69 | expect(count).toBe(2);
70 | setA(1);
71 | flushSync();
72 | expect(count).toBe(3);
73 | });
74 |
--------------------------------------------------------------------------------
/tests/runWithOwner.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createErrorBoundary,
3 | createRenderEffect,
4 | createRoot,
5 | flushSync,
6 | getOwner,
7 | Owner,
8 | runWithOwner
9 | } from "../src/index.js";
10 |
11 | it("should scope function to current scope", () => {
12 | let owner!: Owner | null;
13 |
14 | createRoot(() => {
15 | owner = getOwner()!;
16 | owner._context = { foo: 1 };
17 | });
18 |
19 | runWithOwner(owner, () => {
20 | expect(getOwner()!._context?.foo).toBe(1);
21 | });
22 | });
23 |
24 | it("should return value", () => {
25 | expect(runWithOwner(null, () => 100)).toBe(100);
26 | });
27 |
28 | it("should handle errors", () => {
29 | const error = new Error(),
30 | handler = vi.fn();
31 |
32 | let owner!: Owner | null;
33 | const b = createErrorBoundary(
34 | () => {
35 | owner = getOwner();
36 | },
37 | err => handler(err)
38 | );
39 | b();
40 |
41 | runWithOwner(owner, () => {
42 | createRenderEffect(
43 | () => {
44 | throw error;
45 | },
46 | () => {}
47 | );
48 | });
49 |
50 | b();
51 | flushSync();
52 | expect(handler).toHaveBeenCalledWith(error);
53 | });
54 |
--------------------------------------------------------------------------------
/tests/store/createProjection.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createMemo,
3 | createProjection,
4 | createRenderEffect,
5 | createRoot,
6 | createSignal,
7 | flushSync
8 | } from "../../src/index.js";
9 |
10 | describe("Projection basics", () => {
11 | it("should observe key changes", () => {
12 | createRoot(dispose => {
13 | let previous;
14 | const [$source, setSource] = createSignal(0),
15 | selected = createProjection(
16 | draft => {
17 | const s = $source();
18 | if (s !== previous) draft[previous] = false;
19 | draft[s] = true;
20 | previous = s;
21 | },
22 | [false, false, false]
23 | ),
24 | effect0 = vi.fn(() => selected[0]),
25 | effect1 = vi.fn(() => selected[1]),
26 | effect2 = vi.fn(() => selected[2]);
27 |
28 | let $effect0 = createMemo(effect0),
29 | $effect1 = createMemo(effect1),
30 | $effect2 = createMemo(effect2);
31 |
32 | expect($effect0()).toBe(true);
33 | expect($effect1()).toBe(false);
34 | expect($effect2()).toBe(false);
35 |
36 | expect(effect0).toHaveBeenCalledTimes(1);
37 | expect(effect1).toHaveBeenCalledTimes(1);
38 | expect(effect2).toHaveBeenCalledTimes(1);
39 |
40 | setSource(1);
41 |
42 | expect($effect0()).toBe(false);
43 | expect($effect1()).toBe(true);
44 | expect($effect2()).toBe(false);
45 |
46 | expect(effect0).toHaveBeenCalledTimes(2);
47 | expect(effect1).toHaveBeenCalledTimes(2);
48 | expect(effect2).toHaveBeenCalledTimes(1);
49 |
50 | setSource(2);
51 |
52 | expect($effect0()).toBe(false);
53 | expect($effect1()).toBe(false);
54 | expect($effect2()).toBe(true);
55 |
56 | expect(effect0).toHaveBeenCalledTimes(2);
57 | expect(effect1).toHaveBeenCalledTimes(3);
58 | expect(effect2).toHaveBeenCalledTimes(2);
59 |
60 | setSource(-1);
61 |
62 | expect($effect0()).toBe(false);
63 | expect($effect1()).toBe(false);
64 | expect($effect2()).toBe(false);
65 |
66 | expect(effect0).toHaveBeenCalledTimes(2);
67 | expect(effect1).toHaveBeenCalledTimes(3);
68 | expect(effect2).toHaveBeenCalledTimes(3);
69 |
70 | dispose();
71 |
72 | setSource(0);
73 | setSource(1);
74 | setSource(2);
75 |
76 | // expect($effect0).toThrow();
77 | // expect($effect1).toThrow();
78 | // expect($effect2).toThrow();
79 |
80 | expect(effect0).toHaveBeenCalledTimes(2);
81 | expect(effect1).toHaveBeenCalledTimes(3);
82 | expect(effect2).toHaveBeenCalledTimes(3);
83 | });
84 | });
85 |
86 | it("should not self track", () => {
87 | const spy = vi.fn();
88 | const [bar, setBar] = createSignal("foo");
89 | const projection = createRoot(() =>
90 | createProjection(
91 | draft => {
92 | draft.foo = draft.bar;
93 | draft.bar = bar();
94 | spy();
95 | },
96 | { foo: "foo", bar: "bar" }
97 | )
98 | );
99 | expect(projection.foo).toBe("bar");
100 | expect(projection.bar).toBe("foo");
101 | expect(spy).toHaveBeenCalledTimes(1);
102 | setBar("baz");
103 | flushSync();
104 | expect(projection.foo).toBe("foo");
105 | expect(projection.bar).toBe("baz");
106 | expect(spy).toHaveBeenCalledTimes(2);
107 | });
108 |
109 | it("should work for chained projections", () => {
110 | const [$x, setX] = createSignal(1);
111 |
112 | const tmp = vi.fn();
113 |
114 | createRoot(() => {
115 | const a = createProjection(
116 | state => {
117 | state.v = $x();
118 | },
119 | {
120 | v: 0
121 | }
122 | );
123 |
124 | const b = createProjection(
125 | state => {
126 | state.v = a.v;
127 | },
128 | {
129 | v: 0
130 | }
131 | );
132 |
133 | createRenderEffect(
134 | () => b.v,
135 | (v, p) => tmp(v, p)
136 | );
137 | });
138 | flushSync();
139 |
140 | expect(tmp).toBeCalledTimes(1);
141 | expect(tmp).toBeCalledWith(1, undefined);
142 |
143 | tmp.mockReset();
144 | setX(2);
145 | flushSync();
146 |
147 | expect(tmp).toBeCalledWith(2, 1);
148 | expect(tmp);
149 | });
150 | });
151 |
152 | describe("selection with projections", () => {
153 | test("simple selection", () => {
154 | let prev: number | undefined;
155 | const [s, set] = createSignal();
156 | let count = 0;
157 | const list: Array = [];
158 |
159 | createRoot(() => {
160 | const isSelected = createProjection>(state => {
161 | const selected = s();
162 | if (prev !== undefined && prev !== selected) delete state[prev];
163 | if (selected) state[selected] = true;
164 | prev = selected;
165 | });
166 | Array.from({ length: 100 }, (_, i) =>
167 | createRenderEffect(
168 | () => isSelected[i],
169 | v => {
170 | count++;
171 | list[i] = v ? "selected" : "no";
172 | }
173 | )
174 | );
175 | });
176 | expect(count).toBe(100);
177 | expect(list[3]).toBe("no");
178 |
179 | count = 0;
180 | set(3);
181 | flushSync();
182 | expect(count).toBe(1);
183 | expect(list[3]).toBe("selected");
184 |
185 | count = 0;
186 | set(6);
187 | flushSync();
188 | expect(count).toBe(2);
189 | expect(list[3]).toBe("no");
190 | expect(list[6]).toBe("selected");
191 | set(undefined);
192 | flushSync();
193 | expect(count).toBe(3);
194 | expect(list[6]).toBe("no");
195 | set(5);
196 | flushSync();
197 | expect(count).toBe(4);
198 | expect(list[5]).toBe("selected");
199 | });
200 |
201 | test("double selection", () => {
202 | let prev: number | undefined;
203 | const [s, set] = createSignal();
204 | let count = 0;
205 | const list: Array[] = [];
206 |
207 | createRoot(() => {
208 | const isSelected = createProjection>(state => {
209 | const selected = s();
210 | if (prev !== undefined && prev !== selected) delete state[prev];
211 | if (selected) state[selected] = true;
212 | prev = selected;
213 | });
214 | Array.from({ length: 100 }, (_, i) => {
215 | list[i] = [];
216 | createRenderEffect(
217 | () => isSelected[i],
218 | v => {
219 | count++;
220 | list[i][0] = v ? "selected" : "no";
221 | }
222 | );
223 | createRenderEffect(
224 | () => isSelected[i],
225 | v => {
226 | count++;
227 | list[i][1] = v ? "oui" : "non";
228 | }
229 | );
230 | });
231 | });
232 | expect(count).toBe(200);
233 | expect(list[3][0]).toBe("no");
234 | expect(list[3][1]).toBe("non");
235 |
236 | count = 0;
237 | set(3);
238 | flushSync();
239 | expect(count).toBe(2);
240 | expect(list[3][0]).toBe("selected");
241 | expect(list[3][1]).toBe("oui");
242 |
243 | count = 0;
244 | set(6);
245 | flushSync();
246 | expect(count).toBe(4);
247 | expect(list[3][0]).toBe("no");
248 | expect(list[6][0]).toBe("selected");
249 | expect(list[3][1]).toBe("non");
250 | expect(list[6][1]).toBe("oui");
251 | });
252 | });
253 |
--------------------------------------------------------------------------------
/tests/store/reconcile.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { createStore, reconcile, unwrap } from "../../src/index.js";
3 |
4 | describe("setState with reconcile", () => {
5 | test("Reconcile a simple object", () => {
6 | const [state, setState] = createStore<{ data: number; missing?: string }>({
7 | data: 2,
8 | missing: "soon"
9 | });
10 | expect(state.data).toBe(2);
11 | expect(state.missing).toBe("soon");
12 | setState(reconcile({ data: 5 }, "id"));
13 | expect(state.data).toBe(5);
14 | expect(state.missing).toBeUndefined();
15 | });
16 |
17 | test("Reconcile array with nulls", () => {
18 | const [state, setState] = createStore>([null, "a"]);
19 | expect(state[0]).toBe(null);
20 | expect(state[1]).toBe("a");
21 | setState(reconcile(["b", null], "id"));
22 | expect(state[0]).toBe("b");
23 | expect(state[1]).toBe(null);
24 | });
25 |
26 | test("Reconcile a simple object on a nested path", () => {
27 | const [state, setState] = createStore<{
28 | data: { user: { firstName: string; middleName: string; lastName?: string } };
29 | }>({
30 | data: { user: { firstName: "John", middleName: "", lastName: "Snow" } }
31 | });
32 | expect(state.data.user.firstName).toBe("John");
33 | expect(state.data.user.lastName).toBe("Snow");
34 | setState(s => {
35 | s.data.user = reconcile({ firstName: "Jake", middleName: "R" }, "id")(s.data.user);
36 | });
37 | expect(state.data.user.firstName).toBe("Jake");
38 | expect(state.data.user.middleName).toBe("R");
39 | expect(state.data.user.lastName).toBeUndefined();
40 | });
41 |
42 | test("Reconcile reorder a keyed array", () => {
43 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" },
44 | NED = { id: 2, firstName: "Ned", lastName: "Stark" },
45 | BRANDON = { id: 3, firstName: "Brandon", lastName: "Start" },
46 | ARYA = { id: 4, firstName: "Arya", lastName: "Start" };
47 | const [state, setState] = createStore({ users: [JOHN, NED, BRANDON] });
48 | expect(Object.is(unwrap(state.users[0]), JOHN)).toBe(true);
49 | expect(Object.is(unwrap(state.users[1]), NED)).toBe(true);
50 | expect(Object.is(unwrap(state.users[2]), BRANDON)).toBe(true);
51 | setState(s => {
52 | s.users = reconcile([NED, JOHN, BRANDON], "id")(s.users);
53 | });
54 | expect(Object.is(unwrap(state.users[0]), NED)).toBe(true);
55 | expect(Object.is(unwrap(state.users[1]), JOHN)).toBe(true);
56 | expect(Object.is(unwrap(state.users[2]), BRANDON)).toBe(true);
57 | setState(s => {
58 | s.users = reconcile([NED, BRANDON, JOHN], "id")(s.users);
59 | });
60 | expect(Object.is(unwrap(state.users[0]), NED)).toBe(true);
61 | expect(Object.is(unwrap(state.users[1]), BRANDON)).toBe(true);
62 | expect(Object.is(unwrap(state.users[2]), JOHN)).toBe(true);
63 | setState(s => {
64 | s.users = reconcile([NED, BRANDON, JOHN, ARYA], "id")(s.users);
65 | });
66 | expect(Object.is(unwrap(state.users[0]), NED)).toBe(true);
67 | expect(Object.is(unwrap(state.users[1]), BRANDON)).toBe(true);
68 | expect(Object.is(unwrap(state.users[2]), JOHN)).toBe(true);
69 | expect(Object.is(unwrap(state.users[3]), ARYA)).toBe(true);
70 | setState(s => {
71 | s.users = reconcile([BRANDON, JOHN, ARYA], "id")(s.users);
72 | });
73 | expect(Object.is(unwrap(state.users[0]), BRANDON)).toBe(true);
74 | expect(Object.is(unwrap(state.users[1]), JOHN)).toBe(true);
75 | expect(Object.is(unwrap(state.users[2]), ARYA)).toBe(true);
76 | });
77 |
78 | test("Reconcile overwrite in non-keyed merge mode", () => {
79 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" },
80 | NED = { id: 2, firstName: "Ned", lastName: "Stark" },
81 | BRANDON = { id: 3, firstName: "Brandon", lastName: "Start" };
82 | const [state, setState] = createStore({
83 | users: [{ ...JOHN }, { ...NED }, { ...BRANDON }]
84 | });
85 | expect(state.users[0].id).toBe(1);
86 | expect(state.users[0].firstName).toBe("John");
87 | expect(state.users[1].id).toBe(2);
88 | expect(state.users[1].firstName).toBe("Ned");
89 | expect(state.users[2].id).toBe(3);
90 | expect(state.users[2].firstName).toBe("Brandon");
91 | setState(s => {
92 | s.users = reconcile([{ ...NED }, { ...JOHN }, { ...BRANDON }], "")(s.users);
93 | });
94 | expect(state.users[0].id).toBe(2);
95 | expect(state.users[0].firstName).toBe("Ned");
96 | expect(state.users[1].id).toBe(1);
97 | expect(state.users[1].firstName).toBe("John");
98 | expect(state.users[2].id).toBe(3);
99 | expect(state.users[2].firstName).toBe("Brandon");
100 | });
101 |
102 | test("Reconcile top level key mismatch", () => {
103 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" },
104 | NED = { id: 2, firstName: "Ned", lastName: "Stark" };
105 |
106 | const [user, setUser] = createStore(JOHN);
107 | expect(user.id).toBe(1);
108 | expect(user.firstName).toBe("John");
109 | expect(() => setUser(reconcile(NED, "id"))).toThrow();
110 | // expect(user.id).toBe(2);
111 | // expect(user.firstName).toBe("Ned");
112 | });
113 |
114 | test("Reconcile nested top level key mismatch", () => {
115 | const JOHN = { id: 1, firstName: "John", lastName: "Snow" },
116 | NED = { id: 2, firstName: "Ned", lastName: "Stark" };
117 |
118 | const [user, setUser] = createStore({ user: JOHN });
119 | expect(user.user.id).toBe(1);
120 | expect(user.user.firstName).toBe("John");
121 | expect(() =>
122 | setUser(s => {
123 | s.user = reconcile(NED, "id")(s.user);
124 | })
125 | ).toThrow();
126 | // expect(user.user.id).toBe(2);
127 | // expect(user.user.firstName).toBe("Ned");
128 | });
129 |
130 | test("Reconcile top level key missing", () => {
131 | const [store, setStore] = createStore<{ id?: number; value?: string }>({
132 | id: 0,
133 | value: "value"
134 | });
135 | expect(() => setStore(reconcile({}, "id"))).toThrow();
136 | // expect(store.id).toBe(undefined);
137 | // expect(store.value).toBe(undefined);
138 | });
139 |
140 | test("Reconcile overwrite an object with an array", () => {
141 | const [store, setStore] = createStore<{ value: {} | [] }>({
142 | value: { a: { b: 1 } }
143 | });
144 |
145 | setStore(reconcile({ value: { c: [1, 2, 3] } }, "id"));
146 | expect(store.value).toEqual({ c: [1, 2, 3] });
147 | });
148 |
149 | test("Reconcile overwrite an array with an object", () => {
150 | const [store, setStore] = createStore<{ value: {} | [] }>({
151 | value: [1, 2, 3]
152 | });
153 | setStore(reconcile({ value: { name: "John" } }, "id"));
154 | expect(Array.isArray(store.value)).toBeFalsy();
155 | expect(store.value).toEqual({ name: "John" });
156 | setStore(reconcile({ value: [1, 2, 3] }, "id"));
157 | expect(store.value).toEqual([1, 2, 3]);
158 | setStore(reconcile({ value: { q: "aa" } }, "id"));
159 | expect(store.value).toEqual({ q: "aa" });
160 | });
161 | });
162 | // type tests
163 |
164 | // reconcile
165 | () => {
166 | const [state, setState] = createStore<{ data: number; missing: string; partial?: { v: number } }>(
167 | {
168 | data: 2,
169 | missing: "soon"
170 | }
171 | );
172 | // @ts-expect-error should not be able to reconcile partial type
173 | setState(reconcile({ data: 5 }));
174 | };
175 |
--------------------------------------------------------------------------------
/tests/store/recursive-effects.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEffect,
3 | createMemo,
4 | createRoot,
5 | createSignal,
6 | createStore,
7 | flushSync,
8 | untrack,
9 | unwrap
10 | } from "../../src/index.js";
11 | import { sharedClone } from "./shared-clone.js";
12 |
13 | describe("recursive effects", () => {
14 | it("can track deeply with cloning", () => {
15 | const [store, setStore] = createStore({ foo: "foo", bar: { baz: "baz" } });
16 |
17 | let called = 0;
18 | let next: any;
19 |
20 | createRoot(() => {
21 | createEffect(
22 | () => {
23 | next = sharedClone(next, store);
24 | called++;
25 | },
26 | () => {}
27 | );
28 | });
29 | flushSync();
30 |
31 | setStore(s => {
32 | s.foo = "1";
33 | });
34 |
35 | setStore(s => {
36 | s.bar.baz = "2";
37 | });
38 |
39 | flushSync();
40 | expect(called).toBe(2);
41 | });
42 |
43 | it("respects untracked", () => {
44 | const [store, setStore] = createStore({ foo: "foo", bar: { baz: "baz" } });
45 |
46 | let called = 0;
47 | let next: any;
48 |
49 | createRoot(() => {
50 | createEffect(
51 | () => {
52 | next = sharedClone(next, untrack(() => store).bar);
53 | called++;
54 | },
55 | () => {}
56 | );
57 | });
58 | flushSync();
59 |
60 | setStore(s => {
61 | s.foo = "1";
62 | });
63 |
64 | setStore(s => {
65 | s.bar.baz = "2";
66 | });
67 |
68 | setStore(s => {
69 | s.bar = {
70 | baz: "3"
71 | };
72 | });
73 |
74 | flushSync();
75 | expect(called).toBe(2);
76 | });
77 |
78 | it("supports unwrapped values", () => {
79 | const [store, setStore] = createStore({ foo: "foo", bar: { baz: "baz" } });
80 |
81 | let called = 0;
82 | let prev: any;
83 | let next: any;
84 |
85 | createRoot(() => {
86 | createEffect(
87 | () => {
88 | prev = next;
89 | next = unwrap(sharedClone(next, store));
90 | called++;
91 | },
92 | () => {}
93 | );
94 | });
95 | flushSync();
96 |
97 | setStore(s => {
98 | s.foo = "1";
99 | });
100 |
101 | setStore(s => {
102 | s.bar.baz = "2";
103 | });
104 |
105 | flushSync();
106 | expect(next).not.toBe(prev);
107 | expect(called).toBe(2);
108 | });
109 |
110 | it("runs parent effects before child effects", () => {
111 | const [x, setX] = createSignal(0);
112 | const simpleM = createMemo(() => x());
113 | let calls = 0;
114 | createRoot(() => {
115 | createEffect(
116 | () => {
117 | createEffect(
118 | () => {
119 | void x();
120 | calls++;
121 | },
122 | () => {}
123 | );
124 | void simpleM();
125 | },
126 | () => {}
127 | );
128 | });
129 | flushSync();
130 | setX(1);
131 | flushSync();
132 | expect(calls).toBe(2);
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
/tests/store/shared-clone.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This function returns `a` if `b` is deeply equal.
3 | *
4 | * If not, it will replace any deeply equal children of `b` with those of `a`.
5 | * This can be used for structural sharing between JSON values for example.
6 | */
7 | export function sharedClone(prev: any, next: T, touchAll?: boolean): T {
8 | const things = new Map();
9 |
10 | function recurse(prev: any, next: any) {
11 | if (prev === next) {
12 | return prev;
13 | }
14 |
15 | if (things.has(next)) {
16 | return things.get(next);
17 | }
18 |
19 | const prevIsArray = Array.isArray(prev);
20 | const nextIsArray = Array.isArray(next);
21 | const prevIsObj = isPlainObject(prev);
22 | const nextIsObj = isPlainObject(next);
23 |
24 | const isArray = prevIsArray && nextIsArray;
25 | const isObj = prevIsObj && nextIsObj;
26 |
27 | const isSameStructure = isArray || isObj;
28 |
29 | // Both are arrays or objects
30 | if (isSameStructure) {
31 | const aSize = isArray ? prev.length : Object.keys(prev).length;
32 | const bItems = isArray ? next : Object.keys(next);
33 | const bSize = bItems.length;
34 | const copy: any = isArray ? [] : {};
35 |
36 | let equalItems = 0;
37 |
38 | for (let i = 0; i < bSize; i++) {
39 | const key = isArray ? i : bItems[i];
40 | if (copy[key] === prev[key]) {
41 | equalItems++;
42 | }
43 | }
44 | if (aSize === bSize && equalItems === aSize) {
45 | things.set(next, prev);
46 | return prev;
47 | }
48 | things.set(next, copy);
49 | for (let i = 0; i < bSize; i++) {
50 | const key = isArray ? i : bItems[i];
51 | if (typeof bItems[i] === "function") {
52 | copy[key] = prev[key];
53 | } else {
54 | copy[key] = recurse(prev[key], next[key]);
55 | }
56 | if (copy[key] === prev[key]) {
57 | equalItems++;
58 | }
59 | }
60 |
61 | return copy;
62 | }
63 |
64 | if (nextIsArray) {
65 | const copy: any[] = [];
66 | things.set(next, copy);
67 | for (let i = 0; i < next.length; i++) {
68 | copy[i] = recurse(undefined, next[i]);
69 | }
70 | return copy as T;
71 | }
72 |
73 | if (nextIsObj) {
74 | const copy = {} as any;
75 | things.set(next, copy);
76 | const nextKeys = Object.keys(next);
77 | for (let i = 0; i < nextKeys.length; i++) {
78 | const key = nextKeys[i]!;
79 | copy[key] = recurse(undefined, next[key]);
80 | }
81 | return copy as T;
82 | }
83 |
84 | return next;
85 | }
86 |
87 | return recurse(prev, next);
88 | }
89 |
90 | // Copied from: https://github.com/jonschlinkert/is-plain-object
91 | function isPlainObject(o: any) {
92 | if (!hasObjectPrototype(o)) {
93 | return false;
94 | }
95 |
96 | // If has modified constructor
97 | const ctor = o.constructor;
98 | if (typeof ctor === "undefined") {
99 | return true;
100 | }
101 |
102 | // If has modified prototype
103 | const prot = ctor.prototype;
104 | if (!hasObjectPrototype(prot)) {
105 | return false;
106 | }
107 |
108 | // If constructor does not have an Object-specific method
109 | if (!prot.hasOwnProperty("isPrototypeOf")) {
110 | return false;
111 | }
112 |
113 | // Most likely a plain Object
114 | return true;
115 | }
116 |
117 | function hasObjectPrototype(o: any) {
118 | return Object.prototype.toString.call(o) === "[object Object]";
119 | }
120 |
--------------------------------------------------------------------------------
/tests/store/utilities.bench.ts:
--------------------------------------------------------------------------------
1 | import { bench } from "vitest";
2 | import { merge, omit } from "../../src/index.js";
3 |
4 | const staticDesc = {
5 | value: 1,
6 | writable: true,
7 | configurable: true,
8 | enumerable: true
9 | };
10 |
11 | const signalDesc = {
12 | get() {
13 | return 1;
14 | },
15 | configurable: true,
16 | enumerable: true
17 | };
18 |
19 | const cache = new Map();
20 |
21 | const createObject = (
22 | name: string,
23 | amount: number,
24 | desc: (index: number) => PropertyDescriptor
25 | ) => {
26 | const key = `${name}-${amount}`;
27 | const cached = cache.get(key);
28 | if (cached) return cached;
29 | const proto: Record = {};
30 | for (let index = 0; index < amount; ++index) proto[`${name}${index}`] = desc(index);
31 | const result = Object.defineProperties({}, proto) as Record