Skip to content

Commit 3955213

Browse files
authored
fix: suspense client component (#78)
This fixes an issue about adding scripts to the HTML document in development mode for HMR when the initial page don't use a client component, but a client component is rendered in a `<Suspense>` boundary. #77 Adds a test to verify this use case from now on.
1 parent 9f3b6b9 commit 3955213

File tree

6 files changed

+71
-14
lines changed

6 files changed

+71
-14
lines changed

packages/react-server/lib/plugins/react-server-runtime.mjs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ export default function viteReactServerRuntime() {
3535
window.$RefreshSig$ = () => (type) => type;
3636
window.__vite_plugin_react_preamble_installed__ = true;
3737
console.log("Hot Module Replacement installed.");
38-
if (typeof __react_server_hydrate__ !== "undefined") {
39-
import(/* @vite-ignore */ "${reactServerDir}/client/entry.client.jsx");
40-
}`;
38+
self.__react_server_hydrate_init__ = () => {
39+
if (typeof __react_server_hydrate__ !== "undefined") {
40+
import(/* @vite-ignore */ "${reactServerDir}/client/entry.client.jsx");
41+
}
42+
};
43+
self.__react_server_hydrate_init__();`;
4144
} else if (id.endsWith("/@__webpack_require__")) {
4245
return `
4346
const moduleCache = new Map();

packages/react-server/server/render-dom.mjs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,16 @@ export const createRenderer = ({
258258
importMap
259259
)}</script>`
260260
: ""
261-
}${bootstrapModules
262-
.map(
263-
(mod) =>
264-
`<script type="module" src="${mod}" async></script>`
265-
)
266-
.join("")}`
261+
}${
262+
hmr
263+
? "<script>self.__react_server_hydrate_init__?.();</script>"
264+
: bootstrapModules
265+
.map(
266+
(mod) =>
267+
`<script type="module" src="${mod}" async></script>`
268+
)
269+
.join("")
270+
}`
267271
);
268272
yield script;
269273
hydrated = true;

packages/react-server/server/render-rsc.jsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -449,11 +449,7 @@ export async function render(Component) {
449449
`const moduleCache = new Map();
450450
self.__webpack_require__ = function (id) {
451451
if (!moduleCache.has(id)) {
452-
${
453-
config.base
454-
? `const modulePromise = import(("${`/${config.base}/`.replace(/\/+/g, "/")}" + id).replace(/\\/+/g, "/"));`
455-
: `const modulePromise = import(id);`
456-
}
452+
const modulePromise = import(("${`/${config.base ?? ""}/`.replace(/\/+/g, "/")}" + id).replace(/\\/+/g, "/"));
457453
modulePromise.then(
458454
(module) => {
459455
modulePromise.value = module;

test/__test__/basic.spec.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
serverLogs,
77
waitForChange,
88
waitForConsole,
9+
waitForHydration,
910
} from "playground/utils";
1011
import { expect, test } from "vitest";
1112

@@ -239,3 +240,32 @@ test("use cache dynamic", async () => {
239240
await page.goto(hostname + "?id=1");
240241
expect(await page.textContent("body")).toBe(time);
241242
});
243+
244+
test("suspense client", async () => {
245+
await server("fixtures/suspense-client.jsx");
246+
await page.goto(hostname);
247+
await waitForHydration();
248+
249+
if (process.env.NODE_ENV === "production") {
250+
const scripts = await page.$$("script");
251+
expect(scripts.length).toBe(2);
252+
expect(await scripts[0].getAttribute("src")).toContain("/client/index");
253+
expect(await scripts[1].getAttribute("src")).toBe(null);
254+
} else {
255+
const button = await page.getByRole("button");
256+
expect(await button.isVisible()).toBe(true);
257+
await button.click();
258+
expect(logs).toContain("use client");
259+
await waitForChange(
260+
() => {},
261+
() => page.$$("script")
262+
);
263+
const scripts = await page.$$("script");
264+
// this is flaky and needs a stable solution
265+
expect(scripts.length).toBeGreaterThanOrEqual(4);
266+
expect(await scripts[0].getAttribute("src")).toBe("/@vite/client");
267+
expect(await scripts[1].getAttribute("src")).toBe("/@hmr");
268+
expect(await scripts[2].getAttribute("src")).toBe("/@__webpack_require__");
269+
expect(await scripts[3].getAttribute("src")).toBe(null);
270+
}
271+
});

test/fixtures/client-component.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"use client";
2+
3+
export default function ClientComponent() {
4+
return (
5+
<button onClick={() => console.log("use client")}>Client Component</button>
6+
);
7+
}

test/fixtures/suspense-client.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Suspense } from "react";
2+
import ClientComponent from "./client-component.jsx";
3+
4+
async function AsyncComponent() {
5+
await new Promise((resolve) => setTimeout(resolve, 100));
6+
return <ClientComponent />;
7+
}
8+
9+
export default function App() {
10+
return (
11+
<div>
12+
<Suspense fallback={<div>Loading...</div>}>
13+
<AsyncComponent />
14+
</Suspense>
15+
</div>
16+
);
17+
}

0 commit comments

Comments
 (0)