@preact/signals-react 1.3.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,735 +0,0 @@
1
- // @ts-ignore-next-line
2
- globalThis.IS_REACT_ACT_ENVIRONMENT = true;
3
-
4
- import {
5
- signal,
6
- computed,
7
- useComputed,
8
- useSignalEffect,
9
- useSignal,
10
- Signal,
11
- } from "@preact/signals-react";
12
- import {
13
- createElement,
14
- Fragment,
15
- forwardRef,
16
- useMemo,
17
- useReducer,
18
- memo,
19
- StrictMode,
20
- createRef,
21
- useState,
22
- useContext,
23
- createContext,
24
- } from "react";
25
-
26
- import { renderToStaticMarkup } from "react-dom/server";
27
- import {
28
- createRoot,
29
- Root,
30
- act,
31
- checkHangingAct,
32
- isReact16,
33
- isProd,
34
- getConsoleErrorSpy,
35
- checkConsoleErrorLogs,
36
- } from "../shared/utils";
37
-
38
- describe("@preact/signals-react updating", () => {
39
- let scratch: HTMLDivElement;
40
- let root: Root;
41
-
42
- async function render(element: Parameters<Root["render"]>[0]) {
43
- await act(() => root.render(element));
44
- }
45
-
46
- beforeEach(async () => {
47
- scratch = document.createElement("div");
48
- document.body.appendChild(scratch);
49
- root = await createRoot(scratch);
50
- getConsoleErrorSpy().resetHistory();
51
- });
52
-
53
- afterEach(async () => {
54
- await act(() => root.unmount());
55
- scratch.remove();
56
-
57
- checkConsoleErrorLogs();
58
- checkHangingAct();
59
- });
60
-
61
- describe("SignalValue bindings", () => {
62
- it("should render text without signals", async () => {
63
- await render(<span>test</span>);
64
- const span = scratch.firstChild;
65
- const text = span?.firstChild;
66
- expect(text).to.have.property("data", "test");
67
- });
68
-
69
- it("should render Signals as SignalValue", async () => {
70
- const sig = signal("test");
71
- await render(<span>{sig}</span>);
72
- const span = scratch.firstChild;
73
- expect(span).to.have.property("firstChild").that.is.an.instanceOf(Text);
74
- const text = span?.firstChild;
75
- expect(text).to.have.property("data", "test");
76
- });
77
-
78
- it("should render computed as SignalValue", async () => {
79
- const sig = signal("test");
80
- const comp = computed(() => `${sig} ${sig}`);
81
- await render(<span>{comp}</span>);
82
- const span = scratch.firstChild;
83
- expect(span).to.have.property("firstChild").that.is.an.instanceOf(Text);
84
- const text = span?.firstChild;
85
- expect(text).to.have.property("data", "test test");
86
- });
87
-
88
- it("should update Signal-based SignalValue (no parent component)", async () => {
89
- const sig = signal("test");
90
- await render(<span>{sig}</span>);
91
-
92
- const text = scratch.firstChild!.firstChild!;
93
- expect(text).to.have.property("data", "test");
94
-
95
- await act(() => {
96
- sig.value = "changed";
97
- });
98
-
99
- // should not remount/replace SignalValue
100
- expect(scratch.firstChild!.firstChild!).to.equal(text);
101
- // should update the text in-place
102
- expect(text).to.have.property("data", "changed");
103
- });
104
-
105
- it("should update Signal-based SignalValue (in a parent component)", async () => {
106
- const sig = signal("test");
107
- function App({ x }: { x: typeof sig }) {
108
- return <span>{x}</span>;
109
- }
110
- await render(<App x={sig} />);
111
-
112
- const text = scratch.firstChild!.firstChild!;
113
- expect(text).to.have.property("data", "test");
114
-
115
- await act(() => {
116
- sig.value = "changed";
117
- });
118
-
119
- // should not remount/replace SignalValue
120
- expect(scratch.firstChild!.firstChild!).to.equal(text);
121
- // should update the text in-place
122
- expect(text).to.have.property("data", "changed");
123
- });
124
-
125
- it("should work with JSX inside signal", async () => {
126
- const sig = signal(<b>test</b>);
127
- function App({ x }: { x: typeof sig }) {
128
- return <span>{x}</span>;
129
- }
130
- await render(<App x={sig} />);
131
-
132
- let text = scratch.firstChild!.firstChild!;
133
- expect(text).to.be.instanceOf(HTMLElement);
134
- expect(text.firstChild).to.have.property("data", "test");
135
-
136
- await act(() => {
137
- sig.value = <div>changed</div>;
138
- });
139
-
140
- text = scratch.firstChild!.firstChild!;
141
- expect(text).to.be.instanceOf(HTMLDivElement);
142
- expect(text.firstChild).to.have.property("data", "changed");
143
- });
144
- });
145
-
146
- describe("Component bindings", () => {
147
- it("should subscribe to signals", async () => {
148
- const sig = signal("foo");
149
-
150
- function App() {
151
- const value = sig.value;
152
- return <p>{value}</p>;
153
- }
154
-
155
- await render(<App />);
156
- expect(scratch.textContent).to.equal("foo");
157
-
158
- await act(() => {
159
- sig.value = "bar";
160
- });
161
- expect(scratch.textContent).to.equal("bar");
162
- });
163
-
164
- it("should rerender components when signals they use change", async () => {
165
- const signal1 = signal(0);
166
- function Child1() {
167
- return <div>{signal1}</div>;
168
- }
169
-
170
- const signal2 = signal(0);
171
- function Child2() {
172
- return <div>{signal2}</div>;
173
- }
174
-
175
- function Parent() {
176
- return (
177
- <Fragment>
178
- <Child1 />
179
- <Child2 />
180
- </Fragment>
181
- );
182
- }
183
-
184
- await render(<Parent />);
185
- expect(scratch.innerHTML).to.equal("<div>0</div><div>0</div>");
186
-
187
- await act(() => {
188
- signal1.value += 1;
189
- });
190
- expect(scratch.innerHTML).to.equal("<div>1</div><div>0</div>");
191
-
192
- await act(() => {
193
- signal2.value += 1;
194
- });
195
- expect(scratch.innerHTML).to.equal("<div>1</div><div>1</div>");
196
- });
197
-
198
- it("should subscribe to signals passed as props to DOM elements", async () => {
199
- const className = signal("foo");
200
- function App() {
201
- // @ts-expect-error React types don't allow signals on DOM elements :/
202
- return <div className={className} />;
203
- }
204
-
205
- await render(<App />);
206
-
207
- expect(scratch.innerHTML).to.equal('<div class="foo"></div>');
208
-
209
- await act(() => {
210
- className.value = "bar";
211
- });
212
-
213
- expect(scratch.innerHTML).to.equal('<div class="bar"></div>');
214
- });
215
-
216
- it("should activate signal accessed in render", async () => {
217
- const sig = signal(null);
218
-
219
- function App() {
220
- const arr = useComputed(() => {
221
- // trigger read
222
- sig.value;
223
-
224
- return [];
225
- });
226
-
227
- const str = arr.value.join(", ");
228
- return <p>{str}</p>;
229
- }
230
-
231
- try {
232
- await render(<App />);
233
- } catch (e: any) {
234
- expect.fail(e.stack);
235
- }
236
- });
237
-
238
- it("should not subscribe to child signals", async () => {
239
- const sig = signal("foo");
240
-
241
- function Child() {
242
- const value = sig.value;
243
- return <p>{value}</p>;
244
- }
245
-
246
- const spy = sinon.spy();
247
- function App() {
248
- spy();
249
- return <Child />;
250
- }
251
-
252
- await render(<App />);
253
- expect(scratch.textContent).to.equal("foo");
254
-
255
- await act(() => {
256
- sig.value = "bar";
257
- });
258
- expect(spy).to.be.calledOnce;
259
- });
260
-
261
- it("should update memo'ed component via signals", async () => {
262
- const sig = signal("foo");
263
-
264
- function Inner() {
265
- const value = sig.value;
266
- return <p>{value}</p>;
267
- }
268
-
269
- function App() {
270
- sig.value;
271
- return useMemo(() => <Inner />, []);
272
- }
273
-
274
- await render(<App />);
275
- expect(scratch.textContent).to.equal("foo");
276
-
277
- await act(() => {
278
- sig.value = "bar";
279
- });
280
- expect(scratch.textContent).to.equal("bar");
281
- });
282
-
283
- it("should update forwardRef'ed component via signals", async () => {
284
- const sig = signal("foo");
285
-
286
- const Inner = forwardRef(() => {
287
- return <p>{sig.value}</p>;
288
- });
289
-
290
- function App() {
291
- return <Inner />;
292
- }
293
-
294
- await render(<App />);
295
- expect(scratch.textContent).to.equal("foo");
296
-
297
- await act(() => {
298
- sig.value = "bar";
299
- });
300
- expect(scratch.textContent).to.equal("bar");
301
- });
302
-
303
- it("should consistently rerender in strict mode", async () => {
304
- const sig = signal(-1);
305
-
306
- const Test = () => <p>{sig.value}</p>;
307
- const App = () => (
308
- <StrictMode>
309
- <Test />
310
- </StrictMode>
311
- );
312
-
313
- await render(<App />);
314
- expect(scratch.textContent).to.equal("-1");
315
-
316
- for (let i = 0; i < 3; i++) {
317
- await act(async () => {
318
- sig.value = i;
319
- });
320
- expect(scratch.textContent).to.equal("" + i);
321
- }
322
- });
323
-
324
- it("should consistently rerender in strict mode (with memo)", async () => {
325
- const sig = signal(-1);
326
-
327
- const Test = memo(() => <p>{sig.value}</p>);
328
- const App = () => (
329
- <StrictMode>
330
- <Test />
331
- </StrictMode>
332
- );
333
-
334
- await render(<App />);
335
- expect(scratch.textContent).to.equal("-1");
336
-
337
- for (let i = 0; i < 3; i++) {
338
- await act(async () => {
339
- sig.value = i;
340
- });
341
- expect(scratch.textContent).to.equal("" + i);
342
- }
343
- });
344
-
345
- it("should render static markup of a component", async () => {
346
- const count = signal(0);
347
-
348
- const Test = () => {
349
- return (
350
- <pre>
351
- {renderToStaticMarkup(<code>{count}</code>)}
352
- {renderToStaticMarkup(<code>{count.value}</code>)}
353
- </pre>
354
- );
355
- };
356
-
357
- await render(<Test />);
358
- expect(scratch.textContent).to.equal("<code>0</code><code>0</code>");
359
-
360
- for (let i = 0; i < 3; i++) {
361
- await act(async () => {
362
- count.value += 1;
363
- });
364
- expect(scratch.textContent).to.equal(
365
- `<code>${count.value}</code><code>${count.value}</code>`
366
- );
367
- }
368
- });
369
-
370
- it("should correctly render components that have useReducer()", async () => {
371
- const count = signal(0);
372
-
373
- let increment: () => void;
374
- const Test = () => {
375
- const [state, dispatch] = useReducer(
376
- (state: number, action: number) => {
377
- return state + action;
378
- },
379
- -2
380
- );
381
-
382
- increment = () => dispatch(1);
383
-
384
- const doubled = count.value * 2;
385
-
386
- return (
387
- <pre>
388
- <code>{state}</code>
389
- <code>{doubled}</code>
390
- </pre>
391
- );
392
- };
393
-
394
- await render(<Test />);
395
- expect(scratch.innerHTML).to.equal(
396
- "<pre><code>-2</code><code>0</code></pre>"
397
- );
398
-
399
- for (let i = 0; i < 3; i++) {
400
- await act(async () => {
401
- count.value += 1;
402
- });
403
- expect(scratch.innerHTML).to.equal(
404
- `<pre><code>-2</code><code>${count.value * 2}</code></pre>`
405
- );
406
- }
407
-
408
- await act(() => {
409
- increment();
410
- });
411
- expect(scratch.innerHTML).to.equal(
412
- `<pre><code>-1</code><code>${count.value * 2}</code></pre>`
413
- );
414
- });
415
-
416
- it("should not fail when a component calls setState while rendering", async () => {
417
- let increment: () => void;
418
- function App() {
419
- const [state, setState] = useState(0);
420
- increment = () => setState(state + 1);
421
-
422
- if (state > 0 && state < 2) {
423
- setState(state + 1);
424
- }
425
-
426
- return <div>{state}</div>;
427
- }
428
-
429
- await render(<App />);
430
- expect(scratch.innerHTML).to.equal("<div>0</div>");
431
-
432
- await act(() => {
433
- increment();
434
- });
435
- expect(scratch.innerHTML).to.equal("<div>2</div>");
436
- });
437
-
438
- it("should not fail when a component calls setState multiple times while rendering", async () => {
439
- let increment: () => void;
440
- function App() {
441
- const [state, setState] = useState(0);
442
- increment = () => setState(state + 1);
443
-
444
- if (state > 0 && state < 5) {
445
- setState(state + 1);
446
- }
447
-
448
- return <div>{state}</div>;
449
- }
450
-
451
- await render(<App />);
452
- expect(scratch.innerHTML).to.equal("<div>0</div>");
453
-
454
- await act(() => {
455
- increment();
456
- });
457
- expect(scratch.innerHTML).to.equal("<div>5</div>");
458
- });
459
-
460
- it("should not fail when a component only uses state-less hooks", async () => {
461
- // This test is suppose to trigger a condition in React where the
462
- // HooksDispatcherOnMountWithHookTypesInDEV is used. This dispatcher is
463
- // used in the development build of React if a component has hook types
464
- // defined but no memoizedState, meaning no stateful hooks (e.g. useState)
465
- // are used. `useContext` is an example of a state-less hook because it
466
- // does not mount any hook state onto the fiber's memoizedState field.
467
- //
468
- // However, as of writing, because our react adapter inserts a
469
- // useSyncExternalStore into all components, all components have memoized
470
- // state and so this condition is never hit. However, I'm leaving the test
471
- // to capture this unique behavior to hopefully catch any errors caused by
472
- // not understanding or handling this in the future.
473
-
474
- const sig = signal(0);
475
- const MyContext = createContext(0);
476
-
477
- function Child() {
478
- const value = useContext(MyContext);
479
- return (
480
- <div>
481
- {sig} {value}
482
- </div>
483
- );
484
- }
485
-
486
- let updateContext: () => void;
487
- function App() {
488
- const [value, setValue] = useState(0);
489
- updateContext = () => setValue(value + 1);
490
-
491
- return (
492
- <MyContext.Provider value={value}>
493
- <Child />
494
- </MyContext.Provider>
495
- );
496
- }
497
-
498
- await render(<App />);
499
- expect(scratch.innerHTML).to.equal("<div>0 0</div>");
500
-
501
- await act(() => {
502
- sig.value++;
503
- });
504
- expect(scratch.innerHTML).to.equal("<div>1 0</div>");
505
-
506
- await act(() => {
507
- updateContext();
508
- });
509
- expect(scratch.innerHTML).to.equal("<div>1 1</div>");
510
- });
511
-
512
- it("should not subscribe to computed signals only created and not used", async () => {
513
- const sig = signal(0);
514
- const childSpy = sinon.spy();
515
- const parentSpy = sinon.spy();
516
-
517
- function Child({ num }: { num: Signal<number> }) {
518
- childSpy();
519
- return <p>{num.value}</p>;
520
- }
521
-
522
- function Parent({ num }: { num: Signal<number> }) {
523
- parentSpy();
524
- const sig2 = useComputed(() => num.value + 1);
525
- return <Child num={sig2} />;
526
- }
527
-
528
- await render(<Parent num={sig} />);
529
- expect(scratch.innerHTML).to.equal("<p>1</p>");
530
- expect(parentSpy).to.be.calledOnce;
531
- expect(childSpy).to.be.calledOnce;
532
-
533
- await act(() => {
534
- sig.value += 1;
535
- });
536
- expect(scratch.innerHTML).to.equal("<p>2</p>");
537
- expect(parentSpy).to.be.calledOnce;
538
- expect(childSpy).to.be.calledTwice;
539
- });
540
-
541
- it("should properly subscribe and unsubscribe to conditionally rendered computed signals ", async () => {
542
- const computedDep = signal(0);
543
- const renderComputed = signal(true);
544
- const renderSpy = sinon.spy();
545
-
546
- function App() {
547
- renderSpy();
548
- const computed = useComputed(() => computedDep.value + 1);
549
- return renderComputed.value ? <p>{computed.value}</p> : null;
550
- }
551
-
552
- await render(<App />);
553
- expect(scratch.innerHTML).to.equal("<p>1</p>");
554
- expect(renderSpy).to.be.calledOnce;
555
-
556
- await act(() => {
557
- computedDep.value += 1;
558
- });
559
- expect(scratch.innerHTML).to.equal("<p>2</p>");
560
- expect(renderSpy).to.be.calledTwice;
561
-
562
- await act(() => {
563
- renderComputed.value = false;
564
- });
565
- expect(scratch.innerHTML).to.equal("");
566
- expect(renderSpy).to.be.calledThrice;
567
-
568
- await act(() => {
569
- computedDep.value += 1;
570
- });
571
- expect(scratch.innerHTML).to.equal("");
572
- expect(renderSpy).to.be.calledThrice; // Should not be called again
573
- });
574
- });
575
-
576
- describe("useSignal()", () => {
577
- it("should create a signal from a primitive value", async () => {
578
- function App() {
579
- const count = useSignal(1);
580
- return (
581
- <div>
582
- {count}
583
- <button onClick={() => count.value++}>Increment</button>
584
- </div>
585
- );
586
- }
587
-
588
- await render(<App />);
589
- expect(scratch.textContent).to.equal("1Increment");
590
-
591
- await act(() => {
592
- scratch.querySelector("button")!.click();
593
- });
594
- expect(scratch.textContent).to.equal("2Increment");
595
- });
596
- });
597
-
598
- describe("useSignalEffect()", () => {
599
- it("should be invoked after commit", async () => {
600
- const ref = createRef<HTMLDivElement>();
601
- const sig = signal("foo");
602
- const spy = sinon.spy();
603
- let count = 0;
604
-
605
- function App() {
606
- useSignalEffect(() =>
607
- spy(
608
- sig.value,
609
- ref.current,
610
- ref.current!.getAttribute("data-render-id")
611
- )
612
- );
613
- return (
614
- <p ref={ref} data-render-id={count++}>
615
- {sig.value}
616
- </p>
617
- );
618
- }
619
-
620
- await render(<App />);
621
- expect(scratch.textContent).to.equal("foo");
622
-
623
- expect(spy).to.have.been.calledOnceWith(
624
- "foo",
625
- scratch.firstElementChild,
626
- "0"
627
- );
628
-
629
- spy.resetHistory();
630
-
631
- await act(() => {
632
- sig.value = "bar";
633
- });
634
-
635
- expect(scratch.textContent).to.equal("bar");
636
-
637
- // NOTE: Ideally, call should receive "1" as its third argument! The "0"
638
- // indicates that React's DOM mutations hadn't yet been performed when the
639
- // callback ran. This happens because we do signal-based effect runs after
640
- // the first, not VDOM. Perhaps we could find a way to defer the callback
641
- // when it coincides with a render? In React 16 when running in production
642
- // however, we do see "1" as expected, likely because we are using a fake
643
- // act() implementation which completes after the DOM has been updated.
644
- expect(spy).to.have.been.calledOnceWith(
645
- "bar",
646
- scratch.firstElementChild,
647
- isReact16 && isProd ? "1" : "0" // ideally always "1" - update if we find a nice way to do so!
648
- );
649
- });
650
-
651
- it("should invoke any returned cleanup function for updates", async () => {
652
- const ref = createRef<HTMLDivElement>();
653
- const sig = signal("foo");
654
- const spy = sinon.spy();
655
- const cleanup = sinon.spy();
656
- let count = 0;
657
-
658
- function App() {
659
- useSignalEffect(() => {
660
- const id = ref.current!.getAttribute("data-render-id");
661
- const value = sig.value;
662
- spy(value, ref.current, id);
663
- return () => cleanup(value, ref.current, id);
664
- });
665
- return (
666
- <p ref={ref} data-render-id={count++}>
667
- {sig.value}
668
- </p>
669
- );
670
- }
671
-
672
- await render(<App />);
673
-
674
- expect(cleanup).not.to.have.been.called;
675
- expect(spy).to.have.been.calledOnceWith(
676
- "foo",
677
- scratch.firstElementChild,
678
- "0"
679
- );
680
- spy.resetHistory();
681
-
682
- await act(() => {
683
- sig.value = "bar";
684
- });
685
-
686
- expect(scratch.textContent).to.equal("bar");
687
-
688
- const child = scratch.firstElementChild;
689
-
690
- expect(cleanup).to.have.been.calledOnceWith("foo", child, "0");
691
-
692
- expect(spy).to.have.been.calledOnceWith(
693
- "bar",
694
- child,
695
- isReact16 && isProd ? "1" : "0" // ideally always "1" - update if we find a nice way to do so!
696
- );
697
- });
698
-
699
- it("should invoke any returned cleanup function for unmounts", async () => {
700
- const ref = createRef<HTMLDivElement>();
701
- const sig = signal("foo");
702
- const spy = sinon.spy();
703
- const cleanup = sinon.spy();
704
-
705
- function App() {
706
- useSignalEffect(() => {
707
- const value = sig.value;
708
- spy(value, ref.current);
709
- return () => cleanup(value, ref.current);
710
- });
711
- return <p ref={ref}>{sig.value}</p>;
712
- }
713
-
714
- await render(<App />);
715
-
716
- const child = scratch.firstElementChild;
717
-
718
- expect(scratch.innerHTML).to.equal("<p>foo</p>");
719
- expect(cleanup).not.to.have.been.called;
720
- expect(spy).to.have.been.calledOnceWith("foo", child);
721
- spy.resetHistory();
722
-
723
- await act(() => {
724
- root.unmount();
725
- });
726
-
727
- expect(scratch.innerHTML).to.equal("");
728
- expect(spy).not.to.have.been.called;
729
- expect(cleanup).to.have.been.calledOnce;
730
- // @note: React v18 cleans up the ref eagerly, so it's already null by the
731
- // time the callback runs. this is probably worth fixing at some point.
732
- expect(cleanup).to.have.been.calledWith("foo", isReact16 ? child : null);
733
- });
734
- });
735
- });