Push HMR updates before the SSR rebuild in dev

Client-only edits are sent to the browser right after the hydration build,
without re-running server functions; SSR rebuilds afterwards and props are
re-fetched only when a .server.ts output changed or after fullRebuild. Page
requests wait for an in-flight SSR rebuild. The SSR build no longer runs
Tailwind, the PostCSS processor is reused across rebuilds, and the watcher
debounce drops from 200ms to 50ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-05 07:32:33 +01:00
co-authored by Claude Opus 5.5
parent ed788dff97
commit 4d4a79a6dd
23 changed files with 460 additions and 98 deletions
@@ -6,6 +6,11 @@ type Params = {
}) => Promise<void> | void;
build_only?: boolean;
start?: boolean;
/**
* Re-fetch server props for every HMR client after the build, even if
* only client code changed. Only applies when the context is recreated.
*/
refresh_props?: boolean;
};
/**
* True when `local_path` is a hydration entry of the current esbuild context.
@@ -88,6 +88,7 @@ export default async function allPagesESBuildContextBundler(params) {
entryToPage,
post_build_fn: params?.post_build_fn,
build_only: params?.build_only || params?.start,
refresh_props_on_first_build: params?.refresh_props,
}),
],
jsx: "automatic",
+5
View File
@@ -15,4 +15,9 @@ type RebuildParams = {
* hydration context rebuild, which re-enters this queue).
*/
export default function enqueueSSRRebuild(params?: RebuildParams): Promise<void>;
/**
* Resolves once the in-flight SSR rebuild (if any) has finished. Lets page
* requests avoid rendering stale SSR modules against a newer client bundle.
*/
export declare function awaitSSRRebuild(): Promise<void>;
export {};
+14 -2
View File
@@ -2,7 +2,7 @@ import * as esbuild from "esbuild";
import grabAllPages from "../../utils/grab-all-pages";
import grabDirNames from "../../utils/grab-dir-names";
import isDevelopment from "../../utils/is-development";
import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin";
import { cssStubEsbuildPlugin } from "../server/web-pages/tailwind-esbuild-plugin";
import grabPageReactComponentString from "../server/web-pages/grab-page-react-component-string";
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin";
@@ -87,6 +87,16 @@ export default async function enqueueSSRRebuild(params) {
rebuild_in_flight = run;
return run;
}
/**
* Resolves once the in-flight SSR rebuild (if any) has finished. Lets page
* requests avoid rendering stale SSR modules against a newer client bundle.
*/
export async function awaitSSRRebuild() {
try {
await rebuild_in_flight;
}
catch (error) { }
}
async function doSSRRebuild(params) {
const force = Boolean(params?.force);
const dev = isDevelopment();
@@ -137,7 +147,9 @@ async function doSSRRebuild(params) {
entryNames: "[dir]/[hash]",
metafile: true,
plugins: [
tailwindEsbuildPlugin,
// SSR CSS output is never served (HTML links the
// hydration build's CSS), so skip Tailwind here.
cssStubEsbuildPlugin,
ssrVirtualFilesPlugin({
getEntryToPage: () => current_entry_map,
}),
@@ -9,6 +9,12 @@ type Params = {
changed_pages?: string[];
}) => Promise<void> | void;
build_only?: boolean;
/**
* Always re-fetch server props after this context's first build, even
* when only client code changed (set by `fullRebuild`, e.g. after a
* config change).
*/
refresh_props_on_first_build?: boolean;
};
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }: Params): Plugin;
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }: Params): Plugin;
export {};
@@ -11,9 +11,11 @@ import path from "path";
import cleanupLogsDirs from "../../cleanup-logs-dir";
import grabRelativePath from "../../../utils/grab-relative-path";
import cleanupArtifacts from "../../server/cleanup-artifacts";
import { pushClientUpdate } from "../../server/server-post-build-fn";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
let build_start = 0;
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }) {
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }) {
let force_props_refresh = Boolean(refresh_props_on_first_build);
const artifactTracker = {
name: "artifact-tracker",
setup(build) {
@@ -78,9 +80,12 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn,
}
global.BUNEXT_BUILD_FAILED = false;
global.BUNEXT_BUNDLER_CTX_DISPOSED = false;
const props_refresh_forced = force_props_refresh;
force_props_refresh = false;
const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE);
// SSR must finish before HMR so server props are fresh.
// The rebuild is single-flighted: concurrent hydration
// Client-only changes are pushed before the SSR rebuild;
// anything that may change server props waits for SSR so
// props are fresh. The rebuild is single-flighted: concurrent hydration
// rebuilds join the in-flight SSR rebuild instead of
// starting overlapping ones.
if (build_only) {
@@ -98,6 +103,35 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn,
cleanupLogsDirs();
await fullRebuild();
}
else if (changed_pages.length > 0 &&
post_build_fn &&
!props_refresh_forced) {
// Client graph changed: push the new bundle right away.
// Server functions live in `.server.ts` files, outside
// the client graph, so the browser's current props are
// still valid and it needn't wait for the SSR rebuild.
pushClientUpdate(changed_pages);
const server_hashes_before = grabServerEntryPaths();
try {
await enqueueSSRRebuild();
}
catch (error) {
log.error(`SSR Bundler Error: ${error}`);
}
// A module shared by a page and its server file changed:
// props may differ, so re-fetch them for every tab.
if (server_hashes_before !== grabServerEntryPaths()) {
try {
await post_build_fn({
artifacts: artifacts || [],
changed_pages: [],
});
}
catch (error) {
log.error(`Post-build Error: ${error}`);
}
}
}
else {
try {
await enqueueSSRRebuild();
@@ -126,3 +160,18 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn,
};
return artifactTracker;
}
/**
* Fingerprint of the SSR outputs of all `.server.ts(x)` entries. Outputs are
* content-hashed, so a changed fingerprint means some server function's code
* (or a module it imports) changed.
*/
function grabServerEntryPaths() {
const map = global.BUNEXT_SSR_BUNDLER_CTX_MAP;
if (!map)
return "";
return Object.keys(map)
.filter((key) => key.match(/\.server\.tsx?$/))
.sort()
.map((key) => `${key}:${map[key]?.path || ""}`)
.join("\n");
}
+1
View File
@@ -37,6 +37,7 @@ async function doFullRebuild(params) {
global.BUNEXT_ROUTER.reload();
await disposeBundlerContexts();
await allPagesESBuildContextBundler({
refresh_props: true,
post_build_fn: async (params) => {
await serverPostBuildFn(params);
},
+9
View File
@@ -10,5 +10,14 @@ type Params = {
*/
changed_pages?: string[];
};
/**
* # Push a client-only HMR update
* Sends the new client bundle to every controller whose page is in
* `changed_pages`, without re-running server functions. The payload carries
* no `page_props`, so the browser keeps its current props and only swaps the
* hydration script/CSS. Used right after the hydration build so the browser
* doesn't wait for the SSR rebuild.
*/
export declare function pushClientUpdate(changed_pages: string[]): void;
export default function serverPostBuildFn(params?: Params): Promise<void>;
export {};
+34
View File
@@ -1,5 +1,39 @@
import _ from "lodash";
import grabPageComponent from "./web-pages/grab-page-component";
/**
* # Push a client-only HMR update
* Sends the new client bundle to every controller whose page is in
* `changed_pages`, without re-running server functions. The payload carries
* no `page_props`, so the browser keeps its current props and only swaps the
* hydration script/CSS. Used right after the hydration build so the browser
* doesn't wait for the SSR rebuild.
*/
export function pushClientUpdate(changed_pages) {
if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) {
return;
}
const changed_set = new Set(changed_pages);
for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) {
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
const local_path = controller?.target_map?.local_path;
if (!controller || !local_path || !changed_set.has(local_path)) {
continue;
}
const target_artifact = global.BUNEXT_BUNDLER_CTX_MAP[local_path];
const payload = target_artifact?.path
? { target_map: target_artifact }
: { reload: true };
try {
controller.controller.enqueue(`event: update\ndata: ${JSON.stringify(payload)}\n\n`);
if (target_artifact?.path) {
controller.target_map = target_artifact;
}
}
catch {
global.BUNEXT_HMR_CONTROLLERS.splice(i, 1);
}
}
}
export default async function serverPostBuildFn(params) {
if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) {
return;
+1 -1
View File
@@ -83,7 +83,7 @@ const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/;
// atomic-swap noise), and edits can land while a rebuild is running.
// Events are debounced into a single runner; the runner loops until the
// event queue drains, so a mid-build edit is never silently dropped.
const REBUILD_DEBOUNCE_MS = 200;
const REBUILD_DEBOUNCE_MS = 50;
let pending_structural = false;
let pending_content_file = undefined;
let debounce_timer = undefined;
+6
View File
@@ -5,6 +5,7 @@ import getCache from "../../cache/get-cache";
import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return";
import grabPageComponent from "./grab-page-component";
import grabPageErrorComponent from "./grab-page-error-component";
import { awaitSSRRebuild } from "../../bundler/pages-ssr-context-bundler";
export default async function handleWebPages({ req, }) {
try {
const is_dev = isDevelopment();
@@ -31,6 +32,11 @@ export default async function handleWebPages({ req, }) {
}
catch (error) { }
}
// HMR pushes client updates before the SSR rebuild finishes; a page
// load in that window waits so SSR HTML matches the new bundle.
if (is_dev) {
await awaitSSRRebuild();
}
const componentRes = await grabPageComponent({
req,
});
@@ -1,3 +1,16 @@
import * as esbuild from "esbuild";
type Params = {
/**
* Run `.css` files through Tailwind/PostCSS. When `false`, every `.css`
* import loads as an empty stylesheet (for builds whose CSS output is
* never used, e.g. SSR).
*/
process_css?: boolean;
};
export declare function createTailwindEsbuildPlugin({ process_css, }?: Params): esbuild.Plugin;
/**
* Resolves `.css` imports like the Tailwind plugin but loads them empty.
*/
export declare const cssStubEsbuildPlugin: esbuild.Plugin;
declare const tailwindEsbuildPlugin: esbuild.Plugin;
export default tailwindEsbuildPlugin;
+51 -38
View File
@@ -8,42 +8,55 @@ import grabDirNames from "../../../utils/grab-dir-names";
import { log } from "../../../utils/log";
const { ROOT_DIR } = grabDirNames();
let error_logged = false;
const tailwindEsbuildPlugin = {
name: "tailwindcss",
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
try {
const source = await readFile(args.path, "utf-8");
const result = await postcss([tailwindcss()]).process(source, {
from: args.path,
});
error_logged = false;
return { contents: result.css, loader: "css" };
}
catch (error) {
return { errors: [{ text: error.message }] };
}
});
build.onResolve({ filter: /\.css$/ }, async (args) => {
const css_path = path.resolve(args.resolveDir, args.path.replace(/\@\//g, ROOT_DIR + "/"));
const does_path_exist = existsSync(css_path);
if (!does_path_exist && !error_logged) {
const err_msg = `CSS Error: ${css_path} file does not exist.`;
log.error(err_msg);
error_logged = true;
// return {
// errors: [
// {
// text: err_msg,
// },
// ],
// pluginName: "tailwindcss",
// };
}
return {
path: css_path,
};
});
},
};
// One processor for the whole process, so the Tailwind plugin's internal
// state survives across rebuilds instead of being recreated per file load.
let css_processor;
function grabCSSProcessor() {
if (!css_processor) {
css_processor = postcss([tailwindcss()]);
}
return css_processor;
}
export function createTailwindEsbuildPlugin({ process_css = true, } = {}) {
return {
name: process_css ? "tailwindcss" : "css-stub",
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
if (!process_css) {
return { contents: "", loader: "css" };
}
try {
const source = await readFile(args.path, "utf-8");
const result = await grabCSSProcessor().process(source, {
from: args.path,
});
error_logged = false;
return { contents: result.css, loader: "css" };
}
catch (error) {
return { errors: [{ text: error.message }] };
}
});
build.onResolve({ filter: /\.css$/ }, async (args) => {
const css_path = path.resolve(args.resolveDir, args.path.replace(/\@\//g, ROOT_DIR + "/"));
const does_path_exist = existsSync(css_path);
if (!does_path_exist && !error_logged) {
const err_msg = `CSS Error: ${css_path} file does not exist.`;
log.error(err_msg);
error_logged = true;
}
return {
path: css_path,
};
});
},
};
}
/**
* Resolves `.css` imports like the Tailwind plugin but loads them empty.
*/
export const cssStubEsbuildPlugin = createTailwindEsbuildPlugin({
process_css: false,
});
const tailwindEsbuildPlugin = createTailwindEsbuildPlugin();
export default tailwindEsbuildPlugin;