From dad7e9b1c3cb01ff7012737d58a25f05269298b4 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Fri, 30 Oct 2026 15:37:21 +0800 Subject: [PATCH 1/2] feat(pack): expose client build completion callback --- packages/pack/README.md | 9 + .../src/__test__/devServerPublishing.test.ts | 199 ++++++++++++++++-- packages/pack/src/commands/dev.ts | 14 ++ packages/pack/src/core/hmr.ts | 163 +++++++------- packages/pack/src/index.ts | 1 + 5 files changed, 297 insertions(+), 89 deletions(-) diff --git a/packages/pack/README.md b/packages/pack/README.md index 08db91266..10a65cc7d 100644 --- a/packages/pack/README.md +++ b/packages/pack/README.md @@ -102,6 +102,15 @@ resolving. Compilation and HTML generation errors reject the call. Await Calling `close()` does not exit the process. To manage the surrounding process yourself, pass `{ port: 3000, returnHandle: true, handleSignals: false }` as the fourth argument. +Pass `onClientBuild({ clientPaths, isRebuild })` in the fourth argument to publish +framework HTML after successful client builds. It runs after assets, enabled +stats and built-in HTML are written, even without a connected browser. The first +call has `isRebuild: false` and precedes `onReady`; subsequent calls have +`isRebuild: true`. Its returned promise is awaited before the next output write. +Do not call `writeAllEntrypointsToDisk()` or await `close()` inside this callback. +Startup callback failures reject `serve()`; background failures are logged and +later builds can retry. Failed compilations do not invoke the callback. + ## 🔌 Webpack Compatibility Mode `@utoo/pack` provides a partial compatibility layer for Webpack. diff --git a/packages/pack/src/__test__/devServerPublishing.test.ts b/packages/pack/src/__test__/devServerPublishing.test.ts index f509a96aa..945319603 100644 --- a/packages/pack/src/__test__/devServerPublishing.test.ts +++ b/packages/pack/src/__test__/devServerPublishing.test.ts @@ -13,7 +13,11 @@ vi.mock("../core/project", () => ({ })); vi.mock("../binding", () => ({})); -import { type DevServerHandle, serve } from "../commands/dev"; +import { + type DevServerHandle, + type StartServerOptions, + serve, +} from "../commands/dev"; import { HtmlPlugin } from "../plugins/HtmlPlugin"; const handles: DevServerHandle[] = []; @@ -124,6 +128,10 @@ function createProject() { libraryPaths: [], issues: [...state.issues], }; + fs.writeFileSync( + path.join(outputDir, "stats.json"), + JSON.stringify({ assets: result.appPaths[0].clientPaths }), + ); nativeResults.push(result); return result; }), @@ -151,7 +159,10 @@ async function getAvailablePort(): Promise { return address.port; } -async function startServer() { +async function startServer( + options: Partial = {}, + stats = false, +) { const handle = await serve( { tracing: false, @@ -165,6 +176,7 @@ async function startServer() { ], output: { path: "./dist", clean: true }, persistentCaching: false, + stats, }, }, projectPath, @@ -175,6 +187,7 @@ async function startServer() { handleSignals: false, returnHandle: true, logServerInfo: false, + ...options, }, ); handles.push(handle); @@ -207,6 +220,76 @@ afterEach(async () => { }); describe("dev server publishing pipeline", () => { + it.each([false, true])( + "awaits initial client output and callback before onReady (stats: %s)", + async (stats) => { + const fixture = createProject(); + const onReady = vi.fn(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const onClientBuild = vi.fn(async (context) => { + if (context.isRebuild) return; + expect(fixture.subscribedEndpoint.clientChanged).toHaveBeenCalledOnce(); + expect(context).toEqual({ + clientPaths: ["main.aaaaaaaa.js"], + isRebuild: false, + }); + expect( + fs.readFileSync(path.join(projectPath, "dist/index.html"), "utf8"), + ).toContain('src="main.aaaaaaaa.js"'); + expect( + fs.readFileSync( + path.join(projectPath, "dist/main.aaaaaaaa.js"), + "utf8", + ), + ).toContain('"A"'); + if (stats) { + expect( + JSON.parse( + fs.readFileSync( + path.join(projectPath, "dist/stats.json"), + "utf8", + ), + ), + ).toEqual({ assets: ["main.aaaaaaaa.js"] }); + } + await gate; + }); + const starting = startServer({ onClientBuild, onReady }, stats); + try { + await vi.waitFor(() => expect(onClientBuild).toHaveBeenCalledOnce()); + expect(onReady).not.toHaveBeenCalled(); + fixture.state.marker = "B"; + fixture.clientChanges.push({ issues: [] }); + } finally { + release(); + } + await starting; + expect(onReady).toHaveBeenCalledOnce(); + await vi.waitFor(() => + expect(onClientBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main.bbbbbbbb.js"], + isRebuild: true, + }), + ); + }, + ); + + it("rejects startup and closes the project if the initial callback fails", async () => { + const fixture = createProject(); + const error = new Error("Framework HTML failed"); + await expect( + startServer({ + onClientBuild: () => { + throw error; + }, + }), + ).rejects.toBe(error); + expect(fixture.project.onExit).toHaveBeenCalledOnce(); + }); + it("publishes HTML with fresh content hashes and preserves the native result", async () => { const fixture = createProject(); const handle = await startServer(); @@ -226,25 +309,106 @@ describe("dev server publishing pipeline", () => { expect(await readHttp(handle, "/main.bbbbbbbb.js")).toContain('"B"'); }); - it("keeps subscribed endpoint HTML current after a full write returns new endpoints", async () => { + it.each([false, true])( + "publishes rebuilds without a browser (stats: %s)", + async (stats) => { + const fixture = createProject(); + const onClientBuild = vi.fn(); + const handle = await startServer({ onClientBuild }, stats); + fixture.state.marker = "B"; + await handle.writeAllEntrypointsToDisk(); + + fixture.state.marker = "C"; + fixture.clientChanges.push({ issues: [] }); + + await vi.waitFor(() => { + const html = fs.readFileSync( + path.join(projectPath, "dist/index.html"), + "utf8", + ); + expect(html).toContain('src="main.cccccccc.js"'); + expect(html).not.toContain("main.bbbbbbbb.js"); + }); + expect(await readHttp(handle)).toContain('src="main.cccccccc.js"'); + expect(await readHttp(handle, "/main.cccccccc.js")).toContain('"C"'); + expect(onClientBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main.cccccccc.js"], + isRebuild: true, + }); + }, + ); + + it("serializes client publication with later writes and recovers after callback failures", async () => { const fixture = createProject(); - const handle = await startServer(); + const onClientBuild = vi.fn(); + const handle = await startServer({ onClientBuild }); + const error = new Error("Framework HTML failed"); + let rejectBuild!: (reason: Error) => void; + onClientBuild.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectBuild = reject; + }), + ); fixture.state.marker = "B"; - await handle.writeAllEntrypointsToDisk(); + const outcome = handle + .writeAllEntrypointsToDisk() + .catch((reason) => reason); + try { + await vi.waitFor(() => expect(onClientBuild).toHaveBeenCalledTimes(2)); + fixture.state.marker = "C"; + fixture.clientChanges.push({ issues: [] }); + await new Promise((resolve) => setImmediate(resolve)); + expect(fixture.subscribedEndpoint.writeToDisk).toHaveBeenCalledOnce(); + expect(await readHttp(handle)).toContain('src="main.bbbbbbbb.js"'); + } finally { + rejectBuild?.(error); + } + expect(await outcome).toBe(error); + await vi.waitFor(() => + expect(onClientBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main.cccccccc.js"], + isRebuild: true, + }), + ); - fixture.state.marker = "C"; + const logError = vi.spyOn(console, "error").mockImplementation(() => {}); + onClientBuild.mockRejectedValueOnce(error); + fixture.clientChanges.push({ issues: [] }); + await vi.waitFor(() => expect(logError).toHaveBeenCalledWith(error)); + fixture.state.marker = "D"; fixture.clientChanges.push({ issues: [] }); + await vi.waitFor(() => + expect(onClientBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main.dddddddd.js"], + isRebuild: true, + }), + ); + }); - await vi.waitFor(() => { - const html = fs.readFileSync( - path.join(projectPath, "dist/index.html"), - "utf8", - ); - expect(html).toContain('src="main.cccccccc.js"'); - expect(html).not.toContain("main.bbbbbbbb.js"); + it("finishes an in-flight write without calling onClientBuild after close", async () => { + const fixture = createProject(); + const onClientBuild = vi.fn(); + const handle = await startServer({ onClientBuild }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; }); - expect(await readHttp(handle)).toContain('src="main.cccccccc.js"'); - expect(await readHttp(handle, "/main.cccccccc.js")).toContain('"C"'); + const generate = vi + .spyOn(HtmlPlugin.prototype, "generate") + .mockImplementationOnce(() => gate); + try { + fixture.clientChanges.push({ issues: [] }); + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()); + const closing = handle.close(); + expect(fixture.project.onExit).not.toHaveBeenCalled(); + release(); + await closing; + } finally { + release(); + } + expect(fixture.project.onExit).toHaveBeenCalledOnce(); + expect(onClientBuild).toHaveBeenCalledOnce(); }); it("awaits HTML generation, propagates its failure, and can publish again", async () => { @@ -294,7 +458,8 @@ describe("dev server publishing pipeline", () => { "rejects %s diagnostics before publishing HTML and recovers on success", async (severity) => { const fixture = createProject(); - const handle = await startServer(); + const onClientBuild = vi.fn(); + const handle = await startServer({ onClientBuild }); fixture.state.marker = "B"; fixture.state.issues = [createIssue(severity)]; @@ -303,12 +468,14 @@ describe("dev server publishing pipeline", () => { message: expect.stringContaining("Publishing fixture diagnostic"), }); expect(await readHttp(handle)).toContain('src="main.aaaaaaaa.js"'); + expect(onClientBuild).toHaveBeenCalledOnce(); fixture.state.marker = "C"; fixture.state.issues = []; const written = await handle.writeAllEntrypointsToDisk(); expect(written.issues).toEqual([]); expect(await readHttp(handle)).toContain('src="main.cccccccc.js"'); + expect(onClientBuild).toHaveBeenCalledTimes(2); }, ); }); diff --git a/packages/pack/src/commands/dev.ts b/packages/pack/src/commands/dev.ts index 997e32ee3..51ce24527 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -141,6 +141,12 @@ export interface DevServerReadyContext { clientPaths: string[]; } +export interface DevServerClientBuildContext { + /** Client asset paths written for all configured endpoints. */ + clientPaths: string[]; + isRebuild: boolean; +} + export interface DevServerHandle { port: number; hostname: string; @@ -160,6 +166,13 @@ export interface StartServerOptions { /** Handle process signals and errors. Defaults to true; embedded servers can opt out. */ handleSignals?: boolean; selfSignedCertificate?: SelfSignedCertificate; + /** + * Awaited after successful client output, stats and HTML writes, including + * rebuilds without a browser. The initial call precedes onReady. + */ + onClientBuild?: ( + context: DevServerClientBuildContext, + ) => void | Promise; /** * Called after the initial dev build has been written and the HTTP server is * listening. Integrations can await this instead of polling the internal @@ -322,6 +335,7 @@ async function runDev( bundleOptions, projectPathResolved, rootPathResolved, + serverOptions?.onClientBuild, ); let server: ReturnType | undefined; let webSocket: ReturnType | undefined; diff --git a/packages/pack/src/core/hmr.ts b/packages/pack/src/core/hmr.ts index bfccead80..b30797609 100644 --- a/packages/pack/src/core/hmr.ts +++ b/packages/pack/src/core/hmr.ts @@ -14,6 +14,7 @@ import type { Socket } from "net"; import { Duplex } from "stream"; import { WebSocketServer } from "ws"; import type { MemoryEvictionMode, NapiWrittenEndpoint } from "../binding"; +import type { StartServerOptions } from "../commands/dev"; import { BundleOptions } from "../config/types"; import { resolveCacheDirectory } from "../utils/cacheDirectory"; import { cleanOutput, getOutputPath } from "../utils/cleanOutput"; @@ -241,6 +242,7 @@ export async function createHotReloader( bundleOptions: BundleOptions, projectPath?: string, rootPath?: string, + onClientBuild?: StartServerOptions["onClientBuild"], ): Promise { const resolvedProjectPath = projectPath || process.cwd(); const resolvedRootPath = rootPath || projectPath || process.cwd(); @@ -355,11 +357,29 @@ export async function createHotReloader( let currentEntrypoints: RawEntrypoints = {}; let backgroundWatchersStarted = false; let backgroundWatchGeneration = 0; - const backgroundEndpointWriteTasks = new Map>(); - let backgroundProjectWriteTask: Promise | undefined; + let outputWriteTask = Promise.resolve(); let closed = false; let closePromise: Promise | undefined; + function queueOutputWrite(write: () => Promise): Promise { + const task = outputWriteTask.then(() => { + if (closed) throw new Error("The dev server is closing or closed"); + return write(); + }); + outputWriteTask = task.then( + () => {}, + () => {}, + ); + return task; + } + + async function notifyClientBuild(isRebuild: boolean) { + const clientPaths = hotReloader.getClientPaths(); + if (!closed && clientPaths.length > 0) { + await onClientBuild?.({ clientPaths, isRebuild }); + } + } + function markHmrEvent() { if (!progress || hmrEventHappened) { return; @@ -494,8 +514,6 @@ export async function createHotReloader( const subscriptions = [...backgroundWatchSubscriptions]; backgroundWatchSubscriptions.clear(); currentEntryIssues.clear(); - backgroundEndpointWriteTasks.clear(); - backgroundProjectWriteTask = undefined; await Promise.all( subscriptions.map((subscription) => subscription.return?.()), ); @@ -510,35 +528,18 @@ export async function createHotReloader( return; } - const previousTask = shouldCreateWebpackStats - ? (backgroundProjectWriteTask ?? Promise.resolve()) - : (backgroundEndpointWriteTasks.get(entrypoint) ?? Promise.resolve()); - const task = previousTask - .catch(() => {}) - .then(async () => { - await currentEntriesHandling; - if (closed || generation !== backgroundWatchGeneration) { - return; - } - - markHmrEvent(); - await writeOutputToDisk(entrypoint); - }) - .finally(() => { - if (shouldCreateWebpackStats) { - if (backgroundProjectWriteTask === task) { - backgroundProjectWriteTask = undefined; - } - } else if (backgroundEndpointWriteTasks.get(entrypoint) === task) { - backgroundEndpointWriteTasks.delete(entrypoint); - } + void currentEntriesHandling + .then(() => + queueOutputWrite(async () => { + if (generation !== backgroundWatchGeneration) return; + markHmrEvent(); + await writeOutputToDisk(entrypoint); + await notifyClientBuild(true); + }), + ) + .catch((error) => { + if (!closed) console.error(error); }); - - if (shouldCreateWebpackStats) { - backgroundProjectWriteTask = task; - } else { - backgroundEndpointWriteTasks.set(entrypoint, task); - } } async function refreshBackgroundWatchers() { @@ -661,6 +662,8 @@ export async function createHotReloader( async function handleEntrypointsSubscription() { for await (const entrypoints of entrypointsSubscription) { + if (closed) return; + ++backgroundWatchGeneration; if (!currentEntriesHandlingResolve) { currentEntriesHandling = new Promise( // eslint-disable-next-line no-loop-func @@ -668,26 +671,28 @@ export async function createHotReloader( ); } - currentEntrypoints = entrypoints; - currentWatchedEntrypoints = [ - ...(entrypoints.apps ?? []), - ...(entrypoints.libraries ?? []), - ]; - setEntrypoints(entrypoints); - if (shouldCreateWebpackStats) { - await writeAllEntrypointsToDisk(); - } else { - await Promise.all( - currentWatchedEntrypoints.map((entrypoint) => - writeEntrypointToDisk(entrypoint, false), - ), - ); - await htmlGenerationManager.generateAll(); - } - - if (backgroundWatchersStarted) { - await refreshBackgroundWatchers(); - } + await queueOutputWrite(async () => { + currentEntrypoints = entrypoints; + currentWatchedEntrypoints = [ + ...(entrypoints.apps ?? []), + ...(entrypoints.libraries ?? []), + ]; + setEntrypoints(entrypoints); + if (shouldCreateWebpackStats) { + await writeAllEntrypointsToDisk(); + } else { + await Promise.all( + currentWatchedEntrypoints.map((entrypoint) => + writeEntrypointToDisk(entrypoint, false), + ), + ); + await htmlGenerationManager.generateAll(); + } + if (backgroundWatchersStarted) { + await refreshBackgroundWatchers(); + await notifyClientBuild(true).catch((error) => console.error(error)); + } + }); currentEntriesHandlingResolve!(); currentEntriesHandlingResolve = undefined; @@ -710,7 +715,11 @@ export async function createHotReloader( async writeAllEntrypointsToDisk() { await currentEntriesHandling; - return writeAllEntrypointsToDisk(); + return queueOutputWrite(async () => { + const result = await writeAllEntrypointsToDisk(); + await notifyClientBuild(true); + return result; + }); }, onHMR(req, socket: Socket, head, onUpgrade) { @@ -964,8 +973,11 @@ export async function createHotReloader( return; } - backgroundWatchersStarted = true; - await refreshBackgroundWatchers(); + await queueOutputWrite(async () => { + backgroundWatchersStarted = true; + await refreshBackgroundWatchers(); + await notifyClientBuild(false); + }); }, async buildFallbackError() { @@ -973,21 +985,26 @@ export async function createHotReloader( }, async activateLazyChunk(chunkPath: string) { - if (!(await project.activateLazyChunk(chunkPath))) { - return false; - } - const owners = [...writtenEndpointPaths.entries()] - .filter(([, written]) => written.clientPaths.includes(chunkPath)) - .map(([endpoint]) => endpoint); - if (owners.length === 0) { - // The chunk is not attributed to a written endpoint yet; rebuild everything. - await writeAllEntrypointsToDisk(); - } else { - await Promise.all( - owners.map((endpoint) => writeEntrypointToDisk(endpoint, false)), - ); - } - return true; + await currentEntriesHandling; + return queueOutputWrite(async () => { + if (!(await project.activateLazyChunk(chunkPath))) { + return false; + } + const owners = [...writtenEndpointPaths.entries()] + .filter(([, written]) => written.clientPaths.includes(chunkPath)) + .map(([endpoint]) => endpoint); + if (owners.length === 0 || shouldCreateWebpackStats) { + // Refresh project-wide stats, or recover a chunk without a known owner. + await writeAllEntrypointsToDisk(); + } else { + await Promise.all( + owners.map((endpoint) => writeEntrypointToDisk(endpoint, false)), + ); + await htmlGenerationManager.generateAll(); + } + await notifyClientBuild(true); + return true; + }); }, async close() { @@ -995,9 +1012,8 @@ export async function createHotReloader( progress?.stop(); closed = true; const disposePromise = disposeBackgroundWatchSubscriptions(); - closePromise ??= ( - persistentCaching ? project.shutdown() : project.onExit() - ) + closePromise ??= outputWriteTask + .then(() => (persistentCaching ? project.shutdown() : project.onExit())) .catch((err) => { console.error(err); }) @@ -1018,6 +1034,7 @@ export async function createHotReloader( markHmrEvent(); handleEntrypointsSubscription().catch((err) => { + if (closed) return; console.error(err); process.exit(1); }); diff --git a/packages/pack/src/index.ts b/packages/pack/src/index.ts index 9861efc8c..1488638e7 100644 --- a/packages/pack/src/index.ts +++ b/packages/pack/src/index.ts @@ -5,6 +5,7 @@ import * as webpackCompat from "./config/webpackCompat"; export { build }; export { serve }; export type { + DevServerClientBuildContext, DevServerHandle, DevServerReadyContext, StartServerOptions, From b463242e07816e1c71af2e519879ec3230b0bcf7 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Fri, 30 Oct 2026 15:46:46 +0800 Subject: [PATCH 2/2] fix(pack): wait for all client entrypoints before publishing --- .../src/__test__/devServerPublishing.test.ts | 88 ++++++++++++++----- packages/pack/src/core/hmr.ts | 5 +- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/pack/src/__test__/devServerPublishing.test.ts b/packages/pack/src/__test__/devServerPublishing.test.ts index 945319603..2bd5b045e 100644 --- a/packages/pack/src/__test__/devServerPublishing.test.ts +++ b/packages/pack/src/__test__/devServerPublishing.test.ts @@ -82,17 +82,21 @@ function createIssue(severity: "warning" | "error" | "fatal"): NapiIssue { }; } -function createProject() { +function createProject(entryNames = ["main"]) { const state = { marker: "A", issues: [] as NapiIssue[] }; const outputDir = path.join(projectPath, "dist"); const nativeResults: TurbopackResult[] = []; - const clientChanges = createStream(); - const serverChanges = createStream(); + const clientChangeStreams = entryNames.map(() => + createStream(), + ); + const serverChangeStreams = entryNames.map(() => + createStream(), + ); const entrypoints = createStream>(); fs.writeFileSync(path.join(projectPath, "entry.js"), "export default 1;\n"); - function writeAsset(): NapiWrittenEndpoint { - const clientPath = `main.${state.marker.toLowerCase().repeat(8)}.js`; + function writeAsset(name: string): NapiWrittenEndpoint { + const clientPath = `${name}.${state.marker.toLowerCase().repeat(8)}.js`; fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync( path.join(outputDir, clientPath), @@ -107,24 +111,29 @@ function createProject() { }; } - function createEndpoint(): Endpoint { + function createEndpoint(index: number): Endpoint { return { - writeToDisk: vi.fn(async () => ({ ...writeAsset(), issues: [] })), - clientChanged: vi.fn(async () => clientChanges.stream), - serverChanged: vi.fn(async () => serverChanges.stream), + writeToDisk: vi.fn(async () => ({ + ...writeAsset(entryNames[index]), + issues: [], + })), + clientChanged: vi.fn(async () => clientChangeStreams[index].stream), + serverChanged: vi.fn(async () => serverChangeStreams[index].stream), }; } - const subscribedEndpoint = createEndpoint(); - entrypoints.push({ apps: [subscribedEndpoint], libraries: [], issues: [] }); + const subscribedEndpoints = entryNames.map((_, index) => + createEndpoint(index), + ); + entrypoints.push({ apps: subscribedEndpoints, libraries: [], issues: [] }); const project = { entrypointsSubscribe: () => entrypoints.stream, writeAllEntrypointsToDisk: vi.fn(async () => { // The binding creates new EndpointImpl objects for every all-write result. const result = { - apps: [createEndpoint()], + apps: entryNames.map((_, index) => createEndpoint(index)), libraries: [], - appPaths: [writeAsset()], + appPaths: entryNames.map(writeAsset), libraryPaths: [], issues: [...state.issues], }; @@ -140,7 +149,14 @@ function createProject() { shutdown: vi.fn(async () => {}), }; projectMocks.projectFactory.mockReturnValue(async () => project); - return { clientChanges, nativeResults, project, state, subscribedEndpoint }; + return { + clientChanges: clientChangeStreams[0], + clientChangeStreams, + nativeResults, + project, + state, + subscribedEndpoint: subscribedEndpoints[0], + }; } async function getAvailablePort(): Promise { @@ -162,18 +178,17 @@ async function getAvailablePort(): Promise { async function startServer( options: Partial = {}, stats = false, + entryNames = ["main"], ) { const handle = await serve( { tracing: false, config: { - entry: [ - { - name: "main", - import: "./entry.js", - html: { filename: "index.html" }, - }, - ], + entry: entryNames.map((name) => ({ + name, + import: "./entry.js", + html: { filename: name === "main" ? "index.html" : `${name}.html` }, + })), output: { path: "./dist", clean: true }, persistentCaching: false, stats, @@ -338,6 +353,37 @@ describe("dev server publishing pipeline", () => { }, ); + it.each(["error", "fatal"] as const)( + "waits for another entrypoint's %s to recover before publication, allowing warnings", + async (severity) => { + const entryNames = ["main", "other"]; + const fixture = createProject(entryNames); + const onClientBuild = vi.fn(); + const handle = await startServer({ onClientBuild }, false, entryNames); + fixture.clientChangeStreams[1].push({ issues: [createIssue(severity)] }); + fixture.state.marker = "B"; + fixture.clientChanges.push({ issues: [] }); + + await vi.waitFor(() => { + expect( + fs.readFileSync(path.join(projectPath, "dist/index.html"), "utf8"), + ).toContain('src="main.bbbbbbbb.js"'); + }); + expect(await readHttp(handle, "/other.html")).toContain( + 'src="other.aaaaaaaa.js"', + ); + expect(onClientBuild).toHaveBeenCalledOnce(); + + fixture.state.marker = "C"; + fixture.clientChangeStreams[1].push({ issues: [createIssue("warning")] }); + await vi.waitFor(() => expect(onClientBuild).toHaveBeenCalledTimes(2)); + expect(onClientBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main.bbbbbbbb.js", "other.cccccccc.js"], + isRebuild: true, + }); + }, + ); + it("serializes client publication with later writes and recovers after callback failures", async () => { const fixture = createProject(); const onClientBuild = vi.fn(); diff --git a/packages/pack/src/core/hmr.ts b/packages/pack/src/core/hmr.ts index b30797609..c522fa6ec 100644 --- a/packages/pack/src/core/hmr.ts +++ b/packages/pack/src/core/hmr.ts @@ -374,8 +374,9 @@ export async function createHotReloader( } async function notifyClientBuild(isRebuild: boolean) { + if (closed || hasBlockingIssues(currentEntryIssues)) return; const clientPaths = hotReloader.getClientPaths(); - if (!closed && clientPaths.length > 0) { + if (clientPaths.length > 0) { await onClientBuild?.({ clientPaths, isRebuild }); } } @@ -528,7 +529,7 @@ export async function createHotReloader( return; } - void currentEntriesHandling + currentEntriesHandling .then(() => queueOutputWrite(async () => { if (generation !== backgroundWatchGeneration) return;