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:
@@ -3,7 +3,9 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
// NOTE: this file runs the REAL grabPageComponent (no module mock). The
|
||||
// callers use `return_server_res_only` + `no_retry`, so no bundling or
|
||||
// rebuilds happen as long as the fake router + artifact map are set up.
|
||||
import serverPostBuildFn from "../../../functions/server/server-post-build-fn";
|
||||
import serverPostBuildFn, {
|
||||
pushClientUpdate,
|
||||
} from "../../../functions/server/server-post-build-fn";
|
||||
|
||||
function makeController(page_url: string, target_map?: { local_path: string }) {
|
||||
const enqueued: string[] = [];
|
||||
@@ -146,4 +148,53 @@ describe("server-post-build-fn", () => {
|
||||
expect(changed.enqueued.length).toBe(1);
|
||||
expect(untouched.enqueued.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushClientUpdate", () => {
|
||||
test("pushes the new bundle without page_props to changed pages only", () => {
|
||||
const changed = makeController("http://localhost/about", {
|
||||
local_path: "pages/about.tsx",
|
||||
});
|
||||
const untouched = makeController("http://localhost/missing", {
|
||||
local_path: "pages/404.tsx",
|
||||
});
|
||||
global.BUNEXT_HMR_CONTROLLERS = [changed as any, untouched as any];
|
||||
|
||||
pushClientUpdate(["pages/about.tsx"]);
|
||||
|
||||
expect(changed.enqueued.length).toBe(1);
|
||||
const data = JSON.parse(
|
||||
changed.enqueued[0]!.replace(/^event: update\ndata: /, ""),
|
||||
);
|
||||
expect(data.target_map.path).toBe("pages/about.js");
|
||||
expect(data.page_props).toBeUndefined();
|
||||
expect(data.reload).toBeUndefined();
|
||||
expect(untouched.enqueued.length).toBe(0);
|
||||
});
|
||||
|
||||
test("reloads a changed page that has no bundled artifact", () => {
|
||||
const c = makeController("http://localhost/new-page", {
|
||||
local_path: "pages/new-page.tsx",
|
||||
});
|
||||
global.BUNEXT_HMR_CONTROLLERS = [c as any];
|
||||
|
||||
pushClientUpdate(["pages/new-page.tsx"]);
|
||||
|
||||
expect(c.enqueued.length).toBe(1);
|
||||
expect(c.enqueued[0]).toContain('"reload":true');
|
||||
});
|
||||
|
||||
test("drops controllers whose stream is closed", () => {
|
||||
const c = makeController("http://localhost/about", {
|
||||
local_path: "pages/about.tsx",
|
||||
});
|
||||
c.controller.enqueue = () => {
|
||||
throw new Error("closed");
|
||||
};
|
||||
global.BUNEXT_HMR_CONTROLLERS = [c as any];
|
||||
|
||||
pushClientUpdate(["pages/about.tsx"]);
|
||||
|
||||
expect(global.BUNEXT_HMR_CONTROLLERS.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,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;
|
||||
};
|
||||
|
||||
type HydrationVirtualEntry = PageFiles & { tsx: string };
|
||||
@@ -124,6 +129,7 @@ export default async function allPagesESBuildContextBundler(params?: 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",
|
||||
|
||||
@@ -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 type { PageFiles } from "../../types";
|
||||
import grabPageReactComponentString from "../server/web-pages/grab-page-react-component-string";
|
||||
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
|
||||
@@ -120,6 +120,16 @@ export default async function enqueueSSRRebuild(params?: RebuildParams) {
|
||||
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?: RebuildParams) {
|
||||
const force = Boolean(params?.force);
|
||||
const dev = isDevelopment();
|
||||
@@ -187,7 +197,9 @@ async function doSSRRebuild(params?: RebuildParams) {
|
||||
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,
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ 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();
|
||||
|
||||
@@ -32,13 +33,22 @@ 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,
|
||||
refresh_props_on_first_build,
|
||||
}: Params) {
|
||||
let force_props_refresh = Boolean(refresh_props_on_first_build);
|
||||
|
||||
const artifactTracker: Plugin = {
|
||||
name: "artifact-tracker",
|
||||
setup(build) {
|
||||
@@ -135,12 +145,16 @@ export default function esbuildCTXArtifactTracker({
|
||||
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) {
|
||||
@@ -158,6 +172,37 @@ export default function esbuildCTXArtifactTracker({
|
||||
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
|
||||
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();
|
||||
@@ -188,3 +233,19 @@ export default function esbuildCTXArtifactTracker({
|
||||
|
||||
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(): string {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ async function doFullRebuild(params?: { msg?: string }) {
|
||||
await disposeBundlerContexts();
|
||||
|
||||
await allPagesESBuildContextBundler({
|
||||
refresh_props: true,
|
||||
post_build_fn: async (params) => {
|
||||
await serverPostBuildFn(params);
|
||||
},
|
||||
|
||||
@@ -15,6 +15,48 @@ 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 function pushClientUpdate(changed_pages: string[]) {
|
||||
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?: Params) {
|
||||
if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) {
|
||||
return;
|
||||
|
||||
@@ -96,7 +96,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: string | undefined = undefined;
|
||||
let debounce_timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
@@ -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";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
@@ -43,6 +44,12 @@ export default async function handleWebPages({
|
||||
} 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,
|
||||
});
|
||||
|
||||
@@ -11,53 +11,81 @@ const { ROOT_DIR } = grabDirNames();
|
||||
|
||||
let error_logged = false;
|
||||
|
||||
const tailwindEsbuildPlugin: esbuild.Plugin = {
|
||||
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,
|
||||
});
|
||||
// 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: postcss.Processor | undefined;
|
||||
|
||||
error_logged = false;
|
||||
function grabCSSProcessor() {
|
||||
if (!css_processor) {
|
||||
css_processor = postcss([tailwindcss()]);
|
||||
}
|
||||
return css_processor;
|
||||
}
|
||||
|
||||
return { contents: result.css, loader: "css" };
|
||||
} catch (error: any) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
},
|
||||
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 function createTailwindEsbuildPlugin({
|
||||
process_css = true,
|
||||
}: Params = {}): esbuild.Plugin {
|
||||
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: any) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user