Forked from CharlieCrisp/PReact Signals Suspense Bug
Last active
November 4, 2025 18:26
-
-
Save andrewiggins/513eb068c31f0e87454b77cb70c8503a to your computer and use it in GitHub Desktop.
This is a bunch of test cases that can be added to `suspense.test.tsx` in https://github.com/preactjs/signals. Two of these tests fail, indicating that when suspenses are used, signals used in components are never unwatched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| it("should clean up signals after unmount with multiple suspense boundaries", async () => { | |
| let watchedCallCount = 0; | |
| let unwatchedCallCount = 0; | |
| // Create a signal with watched/unwatched callbacks to track cleanup | |
| const trackedSignal = signal(0, { | |
| name: "trackedSignal", | |
| watched: function () { | |
| watchedCallCount++; | |
| }, | |
| unwatched: function () { | |
| unwatchedCallCount++; | |
| }, | |
| }); | |
| let resolveFirstProm!: () => void; | |
| let firstPromResolved = false; | |
| const firstProm = new Promise(resolve => { | |
| resolveFirstProm = () => { | |
| firstPromResolved = true; | |
| resolve(undefined); | |
| }; | |
| }); | |
| let resolveSecondProm!: () => void; | |
| let secondPromResolved = false; | |
| const secondProm = new Promise(resolve => { | |
| resolveSecondProm = () => { | |
| secondPromResolved = true; | |
| resolve(undefined); | |
| }; | |
| }); | |
| function FirstSuspendingComponent() { | |
| useSignals(0); | |
| // Access the signal before any suspense | |
| const value = trackedSignal.value; | |
| if (!firstPromResolved) throw firstProm; | |
| return <div data-first={value}>First</div>; | |
| } | |
| function SecondSuspendingComponent() { | |
| useSignals(); | |
| // Access the signal after first suspense | |
| const value = trackedSignal.value; | |
| if (!secondPromResolved) throw secondProm; | |
| return <div data-second={value}>Second</div>; | |
| } | |
| function RegularComponent() { | |
| useSignals(); | |
| // Access the signal normally | |
| return <div data-regular={trackedSignal.value}>Regular</div>; | |
| } | |
| function Parent() { | |
| useSignals(); | |
| // Access the signal at the top level | |
| const value = trackedSignal.value; | |
| return ( | |
| <div data-parent={value}> | |
| <RegularComponent /> | |
| <Suspense fallback={<span>Loading first...</span>}> | |
| <FirstSuspendingComponent /> | |
| </Suspense> | |
| <Suspense fallback={<span>Loading second...</span>}> | |
| <SecondSuspendingComponent /> | |
| </Suspense> | |
| </div> | |
| ); | |
| } | |
| // Initial render - should trigger watched callback | |
| await render(<Parent />); | |
| expect(scratch.innerHTML).to.contain("Loading first..."); | |
| expect(scratch.innerHTML).to.contain("Loading second..."); | |
| expect(scratch.innerHTML).to.contain("Regular"); | |
| // Signal should be watched by now | |
| expect(watchedCallCount).to.be.greaterThan(0); | |
| expect(unwatchedCallCount).to.equal(0); | |
| // Resolve first suspense | |
| await act(async () => { | |
| resolveFirstProm(); | |
| await firstProm; | |
| }); | |
| expect(scratch.innerHTML).to.contain("First"); | |
| expect(scratch.innerHTML).to.contain("Loading second..."); | |
| // Resolve second suspense | |
| await act(async () => { | |
| resolveSecondProm(); | |
| await secondProm; | |
| }); | |
| expect(scratch.innerHTML).to.contain("First"); | |
| expect(scratch.innerHTML).to.contain("Second"); | |
| expect(scratch.innerHTML).to.contain("Regular"); | |
| // Update signal to verify it's still being watched | |
| await act(async () => { | |
| trackedSignal.value = 42; | |
| }); | |
| expect(scratch.innerHTML).to.contain('data-parent="42"'); | |
| expect(scratch.innerHTML).to.contain('data-regular="42"'); | |
| expect(scratch.innerHTML).to.contain('data-first="42"'); | |
| expect(scratch.innerHTML).to.contain('data-second="42"'); | |
| // Now unmount the entire tree | |
| await act(async () => { | |
| root.unmount(); | |
| }); | |
| expect(scratch.innerHTML).to.equal(""); | |
| // Wait for cleanup to complete | |
| await new Promise(resolve => setTimeout(resolve, 10)); | |
| // After unmount, the signal should be unwatched | |
| expect(unwatchedCallCount).to.be.greaterThan(0); | |
| // Verify the signal is no longer being watched by trying to update it | |
| // (this won't trigger any re-renders since no components are subscribed) | |
| trackedSignal.value = 999; | |
| // The signal value should have changed but no components should re-render | |
| expect(trackedSignal.value).to.equal(999); | |
| expect(scratch.innerHTML).to.equal(""); | |
| }); | |
| it("should clean up signals after unmount with multiple suspense boundaries and use of try catch", async () => { | |
| let watchedCallCount = 0; | |
| let unwatchedCallCount = 0; | |
| // Create a signal with watched/unwatched callbacks to track cleanup | |
| const trackedSignal = signal(0, { | |
| name: "trackedSignal", | |
| watched: function () { | |
| watchedCallCount++; | |
| }, | |
| unwatched: function () { | |
| unwatchedCallCount++; | |
| }, | |
| }); | |
| let resolveFirstProm!: () => void; | |
| let firstPromResolved = false; | |
| const firstProm = new Promise(resolve => { | |
| resolveFirstProm = () => { | |
| firstPromResolved = true; | |
| resolve(undefined); | |
| }; | |
| }); | |
| let resolveSecondProm!: () => void; | |
| let secondPromResolved = false; | |
| const secondProm = new Promise(resolve => { | |
| resolveSecondProm = () => { | |
| secondPromResolved = true; | |
| resolve(undefined); | |
| }; | |
| }); | |
| function FirstSuspendingComponent() { | |
| const store = useSignals(1, true); | |
| try { | |
| // Access the signal before any suspense | |
| const value = trackedSignal.value; | |
| if (!firstPromResolved) throw firstProm; | |
| return <div data-first={value}>First</div>; | |
| } finally { | |
| store.f(); | |
| } | |
| } | |
| function SecondSuspendingComponent() { | |
| const store = useSignals(1, true); | |
| try { | |
| // Access the signal after first suspense | |
| const value = trackedSignal.value; | |
| if (!secondPromResolved) throw secondProm; | |
| return <div data-second={value}>Second</div>; | |
| } finally { | |
| store.f(); | |
| } | |
| } | |
| function RegularComponent() { | |
| const store = useSignals(1, true); | |
| try { | |
| // Access the signal normally | |
| return <div data-regular={trackedSignal.value}>Regular</div>; | |
| } finally { | |
| store.f(); | |
| } | |
| } | |
| function Parent() { | |
| const store = useSignals(1, true); | |
| try { | |
| // Access the signal at the top level | |
| const value = trackedSignal.value; | |
| return ( | |
| <div data-parent={value}> | |
| <RegularComponent /> | |
| <Suspense fallback={<span>Loading first...</span>}> | |
| <FirstSuspendingComponent /> | |
| </Suspense> | |
| <Suspense fallback={<span>Loading second...</span>}> | |
| <SecondSuspendingComponent /> | |
| </Suspense> | |
| </div> | |
| ); | |
| } finally { | |
| store.f(); | |
| } | |
| } | |
| // Initial render - should trigger watched callback | |
| await render(<Parent />); | |
| expect(scratch.innerHTML).to.contain("Loading first..."); | |
| expect(scratch.innerHTML).to.contain("Loading second..."); | |
| expect(scratch.innerHTML).to.contain("Regular"); | |
| // Signal should be watched by now | |
| expect(watchedCallCount).to.be.greaterThan(0); | |
| expect(unwatchedCallCount).to.equal(0); | |
| // Resolve first suspense | |
| await act(async () => { | |
| resolveFirstProm(); | |
| await firstProm; | |
| }); | |
| expect(scratch.innerHTML).to.contain("First"); | |
| expect(scratch.innerHTML).to.contain("Loading second..."); | |
| // Resolve second suspense | |
| await act(async () => { | |
| resolveSecondProm(); | |
| await secondProm; | |
| }); | |
| expect(scratch.innerHTML).to.contain("First"); | |
| expect(scratch.innerHTML).to.contain("Second"); | |
| expect(scratch.innerHTML).to.contain("Regular"); | |
| // Update signal to verify it's still being watched | |
| await act(async () => { | |
| trackedSignal.value = 42; | |
| }); | |
| expect(scratch.innerHTML).to.contain('data-parent="42"'); | |
| expect(scratch.innerHTML).to.contain('data-regular="42"'); | |
| expect(scratch.innerHTML).to.contain('data-first="42"'); | |
| expect(scratch.innerHTML).to.contain('data-second="42"'); | |
| // Now unmount the entire tree | |
| await act(async () => { | |
| root.unmount(); | |
| }); | |
| expect(scratch.innerHTML).to.equal(""); | |
| // Wait for cleanup to complete | |
| await new Promise(resolve => setTimeout(resolve, 10)); | |
| // After unmount, the signal should be unwatched | |
| expect(unwatchedCallCount).to.be.greaterThan(0); | |
| // Verify the signal is no longer being watched by trying to update it | |
| // (this won't trigger any re-renders since no components are subscribed) | |
| trackedSignal.value = 999; | |
| // The signal value should have changed but no components should re-render | |
| expect(trackedSignal.value).to.equal(999); | |
| expect(scratch.innerHTML).to.equal(""); | |
| }); | |
| it("should maintain signal watching and clean up after unmount", async () => { | |
| let watchedCallCount = 0; | |
| let unwatchedCallCount = 0; | |
| // Create a signal with watched/unwatched callbacks to track cleanup | |
| const trackedSignal = signal(0, { | |
| name: "trackedSignal", | |
| watched: function () { | |
| watchedCallCount++; | |
| }, | |
| unwatched: function () { | |
| unwatchedCallCount++; | |
| }, | |
| }); | |
| function RegularComponent() { | |
| useSignals(); | |
| // Access the signal normally | |
| return <div data-regular={trackedSignal.value}>Regular</div>; | |
| } | |
| function Parent() { | |
| useSignals(); | |
| // Access the signal at the top level | |
| const value = trackedSignal.value; | |
| return ( | |
| <div data-parent={value}> | |
| <RegularComponent /> | |
| </div> | |
| ); | |
| } | |
| // Initial render - should trigger watched callback | |
| await render(<Parent />); | |
| expect(scratch.innerHTML).to.contain("Regular"); | |
| // Signal should be watched by now | |
| expect(watchedCallCount).to.be.greaterThan(0); | |
| expect(unwatchedCallCount).to.equal(0); | |
| // Update signal - should work normally | |
| await act(async () => { | |
| trackedSignal.value = 10; | |
| }); | |
| expect(scratch.innerHTML).to.contain('data-parent="10"'); | |
| expect(scratch.innerHTML).to.contain('data-regular="10"'); | |
| // Update signal again | |
| await act(async () => { | |
| trackedSignal.value = 20; | |
| }); | |
| expect(scratch.innerHTML).to.contain('data-parent="20"'); | |
| expect(scratch.innerHTML).to.contain('data-regular="20"'); | |
| // Signal should still be watched (no unwatched calls yet) | |
| expect(unwatchedCallCount).to.equal(0); | |
| // Now unmount the entire tree | |
| await act(async () => { | |
| root.unmount(); | |
| }); | |
| expect(scratch.innerHTML).to.equal(""); | |
| // Wait for cleanup to complete | |
| await new Promise(resolve => setTimeout(resolve, 10)); | |
| // After unmount, the signal should be unwatched | |
| expect(unwatchedCallCount).to.be.greaterThan(0); | |
| // Verify the signal is no longer being watched by trying to update it | |
| // (this won't trigger any re-renders since no components are subscribed) | |
| trackedSignal.value = 999; | |
| // The signal value should have changed but no components should re-render | |
| expect(trackedSignal.value).to.equal(999); | |
| expect(scratch.innerHTML).to.equal(""); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment