From 7dd87a6bc07c60549aac6e78d4cd6757883b0cc9 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 25 Nov 2025 16:23:23 +0100 Subject: [PATCH 01/24] Experimental SSR playground. --- CLAUDE.md | 111 +++++++++++++++++++++++++++++++ packages/sourdough/.gitignore | 34 ++++++++++ packages/sourdough/App.tsx | 49 ++++++++++++++ packages/sourdough/README.md | 15 +++++ packages/sourdough/bun.lock | 45 +++++++++++++ packages/sourdough/client.tsx | 10 +++ packages/sourdough/index.html | 10 +++ packages/sourdough/index.tsx | 67 +++++++++++++++++++ packages/sourdough/package.json | 17 +++++ packages/sourdough/tsconfig.json | 29 ++++++++ 10 files changed, 387 insertions(+) create mode 100644 CLAUDE.md create mode 100644 packages/sourdough/.gitignore create mode 100644 packages/sourdough/App.tsx create mode 100644 packages/sourdough/README.md create mode 100644 packages/sourdough/bun.lock create mode 100644 packages/sourdough/client.tsx create mode 100644 packages/sourdough/index.html create mode 100644 packages/sourdough/index.tsx create mode 100644 packages/sourdough/package.json create mode 100644 packages/sourdough/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/packages/sourdough/.gitignore b/packages/sourdough/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/sourdough/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/sourdough/App.tsx b/packages/sourdough/App.tsx new file mode 100644 index 0000000..0e36e6d --- /dev/null +++ b/packages/sourdough/App.tsx @@ -0,0 +1,49 @@ +import { Route, Link, Switch } from "wouter"; + +export function App() { + return ( + <> + + +
+ + +

Home Page

+

Welcome to the Wouter SSR demo!

+

This page is server-side rendered with Bun + React + Wouter.

+
+ + +

About

+

This is a simple SSR demo showcasing wouter v3.8.0.

+
    +
  • Server-side rendering with React 19
  • +
  • Client-side hydration
  • +
  • Routing with wouter
  • +
  • Powered by Bun
  • +
+
+ + + {(params) => ( + <> +

User Profile

+

Hello, {params.name}!

+

This route demonstrates dynamic parameters in SSR.

+ + )} +
+ + +

404 - Not Found

+

The page you're looking for doesn't exist.

+
+
+
+ + ); +} diff --git a/packages/sourdough/README.md b/packages/sourdough/README.md new file mode 100644 index 0000000..09839e7 --- /dev/null +++ b/packages/sourdough/README.md @@ -0,0 +1,15 @@ +# sourdough + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/packages/sourdough/bun.lock b/packages/sourdough/bun.lock new file mode 100644 index 0000000..d419404 --- /dev/null +++ b/packages/sourdough/bun.lock @@ -0,0 +1,45 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "sourdough", + "dependencies": { + "react": "19", + "react-dom": "19", + "wouter": "3.8.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "wouter": ["wouter@3.8.0", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-oFQKbZVQxaXGXSuUg++FETTORSKQUur1Q+tvIlVm9TjPt+bpzS/IkRy2mXdSuqTjJ/PorSPxbRz4UwWsICes1Q=="], + } +} diff --git a/packages/sourdough/client.tsx b/packages/sourdough/client.tsx new file mode 100644 index 0000000..3bcd31a --- /dev/null +++ b/packages/sourdough/client.tsx @@ -0,0 +1,10 @@ +import { hydrateRoot } from "react-dom/client"; +import { Router } from "wouter"; +import { App } from "./App"; + +hydrateRoot( + document.body, + + + +); diff --git a/packages/sourdough/index.html b/packages/sourdough/index.html new file mode 100644 index 0000000..0f24d1b --- /dev/null +++ b/packages/sourdough/index.html @@ -0,0 +1,10 @@ + + + + + + Wouter SSR + Hydration Demo + + + + diff --git a/packages/sourdough/index.tsx b/packages/sourdough/index.tsx new file mode 100644 index 0000000..8184c46 --- /dev/null +++ b/packages/sourdough/index.tsx @@ -0,0 +1,67 @@ +import { renderToReadableStream } from "react-dom/server"; +import { Router } from "wouter"; +import { App } from "./App"; +import indexHtml from "./index.html"; + +Bun.serve({ + port: 3002, + async fetch(req) { + const url = new URL(req.url); + + // Check if this is a request for bundled assets + // In dev mode, Bun generates these on the fly + const isAsset = + url.pathname.includes("/_bun/") || + url.pathname.endsWith(".js") || + url.pathname.endsWith(".css") || + url.pathname.endsWith(".tsx") || + url.pathname.endsWith(".ts"); + + // If it's an asset, check HTMLBundle files or serve directly + if (isAsset) { + // If files array exists (production build), serve from there + if (indexHtml.files) { + const file = indexHtml.files.find(f => url.pathname === f.path || url.pathname.endsWith(f.path)); + if (file) { + return new Response(Bun.file(file.path), { + headers: file.headers, + }); + } + } + // In dev mode, return undefined to let Bun's bundler handle it + return; + } + + // Get the HTML template using the file path from HTMLBundle + const html = await Bun.file(indexHtml.index).text(); + + // Extract path and search from URL + const ssrPath = url.pathname; + const ssrSearch = url.search; + + // Render the app with Router and SSR props + const stream = await renderToReadableStream( + + + + ); + + // Convert stream to string + const appHtml = await new Response(stream).text(); + + // Use HTMLRewriter to inject the SSR content into body + const rewriter = new HTMLRewriter().on("body", { + element(element) { + element.setInnerContent(appHtml, { html: true }); + }, + }); + + const transformedResponse = rewriter.transform(new Response(html)); + + return new Response(transformedResponse.body, { + headers: { "Content-Type": "text/html" }, + }); + }, +}); + +console.log("Server running at http://localhost:3002"); diff --git a/packages/sourdough/package.json b/packages/sourdough/package.json new file mode 100644 index 0000000..3f5f965 --- /dev/null +++ b/packages/sourdough/package.json @@ -0,0 +1,17 @@ +{ + "name": "sourdough", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "react": "19", + "react-dom": "19", + "wouter": "3.8.0" + } +} diff --git a/packages/sourdough/tsconfig.json b/packages/sourdough/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/packages/sourdough/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} From 8f3d18a1abd87f52ab4ac70fe9502f4a11e02a9d Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 25 Nov 2025 16:23:40 +0100 Subject: [PATCH 02/24] Update. --- packages/sourdough/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sourdough/index.tsx b/packages/sourdough/index.tsx index 8184c46..7381eb8 100644 --- a/packages/sourdough/index.tsx +++ b/packages/sourdough/index.tsx @@ -21,7 +21,9 @@ Bun.serve({ if (isAsset) { // If files array exists (production build), serve from there if (indexHtml.files) { - const file = indexHtml.files.find(f => url.pathname === f.path || url.pathname.endsWith(f.path)); + const file = indexHtml.files.find( + (f) => url.pathname === f.path || url.pathname.endsWith(f.path) + ); if (file) { return new Response(Bun.file(file.path), { headers: file.headers, From 853031f3bbad280d5278dde8f9197adc0196e1be Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 Dec 2025 16:17:28 +0100 Subject: [PATCH 03/24] Fix useSearch in SSR regression. --- packages/wouter/src/index.js | 4 +- packages/wouter/src/use-browser-location.js | 2 +- packages/wouter/test/setup.ts | 15 +++++++ packages/wouter/test/ssr.test.tsx | 43 +++++++++++++++------ 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 3c759dc..238cd7c 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -174,8 +174,8 @@ export const Router = ({ children, ...props }) => { const option = k === "base" ? /* base is special case, it is appended to the parent's base */ - parent[k] + (props[k] || "") - : props[k] || parent[k]; + parent[k] + (props[k] ?? "") + : props[k] ?? parent[k]; if (prev === next && option !== next[k]) { ref.current = next = { ...next }; diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 6d91a49..4c8f257 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -33,7 +33,7 @@ const currentSearch = () => location.search; export const useSearch = ({ ssrSearch } = {}) => useLocationProperty( currentSearch, - ssrSearch != null ? () => ssrSearch : currentSearch + ssrSearch != null ? () => ssrSearch : () => "" ); const currentPathname = () => location.pathname; diff --git a/packages/wouter/test/setup.ts b/packages/wouter/test/setup.ts index fa38dbb..8f17b3b 100644 --- a/packages/wouter/test/setup.ts +++ b/packages/wouter/test/setup.ts @@ -11,3 +11,18 @@ GlobalRegistrator.register({ // Extend Bun's expect with jest-dom matchers (expect as any).extend(matchers); + +/** + * Runs a function with `location` temporarily removed from globalThis. + * Simulates pure Node.js SSR environment for testing. + */ +export const withoutLocation = (fn: () => T): T => { + const original = globalThis.location; + // @ts-expect-error - intentionally removing location + delete globalThis.location; + try { + return fn(); + } finally { + globalThis.location = original; + } +}; diff --git a/packages/wouter/test/ssr.test.tsx b/packages/wouter/test/ssr.test.tsx index f47eb89..f2af608 100644 --- a/packages/wouter/test/ssr.test.tsx +++ b/packages/wouter/test/ssr.test.tsx @@ -10,6 +10,7 @@ import { useLocation, SsrContext, } from "../src/index.js"; +import { withoutLocation } from "./setup.js"; describe("server-side rendering", () => { test("works via `ssrPath` prop", () => { @@ -88,18 +89,6 @@ describe("server-side rendering", () => { }); describe("rendering with given search string", () => { - test("is empty when not specified", () => { - const PrintSearch = () => <>{useSearch()}; - - const rendered = renderToStaticMarkup( - - - - ); - - expect(rendered).toBe(""); - }); - test("allows to override search string", () => { const App = () => { const search = useSearch(); @@ -120,5 +109,35 @@ describe("server-side rendering", () => { expect(rendered).toBe("/catalog filter by sort=created_at"); }); + + // issue #550: useSearch should work in pure Node.js without `location` global + test("works without location global (issue #550)", () => { + const PrintSearch = () => <>{useSearch()}; + + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + ) + ); + + expect(rendered).toBe(""); + }); + + // issue #550: passing ssrSearch="" explicitly should work + test("empty ssrSearch is respected (issue #550)", () => { + const PrintSearch = () => <>{useSearch()}; + + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + ) + ); + + expect(rendered).toBe(""); + }); }); }); From e5aa21124896bc7d1c1d7f77df19de8f7e14be08 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 Dec 2025 21:40:15 +0100 Subject: [PATCH 04/24] Improve the fix. --- packages/wouter/src/index.js | 9 ++++++--- packages/wouter/src/use-browser-location.js | 8 +++++++- packages/wouter/test/router.test.tsx | 2 +- packages/wouter/test/ssr.test.tsx | 6 ++---- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 238cd7c..f45d930 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -146,9 +146,12 @@ export const Router = ({ children, ...props }) => { // holds to the context value: the router object let value = parent; - // when `ssrPath` contains a `?` character, we can extract the search from it - const [path, search] = props.ssrPath?.split("?") ?? []; - if (search) (props.ssrSearch = search), (props.ssrPath = path); + // when `ssrPath` contains a `?` character, we can extract the search from it. + // also, ensure ssrSearch is always defined when ssrPath is provided, so that + // useSearch behavior matches usePathname (proper SSR hydration when client + // renders without props after server rendered with ssrPath/ssrSearch) + const [path, search = props.ssrSearch ?? ""] = props.ssrPath?.split("?") ?? []; + if (path) props.ssrSearch = search, props.ssrPath = path; // hooks can define their own `href` formatter (e.g. for hash location) props.hrefs = props.hrefs ?? props.hook?.hrefs; diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 4c8f257..4b8b690 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -33,7 +33,10 @@ const currentSearch = () => location.search; export const useSearch = ({ ssrSearch } = {}) => useLocationProperty( currentSearch, - ssrSearch != null ? () => ssrSearch : () => "" + // != null checks for both null and undefined, but allows empty string "" + // This allows proper hydration: server renders with ssrSearch="?foo", + // client hydrates with just and reads from location.search + ssrSearch != null ? () => ssrSearch : currentSearch ); const currentPathname = () => location.pathname; @@ -41,6 +44,9 @@ const currentPathname = () => location.pathname; export const usePathname = ({ ssrPath } = {}) => useLocationProperty( currentPathname, + // != null checks for both null and undefined, but allows empty string "" + // This allows proper hydration: server renders with ssrPath="/foo", + // client hydrates with just and reads from location.pathname ssrPath != null ? () => ssrPath : currentPathname ); diff --git a/packages/wouter/test/router.test.tsx b/packages/wouter/test/router.test.tsx index 2868256..282305f 100644 --- a/packages/wouter/test/router.test.tsx +++ b/packages/wouter/test/router.test.tsx @@ -76,7 +76,7 @@ it("can extract `ssrSearch` from `ssrPath` after the '?' symbol", () => { }); expect(result.current.ssrPath).toBe("/no-search"); - expect(result.current.ssrSearch).toBe(undefined); + expect(result.current.ssrSearch).toBe(""); ssrPath = "/with-search?a=b&c=d"; rerender(); diff --git a/packages/wouter/test/ssr.test.tsx b/packages/wouter/test/ssr.test.tsx index f2af608..496f4c8 100644 --- a/packages/wouter/test/ssr.test.tsx +++ b/packages/wouter/test/ssr.test.tsx @@ -110,8 +110,7 @@ describe("server-side rendering", () => { expect(rendered).toBe("/catalog filter by sort=created_at"); }); - // issue #550: useSearch should work in pure Node.js without `location` global - test("works without location global (issue #550)", () => { + test("doesn't break useSearch hook if not specified", () => { const PrintSearch = () => <>{useSearch()}; const rendered = withoutLocation(() => @@ -125,8 +124,7 @@ describe("server-side rendering", () => { expect(rendered).toBe(""); }); - // issue #550: passing ssrSearch="" explicitly should work - test("empty ssrSearch is respected (issue #550)", () => { + test("works with empty ssrSearch", () => { const PrintSearch = () => <>{useSearch()}; const rendered = withoutLocation(() => From d7845f58a86b5e2f566c735f74faf333a47a01f7 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 Dec 2025 22:19:55 +0100 Subject: [PATCH 05/24] One more test. --- packages/wouter/test/router.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/wouter/test/router.test.tsx b/packages/wouter/test/router.test.tsx index 282305f..b1abe15 100644 --- a/packages/wouter/test/router.test.tsx +++ b/packages/wouter/test/router.test.tsx @@ -89,6 +89,15 @@ it("can extract `ssrSearch` from `ssrPath` after the '?' symbol", () => { expect(result.current.ssrSearch).toBe("a=b&c=d"); }); +it("keeps the ssrSearch undefined if not in SSR mode", () => { + const { result } = renderHook(() => useRouter(), { + wrapper: (props) => {props.children}, + }); + + expect(result.current.ssrPath).toBe(undefined); + expect(result.current.ssrSearch).toBe(undefined); +}); + it("shares one router instance between components", () => { const routers: any[] = []; From 37bf221ba10996e1a7e35406b7360d0fc5be3f3e Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 2 Dec 2025 10:46:43 +0100 Subject: [PATCH 06/24] Fix the app. --- packages/sourdough/App.tsx | 66 +++++++++++++++++++++++++-- packages/sourdough/index.tsx | 76 +++++++++++++++++--------------- packages/sourdough/tsconfig.json | 2 +- 3 files changed, 103 insertions(+), 41 deletions(-) diff --git a/packages/sourdough/App.tsx b/packages/sourdough/App.tsx index 0e36e6d..86854a1 100644 --- a/packages/sourdough/App.tsx +++ b/packages/sourdough/App.tsx @@ -1,4 +1,64 @@ -import { Route, Link, Switch } from "wouter"; +import { Route, Link, Switch, useSearch } from "wouter"; +import { navigate } from "wouter/use-browser-location"; + +function HomePage() { + const search = useSearch(); + const params = new URLSearchParams(search); + const category = params.get("category") || "all"; + const sort = params.get("sort") || "newest"; + + const handleFilterChange = (key: string, value: string) => { + const newParams = new URLSearchParams(search); + if (value === "all" || value === "newest") { + newParams.delete(key); + } else { + newParams.set(key, value); + } + const queryString = newParams.toString(); + navigate(queryString ? `/?${queryString}` : "/"); + }; + + return ( + <> +

Home Page

+

Welcome to the Wouter SSR demo!

+

This page is server-side rendered with Bun + React + Wouter.

+ +
+

Filters (using useSearch)

+
+ + + +
+

+ Current search: {search || "(empty)"} +

+
+ + ); +} export function App() { return ( @@ -12,9 +72,7 @@ export function App() {
-

Home Page

-

Welcome to the Wouter SSR demo!

-

This page is server-side rendered with Bun + React + Wouter.

+
diff --git a/packages/sourdough/index.tsx b/packages/sourdough/index.tsx index 7381eb8..9fd2082 100644 --- a/packages/sourdough/index.tsx +++ b/packages/sourdough/index.tsx @@ -1,49 +1,53 @@ import { renderToReadableStream } from "react-dom/server"; import { Router } from "wouter"; -import { App } from "./App"; -import indexHtml from "./index.html"; +import { App } from "./App.tsx"; + +// Build the HTML and all its assets before starting the server +const build = await Bun.build({ + entrypoints: ["./index.html"], + outdir: "./dist", + minify: false, +}); + +if (!build.success) { + console.error("Build failed:", build.logs); + process.exit(1); +} + +// Create a map of assets by their path for quick lookup +const assets = new Map(); +let htmlTemplate: string | null = null; + +for (const output of build.outputs) { + // The HTML file will be used as template for SSR + if (output.path.endsWith(".html")) { + htmlTemplate = await output.text(); + } else { + // Store other assets (JS, CSS, etc.) by their basename + const basename = "/" + output.path.split("/").pop()!; + assets.set(basename, output); + } +} + +if (!htmlTemplate) { + console.error("No HTML template found in build outputs"); + process.exit(1); +} Bun.serve({ port: 3002, async fetch(req) { const url = new URL(req.url); - // Check if this is a request for bundled assets - // In dev mode, Bun generates these on the fly - const isAsset = - url.pathname.includes("/_bun/") || - url.pathname.endsWith(".js") || - url.pathname.endsWith(".css") || - url.pathname.endsWith(".tsx") || - url.pathname.endsWith(".ts"); - - // If it's an asset, check HTMLBundle files or serve directly - if (isAsset) { - // If files array exists (production build), serve from there - if (indexHtml.files) { - const file = indexHtml.files.find( - (f) => url.pathname === f.path || url.pathname.endsWith(f.path) - ); - if (file) { - return new Response(Bun.file(file.path), { - headers: file.headers, - }); - } - } - // In dev mode, return undefined to let Bun's bundler handle it - return; + // Check if this is a request for a built asset + const asset = assets.get(url.pathname); + if (asset) { + return new Response(asset); } - // Get the HTML template using the file path from HTMLBundle - const html = await Bun.file(indexHtml.index).text(); - - // Extract path and search from URL - const ssrPath = url.pathname; - const ssrSearch = url.search; - - // Render the app with Router and SSR props + // Otherwise, it's a page request - render with SSR const stream = await renderToReadableStream( - + ); @@ -58,7 +62,7 @@ Bun.serve({ }, }); - const transformedResponse = rewriter.transform(new Response(html)); + const transformedResponse = rewriter.transform(new Response(htmlTemplate)); return new Response(transformedResponse.body, { headers: { "Content-Type": "text/html" }, diff --git a/packages/sourdough/tsconfig.json b/packages/sourdough/tsconfig.json index bfa0fea..be3d138 100644 --- a/packages/sourdough/tsconfig.json +++ b/packages/sourdough/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", From bc54b6d2fa75e0221f14b350aa4ebb8caafc1d25 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 2 Dec 2025 11:02:34 +0100 Subject: [PATCH 07/24] Build in memory. --- packages/sourdough/App.tsx | 4 +++- packages/sourdough/index.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/sourdough/App.tsx b/packages/sourdough/App.tsx index 86854a1..4eeb08e 100644 --- a/packages/sourdough/App.tsx +++ b/packages/sourdough/App.tsx @@ -24,7 +24,9 @@ function HomePage() {

Welcome to the Wouter SSR demo!

This page is server-side rendered with Bun + React + Wouter.

-
+

Filters (using useSearch)