From 6b7d29bc53c3f7b274f8465adaef30897f1b9f26 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 8 Apr 2026 07:46:09 +0100 Subject: [PATCH] Bugfix: hydration mismatch --- .../bundler/grab-client-hydration-script.js | 2 +- dist/functions/server/watcher-esbuild-ctx.js | 4 +- .../server/web-pages/generate-web-html.js | 20 ++++ .../grab-page-react-component-string.js | 16 +-- .../web-pages/grab-tsx-string-module.js | 93 +++++---------- ...grab-page-bundled-react-component.test.tsx | 75 ++++++++++++ src/functions/server/watcher-esbuild-ctx.ts | 5 +- .../server/web-pages/generate-web-html.tsx | 27 ++++- .../grab-page-react-component-string.tsx | 15 +-- .../web-pages/grab-tsx-string-module.tsx | 110 ++++++------------ 10 files changed, 192 insertions(+), 175 deletions(-) create mode 100644 src/__tests__/functions/server/grab-page-bundled-react-component.test.tsx diff --git a/dist/functions/bundler/grab-client-hydration-script.js b/dist/functions/bundler/grab-client-hydration-script.js index c15b846..c7f597e 100644 --- a/dist/functions/bundler/grab-client-hydration-script.js +++ b/dist/functions/bundler/grab-client-hydration-script.js @@ -44,7 +44,7 @@ export default async function grabClientHydrationScript({ page_local_path, }) { txt += ` window.${ClientRootComponentWindowName}.render(component);\n`; txt += `} else {\n`; txt += ` const root = hydrateRoot(document.getElementById("${ClientRootElementIDName}"), component, { onRecoverableError: () => {\n\n`; - txt += ` console.log(\`Hydration Error.\`)\n\n`; + // txt += ` console.log(\`Hydration Error.\`)\n\n`; txt += ` } });\n\n`; txt += ` window.${ClientRootComponentWindowName} = root;\n`; txt += ` window.__BUNEXT_RERENDER__ = (NewPage) => {\n`; diff --git a/dist/functions/server/watcher-esbuild-ctx.js b/dist/functions/server/watcher-esbuild-ctx.js index 1bdf4e1..8035ec1 100644 --- a/dist/functions/server/watcher-esbuild-ctx.js +++ b/dist/functions/server/watcher-esbuild-ctx.js @@ -10,8 +10,6 @@ export default async function watcherEsbuildCTX() { recursive: true, persistent: true, }, async (event, filename) => { - // log.info(`event: ${event}`); - // log.info(`filename: ${filename}`); if (!filename) return; if (filename.match(/^\.\w+/)) { @@ -105,7 +103,7 @@ async function fullRebuild(params) { watcherEsbuildCTX(); } } -function reloadWatcher(params) { +function reloadWatcher() { if (global.PAGES_SRC_WATCHER) { global.PAGES_SRC_WATCHER.close(); watcherEsbuildCTX(); diff --git a/dist/functions/server/web-pages/generate-web-html.js b/dist/functions/server/web-pages/generate-web-html.js index 719f4d7..6f55ceb 100644 --- a/dist/functions/server/web-pages/generate-web-html.js +++ b/dist/functions/server/web-pages/generate-web-html.js @@ -56,6 +56,26 @@ export default async function genWebHTML({ component, pageProps, bundledMap, mod }, }); const htmlBody = await new Response(stream).text(); + // const originalConsole = { + // log: console.log, + // warn: console.warn, + // error: console.error, + // info: console.info, + // debug: console.debug, + // }; + // console.log = () => {}; + // console.warn = () => {}; + // console.error = () => {}; + // console.info = () => {}; + // console.debug = () => {}; + // const stream = await renderToReadableStream(final_component, { + // onError(error: any) { + // if (error.message.includes('unique "key" prop')) return; + // originalConsole.error(error); + // }, + // }); + // const htmlBody = await new Response(stream).text(); + // Object.assign(console, originalConsole); html += htmlBody; return html; } diff --git a/dist/functions/server/web-pages/grab-page-react-component-string.js b/dist/functions/server/web-pages/grab-page-react-component-string.js index 23cea5d..a006258 100644 --- a/dist/functions/server/web-pages/grab-page-react-component-string.js +++ b/dist/functions/server/web-pages/grab-page-react-component-string.js @@ -1,22 +1,16 @@ import EJSON from "../../../utils/ejson"; -import isDevelopment from "../../../utils/is-development"; import { log } from "../../../utils/log"; export default function grabPageReactComponentString({ file_path, root_file_path, server_res, }) { - const now = Date.now(); - const dev = isDevelopment(); try { - const import_suffix = dev ? `?t=${now}` : ""; let tsx = ``; const server_res_json = JSON.stringify(EJSON.stringify(server_res || {}) ?? "{}"); - // Import Root from its original source path so that all sub-components - // that import __root (e.g. AppContext) resolve to the same module instance. - // Using the rewritten .bunext/pages/__root would create a separate - // createContext() call, breaking context for any sub-component that - // imports AppContext via a relative path to the source __root. + // Import directly from the source page files. The generated TSX is + // bundled before execution, which keeps Root, Page, and any __root + // context consumers inside one module graph for SSR. if (root_file_path) { - tsx += `import Root from "${root_file_path}${import_suffix}"\n`; + tsx += `import Root from "${root_file_path}"\n`; } - tsx += `import Page from "${file_path}${import_suffix}"\n`; + tsx += `import Page from "${file_path}"\n`; tsx += `export default function Main() {\n\n`; tsx += `const props = JSON.parse(${server_res_json})\n\n`; tsx += ` return (\n`; diff --git a/dist/functions/server/web-pages/grab-tsx-string-module.js b/dist/functions/server/web-pages/grab-tsx-string-module.js index 95acea4..91ddb9c 100644 --- a/dist/functions/server/web-pages/grab-tsx-string-module.js +++ b/dist/functions/server/web-pages/grab-tsx-string-module.js @@ -1,75 +1,36 @@ import isDevelopment from "../../../utils/is-development"; -import { transform } from "esbuild"; +import * as esbuild from "esbuild"; +import grabDirNames from "../../../utils/grab-dir-names"; +import path from "path"; export default async function grabTsxStringModule({ tsx, }) { const dev = isDevelopment(); const now = Date.now(); - const final_tsx = dev ? tsx + `\n// v_${now}` : tsx; - const result = await transform(final_tsx, { - loader: "tsx", + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + const target_cache_file_path = path.join(BUNX_CWD_MODULE_CACHE_DIR, `server-render-${now}.js`); + await esbuild.build({ + stdin: { + contents: dev ? tsx + `\n// v_${now}` : tsx, + resolveDir: process.cwd(), + loader: "tsx", + }, + bundle: true, format: "esm", - jsx: "automatic", + target: "es2020", + platform: "node", + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ], minify: !dev, + define: { + "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), + }, + jsx: "automatic", + outfile: target_cache_file_path, }); - const blob = new Blob([result.code], { type: "text/javascript" }); - const url = URL.createObjectURL(blob); - const mod = await import(url); - URL.revokeObjectURL(url); + Loader.registry.delete(target_cache_file_path); + const mod = await import(`${target_cache_file_path}?t=${now}`); return mod; } -// const trimmed_file_path = file_path -// .replace(/.*\/src\/pages\//, "") -// .replace(/\.tsx$/, ""); -// const src_file_path = path.join( -// BUNX_CWD_MODULE_CACHE_DIR, -// `${trimmed_file_path}.tsx`, -// ); -// const out_file_path = path.join( -// BUNX_CWD_MODULE_CACHE_DIR, -// `${trimmed_file_path}.js`, -// ); -// await Bun.write(src_file_path, tsx); -// const build = await Bun.build({ -// entrypoints: [src_file_path], -// format: "esm", -// target: "bun", -// // external: ["react", "react-dom"], -// minify: true, -// define: { -// "process.env.NODE_ENV": JSON.stringify( -// dev ? "development" : "production", -// ), -// }, -// metafile: true, -// plugins: [tailwindcss, BunSkipNonBrowserPlugin], -// jsx: { -// runtime: "automatic", -// development: dev, -// }, -// outdir: BUNX_CWD_MODULE_CACHE_DIR, -// }); -// Loader.registry.delete(out_file_path); -// const module = await import(`${out_file_path}?t=${Date.now()}`); -// return module as T; -// await esbuild.build({ -// stdin: { -// contents: tsx, -// resolveDir: process.cwd(), -// loader: "tsx", -// }, -// bundle: true, -// format: "esm", -// target: "es2020", -// platform: "node", -// external: ["react", "react-dom"], -// minify: true, -// define: { -// "process.env.NODE_ENV": JSON.stringify( -// dev ? "development" : "production", -// ), -// }, -// metafile: true, -// plugins: [tailwindEsbuildPlugin], -// jsx: "automatic", -// write: true, -// outfile: out_file_path, -// }); diff --git a/src/__tests__/functions/server/grab-page-bundled-react-component.test.tsx b/src/__tests__/functions/server/grab-page-bundled-react-component.test.tsx new file mode 100644 index 0000000..249e55b --- /dev/null +++ b/src/__tests__/functions/server/grab-page-bundled-react-component.test.tsx @@ -0,0 +1,75 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import fs from "fs"; +import path from "path"; +import { renderToString } from "react-dom/server"; +import grabPageBundledReactComponent from "../../../../src/functions/server/web-pages/grab-page-bundled-react-component"; +import grabDirNames from "../../../../src/utils/grab-dir-names"; + +const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); + +describe("grabPageBundledReactComponent", () => { + const fixtureDirs: string[] = []; + const originalConfig = global.CONFIG; + + global.CONFIG = { development: true } as any; + + afterEach(() => { + for (const fixtureDir of fixtureDirs.splice(0)) { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + } + + global.CONFIG = { development: true } as any; + }); + + afterAll(() => { + global.CONFIG = originalConfig; + }); + + test("keeps __root context connected during SSR", async () => { + fs.mkdirSync(BUNX_CWD_MODULE_CACHE_DIR, { recursive: true }); + fs.mkdirSync(BUNX_TMP_DIR, { recursive: true }); + + const fixtureDir = path.join(BUNX_TMP_DIR, `ssr-context-${Date.now()}`); + fixtureDirs.push(fixtureDir); + fs.mkdirSync(fixtureDir, { recursive: true }); + + const rootFilePath = path.join(fixtureDir, "__root.tsx"); + const pageFilePath = path.join(fixtureDir, "page.tsx"); + + fs.writeFileSync( + rootFilePath, + `import { createContext } from "react"; +export const AppContext = createContext("missing-context"); + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + + fs.writeFileSync( + pageFilePath, + `import { useContext } from "react"; +import { AppContext } from "./__root"; + +export default function Page() { + const value = useContext(AppContext); + + return
{value}
; +} +`, + ); + + const result = await grabPageBundledReactComponent({ + file_path: pageFilePath, + root_file_path: rootFilePath, + }); + + expect(result?.component).toBeDefined(); + expect(renderToString(result!.component)).toContain("server-context"); + }); +}); diff --git a/src/functions/server/watcher-esbuild-ctx.ts b/src/functions/server/watcher-esbuild-ctx.ts index add1e45..81c8877 100644 --- a/src/functions/server/watcher-esbuild-ctx.ts +++ b/src/functions/server/watcher-esbuild-ctx.ts @@ -15,9 +15,6 @@ export default async function watcherEsbuildCTX() { persistent: true, }, async (event, filename) => { - // log.info(`event: ${event}`); - // log.info(`filename: ${filename}`); - if (!filename) return; if (filename.match(/^\.\w+/)) { @@ -138,7 +135,7 @@ async function fullRebuild(params?: { msg?: string }) { } } -function reloadWatcher(params?: { msg?: string }) { +function reloadWatcher() { if (global.PAGES_SRC_WATCHER) { global.PAGES_SRC_WATCHER.close(); watcherEsbuildCTX(); diff --git a/src/functions/server/web-pages/generate-web-html.tsx b/src/functions/server/web-pages/generate-web-html.tsx index 4bccc1d..3dd4511 100644 --- a/src/functions/server/web-pages/generate-web-html.tsx +++ b/src/functions/server/web-pages/generate-web-html.tsx @@ -143,15 +143,40 @@ export default async function genWebHTML({ let html = `\n`; + // const stream = await renderToReadableStream(final_component, { + // onError(error: any) { + // if (error.message.includes('unique "key" prop')) return; + // console.error(error); + // }, + // }); + + // const htmlBody = await new Response(stream).text(); + + const originalConsole = { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info, + debug: console.debug, + }; + + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + console.info = () => {}; + console.debug = () => {}; + const stream = await renderToReadableStream(final_component, { onError(error: any) { if (error.message.includes('unique "key" prop')) return; - console.error(error); + originalConsole.error(error); }, }); const htmlBody = await new Response(stream).text(); + Object.assign(console, originalConsole); + html += htmlBody; return html; diff --git a/src/functions/server/web-pages/grab-page-react-component-string.tsx b/src/functions/server/web-pages/grab-page-react-component-string.tsx index 68667d2..5a50691 100644 --- a/src/functions/server/web-pages/grab-page-react-component-string.tsx +++ b/src/functions/server/web-pages/grab-page-react-component-string.tsx @@ -1,5 +1,4 @@ import EJSON from "../../../utils/ejson"; -import isDevelopment from "../../../utils/is-development"; import { log } from "../../../utils/log"; type Params = { @@ -13,28 +12,18 @@ export default function grabPageReactComponentString({ root_file_path, server_res, }: Params): string | undefined { - const now = Date.now(); - const dev = isDevelopment(); - try { - const import_suffix = dev ? `?t=${now}` : ""; - let tsx = ``; const server_res_json = JSON.stringify( EJSON.stringify(server_res || {}) ?? "{}", ); - // Import Root from its original source path so that all sub-components - // that import __root (e.g. AppContext) resolve to the same module instance. - // Using the rewritten .bunext/pages/__root would create a separate - // createContext() call, breaking context for any sub-component that - // imports AppContext via a relative path to the source __root. if (root_file_path) { - tsx += `import Root from "${root_file_path}${import_suffix}"\n`; + tsx += `import Root from "${root_file_path}"\n`; } - tsx += `import Page from "${file_path}${import_suffix}"\n`; + tsx += `import Page from "${file_path}"\n`; tsx += `export default function Main() {\n\n`; tsx += `const props = JSON.parse(${server_res_json})\n\n`; tsx += ` return (\n`; diff --git a/src/functions/server/web-pages/grab-tsx-string-module.tsx b/src/functions/server/web-pages/grab-tsx-string-module.tsx index 6123d7b..81bbc2c 100644 --- a/src/functions/server/web-pages/grab-tsx-string-module.tsx +++ b/src/functions/server/web-pages/grab-tsx-string-module.tsx @@ -1,5 +1,8 @@ import isDevelopment from "../../../utils/is-development"; -import { transform } from "esbuild"; +import * as esbuild from "esbuild"; +import grabDirNames from "../../../utils/grab-dir-names"; +import path from "path"; +import tailwindEsbuildPlugin from "./tailwind-esbuild-plugin"; type Params = { tsx: string; @@ -10,86 +13,41 @@ export default async function grabTsxStringModule({ }: Params): Promise { const dev = isDevelopment(); const now = Date.now(); + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + const target_cache_file_path = path.join( + BUNX_CWD_MODULE_CACHE_DIR, + `server-render-${now}.js`, + ); - const final_tsx = dev ? tsx + `\n// v_${now}` : tsx; - - const result = await transform(final_tsx, { - loader: "tsx", + await esbuild.build({ + stdin: { + contents: dev ? tsx + `\n// v_${now}` : tsx, + resolveDir: process.cwd(), + loader: "tsx", + }, + bundle: true, format: "esm", - jsx: "automatic", + target: "es2020", + platform: "node", + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ], minify: !dev, + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + jsx: "automatic", + outfile: target_cache_file_path, + plugins: [tailwindEsbuildPlugin], }); - const blob = new Blob([result.code], { type: "text/javascript" }); - const url = URL.createObjectURL(blob); - const mod = await import(url); - - URL.revokeObjectURL(url); + Loader.registry.delete(target_cache_file_path); + const mod = await import(`${target_cache_file_path}?t=${now}`); return mod as T; } - -// const trimmed_file_path = file_path -// .replace(/.*\/src\/pages\//, "") -// .replace(/\.tsx$/, ""); - -// const src_file_path = path.join( -// BUNX_CWD_MODULE_CACHE_DIR, -// `${trimmed_file_path}.tsx`, -// ); - -// const out_file_path = path.join( -// BUNX_CWD_MODULE_CACHE_DIR, -// `${trimmed_file_path}.js`, -// ); - -// await Bun.write(src_file_path, tsx); - -// const build = await Bun.build({ -// entrypoints: [src_file_path], -// format: "esm", -// target: "bun", -// // external: ["react", "react-dom"], -// minify: true, -// define: { -// "process.env.NODE_ENV": JSON.stringify( -// dev ? "development" : "production", -// ), -// }, -// metafile: true, -// plugins: [tailwindcss, BunSkipNonBrowserPlugin], -// jsx: { -// runtime: "automatic", -// development: dev, -// }, -// outdir: BUNX_CWD_MODULE_CACHE_DIR, -// }); - -// Loader.registry.delete(out_file_path); -// const module = await import(`${out_file_path}?t=${Date.now()}`); - -// return module as T; - -// await esbuild.build({ -// stdin: { -// contents: tsx, -// resolveDir: process.cwd(), -// loader: "tsx", -// }, -// bundle: true, -// format: "esm", -// target: "es2020", -// platform: "node", -// external: ["react", "react-dom"], -// minify: true, -// define: { -// "process.env.NODE_ENV": JSON.stringify( -// dev ? "development" : "production", -// ), -// }, -// metafile: true, -// plugins: [tailwindEsbuildPlugin], -// jsx: "automatic", -// write: true, -// outfile: out_file_path, -// });