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
@@ -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");
}