Skip to content

Commit 503128f

Browse files
authored
[React] Don't track signals read during SSR render (#640)
* Run react/runtime mocha tests * Add test validating signal targets are cleaned up properly * If window is not defined, don't track signal usage in component render * Add changeset * Attempt to fix react-signals/runtime node tests in CI * Attempt to fix running test in CI again * Add note to Readme about React SSR
1 parent 674c310 commit 503128f

File tree

5 files changed

+91
-2
lines changed

5 files changed

+91
-2
lines changed

.changeset/dirty-poets-cover.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals-react": patch
3+
---
4+
5+
Don't track signals read during an SSR render (e.g. renderToString, renderToStaticMarkup)

packages/react/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ function Counter() {
9898
}
9999
```
100100

101+
### Using signals with React's SSR APIs
102+
103+
Components rendered using SSR APIs (e.g. `renderToString`) in a server environment (i.e. an environment without a global `window` object) will not track signals used during render. Components generally don't rerender when using SSR APIs so tracking signal usage is useless since changing these signals can't trigger a rerender.
104+
101105
### Rendering optimizations
102106

103107
The React adapter ships with several optimizations it can apply out of the box to skip virtual-dom rendering entirely. If you pass a signal directly into JSX, it will bind directly to the DOM `Text` node that is created and update that whenever the signal changes.
@@ -132,7 +136,7 @@ To opt into this optimization, simply pass the signal directly instead of access
132136

133137
This version of React integration does not support passing signals as DOM attributes. Support for this may be added at a later date.
134138

135-
Using signals into render props is not recommended. In this situation, the component that reads the signal is the component that calls the render prop, which may or may not be hooked up to track signals. For example:
139+
Using signals in render props is not recommended. In this situation, the component that reads the signal is the component that calls the render prop, which may or may not be hooked up to track signals. For example:
136140

137141
```js
138142
const count = signal(0);

packages/react/runtime/src/index.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,33 @@ function createEffectStore(_usage: EffectStoreUsage): EffectStore {
285285
};
286286
}
287287

288+
const noop = () => {};
289+
290+
function createEmptyEffectStore(): EffectStore {
291+
return {
292+
_usage: UNMANAGED,
293+
effect: {
294+
_sources: undefined,
295+
_callback() {},
296+
_start() {
297+
return /* endEffect */ noop;
298+
},
299+
_dispose() {},
300+
},
301+
subscribe() {
302+
return /* unsubscribe */ noop;
303+
},
304+
getSnapshot() {
305+
return 0;
306+
},
307+
_start() {},
308+
f() {},
309+
[symDispose]() {},
310+
};
311+
}
312+
313+
const emptyEffectStore = createEmptyEffectStore();
314+
288315
const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve());
289316

290317
let finalCleanup: Promise<void> | undefined;
@@ -312,7 +339,11 @@ export function _useSignalsImplementation(
312339

313340
const storeRef = useRef<EffectStore>();
314341
if (storeRef.current == null) {
315-
storeRef.current = createEffectStore(_usage);
342+
if (typeof window === "undefined") {
343+
storeRef.current = emptyEffectStore;
344+
} else {
345+
storeRef.current = createEffectStore(_usage);
346+
}
316347
}
317348

318349
const store = storeRef.current;

packages/react/runtime/test/browser/useSignals.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,32 @@ describe("useSignals", () => {
453453
expect(scratch.innerHTML).to.equal("<div>Hello John!</div>");
454454
});
455455

456+
it("should clean up signal dependencies after unmounting", async () => {
457+
const getTargets = (s: any): any => s.t ?? s._targets;
458+
459+
const sig = signal(0);
460+
function App() {
461+
const effectStore = useSignals(/* MANAGED_COMPONENT */ 1);
462+
try {
463+
return <p>{sig.value}</p>;
464+
} finally {
465+
effectStore.f();
466+
}
467+
}
468+
469+
expect(getTargets(sig)).to.be.undefined;
470+
471+
await render(<App />);
472+
expect(scratch.innerHTML).to.equal("<p>0</p>");
473+
expect(getTargets(sig)).to.be.not.null.and.not.undefined;
474+
475+
act(() => root.unmount());
476+
expect(scratch.innerHTML).to.equal("");
477+
478+
await Promise.resolve();
479+
expect(getTargets(sig)).to.be.undefined;
480+
});
481+
456482
// TODO: Figure out what to do here...
457483
it.skip("(managed) should work with components that use render props", async () => {
458484
function AutoFocusWithin({

packages/react/test/node/renderToStaticMarkup.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { signal, useSignalEffect } from "@preact/signals-react";
2+
import { useSignals } from "@preact/signals-react/runtime";
23
import { createElement } from "react";
34
import { renderToStaticMarkup } from "react-dom/server";
45
import { mountSignalsTests } from "../shared/mounting";
@@ -19,4 +20,26 @@ describe("renderToStaticMarkup", () => {
1920
expect(html).to.equal("<p>foo</p>");
2021
expect(spy.called).to.be.false;
2122
});
23+
24+
it("should clean up signal dependencies after executing", async () => {
25+
const getTargets = (s: any): any => s.t ?? s._targets;
26+
27+
const sig = signal(0);
28+
function App() {
29+
const effectStore = useSignals(/* MANAGED_COMPONENT */ 1);
30+
try {
31+
return <p>{sig.value}</p>;
32+
} finally {
33+
effectStore.f();
34+
}
35+
}
36+
37+
expect(getTargets(sig)).to.be.undefined;
38+
39+
const html = renderToStaticMarkup(<App />);
40+
expect(html).to.equal("<p>0</p>");
41+
42+
await Promise.resolve();
43+
expect(getTargets(sig)).to.be.undefined;
44+
});
2245
});

0 commit comments

Comments
 (0)