fix: vite build does not produce index.html for Tauri release builds #41

Closed
opened 2026-02-23 16:11:05 -08:00 by puregarlic · 0 comments
Owner

Problem

Running deno task build (tsc && vite build) successfully compiles all JS/CSS bundles into build/client/assets/ but never produces build/client/index.html. Since tauri.conf.json points frontendDist at ../build/client, the Tauri release build fails to find an entry point.

Root Cause

React Router v7's HTML generation — both SPA mode and pre-rendering — lives inside the SSR build's closeBundle hook, guarded by:

if (!viteConfigEnv.isSsrBuild) return;

deno task build only runs a single vite build (client pass), so isSsrBuild is always false and the hook returns early every time. The JS/CSS bundles are emitted but no HTML is ever written.

Secondary issue: renderToPipeableStream unavailable in Deno

Once vite build --ssr is added to the task, the SSR pass runs and React Router's handleSpaMode correctly generates index.html — but only after adding a custom app/entry.server.tsx. The default React Router server entry imports renderToPipeableStream from react-dom/server. Deno resolves react-dom/server to the browser-flavour module (which exports renderToReadableStream, not renderToPipeableStream), causing a runtime error when the compiled server bundle is loaded:

The requested module 'react-dom/server' does not provide an export named 'renderToPipeableStream'

Tertiary issue: build process hangs after cleanup

After index.html is generated, React Router calls fs.rmSync('build/server/', { force: true, recursive: true }) to remove the temporary server bundle. The process then hangs indefinitely — likely because the dynamically import()-ed server module keeps the Node.js event loop alive inside Deno's Node compat layer.

Solution

Three files changed:

1. deno.jsonc — add the SSR pass

-    "build": "tsc && vite build",
+    "build": "tsc && vite build && vite build --ssr",

2. app/entry.server.tsx — new file using Web Streams API

Overrides the default React Router server entry to use renderToReadableStream (available in the Deno/browser flavour of react-dom/server) instead of renderToPipeableStream:

import { renderToReadableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  entryContext: EntryContext,
  _loadContext: AppLoadContext,
) {
  const body = await renderToReadableStream(
    <ServerRouter context={entryContext} url={request.url} />,
    {
      signal: request.signal,
      onError(error: unknown) {
        console.error(error);
        responseStatusCode = 500;
      },
    },
  );

  await body.allReady;

  responseHeaders.set("Content-Type", "text/html");
  return new Response(body, { headers: responseHeaders, status: responseStatusCode });
}

3. vite.config.ts — force-exit plugin to unblock the stalled cleanup

After index.html appears on disk (proof that React Router has finished writing it), wait 500 ms for rmSync to finish and then call process.exit(0):

isSsrBuild && {
  name: "force-exit-after-spa-build",
  enforce: "post",
  writeBundle() {
    const target = resolve("build/client/index.html");
    const check = () => {
      if (existsSync(target)) {
        setTimeout(() => process.exit(0), 500);
      } else {
        setTimeout(check, 100);
      }
    };
    setTimeout(check, 500);
  },
},
## Problem Running `deno task build` (`tsc && vite build`) successfully compiles all JS/CSS bundles into `build/client/assets/` but never produces `build/client/index.html`. Since `tauri.conf.json` points `frontendDist` at `../build/client`, the Tauri release build fails to find an entry point. ## Root Cause React Router v7's HTML generation — both SPA mode and pre-rendering — lives inside the **SSR build's** `closeBundle` hook, guarded by: ```js if (!viteConfigEnv.isSsrBuild) return; ``` `deno task build` only runs a single `vite build` (client pass), so `isSsrBuild` is always `false` and the hook returns early every time. The JS/CSS bundles are emitted but no HTML is ever written. ## Secondary issue: renderToPipeableStream unavailable in Deno Once `vite build --ssr` is added to the task, the SSR pass runs and React Router's `handleSpaMode` correctly generates `index.html` — but only after adding a custom `app/entry.server.tsx`. The default React Router server entry imports `renderToPipeableStream` from `react-dom/server`. Deno resolves `react-dom/server` to the **browser-flavour** module (which exports `renderToReadableStream`, not `renderToPipeableStream`), causing a runtime error when the compiled server bundle is loaded: ``` The requested module 'react-dom/server' does not provide an export named 'renderToPipeableStream' ``` ## Tertiary issue: build process hangs after cleanup After `index.html` is generated, React Router calls `fs.rmSync('build/server/', { force: true, recursive: true })` to remove the temporary server bundle. The process then hangs indefinitely — likely because the dynamically `import()`-ed server module keeps the Node.js event loop alive inside Deno's Node compat layer. ## Solution Three files changed: ### 1. `deno.jsonc` — add the SSR pass ```diff - "build": "tsc && vite build", + "build": "tsc && vite build && vite build --ssr", ``` ### 2. `app/entry.server.tsx` — new file using Web Streams API Overrides the default React Router server entry to use `renderToReadableStream` (available in the Deno/browser flavour of `react-dom/server`) instead of `renderToPipeableStream`: ```tsx import { renderToReadableStream } from "react-dom/server"; import type { AppLoadContext, EntryContext } from "react-router"; import { ServerRouter } from "react-router"; export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, entryContext: EntryContext, _loadContext: AppLoadContext, ) { const body = await renderToReadableStream( <ServerRouter context={entryContext} url={request.url} />, { signal: request.signal, onError(error: unknown) { console.error(error); responseStatusCode = 500; }, }, ); await body.allReady; responseHeaders.set("Content-Type", "text/html"); return new Response(body, { headers: responseHeaders, status: responseStatusCode }); } ``` ### 3. `vite.config.ts` — force-exit plugin to unblock the stalled cleanup After `index.html` appears on disk (proof that React Router has finished writing it), wait 500 ms for `rmSync` to finish and then call `process.exit(0)`: ```ts isSsrBuild && { name: "force-exit-after-spa-build", enforce: "post", writeBundle() { const target = resolve("build/client/index.html"); const check = () => { if (existsSync(target)) { setTimeout(() => process.exit(0), 500); } else { setTimeout(check, 100); } }; setTimeout(check, 500); }, }, ```
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
puregarlic/microclimate#41
No description provided.