From 9f8c80337c90dcc754dcfd5363fbd7c6cc0b4714 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:14:30 -0700 Subject: [PATCH 01/17] fix(next): converge workflow HMR rebuilds --- .changeset/fix-next-hmr-build-race.md | 5 + packages/core/e2e/dev.test.ts | 47 ++- packages/next/src/builder-eager.ts | 361 +++++++------------ packages/next/src/watch-rebuild.test.ts | 366 ++++++++----------- packages/next/src/watch-rebuild.ts | 448 ++++++------------------ 5 files changed, 409 insertions(+), 818 deletions(-) create mode 100644 .changeset/fix-next-hmr-build-race.md diff --git a/.changeset/fix-next-hmr-build-race.md b/.changeset/fix-next-hmr-build-race.md new file mode 100644 index 0000000000..f2e1773854 --- /dev/null +++ b/.changeset/fix-next-hmr-build-race.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Preserve source changes made during Next.js development rebuilds for the next HMR pass. diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index afe8f4618b..eac7b688b6 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -225,6 +225,7 @@ export function createDevTests(config?: DevTestConfig) { skip: 'workflow dev hmr: skip', hot: 'workflow dev hmr: hot rebuild', full: 'workflow dev hmr: full rediscovery', + idle: 'workflow dev hmr: idle', }; const hmrRebuildCompleteMessage = 'workflow dev hmr: rebuild complete'; @@ -320,6 +321,13 @@ export function createDevTests(config?: DevTestConfig) { const countLogMessage = (log: string, message: string) => log.split(message).length - 1; type ExpectedHmrLogCount = number | { min?: number; max?: number }; + type ExpectedHmrLogCounts = + | 'any' + | { + skip?: ExpectedHmrLogCount; + hot?: ExpectedHmrLogCount; + full?: ExpectedHmrLogCount; + }; const expectLogCount = ( actual: number, expected: ExpectedHmrLogCount | undefined @@ -341,11 +349,7 @@ export function createDevTests(config?: DevTestConfig) { }; const expectHmrLogCounts = async ( cursor: number | undefined, - expected: { - skip?: ExpectedHmrLogCount; - hot?: ExpectedHmrLogCount; - full?: ExpectedHmrLogCount; - } + expected: ExpectedHmrLogCounts ) => { if (cursor === undefined) { return; @@ -356,6 +360,20 @@ export function createDevTests(config?: DevTestConfig) { intervalMs: 250, check: async () => { const log = (await readDevServerLog()).slice(cursor); + expect(log).toContain(hmrLogMessages.idle); + if (expected === 'any') { + expect( + [ + hmrLogMessages.skip, + hmrLogMessages.hot, + hmrLogMessages.full, + ].reduce( + (count, message) => count + countLogMessage(log, message), + 0 + ) + ).toBeGreaterThan(0); + return; + } expectLogCount( countLogMessage(log, hmrLogMessages.skip), expected.skip @@ -1045,6 +1063,7 @@ ${apiFileContent}` } await waitForHmrReady(); + const setupLogCursor = await readDevServerLogCursor(); const writeFuzzSources = async (iteration: number) => { await Promise.all([ @@ -1161,6 +1180,15 @@ ${apiFileContent}` }, }); assert(workflow); + await pollUntil({ + description: 'HMR fuzz fixture rebuilds to finish', + timeoutMs: flowRouteHmrRediscoveryTimeoutMs, + intervalMs: 250, + check: async () => { + const log = (await readDevServerLog()).slice(setupLogCursor); + expect(log).toContain(hmrLogMessages.idle); + }, + }); const runWorkflow = async () => { const run = await start< [], @@ -1198,7 +1226,7 @@ ${apiFileContent}` { file: files.step, kind: 'none', - expectedLogCounts: { skip: 1 }, + expectedLogCounts: 'any', expectedStepValue: (iteration: number) => `step-only-${iteration}`, source: ( iteration: number @@ -1456,6 +1484,9 @@ export async function hmrFuzzAddedWorkflow() { }, { description: 'workflow file added through API import', + expectedLogCounts: { + full: { min: 1, max: 2 }, + }, write: async (iteration: number) => { await fs.writeFile( files.addedWorkflow, @@ -1489,7 +1520,9 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: { full: 1, skip: 1 }, + expectedLogCounts: { + full: { min: 1, max: 2 }, + }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index ca4d006583..bc75c197bc 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -10,11 +10,10 @@ import type { NextConfig as ProjectNextConfig } from 'next'; import { createWatchIgnorePredicate } from './watch-ignore.js'; import { classifyRebuild, + createRebuildScheduler, createSourceSnapshot, - type FileChanges, getRelevantFiles, - pinBaselinesAcrossFullRebuild, - replaceSourceSnapshots, + readSourceSnapshots, type SourceSnapshot, } from './watch-rebuild.js'; @@ -191,7 +190,8 @@ export async function getNextBuilderEager( ? pathname : resolve(this.config.workingDir, pathname) ).replace(/\\/g, '/'); - const sourceSnapshots = new Map(); + let sourceSnapshots = new Map(); + let buildInProgress = false; const watchableExtensions = new Set([ '.js', @@ -229,25 +229,15 @@ export async function getNextBuilderEager( return isIgnoredWatchPath(normalizedPath); }; - let rebuildQueue = Promise.resolve(); - - const enqueue = (task: () => Promise) => { - rebuildQueue = rebuildQueue.then(task).catch((error) => { - console.error('Failed to process file change', error); - }); - return rebuildQueue; - }; - const readSourceSnapshot = (file: string) => createSourceSnapshot({ file, detectWorkflowPatterns }); - const refreshSourceSnapshots = () => - replaceSourceSnapshots({ + const snapshotSources = () => + readSourceSnapshots({ discoveredEntries, inputFiles: options.inputFiles, normalizePath, readSnapshot: readSourceSnapshot, - sourceSnapshots, }); const mergeCombinedManifest = ( @@ -264,6 +254,15 @@ export async function getNextBuilderEager( }, }); + const runBuild = async (build: () => Promise) => { + buildInProgress = true; + try { + await build(); + } finally { + buildInProgress = false; + } + }; + const hotRebuild = async (refreshStepRegistrations: boolean) => { if (refreshStepRegistrations) { if (stepsCtx) { @@ -290,54 +289,42 @@ export async function getNextBuilderEager( await writeManifest(mergeCombinedManifest(stepsManifest)); }; - // The pin helper owns the capture-before-build / restore-after- - // refresh ordering (including that the capture reads the CURRENT - // discovered entries and input files, before the rebuild replaces - // them), so an edit landing while the multi-second rebuild runs still - // diffs against what the rebuild consumed instead of being absorbed - // into the refreshed baseline. See `pinBaselinesAcrossFullRebuild` - // for the full reasoning. - const fullRebuild = () => - pinBaselinesAcrossFullRebuild({ - discoveredEntries, - inputFiles: options.inputFiles, - normalizePath, - readSnapshot: readSourceSnapshot, - sourceSnapshots, - rebuild: async () => { - this.clearDiscoveredEntriesCache(); - const newInputFiles = await this.getInputFiles(); - options.inputFiles = newInputFiles; - - await stepsCtx?.dispose(); - await workflowsCtx.interimBundleCtx.dispose(); - - const newCombined = await this.buildCombinedFunction(options); - stepsCtx = newCombined.stepsContext; - discoveredEntries = newCombined.discoveredEntries; - stepsManifest = newCombined.stepsManifest; - workflowsManifest = newCombined.workflowsManifest; - - if (!newCombined?.interimBundleCtx || !newCombined?.bundleFinal) { - throw new Error( - 'Invariant: expected workflows bundle context after rebuild' - ); - } - workflowsCtx = { - interimBundleCtx: newCombined.interimBundleCtx, - bundleFinal: newCombined.bundleFinal, - }; - - await writeManifest(newCombined.manifest); - await refreshSourceSnapshots(); - }, - }); + const fullRebuild = async () => { + this.clearDiscoveredEntriesCache(); + const newInputFiles = await this.getInputFiles(); + options.inputFiles = newInputFiles; + + // Snapshot before building so edits made during the build remain + // dirty and trigger the file event already queued behind this task. + const nextSourceSnapshots = await snapshotSources(); + + await stepsCtx?.dispose(); + await workflowsCtx.interimBundleCtx.dispose(); + + const newCombined = await this.buildCombinedFunction(options); + stepsCtx = newCombined.stepsContext; + discoveredEntries = newCombined.discoveredEntries; + stepsManifest = newCombined.stepsManifest; + workflowsManifest = newCombined.workflowsManifest; + + if (!newCombined?.interimBundleCtx || !newCombined?.bundleFinal) { + throw new Error( + 'Invariant: expected workflows bundle context after rebuild' + ); + } + workflowsCtx = { + interimBundleCtx: newCombined.interimBundleCtx, + bundleFinal: newCombined.bundleFinal, + }; + + await writeManifest(newCombined.manifest); + sourceSnapshots = nextSourceSnapshots; + }; const isWatchableFile = (path: string) => watchableExtensions.has(extname(path)); - const readKnownFiles = async () => { - const files = new Set(); + const readKnownFileAliases = async () => { const aliases = new Map(); const relevantFiles = getRelevantFiles({ discoveredEntries, @@ -354,7 +341,6 @@ export async function getNextBuilderEager( const canonicalPath = relevantFiles.has(realFilePath) ? realFilePath : filePath; - files.add(canonicalPath); aliases.set(filePath, canonicalPath); aliases.set(realFilePath, canonicalPath); return canonicalPath; @@ -402,216 +388,105 @@ export async function getNextBuilderEager( }; await visit(this.config.workingDir); - return { files, aliases, addKnownFile }; + return { aliases, addKnownFile }; }; - const mergeFileChanges = ( - left: FileChanges, - right: FileChanges - ): FileChanges => ({ - addedFiles: unique([...left.addedFiles, ...right.addedFiles]), - modifiedFiles: unique([ - ...left.modifiedFiles, - ...right.modifiedFiles, - ]), - removedFiles: unique([...left.removedFiles, ...right.removedFiles]), - }); - - const unique = (paths: string[]) => [...new Set(paths)]; - - const classifyFileChanges = ({ - changedFiles, - knownFiles, - removedFiles, - }: { - changedFiles: string[]; - knownFiles: Set; - removedFiles: string[]; - }): FileChanges => { - const addedFiles: string[] = []; - const modifiedFiles: string[] = []; - - for (const file of unique(changedFiles)) { - if (knownFiles.has(file)) { - modifiedFiles.push(file); - } else { - addedFiles.push(file); - knownFiles.add(file); - } - } - - for (const file of removedFiles) { - knownFiles.delete(file); - } - - return { - addedFiles, - modifiedFiles, - removedFiles: unique(removedFiles), - }; - }; - - const hasFileChanges = ({ - addedFiles, - modifiedFiles, - removedFiles, - }: FileChanges) => - addedFiles.length > 0 || - modifiedFiles.length > 0 || - removedFiles.length > 0; const logDevHmr = (...args: unknown[]) => { if (process.env.WORKFLOW_DEV_HMR_LOGS === '1') { console.log(...args); } }; - // Known gap: the initial build has the same two-read shape (the - // combined build above consumed sources, and this refresh re-reads - // them), but no pinning, and the watcher below attaches with - // `ignoreInitial: true`, so an edit landing inside the startup window - // is absorbed with no straggler event to recover it. Bounded by dev - // server startup rather than recurring per rebuild; knowingly out of - // scope for the mid-rebuild pinning above. - await refreshSourceSnapshots(); - let { - files: knownFiles, - aliases: knownFileAliases, - addKnownFile: rememberKnownFile, - } = await readKnownFiles(); + sourceSnapshots = await snapshotSources(); + let { aliases: knownFileAliases, addKnownFile: rememberKnownFile } = + await readKnownFileAliases(); const refreshKnownFiles = async () => { - const nextKnown = await readKnownFiles(); - knownFiles = nextKnown.files; + const nextKnown = await readKnownFileAliases(); knownFileAliases = nextKnown.aliases; rememberKnownFile = nextKnown.addKnownFile; }; - const processFileChanges = async (fileChanges: FileChanges) => { - if (!hasFileChanges(fileChanges)) { - return; - } + const runFullRebuild = async () => { + logDevHmr('workflow dev hmr: full rediscovery'); + await runBuild(fullRebuild); + await refreshKnownFiles(); + }; + const processFileChanges = async (files: string[]) => { const decision = await classifyRebuild({ + files, discoveredEntries, - fileChanges, inputFiles: options.inputFiles, normalizePath, parentHasChild, readSnapshot: readSourceSnapshot, sourceSnapshots, }); - if (decision.kind === 'none') { - logDevHmr('workflow dev hmr: skip'); - for (const [file, snapshot] of decision.snapshots || []) { - sourceSnapshots.set(file, snapshot); - } - return; - } - if (decision.kind === 'full') { - logDevHmr('workflow dev hmr: full rediscovery'); - try { - await fullRebuild(); - await refreshKnownFiles(); - } finally { - // Lets a log reader tell "quiet" from "rebuild in flight". - // The e2e HMR tests drain-to-quiet before counting lines. - logDevHmr('workflow dev hmr: rebuild complete'); - } - return; - } - - logDevHmr( - `workflow dev hmr: hot rebuild${decision.refreshStepRegistrations ? ' with step registration refresh' : ''}` - ); - try { - await hotRebuild(decision.refreshStepRegistrations); - for (const [file, snapshot] of decision.snapshots) { - sourceSnapshots.set(file, snapshot); - } - } finally { - // See the matching line on the full path above. - logDevHmr('workflow dev hmr: rebuild complete'); + switch (decision.kind) { + case 'skip': + logDevHmr('workflow dev hmr: skip'); + break; + case 'hot': + logDevHmr( + `workflow dev hmr: hot rebuild${decision.refreshStepRegistrations ? ' with step registration refresh' : ''}` + ); + await runBuild(() => + hotRebuild(decision.refreshStepRegistrations) + ); + break; + case 'full': + await runFullRebuild(); + return; + default: + decision satisfies never; + throw new Error('Unknown rebuild decision'); } + sourceSnapshots = new Map([ + ...sourceSnapshots, + ...decision.snapshots, + ]); }; - let pendingFileChanges: FileChanges = { - addedFiles: [], - modifiedFiles: [], - removedFiles: [], - }; - let flushTimer: ReturnType | undefined; - - const scheduleFileChanges = (fileChanges: FileChanges) => { - pendingFileChanges = mergeFileChanges( - pendingFileChanges, - fileChanges - ); - if (flushTimer) { - return; - } - flushTimer = setTimeout(() => { - const fileChanges = pendingFileChanges; - pendingFileChanges = { - addedFiles: [], - modifiedFiles: [], - removedFiles: [], - }; - flushTimer = undefined; - enqueue(() => processFileChanges(fileChanges)); - }, 10); + const scheduleRebuild = createRebuildScheduler( + async (request) => { + try { + switch (request.kind) { + case 'files': + await processFileChanges(request.files); + return; + case 'full': + await runFullRebuild(); + return; + default: + request satisfies never; + throw new Error('Unknown scheduled rebuild'); + } + } catch (error) { + console.error('Failed to process file change', error); + } + }, + () => logDevHmr('workflow dev hmr: idle') + ); + const scheduleFileChange = (file: string) => { + scheduleRebuild({ kind: 'files', files: [file] }); }; - - const resolveExistingEventPath = async (pathname: string) => { - const normalizedPath = normalizePath(pathname); - if (!isWatchableFile(normalizedPath)) { - return; - } - - const knownPath = knownFileAliases.get(normalizedPath); - if (knownPath) { - return knownPath; - } - - try { - const realFilePath = normalizePath(await realpath(normalizedPath)); - return knownFileAliases.get(realFilePath) ?? normalizedPath; - } catch { - return normalizedPath; + const scheduleBuildOverlap = () => { + if (buildInProgress) { + scheduleRebuild({ kind: 'full' }); } }; - const handleFileAdded = async (pathname: string) => { + const handleFileWritten = async (pathname: string) => { const normalizedPath = normalizePath(pathname); if (!isWatchableFile(normalizedPath)) { return; } - const existingPath = await resolveExistingEventPath(normalizedPath); - const wasKnown = existingPath ? knownFiles.has(existingPath) : false; - const canonicalPath = await rememberKnownFile(normalizedPath); - knownFiles.add(canonicalPath); - scheduleFileChanges({ - addedFiles: wasKnown ? [] : [canonicalPath], - modifiedFiles: wasKnown ? [canonicalPath] : [], - removedFiles: [], - }); - }; - - const handleFileChanged = async (pathname: string) => { - const canonicalPath = await resolveExistingEventPath(pathname); - if (!canonicalPath) { - return; - } - - const fileChanges = classifyFileChanges({ - changedFiles: [canonicalPath], - knownFiles, - removedFiles: [], - }); - if (!knownFileAliases.has(canonicalPath)) { - await rememberKnownFile(canonicalPath); - } - scheduleFileChanges(fileChanges); + const canonicalPath = + knownFileAliases.get(normalizedPath) ?? + (await rememberKnownFile(normalizedPath)); + scheduleFileChange(canonicalPath); }; const handleFileRemoved = (pathname: string) => { @@ -622,13 +497,8 @@ export async function getNextBuilderEager( const canonicalPath = knownFileAliases.get(normalizedPath) ?? normalizedPath; - const fileChanges = classifyFileChanges({ - changedFiles: [], - knownFiles, - removedFiles: [canonicalPath], - }); knownFileAliases.delete(normalizedPath); - scheduleFileChanges(fileChanges); + scheduleFileChange(canonicalPath); }; const watcher = chokidar.watch(this.config.workingDir, { @@ -645,12 +515,15 @@ export async function getNextBuilderEager( }); watcher.on('add', (pathname) => { - void handleFileAdded(pathname); + scheduleBuildOverlap(); + void handleFileWritten(pathname); }); watcher.on('change', (pathname) => { - void handleFileChanged(pathname); + scheduleBuildOverlap(); + void handleFileWritten(pathname); }); watcher.on('unlink', (pathname) => { + scheduleBuildOverlap(); handleFileRemoved(pathname); }); watcher.on('error', (error) => { diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 85de08e965..7bfe16d801 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -1,13 +1,74 @@ -import { describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { classifyRebuild, + createRebuildScheduler, createSourceSnapshotFromSource, extractImportSignature, - pinBaselinesAcrossFullRebuild, type SourceSnapshot, stripCommentsFromSource, } from './watch-rebuild.js'; +afterEach(() => { + vi.useRealTimers(); +}); + +describe('watch-rebuild scheduling', () => { + test('merges changes until filesystem writes become quiet', async () => { + vi.useFakeTimers(); + const rebuild = vi.fn(async () => {}); + const schedule = createRebuildScheduler(rebuild, () => {}); + + schedule({ + kind: 'files', + files: ['/app/workflow.ts'], + }); + await vi.advanceTimersByTimeAsync(99); + schedule({ + kind: 'files', + files: ['/app/helper.ts'], + }); + await vi.advanceTimersByTimeAsync(99); + + expect(rebuild).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(rebuild).toHaveBeenCalledWith({ + kind: 'files', + files: ['/app/workflow.ts', '/app/helper.ts'], + }); + }); + + test('collapses full rebuild requests while a rebuild runs', async () => { + vi.useFakeTimers(); + const firstBuild = Promise.withResolvers(); + const fullBuild = Promise.withResolvers(); + const idle = Promise.withResolvers(); + const requests: string[] = []; + const onIdle = vi.fn(idle.resolve); + const schedule = createRebuildScheduler(async (request) => { + requests.push(request.kind); + if (requests.length === 1) { + await firstBuild.promise; + } else { + fullBuild.resolve(); + } + }, onIdle); + + schedule({ kind: 'full' }); + await vi.advanceTimersByTimeAsync(100); + + schedule({ kind: 'full' }); + schedule({ kind: 'full' }); + await vi.advanceTimersByTimeAsync(100); + expect(onIdle).not.toHaveBeenCalled(); + firstBuild.resolve(); + await fullBuild.promise; + await idle.promise; + + expect(requests).toEqual(['full', 'full']); + expect(onIdle).toHaveBeenCalledOnce(); + }); +}); + const detectWorkflowPatterns = (source: string) => ({ hasDirective: source.includes("'use workflow'") || @@ -101,11 +162,7 @@ export const allWorkflows = { discoveredSerdeFiles: new Set(), discoveredFiles: new Set([pageFile, registryFile, workflowFile]), }, - fileChanges: { - addedFiles: [], - modifiedFiles: [registryFile], - removedFiles: [], - }, + files: [registryFile], inputFiles: [pageFile], parentHasChild: () => false, readSnapshot: async (file) => @@ -135,11 +192,7 @@ export const allWorkflows = {} as const; discoveredSerdeFiles: new Set(), discoveredFiles: new Set([registryFile]), }, - fileChanges: { - addedFiles: [stepFile], - modifiedFiles: [registryFile], - removedFiles: [], - }, + files: [stepFile, registryFile], inputFiles: [registryFile], parentHasChild: () => false, readSnapshot: async (file) => @@ -152,237 +205,112 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); - test('an edit landing during a full rebuild is not absorbed into the baseline', async () => { - // Reproduces the flow-route HMR race: a step definition is added to an - // already-discovered step file while a full rebuild is in flight. The - // post-rebuild baseline refresh reads the file from disk (post-edit), so - // without reconciliation the queued watcher event diffs the edit against - // itself and classifies as a no-op — the added step never reaches the - // manifest. - const stepFile = '/app/workflows/hmr-fuzz-step.ts'; - const pageFile = '/app/app/page.tsx'; - const preBuildSource = `export async function hmrFuzzStep() { - 'use step'; - return 'step-value'; -} -`; - const postEditSource = `export async function hmrFuzzStep() { - 'use step'; - return 'step-value'; -} - -export async function hmrFuzzAddedStep() { - 'use step'; - return 'added-step'; + test('fully rebuilds byte-identical workflow notifications', async () => { + const workflowFile = '/app/workflows/example.ts'; + const source = `export async function example() { + 'use workflow'; } `; - const discoveredEntries = { - discoveredSteps: new Set([stepFile]), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), - discoveredFiles: new Set([pageFile, stepFile]), - }; - const sources = new Map([ - [stepFile, preBuildSource], - [pageFile, ''], - ]); - const readSnapshot = async (file: string) => - createSourceSnapshotFromSource( - sources.get(file) ?? '', - detectWorkflowPatterns - ); - - const sourceSnapshots = new Map(); - - // Full rebuild: the helper captures what the build reads before invoking - // the rebuild; the edit lands mid-rebuild and the post-rebuild refresh - // reads it from disk. - await pinBaselinesAcrossFullRebuild({ - discoveredEntries, - inputFiles: [pageFile], - readSnapshot, - sourceSnapshots, - rebuild: async () => { - sources.set(stepFile, postEditSource); - sourceSnapshots.set(stepFile, await readSnapshot(stepFile)); - sourceSnapshots.set(pageFile, await readSnapshot(pageFile)); - }, - }); + const snapshot = createSourceSnapshotFromSource( + source, + detectWorkflowPatterns + ); - // The queued watcher event for the edit must still trigger a rebuild. await expect( classifyRebuild({ - discoveredEntries, - fileChanges: { - addedFiles: [], - modifiedFiles: [stepFile], - removedFiles: [], + discoveredEntries: { + discoveredSteps: new Set(), + discoveredWorkflows: new Set([workflowFile]), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([workflowFile]), }, - inputFiles: [pageFile], + files: [workflowFile], + inputFiles: [workflowFile], parentHasChild: () => false, - readSnapshot, - sourceSnapshots, + readSnapshot: async () => snapshot, + sourceSnapshots: new Map([[workflowFile, snapshot]]), }) ).resolves.toEqual({ kind: 'full' }); }); - test('a duplicate watcher event landing during a full rebuild stays a no-op', async () => { - // Watchers routinely emit several events for one edit, and the edit that - // triggered a full rebuild is itself a source of such stragglers landing - // mid-rebuild. The straggler carries the same content the rebuild - // consumed, so it must diff equal against the pinned pre-build baseline - // and classify as 'none' — evicting the baseline instead cascades into - // back-to-back full rebuilds. - const stepFile = '/app/workflows/hmr-fuzz-step.ts'; - const pageFile = '/app/app/page.tsx'; - const source = `export async function hmrFuzzStep() { - 'use step'; - return 'step-value'; -} -`; - const discoveredEntries = { - discoveredSteps: new Set([stepFile]), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), - discoveredFiles: new Set([pageFile, stepFile]), - }; - const sources = new Map([ - [stepFile, source], - [pageFile, ''], - ]); - const readSnapshot = async (file: string) => - createSourceSnapshotFromSource( - sources.get(file) ?? '', - detectWorkflowPatterns - ); - - // Full rebuild (triggered by the edit that wrote `source`): the helper's - // capture reads that same content before the rebuild, and the refresh - // reads it again after. - const sourceSnapshots = new Map(); - await pinBaselinesAcrossFullRebuild({ - discoveredEntries, - inputFiles: [pageFile], - readSnapshot, - sourceSnapshots, - rebuild: async () => { - sourceSnapshots.set(stepFile, await readSnapshot(stepFile)); - sourceSnapshots.set(pageFile, await readSnapshot(pageFile)); - }, - }); + test('rebuilds relevant files without snapshots', async () => { + const helperFile = '/app/workflows/helper.ts'; - // The baseline survives (pinned, not evicted), so the queued duplicate - // classifies as a no-op instead of another full rebuild. - expect(sourceSnapshots.has(stepFile)).toBe(true); - const decision = await classifyRebuild({ - discoveredEntries, - fileChanges: { - addedFiles: [], - modifiedFiles: [stepFile], - removedFiles: [], - }, - inputFiles: [pageFile], - parentHasChild: () => false, - readSnapshot, - sourceSnapshots, - }); - expect(decision.kind).toBe('none'); + await expect( + classifyRebuild({ + discoveredEntries: { + discoveredSteps: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([helperFile]), + }, + files: [helperFile], + inputFiles: [], + parentHasChild: () => false, + readSnapshot: async () => + createSourceSnapshotFromSource( + "export const value = 'helper';\n", + detectWorkflowPatterns + ), + sourceSnapshots: new Map(), + }) + ).resolves.toEqual({ kind: 'full' }); }); - test('a file created mid-rebuild that the build missed still forces a follow-up rebuild', async () => { - // A file the rebuild never discovered has no baseline after the - // post-rebuild refresh either, so its queued add event classifies - // conservatively — no eviction machinery required. - const stepFile = '/app/workflows/newly-created-step.ts'; - const pageFile = '/app/app/page.tsx'; - const source = `export async function newStep() { - 'use step'; - return 'new-step'; -} -`; - const sources = new Map([ - [stepFile, source], - [pageFile, ''], - ]); - const readSnapshot = async (file: string) => - createSourceSnapshotFromSource( - sources.get(file) ?? '', - detectWorkflowPatterns - ); - - // The build that just finished never saw stepFile: it is in neither the - // discovered entries nor the refreshed baseline. - const sourceSnapshots = new Map(); - await pinBaselinesAcrossFullRebuild({ - discoveredEntries: { - discoveredSteps: new Set(), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), - discoveredFiles: new Set([pageFile]), - }, - inputFiles: [pageFile], - readSnapshot, - sourceSnapshots, - rebuild: async () => { - sourceSnapshots.set(pageFile, await readSnapshot(pageFile)); - }, - }); + test('rebuilds new files that can introduce graph entries', async () => { + const routeFile = '/app/app/new/route.ts'; await expect( classifyRebuild({ discoveredEntries: { - discoveredSteps: new Set(), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), - discoveredFiles: new Set([pageFile]), - }, - fileChanges: { - addedFiles: [stepFile], - modifiedFiles: [], - removedFiles: [], + discoveredSteps: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set(), }, - inputFiles: [pageFile], + files: [routeFile], + inputFiles: [], parentHasChild: () => false, - readSnapshot, - sourceSnapshots, + readSnapshot: async () => + createSourceSnapshotFromSource( + "import './workflow';\n", + detectWorkflowPatterns + ), + sourceSnapshots: new Map(), }) ).resolves.toEqual({ kind: 'full' }); }); - test('ignores stale add events for already snapshotted files', async () => { - const stepFile = '/app/workflows/hmr-fuzz-step.ts'; - const pageFile = '/app/app/page.tsx'; - const stepSource = `export async function hmrFuzzStep() { - 'use step'; - return 'step-value'; -} -`; - const sourceSnapshots = new Map([ - [ - stepFile, - createSourceSnapshotFromSource(stepSource, detectWorkflowPatterns), - ], - ]); - - const decision = await classifyRebuild({ - discoveredEntries: { - discoveredSteps: new Set([stepFile]), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), - discoveredFiles: new Set([pageFile, stepFile]), - }, - fileChanges: { - addedFiles: [stepFile], - modifiedFiles: [], - removedFiles: [], - }, - inputFiles: [pageFile], - parentHasChild: () => false, - readSnapshot: async () => - createSourceSnapshotFromSource(stepSource, detectWorkflowPatterns), - sourceSnapshots, + test('hot rebuilds body changes used by workflows', async () => { + const helperFile = '/app/workflows/helper.ts'; + const workflowFile = '/app/workflows/workflow.ts'; + const previousHelperSnapshot = createSourceSnapshotFromSource( + "export const value = 'before';\n", + detectWorkflowPatterns + ); + const nextHelperSnapshot = createSourceSnapshotFromSource( + "export const value = 'after';\n", + detectWorkflowPatterns + ); + await expect( + classifyRebuild({ + discoveredEntries: { + discoveredSteps: new Set(), + discoveredWorkflows: new Set([workflowFile]), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([helperFile, workflowFile]), + }, + files: [helperFile], + inputFiles: [workflowFile], + parentHasChild: (parent, child) => + parent === workflowFile && child === helperFile, + readSnapshot: async () => nextHelperSnapshot, + sourceSnapshots: new Map([[helperFile, previousHelperSnapshot]]), + }) + ).resolves.toEqual({ + kind: 'hot', + refreshStepRegistrations: false, + snapshots: new Map([[helperFile, nextHelperSnapshot]]), }); - - expect(decision.kind).toBe('none'); }); }); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 2fc87ca13f..d9eb6bde8d 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; export interface DiscoveredEntriesLike { @@ -7,13 +8,12 @@ export interface DiscoveredEntriesLike { discoveredFiles?: Set; } -export interface FileChanges { - addedFiles: string[]; - modifiedFiles: string[]; - removedFiles: string[]; -} +export type ScheduledRebuild = + | { kind: 'files'; files: string[] } + | { kind: 'full' }; export interface SourceSnapshot { + sourceHash: string; importSignature: string; definitionSignature: string; hasDirective: boolean; @@ -21,7 +21,7 @@ export interface SourceSnapshot { } export type RebuildDecision = - | { kind: 'none'; snapshots?: Map } + | { kind: 'skip'; snapshots: Map } | { kind: 'hot'; refreshStepRegistrations: boolean; @@ -216,6 +216,7 @@ export const createSourceSnapshotFromSource = ( const patterns = detectWorkflowPatterns(sourceWithoutComments); return { + sourceHash: createHash('sha256').update(source).digest('base64url'), importSignature: extractImportSignature(sourceWithoutComments), definitionSignature: extractDefinitionSignature(sourceWithoutComments), hasDirective: patterns.hasDirective, @@ -254,55 +255,17 @@ export const getRelevantFiles = ({ ].map(normalizePath) ); -export const replaceSourceSnapshots = async ({ +export const readSourceSnapshots = async ({ discoveredEntries, inputFiles, normalizePath = defaultNormalizePath, readSnapshot, - sourceSnapshots, }: { discoveredEntries: DiscoveredEntriesLike; inputFiles: string[]; normalizePath?: (path: string) => string; readSnapshot: (file: string) => Promise; - sourceSnapshots: Map; }) => { - sourceSnapshots.clear(); - await Promise.all( - [ - ...getRelevantFiles({ - discoveredEntries, - inputFiles, - normalizePath, - }), - ].map(async (file) => { - try { - sourceSnapshots.set(file, await readSnapshot(file)); - } catch { - // Unreadable (e.g. just deleted) files stay absent from the - // freshly cleared map. - } - }) - ); -}; - -/** - * Read snapshots for every currently relevant file without mutating the - * shared baseline map. Used by `pinBaselinesAcrossFullRebuild` to capture - * content at rebuild start, so the baseline can later be pinned to what the - * rebuild actually consumed. - */ -const captureSourceSnapshots = async ({ - discoveredEntries, - inputFiles, - normalizePath = defaultNormalizePath, - readSnapshot, -}: { - discoveredEntries: DiscoveredEntriesLike; - inputFiles: string[]; - normalizePath?: (path: string) => string; - readSnapshot: (file: string) => Promise; -}): Promise> => { const snapshots = new Map(); await Promise.all( [ @@ -320,77 +283,7 @@ const captureSourceSnapshots = async ({ return snapshots; }; -/** - * Run a full rebuild while keeping the classifier baseline anchored to the - * content the rebuild consumed. - * - * A full rebuild reads sources twice: once when the bundler consumes them and - * once when the baseline is refreshed from disk afterwards (the `rebuild` - * callback owns both, in that order). An edit that lands between those reads - * would be absorbed into the baseline without ever being built, and its - * queued watcher event would then classify as a no-op, silently dropping - * the change until the next unrelated rebuild. - * - * To prevent that, the relevant files are re-read from disk immediately - * before the rebuild starts, and files present both before and after get - * that captured value restored. A mid-(multi-second-)rebuild edit then still - * diffs against what the rebuild consumed, while a duplicate watcher event - * for content the rebuild already consumed (watchers routinely emit several - * events per edit, the triggering edit included) diffs equal and stays a - * no-op instead of cascading into back-to-back full rebuilds. - * - * The capture costs one serial read of the relevant set (~150-250ms at ~250 - * files) per full rediscovery. A zero-read formulation (cloning the live - * baseline map and pinning the triggering batch to the snapshots - * `classifyRebuild` read) was tried and reverted: writes landing in the - * capture window (test-teardown restores, multi-flush setup bursts) are - * content the imminent build consumes anyway, and the clone un-absorbs them - * into follow-up full rebuilds; with real-world multi-second rebuilds that - * bursts into rebuild chains. The disk capture intentionally coalesces such - * writes into the in-flight rebuild. - * - * Files the rebuild discovered for the first time have no captured content - * and keep their post-build baseline. That is already sound for the two - * cases that matter: a new file the build missed has no baseline at all, so - * its queued add event forces the follow-up rebuild, and a new file the - * build did consume gets a baseline matching what it consumed. What stays - * narrowed rather than closed is a file created and then edited again within - * one rebuild window; eviction-style conservatism was tried against that - * and rejected too: it turned every added file's routine duplicate watcher - * events into redundant full rebuilds. - */ -export const pinBaselinesAcrossFullRebuild = async ({ - discoveredEntries, - inputFiles, - normalizePath = defaultNormalizePath, - readSnapshot, - rebuild, - sourceSnapshots, -}: { - discoveredEntries: DiscoveredEntriesLike; - inputFiles: string[]; - normalizePath?: (path: string) => string; - readSnapshot: (file: string) => Promise; - rebuild: () => Promise; - sourceSnapshots: Map; -}): Promise => { - const preBuildSnapshots = await captureSourceSnapshots({ - discoveredEntries, - inputFiles, - normalizePath, - readSnapshot, - }); - - await rebuild(); - - for (const [file, snapshot] of preBuildSnapshots) { - if (sourceSnapshots.has(file)) { - sourceSnapshots.set(file, snapshot); - } - } -}; - -const didSourceSnapshotChange = ( +const didSourceStructureChange = ( previousSnapshot: SourceSnapshot, nextSnapshot: SourceSnapshot ) => @@ -399,184 +292,58 @@ const didSourceSnapshotChange = ( previousSnapshot.hasDirective !== nextSnapshot.hasDirective || previousSnapshot.hasSerde !== nextSnapshot.hasSerde; -const unique = (paths: string[]) => [...new Set(paths)]; - -const snapshotChangedFile = async ({ - file, - nextSnapshots, - readSnapshot, - sourceSnapshots, -}: { - file: string; - nextSnapshots: Map; - readSnapshot: (file: string) => Promise; - sourceSnapshots: Map; -}) => { - const previousSnapshot = sourceSnapshots.get(file); - if (!previousSnapshot) { - return false; - } - - const nextSnapshot = await readSnapshot(file); - if (didSourceSnapshotChange(previousSnapshot, nextSnapshot)) { - return false; - } - - nextSnapshots.set(file, nextSnapshot); - return true; -}; - -const removedFilesRequireFullRebuild = ({ - discoveredEntries, - inputFiles, - normalizePath, - removedFiles, -}: { - discoveredEntries: DiscoveredEntriesLike; - inputFiles: string[]; - normalizePath: (path: string) => string; - removedFiles: string[]; -}) => { - const relevantFiles = getRelevantFiles({ - discoveredEntries, - inputFiles, - normalizePath, - }); - return removedFiles.some((file) => relevantFiles.has(file)); -}; - -const addedFilesRequireFullRebuild = async ({ - addedFiles, - readSnapshot, -}: { - addedFiles: string[]; - readSnapshot: (file: string) => Promise; -}) => { - for (const file of addedFiles) { - try { - const snapshot = await readSnapshot(file); - if (snapshot.hasDirective || snapshot.hasSerde) { - return true; - } - } catch { - return true; - } - } - return false; -}; +export const createRebuildScheduler = ( + rebuild: (request: ScheduledRebuild) => Promise, + onIdle: () => void +) => { + let pending: ScheduledRebuild | undefined; + let rebuilding = false; + let timer: ReturnType | undefined; -const pruneStaleAddedFiles = async ({ - addedFiles, - readSnapshot, - sourceSnapshots, -}: { - addedFiles: string[]; - readSnapshot: (file: string) => Promise; - sourceSnapshots: Map; -}) => { - const nextAddedFiles: string[] = []; - const snapshots = new Map(); - - for (const file of unique(addedFiles)) { - const previousSnapshot = sourceSnapshots.get(file); - if (!previousSnapshot) { - nextAddedFiles.push(file); - continue; + const flush = async () => { + if (rebuilding || timer || !pending) { + return; } + const request = pending; + pending = undefined; + rebuilding = true; try { - const nextSnapshot = await readSnapshot(file); - if (didSourceSnapshotChange(previousSnapshot, nextSnapshot)) { - nextAddedFiles.push(file); - continue; + await rebuild(request); + } finally { + rebuilding = false; + if (pending && !timer) { + void flush(); + } else if (!pending) { + onIdle(); } - snapshots.set(file, nextSnapshot); - } catch { - nextAddedFiles.push(file); } - } - - return { addedFiles: nextAddedFiles, snapshots }; -}; + }; -const modifiedFilesRequireFullRebuild = async ({ - modifiedFiles, - readSnapshot, - sourceSnapshots, -}: { - modifiedFiles: string[]; - readSnapshot: (file: string) => Promise; - sourceSnapshots: Map; -}) => { - for (const file of unique(modifiedFiles)) { - try { - const nextSnapshot = await readSnapshot(file); - const previousSnapshot = sourceSnapshots.get(file); - if (!previousSnapshot) { - if ( - nextSnapshot.importSignature || - nextSnapshot.definitionSignature || - nextSnapshot.hasDirective || - nextSnapshot.hasSerde - ) { - return true; + return (request: ScheduledRebuild) => { + switch (request.kind) { + case 'files': + if (pending?.kind !== 'full') { + pending = { + kind: 'files', + files: [...new Set([...(pending?.files ?? []), ...request.files])], + }; } - continue; - } - if (didSourceSnapshotChange(previousSnapshot, nextSnapshot)) { - return true; - } - } catch { - return true; + break; + case 'full': + pending = request; + break; + default: + request satisfies never; + throw new Error('Unknown scheduled rebuild'); } - } - return false; -}; - -const getChangedRelevantFiles = ({ - discoveredEntries, - fileChanges, - inputFiles, - normalizePath, -}: { - discoveredEntries: DiscoveredEntriesLike; - fileChanges: FileChanges; - inputFiles: string[]; - normalizePath: (path: string) => string; -}) => { - const relevantFiles = getRelevantFiles({ - discoveredEntries, - inputFiles, - normalizePath, - }); - return unique(fileChanges.modifiedFiles).filter((file) => - relevantFiles.has(file) - ); -}; -const collectHotRebuildSnapshots = async ({ - changedFiles, - readSnapshot, - sourceSnapshots, -}: { - changedFiles: string[]; - readSnapshot: (file: string) => Promise; - sourceSnapshots: Map; -}) => { - const snapshots = new Map(); - for (const file of changedFiles) { - if ( - !(await snapshotChangedFile({ - file, - nextSnapshots: snapshots, - readSnapshot, - sourceSnapshots, - })) - ) { - return; - } - } - return snapshots; + clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, 100); + }; }; const workflowEntryFilesChanged = ({ @@ -633,16 +400,16 @@ const stepRegistrationsNeedRefresh = ({ }; export const classifyRebuild = async ({ + files, discoveredEntries, - fileChanges, inputFiles, normalizePath = defaultNormalizePath, parentHasChild, readSnapshot, sourceSnapshots, }: { + files: string[]; discoveredEntries: DiscoveredEntriesLike; - fileChanges: FileChanges; inputFiles: string[]; normalizePath?: (path: string) => string; parentHasChild: ( @@ -653,74 +420,59 @@ export const classifyRebuild = async ({ readSnapshot: (file: string) => Promise; sourceSnapshots: Map; }): Promise => { - const prunedAddedFiles = await pruneStaleAddedFiles({ - addedFiles: fileChanges.addedFiles, - readSnapshot, - sourceSnapshots, - }); - const normalizedFileChanges = { - ...fileChanges, - addedFiles: prunedAddedFiles.addedFiles, - }; - - if ( - removedFilesRequireFullRebuild({ - discoveredEntries, - inputFiles, - normalizePath, - removedFiles: normalizedFileChanges.removedFiles, - }) || - (await addedFilesRequireFullRebuild({ - addedFiles: normalizedFileChanges.addedFiles, - readSnapshot, - })) || - (await modifiedFilesRequireFullRebuild({ - modifiedFiles: normalizedFileChanges.modifiedFiles, - readSnapshot, - sourceSnapshots, - })) - ) { - return { kind: 'full' }; - } - - const changedRelevantFiles = getChangedRelevantFiles({ + const relevantFiles = getRelevantFiles({ discoveredEntries, - fileChanges: normalizedFileChanges, inputFiles, normalizePath, }); - if (changedRelevantFiles.length === 0) { - return prunedAddedFiles.snapshots.size > 0 - ? { kind: 'none', snapshots: prunedAddedFiles.snapshots } - : { kind: 'none' }; - } + const snapshots = new Map(); + for (const file of files) { + let nextSnapshot: SourceSnapshot; + try { + nextSnapshot = await readSnapshot(file); + } catch { + if (relevantFiles.has(file)) { + return { kind: 'full' }; + } + continue; + } - try { - const snapshots = await collectHotRebuildSnapshots({ - changedFiles: changedRelevantFiles, - readSnapshot, - sourceSnapshots, - }); - if (!snapshots) { + const previousSnapshot = sourceSnapshots.get(file); + if (!previousSnapshot) { + if ( + relevantFiles.has(file) || + nextSnapshot.importSignature || + nextSnapshot.hasDirective || + nextSnapshot.hasSerde + ) { + return { kind: 'full' }; + } + continue; + } + if (didSourceStructureChange(previousSnapshot, nextSnapshot)) { return { kind: 'full' }; } - return workflowEntryFilesChanged({ - changedFiles: changedRelevantFiles, - discoveredEntries, - normalizePath, - parentHasChild, - }) - ? { - kind: 'hot', - refreshStepRegistrations: stepRegistrationsNeedRefresh({ - changedFiles: changedRelevantFiles, - discoveredEntries, - normalizePath, - }), - snapshots, - } - : { kind: 'none', snapshots }; - } catch { - return { kind: 'full' }; + if (previousSnapshot.sourceHash === nextSnapshot.sourceHash) { + return { kind: 'full' }; + } + snapshots.set(file, nextSnapshot); } + + const changedFiles = [...snapshots.keys()]; + return workflowEntryFilesChanged({ + changedFiles, + discoveredEntries, + normalizePath, + parentHasChild, + }) + ? { + kind: 'hot', + refreshStepRegistrations: stepRegistrationsNeedRefresh({ + changedFiles, + discoveredEntries, + normalizePath, + }), + snapshots, + } + : { kind: 'skip', snapshots }; }; From f80c04e87c5f32942a55d49de9e89941b0236203 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:50:34 -0700 Subject: [PATCH 02/17] fix(next): rediscover directive file changes --- packages/next/src/watch-rebuild.test.ts | 36 +++++++++++++++++++++++++ packages/next/src/watch-rebuild.ts | 5 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 7bfe16d801..00fcb5b6f3 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -257,6 +257,42 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); + test('fully rebuilds every directive file change', async () => { + const stepFile = '/app/workflows/step.ts'; + const previousSnapshot = createSourceSnapshotFromSource( + `export let example = async () => { + 'use step'; + return 'before'; +}; +`, + detectWorkflowPatterns + ); + const nextSnapshot = createSourceSnapshotFromSource( + `export let example = async () => { + 'use step'; + return 'after'; +}; +`, + detectWorkflowPatterns + ); + + await expect( + classifyRebuild({ + discoveredEntries: { + discoveredSteps: new Set([stepFile]), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([stepFile]), + }, + files: [stepFile], + inputFiles: [], + parentHasChild: () => false, + readSnapshot: async () => nextSnapshot, + sourceSnapshots: new Map([[stepFile, previousSnapshot]]), + }) + ).resolves.toEqual({ kind: 'full' }); + }); + test('rebuilds new files that can introduce graph entries', async () => { const routeFile = '/app/app/new/route.ts'; diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index d9eb6bde8d..cc3f1d868c 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -283,10 +283,11 @@ export const readSourceSnapshots = async ({ return snapshots; }; -const didSourceStructureChange = ( +const requiresFullRediscovery = ( previousSnapshot: SourceSnapshot, nextSnapshot: SourceSnapshot ) => + nextSnapshot.hasDirective || previousSnapshot.importSignature !== nextSnapshot.importSignature || previousSnapshot.definitionSignature !== nextSnapshot.definitionSignature || previousSnapshot.hasDirective !== nextSnapshot.hasDirective || @@ -449,7 +450,7 @@ export const classifyRebuild = async ({ } continue; } - if (didSourceStructureChange(previousSnapshot, nextSnapshot)) { + if (requiresFullRediscovery(previousSnapshot, nextSnapshot)) { return { kind: 'full' }; } if (previousSnapshot.sourceHash === nextSnapshot.sourceHash) { From 7a0424fd1f84fef42ac45f9d129ca3ee9cc61f6e Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:03:39 -0700 Subject: [PATCH 03/17] test(next): await HMR scheduler idle --- packages/core/e2e/dev.test.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index eac7b688b6..d26db4925f 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -227,8 +227,6 @@ export function createDevTests(config?: DevTestConfig) { full: 'workflow dev hmr: full rediscovery', idle: 'workflow dev hmr: idle', }; - const hmrRebuildCompleteMessage = 'workflow dev hmr: rebuild complete'; - const fetchWithTimeout = (pathname: string) => { if (!deploymentUrl) { return Promise.resolve(); @@ -267,11 +265,8 @@ export function createDevTests(config?: DevTestConfig) { .catch(() => ''); }; /** - * Wait until the dev server's HMR pipeline is quiescent: every rebuild - * the log says started (`hot rebuild` / `full rediscovery`) has logged - * `rebuild complete`, and no new HMR line has appeared for a short - * window (covering watcher latency for a just-landed write plus the - * flush debounce). + * Wait until the dev server's HMR pipeline is idle and no new HMR line + * has appeared for a short window covering watcher latency and debounce. * * Rebuilds are serialized and can take multi-second on CI, so a write * from a previous case (or a teardown restore) can still be rebuilding @@ -295,13 +290,19 @@ export function createDevTests(config?: DevTestConfig) { const hot = countLogMessage(log, hmrLogMessages.hot); const full = countLogMessage(log, hmrLogMessages.full); const skip = countLogMessage(log, hmrLogMessages.skip); - const complete = countLogMessage(log, hmrRebuildCompleteMessage); - const counts = `${hot}/${full}/${skip}/${complete}`; + const idle = countLogMessage(log, hmrLogMessages.idle); + const counts = `${hot}/${full}/${skip}/${idle}`; if (counts !== lastCounts) { lastCounts = counts; quietSince = Date.now(); } - expect(complete).toBeGreaterThanOrEqual(hot + full); + expect(log.lastIndexOf(hmrLogMessages.idle)).toBeGreaterThanOrEqual( + Math.max( + log.lastIndexOf(hmrLogMessages.hot), + log.lastIndexOf(hmrLogMessages.full), + log.lastIndexOf(hmrLogMessages.skip) + ) + ); expect(Date.now() - quietSince).toBeGreaterThanOrEqual( hmrQuiescenceQuietMs ); @@ -1226,7 +1227,7 @@ ${apiFileContent}` { file: files.step, kind: 'none', - expectedLogCounts: 'any', + expectedLogCounts: { full: 1 }, expectedStepValue: (iteration: number) => `step-only-${iteration}`, source: ( iteration: number @@ -1255,7 +1256,7 @@ export async function hmrFuzzStep() { { file: files.workflow, kind: 'workflow', - expectedLogCounts: { hot: 1 }, + expectedLogCounts: { full: 1 }, expectedWorkflowValue: (iteration: number) => `workflow-body-${iteration}`, source: ( From f9336a23b17024e1034bf2dbde8bcd8a89a02dbe Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:15:41 -0700 Subject: [PATCH 04/17] test(next): classify directive edits as full --- packages/core/e2e/dev.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index d26db4925f..781ec12426 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1226,7 +1226,7 @@ ${apiFileContent}` const cases = [ { file: files.step, - kind: 'none', + kind: 'full', expectedLogCounts: { full: 1 }, expectedStepValue: (iteration: number) => `step-only-${iteration}`, source: ( @@ -1255,7 +1255,7 @@ export async function hmrFuzzStep() { }, { file: files.workflow, - kind: 'workflow', + kind: 'full', expectedLogCounts: { full: 1 }, expectedWorkflowValue: (iteration: number) => `workflow-body-${iteration}`, From 26ccbf159e75709fc1d350f64deb73992bd50c95 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:46:57 -0700 Subject: [PATCH 05/17] refactor(next): simplify HMR convergence --- packages/core/e2e/dev.test.ts | 137 +++++++++--------------- packages/next/src/builder-eager.ts | 64 ++++------- packages/next/src/watch-rebuild.test.ts | 38 ++----- packages/next/src/watch-rebuild.ts | 24 ++--- 4 files changed, 86 insertions(+), 177 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 781ec12426..0e5968700b 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -279,7 +279,7 @@ export function createDevTests(config?: DevTestConfig) { if (!devServerLogPath || !shouldAssertDevHmrLogs) { return; } - let lastCounts = ''; + let lastHmrLogIndex = -1; let quietSince = Date.now(); await pollUntil({ description: 'dev server HMR pipeline to go quiescent', @@ -287,22 +287,18 @@ export function createDevTests(config?: DevTestConfig) { intervalMs: 250, check: async () => { const log = await readDevServerLog(); - const hot = countLogMessage(log, hmrLogMessages.hot); - const full = countLogMessage(log, hmrLogMessages.full); - const skip = countLogMessage(log, hmrLogMessages.skip); - const idle = countLogMessage(log, hmrLogMessages.idle); - const counts = `${hot}/${full}/${skip}/${idle}`; - if (counts !== lastCounts) { - lastCounts = counts; + const lastDecisionIndex = Math.max( + log.lastIndexOf(hmrLogMessages.hot), + log.lastIndexOf(hmrLogMessages.full), + log.lastIndexOf(hmrLogMessages.skip) + ); + const lastIdleIndex = log.lastIndexOf(hmrLogMessages.idle); + const latestHmrLogIndex = Math.max(lastDecisionIndex, lastIdleIndex); + if (latestHmrLogIndex !== lastHmrLogIndex) { + lastHmrLogIndex = latestHmrLogIndex; quietSince = Date.now(); } - expect(log.lastIndexOf(hmrLogMessages.idle)).toBeGreaterThanOrEqual( - Math.max( - log.lastIndexOf(hmrLogMessages.hot), - log.lastIndexOf(hmrLogMessages.full), - log.lastIndexOf(hmrLogMessages.skip) - ) - ); + expect(lastIdleIndex).toBeGreaterThanOrEqual(lastDecisionIndex); expect(Date.now() - quietSince).toBeGreaterThanOrEqual( hmrQuiescenceQuietMs ); @@ -321,31 +317,30 @@ export function createDevTests(config?: DevTestConfig) { }; const countLogMessage = (log: string, message: string) => log.split(message).length - 1; - type ExpectedHmrLogCount = number | { min?: number; max?: number }; - type ExpectedHmrLogCounts = - | 'any' - | { - skip?: ExpectedHmrLogCount; - hot?: ExpectedHmrLogCount; - full?: ExpectedHmrLogCount; - }; + type ExpectedHmrLogCount = + | number + | { kind: 'range'; min: number; max: number }; + type ExpectedHmrLogCounts = { + skip?: ExpectedHmrLogCount; + hot?: ExpectedHmrLogCount; + full?: ExpectedHmrLogCount; + }; const expectLogCount = ( actual: number, expected: ExpectedHmrLogCount | undefined ) => { - if (typeof expected === 'number') { - // Canary webpack can emit duplicate watcher events for one edit; keep - // stable exact while treating canary counts as lower bounds. - if (finalConfig.canary) { - expect(actual).toBeGreaterThanOrEqual(expected); - return; - } - expect(actual).toBe(expected); + if (expected === undefined || typeof expected === 'number') { + expect(actual).toBe(expected ?? 0); return; } - expect(actual).toBeGreaterThanOrEqual(expected?.min ?? 0); - if (expected?.max !== undefined) { - expect(actual).toBeLessThanOrEqual(expected.max); + switch (expected.kind) { + case 'range': + expect(actual).toBeGreaterThanOrEqual(expected.min); + expect(actual).toBeLessThanOrEqual(expected.max); + return; + default: + expected satisfies never; + throw new Error('Unknown HMR log count expectation'); } }; const expectHmrLogCounts = async ( @@ -355,40 +350,12 @@ export function createDevTests(config?: DevTestConfig) { if (cursor === undefined) { return; } - await pollUntil({ - description: 'dev server HMR logs to match expected rebuild counts', - timeoutMs: hmrRediscoveryTimeoutMs, - intervalMs: 250, - check: async () => { - const log = (await readDevServerLog()).slice(cursor); - expect(log).toContain(hmrLogMessages.idle); - if (expected === 'any') { - expect( - [ - hmrLogMessages.skip, - hmrLogMessages.hot, - hmrLogMessages.full, - ].reduce( - (count, message) => count + countLogMessage(log, message), - 0 - ) - ).toBeGreaterThan(0); - return; - } - expectLogCount( - countLogMessage(log, hmrLogMessages.skip), - expected.skip - ); - expectLogCount( - countLogMessage(log, hmrLogMessages.hot), - expected.hot - ); - expectLogCount( - countLogMessage(log, hmrLogMessages.full), - expected.full - ); - }, - }); + await waitForHmrQuiescence(); + const log = (await readDevServerLog()).slice(cursor); + expect(log).toContain(hmrLogMessages.idle); + expectLogCount(countLogMessage(log, hmrLogMessages.skip), expected.skip); + expectLogCount(countLogMessage(log, hmrLogMessages.hot), expected.hot); + expectLogCount(countLogMessage(log, hmrLogMessages.full), expected.full); }; const pollUntil = async ({ @@ -569,7 +536,10 @@ export async function hmrPageWorkflow() { ); }, }); - await expectHmrLogCounts(logCursor, { full: 1, skip: { max: 1 } }); + await expectHmrLogCounts(logCursor, { + full: 1, + skip: { kind: 'range', min: 0, max: 1 }, + }); } ); @@ -1064,7 +1034,6 @@ ${apiFileContent}` } await waitForHmrReady(); - const setupLogCursor = await readDevServerLogCursor(); const writeFuzzSources = async (iteration: number) => { await Promise.all([ @@ -1181,15 +1150,7 @@ ${apiFileContent}` }, }); assert(workflow); - await pollUntil({ - description: 'HMR fuzz fixture rebuilds to finish', - timeoutMs: flowRouteHmrRediscoveryTimeoutMs, - intervalMs: 250, - check: async () => { - const log = (await readDevServerLog()).slice(setupLogCursor); - expect(log).toContain(hmrLogMessages.idle); - }, - }); + await waitForHmrQuiescence(); const runWorkflow = async () => { const run = await start< [], @@ -1242,7 +1203,7 @@ export async function hmrFuzzStep() { }, { file: files.stepHelper, - kind: 'none', + kind: 'skip', expectedLogCounts: { skip: 1 }, expectedStepValue: (iteration: number) => `step-helper-only-${iteration}`, @@ -1357,7 +1318,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { }); } - if (testCase.kind === 'none') { + if (testCase.kind === 'skip') { await expectHmrLogCounts(logCursor, testCase.expectedLogCounts); snapshot = await waitForGeneratedArtifactStability(); continue; @@ -1377,6 +1338,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { const fullCases = [ { description: 'workflow import graph change', + expectedLogCounts: { full: 1 }, write: async () => { await fs.writeFile( files.workflow, @@ -1410,6 +1372,7 @@ export async function hmrFuzzWorkflow() { }, { description: 'step definition added', + expectedLogCounts: { full: 1 }, write: async (iteration: number) => { await fs.writeFile( files.step, @@ -1444,6 +1407,7 @@ export async function hmrFuzzAddedStep() { }, { description: 'workflow definition added', + expectedLogCounts: { full: 1 }, write: async (iteration: number) => { await fs.writeFile( files.workflow, @@ -1486,7 +1450,7 @@ export async function hmrFuzzAddedWorkflow() { { description: 'workflow file added through API import', expectedLogCounts: { - full: { min: 1, max: 2 }, + full: { kind: 'range', min: 1, max: 2 }, }, write: async (iteration: number) => { await fs.writeFile( @@ -1522,7 +1486,7 @@ ${apiFileContent}` { description: 'workflow file removed from API import', expectedLogCounts: { - full: { min: 1, max: 2 }, + full: { kind: 'range', min: 1, max: 2 }, }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); @@ -1554,12 +1518,7 @@ ${apiFileContent}` const logCursor = await readDevServerLogCursor(); await fullCase.write(index + 1); await fullCase.assert(index + 1); - await expectHmrLogCounts( - logCursor, - 'expectedLogCounts' in fullCase - ? fullCase.expectedLogCounts - : { full: 1 } - ); + await expectHmrLogCounts(logCursor, fullCase.expectedLogCounts); snapshot = await waitForGeneratedArtifactStability(); } diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index bc75c197bc..39db21f468 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -191,7 +191,6 @@ export async function getNextBuilderEager( : resolve(this.config.workingDir, pathname) ).replace(/\\/g, '/'); let sourceSnapshots = new Map(); - let buildInProgress = false; const watchableExtensions = new Set([ '.js', @@ -254,15 +253,6 @@ export async function getNextBuilderEager( }, }); - const runBuild = async (build: () => Promise) => { - buildInProgress = true; - try { - await build(); - } finally { - buildInProgress = false; - } - }; - const hotRebuild = async (refreshStepRegistrations: boolean) => { if (refreshStepRegistrations) { if (stepsCtx) { @@ -298,25 +288,29 @@ export async function getNextBuilderEager( // dirty and trigger the file event already queued behind this task. const nextSourceSnapshots = await snapshotSources(); - await stepsCtx?.dispose(); - await workflowsCtx.interimBundleCtx.dispose(); - const newCombined = await this.buildCombinedFunction(options); - stepsCtx = newCombined.stepsContext; - discoveredEntries = newCombined.discoveredEntries; - stepsManifest = newCombined.stepsManifest; - workflowsManifest = newCombined.workflowsManifest; - if (!newCombined?.interimBundleCtx || !newCombined?.bundleFinal) { throw new Error( 'Invariant: expected workflows bundle context after rebuild' ); } + + const previousStepsCtx = stepsCtx; + const previousWorkflowsCtx = workflowsCtx.interimBundleCtx; + stepsCtx = newCombined.stepsContext; + discoveredEntries = newCombined.discoveredEntries; + stepsManifest = newCombined.stepsManifest; + workflowsManifest = newCombined.workflowsManifest; workflowsCtx = { interimBundleCtx: newCombined.interimBundleCtx, bundleFinal: newCombined.bundleFinal, }; + await Promise.all([ + previousStepsCtx?.dispose(), + previousWorkflowsCtx.dispose(), + ]); + await writeManifest(newCombined.manifest); sourceSnapshots = nextSourceSnapshots; }; @@ -409,7 +403,7 @@ export async function getNextBuilderEager( const runFullRebuild = async () => { logDevHmr('workflow dev hmr: full rediscovery'); - await runBuild(fullRebuild); + await fullRebuild(); await refreshKnownFiles(); }; @@ -431,9 +425,7 @@ export async function getNextBuilderEager( logDevHmr( `workflow dev hmr: hot rebuild${decision.refreshStepRegistrations ? ' with step registration refresh' : ''}` ); - await runBuild(() => - hotRebuild(decision.refreshStepRegistrations) - ); + await hotRebuild(decision.refreshStepRegistrations); break; case 'full': await runFullRebuild(); @@ -442,10 +434,9 @@ export async function getNextBuilderEager( decision satisfies never; throw new Error('Unknown rebuild decision'); } - sourceSnapshots = new Map([ - ...sourceSnapshots, - ...decision.snapshots, - ]); + for (const [file, snapshot] of decision.snapshots) { + sourceSnapshots.set(file, snapshot); + } }; const scheduleRebuild = createRebuildScheduler( @@ -471,12 +462,6 @@ export async function getNextBuilderEager( const scheduleFileChange = (file: string) => { scheduleRebuild({ kind: 'files', files: [file] }); }; - const scheduleBuildOverlap = () => { - if (buildInProgress) { - scheduleRebuild({ kind: 'full' }); - } - }; - const handleFileWritten = async (pathname: string) => { const normalizedPath = normalizePath(pathname); if (!isWatchableFile(normalizedPath)) { @@ -514,18 +499,9 @@ export async function getNextBuilderEager( }, }); - watcher.on('add', (pathname) => { - scheduleBuildOverlap(); - void handleFileWritten(pathname); - }); - watcher.on('change', (pathname) => { - scheduleBuildOverlap(); - void handleFileWritten(pathname); - }); - watcher.on('unlink', (pathname) => { - scheduleBuildOverlap(); - handleFileRemoved(pathname); - }); + watcher.on('add', handleFileWritten); + watcher.on('change', handleFileWritten); + watcher.on('unlink', handleFileRemoved); watcher.on('error', (error) => { console.error('Workflow dev watcher error', error); }); diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 00fcb5b6f3..d71646ccd8 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; +import { detectWorkflowPatterns } from '@workflow/builders'; import { classifyRebuild, createRebuildScheduler, @@ -37,7 +38,7 @@ describe('watch-rebuild scheduling', () => { }); }); - test('collapses full rebuild requests while a rebuild runs', async () => { + test('fully rebuilds after files change during a rebuild', async () => { vi.useFakeTimers(); const firstBuild = Promise.withResolvers(); const fullBuild = Promise.withResolvers(); @@ -53,33 +54,21 @@ describe('watch-rebuild scheduling', () => { } }, onIdle); - schedule({ kind: 'full' }); + schedule({ kind: 'files', files: ['/app/workflow.ts'] }); await vi.advanceTimersByTimeAsync(100); - schedule({ kind: 'full' }); - schedule({ kind: 'full' }); + schedule({ kind: 'files', files: ['/app/helper.ts'] }); await vi.advanceTimersByTimeAsync(100); expect(onIdle).not.toHaveBeenCalled(); firstBuild.resolve(); await fullBuild.promise; await idle.promise; - expect(requests).toEqual(['full', 'full']); + expect(requests).toEqual(['files', 'full']); expect(onIdle).toHaveBeenCalledOnce(); }); }); -const detectWorkflowPatterns = (source: string) => ({ - hasDirective: - source.includes("'use workflow'") || - source.includes('"use workflow"') || - source.includes("'use step'") || - source.includes('"use step"'), - hasSerde: /Symbol\.for\(['"]workflow-(?:serialize|deserialize)['"]\)/.test( - source - ), -}); - describe('watch-rebuild source snapshots', () => { test('ignores imports inside line and block comments', () => { const source = stripCommentsFromSource(` @@ -103,22 +92,18 @@ import * as active from './workflows/active'; expect(extractImportSignature(source)).toBe('./workflows/active'); }); - test('ignores workflow definitions inside comments', () => { + test('ignores directives inside comments', () => { const snapshot = createSourceSnapshotFromSource( ` // export async function commentedWorkflow() { 'use workflow'; } /* export async function commentedStep() { 'use step'; } */ -export async function realWorkflow() { - 'use workflow'; -} `, detectWorkflowPatterns ); - expect(snapshot.definitionSignature).toBe('workflow:realWorkflow'); - expect(snapshot.hasDirective).toBe(true); + expect(snapshot.hasDirective).toBe(false); }); test('commenting out a registry import requires full rediscovery', async () => { @@ -205,12 +190,9 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); - test('fully rebuilds byte-identical workflow notifications', async () => { - const workflowFile = '/app/workflows/example.ts'; - const source = `export async function example() { - 'use workflow'; -} -`; + test('fully rebuilds byte-identical relevant notifications', async () => { + const workflowFile = '/app/workflows/helper.ts'; + const source = "export const value = 'unchanged';\n"; const snapshot = createSourceSnapshotFromSource( source, detectWorkflowPatterns diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index cc3f1d868c..aa02df6531 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -15,7 +15,7 @@ export type ScheduledRebuild = export interface SourceSnapshot { sourceHash: string; importSignature: string; - definitionSignature: string; + serdeSignature: string; hasDirective: boolean; hasSerde: boolean; } @@ -40,10 +40,6 @@ const importSpecifierPatterns = [ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, ]; -const directiveFunctionPatterns = [ - /\b(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)[^{]*\{\s*['"]use\s+(workflow|step)['"]/g, - /\b(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{\s*['"]use\s+(workflow|step)['"]/g, -]; const serdeClassPattern = /\bclass\s+([A-Za-z_$][\w$]*)[\s\S]*?(?:static\s+classId\s*=\s*['"]([^'"]+)['"]|Symbol\.for\s*\(\s*['"]workflow-(?:serialize|deserialize)['"]\s*\)|\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\])/g; @@ -193,14 +189,8 @@ export const extractImportSignature = (source: string) => ? collectImportSpecifiers(source) : ''; -export const extractDefinitionSignature = (source: string) => { +export const extractSerdeSignature = (source: string) => { const definitions: string[] = []; - for (const pattern of directiveFunctionPatterns) { - pattern.lastIndex = 0; - for (const match of source.matchAll(pattern)) { - definitions.push(`${match[2]}:${match[1]}`); - } - } serdeClassPattern.lastIndex = 0; for (const match of source.matchAll(serdeClassPattern)) { definitions.push(`serde:${match[2] ?? match[1]}`); @@ -218,7 +208,7 @@ export const createSourceSnapshotFromSource = ( return { sourceHash: createHash('sha256').update(source).digest('base64url'), importSignature: extractImportSignature(sourceWithoutComments), - definitionSignature: extractDefinitionSignature(sourceWithoutComments), + serdeSignature: extractSerdeSignature(sourceWithoutComments), hasDirective: patterns.hasDirective, hasSerde: patterns.hasSerde, }; @@ -287,10 +277,10 @@ const requiresFullRediscovery = ( previousSnapshot: SourceSnapshot, nextSnapshot: SourceSnapshot ) => + previousSnapshot.hasDirective || nextSnapshot.hasDirective || previousSnapshot.importSignature !== nextSnapshot.importSignature || - previousSnapshot.definitionSignature !== nextSnapshot.definitionSignature || - previousSnapshot.hasDirective !== nextSnapshot.hasDirective || + previousSnapshot.serdeSignature !== nextSnapshot.serdeSignature || previousSnapshot.hasSerde !== nextSnapshot.hasSerde; export const createRebuildScheduler = ( @@ -324,7 +314,9 @@ export const createRebuildScheduler = ( return (request: ScheduledRebuild) => { switch (request.kind) { case 'files': - if (pending?.kind !== 'full') { + if (rebuilding) { + pending = { kind: 'full' }; + } else if (pending?.kind !== 'full') { pending = { kind: 'files', files: [...new Set([...(pending?.files ?? []), ...request.files])], From 0052815d0653312edca6e86b92815d80599baba6 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:33:46 -0700 Subject: [PATCH 06/17] style(next): order HMR test imports --- packages/next/src/watch-rebuild.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index d71646ccd8..5f492a7d7d 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; import { detectWorkflowPatterns } from '@workflow/builders'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { classifyRebuild, createRebuildScheduler, From 9505c6baec353cccb2581bb868d0cf16df47e815 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:54:54 -0700 Subject: [PATCH 07/17] fix(next): close remaining HMR consistency gaps --- .changeset/fix-next-hmr-build-race.md | 3 +- packages/builders/src/base-builder.ts | 28 +++++- .../discover-entries-esbuild-plugin.test.ts | 10 +- .../src/discover-entries-esbuild-plugin.ts | 9 +- packages/builders/src/fast-discovery.test.ts | 97 +++++++++++++++---- packages/builders/src/fast-discovery.ts | 19 ++-- packages/builders/src/index.ts | 1 + .../src/step-source-registration.test.ts | 2 + .../builders/src/swc-esbuild-plugin.test.ts | 16 ++- packages/builders/src/swc-esbuild-plugin.ts | 16 ++- .../src/workflow-bundle-boundary.test.ts | 2 + packages/next/src/builder-eager.ts | 69 +++++++++---- packages/next/src/watch-rebuild.test.ts | 60 ++++++++++++ packages/next/src/watch-rebuild.ts | 50 +++++----- 14 files changed, 288 insertions(+), 94 deletions(-) diff --git a/.changeset/fix-next-hmr-build-race.md b/.changeset/fix-next-hmr-build-race.md index f2e1773854..787656ba0f 100644 --- a/.changeset/fix-next-hmr-build-race.md +++ b/.changeset/fix-next-hmr-build-race.md @@ -1,5 +1,6 @@ --- '@workflow/next': patch +'@workflow/builders': patch --- -Preserve source changes made during Next.js development rebuilds for the next HMR pass. +Keep Next.js development rebuilds consistent with source and import-graph changes. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 3a18648ac2..c3d4a52b6d 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -510,6 +510,7 @@ export abstract class BaseBuilder { discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), discoveredFiles: new Set(), + importParents: new Map(), }; const discoverStart = Date.now(); @@ -1149,6 +1150,7 @@ export const __steps_registered = true; createPseudoPackagePlugin(), createSwcPlugin({ mode: 'step', + importParents: discovered.importParents, entriesToBundle: normalizedEntriesToBundle, outdir: outfile ? dirname(outfile) : undefined, projectRoot: this.transformProjectRoot, @@ -1388,6 +1390,7 @@ export const __steps_registered = true; createPseudoPackagePlugin(), createSwcPlugin({ mode: 'workflow', + importParents: discovered.importParents, projectRoot: this.transformProjectRoot, moduleSpecifierRoot: this.moduleSpecifierRoot, workflowManifest, @@ -1689,10 +1692,28 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr outfile: tempWorkflowOutfile, format, bundleFinalOutput: false, + includeMetafile: this.config.watch, tsconfigPath, discoveredEntries: effectiveDiscoveredEntries, }); + if (this.config.watch) { + const metafile = workflowsResult.interimBundleMetafile; + if (!metafile) { + throw new Error('Invariant: expected workflow build inputs'); + } + for (const input of Object.keys(metafile.inputs)) { + const file = resolve(this.config.workingDir, input).replace(/\\/g, '/'); + if ( + input !== 'virtual-entry.js' && + !file.includes('/node_modules/') && + !file.includes('/.pnpm/') + ) { + effectiveDiscoveredEntries.discoveredFiles.add(file); + } + } + } + const workflowVMCode = workflowsResult.interimBundleText; if (!workflowVMCode) { throw new Error('createWorkflowsBundle did not return interimBundleText'); @@ -1856,10 +1877,8 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr // Discover serde files from the input files' dependency tree for cross-context class registration. // Classes need to be registered in the client bundle so they can be serialized // when passing data to workflows via start() and deserialized when receiving workflow results. - const { discoveredSerdeFiles } = await this.discoverEntries( - inputFiles, - outputDir - ); + const discoveredEntries = await this.discoverEntries(inputFiles, outputDir); + const { discoveredSerdeFiles } = discoveredEntries; // Identify serde files that aren't in the inputFiles (deduplicated) const inputFilesNormalized = new Set( @@ -1943,6 +1962,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr plugins: [ createSwcPlugin({ mode: 'step', + importParents: discoveredEntries.importParents, projectRoot: this.transformProjectRoot, moduleSpecifierRoot: this.moduleSpecifierRoot, onAfterTransform: this.config.onAfterTransform, diff --git a/packages/builders/src/discover-entries-esbuild-plugin.test.ts b/packages/builders/src/discover-entries-esbuild-plugin.test.ts index 4467123a12..6c1ae51a9a 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.test.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.test.ts @@ -20,10 +20,11 @@ vi.mock('./apply-swc-transform.js', () => ({ import { createDiscoverEntriesPlugin, - importParents, parentHasChild, } from './discover-entries-esbuild-plugin.js'; +const importParents = new Map>(); + const realTmpdir = realpathSync(tmpdir()); function normalizeSlashes(path: string): string { @@ -104,6 +105,7 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; const result = await esbuild.build({ @@ -137,6 +139,7 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; const result = await esbuild.build({ @@ -186,6 +189,7 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; const result = await esbuild.build({ @@ -215,6 +219,8 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { expect(pkgChildren!.has(normalizedSerde)).toBe(true); // parentHasChild should transitively find serde.js from entry.ts - expect(parentHasChild(normalizedEntry, normalizedSerde)).toBe(true); + expect( + parentHasChild(importParents, normalizedEntry, normalizedSerde) + ).toBe(true); }); }); diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index a18965e5d2..df9311a311 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -52,13 +52,13 @@ function isGeneratedBuildArtifactPath(filePath: string): boolean { ); } -// parent -> children relationship (a file can import multiple files) -export const importParents = new Map>(); +export type ImportParents = Map>; // check if a parent has a child in its import chain // e.g. if a dependency needs to be bundled because it has // a 'use workflow/'use step' directive in it export function parentHasChild( + importParents: ImportParents, parent: string, childToFind: string, { @@ -103,6 +103,7 @@ export function createDiscoverEntriesPlugin( discoveredSteps: Set; discoveredWorkflows: Set; discoveredSerdeFiles: Set; + importParents: ImportParents; }, projectRoot?: string ): Plugin { @@ -123,10 +124,10 @@ export function createDiscoverEntriesPlugin( const normalizedImporter = args.importer.replace(/\\/g, '/'); const normalizedResolved = resolved.replace(/\\/g, '/'); // A file can import multiple files, so we store a Set of children - let children = importParents.get(normalizedImporter); + let children = state.importParents.get(normalizedImporter); if (!children) { children = new Set(); - importParents.set(normalizedImporter, children); + state.importParents.set(normalizedImporter, children); } children.add(normalizedResolved); } diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index b58ca0c57f..65693413d3 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -9,10 +9,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BaseBuilder, type DiscoveredEntries } from './base-builder.js'; -import { - importParents, - parentHasChild, -} from './discover-entries-esbuild-plugin.js'; +import { parentHasChild } from './discover-entries-esbuild-plugin.js'; import type { StandaloneConfig } from './types.js'; class TestBuilder extends BaseBuilder { @@ -68,11 +65,9 @@ describe('fast workflow discovery', () => { beforeEach(() => { testRoot = mkdtempSync(join(realTmpdir, 'workflow-fast-discovery-')); - importParents.clear(); }); afterEach(() => { - importParents.clear(); rmSync(testRoot, { recursive: true, force: true }); }); @@ -98,9 +93,51 @@ describe('fast workflow discovery', () => { ); expect(discovered.discoveredSteps).toEqual(new Set([normalize(stepFile)])); - expect(parentHasChild(normalize(entryFile), normalize(stepFile))).toBe( - true + expect( + parentHasChild( + discovered.importParents, + normalize(entryFile), + normalize(stepFile) + ) + ).toBe(true); + }); + + it('replaces the import graph on rediscovery', async () => { + const entryFile = join(testRoot, 'src', 'entry.ts'); + const workflowFile = join(testRoot, 'src', 'workflow.ts'); + const builder = createBuilder(testRoot); + const inputFiles = [entryFile]; + + writeFile(entryFile, `import './workflow';\n`); + writeFile( + workflowFile, + `export async function run() { 'use workflow'; }\n` + ); + const first = await builder.discoverEntriesPublic( + inputFiles, + join(testRoot, 'out') + ); + expect( + parentHasChild( + first.importParents, + normalize(entryFile), + normalize(workflowFile) + ) + ).toBe(true); + + writeFile(entryFile, `export const value = 1;\n`); + builder.clearDiscoveredEntriesCache(); + const second = await builder.discoverEntriesPublic( + inputFiles, + join(testRoot, 'out') ); + expect( + parentHasChild( + second.importParents, + normalize(entryFile), + normalize(workflowFile) + ) + ).toBe(false); }); it('discovers relative JS imports whose basename includes .step', async () => { @@ -125,9 +162,13 @@ describe('fast workflow discovery', () => { expect(discovered.discoveredWorkflows).toEqual( new Set([normalize(workflowFile)]) ); - expect(parentHasChild(normalize(entryFile), normalize(workflowFile))).toBe( - true - ); + expect( + parentHasChild( + discovered.importParents, + normalize(entryFile), + normalize(workflowFile) + ) + ).toBe(true); }); it('discovers workflow files reached through an imported package re-export', async () => { @@ -167,7 +208,11 @@ describe('fast workflow discovery', () => { new Set([normalize(packageWorkflow)]) ); expect( - parentHasChild(normalize(packageIndex), normalize(packageWorkflow)) + parentHasChild( + discovered.importParents, + normalize(packageIndex), + normalize(packageWorkflow) + ) ).toBe(true); }); @@ -240,11 +285,15 @@ describe('fast workflow discovery', () => { // descended into at all, so none of them show up as discovered files and // the parent edge into the package is not recorded. for (const file of [packageIndex, packageWorkflow, packageStep]) { - expect(discovered.discoveredFiles?.has(normalize(file))).toBe(false); + expect(discovered.discoveredFiles.has(normalize(file))).toBe(false); } - expect(parentHasChild(normalize(entryFile), normalize(packageIndex))).toBe( - false - ); + expect( + parentHasChild( + discovered.importParents, + normalize(entryFile), + normalize(packageIndex) + ) + ).toBe(false); }); it('still follows imports within node_modules so seeded package entries resolve their subtree', async () => { @@ -286,7 +335,11 @@ describe('fast workflow discovery', () => { true ); expect( - parentHasChild(normalize(packageIndex), normalize(packageWorkflow)) + parentHasChild( + discovered.importParents, + normalize(packageIndex), + normalize(packageWorkflow) + ) ).toBe(true); }); @@ -331,9 +384,13 @@ export const allWorkflows = { workflow }; expect(discovered.discoveredWorkflows).toEqual( new Set([normalize(workflowFile)]) ); - expect(parentHasChild(normalize(entryFile), normalize(workflowFile))).toBe( - true - ); + expect( + parentHasChild( + discovered.importParents, + normalize(entryFile), + normalize(workflowFile) + ) + ).toBe(true); }); it('discovers dotted files reached through tsconfig path aliases', async () => { diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index 03a90ee276..a7f459af17 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -5,7 +5,7 @@ import { promisify } from 'node:util'; import enhancedResolveOriginal from 'enhanced-resolve'; import { findUp } from 'find-up'; import JSON5 from 'json5'; -import { importParents } from './discover-entries-esbuild-plugin.js'; +import type { ImportParents } from './discover-entries-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; const FAST_DISCOVERY_SOURCE_EXTENSIONS = [ @@ -49,12 +49,9 @@ export interface DiscoveredEntries { discoveredSteps: Set; discoveredWorkflows: Set; discoveredSerdeFiles: Set; - /** - * All JS/TS files visited while walking the workflow import graph. - * Watch-mode integrations use this to distinguish relevant HMR changes from - * unrelated application file edits. - */ - discoveredFiles?: Set; + importParents: ImportParents; + /** Source files visited by discovery or consumed by the workflow bundle. */ + discoveredFiles: Set; } interface FastDiscoverEntriesOptions { @@ -240,7 +237,11 @@ function isNodeModulesPath(filePath: string): boolean { ); } -function addImportParent(parent: string, child: string): void { +function addImportParent( + importParents: ImportParents, + parent: string, + child: string +): void { const normalizedParent = normalizePath(parent); const normalizedChild = normalizePath(child); let children = importParents.get(normalizedParent); @@ -959,7 +960,7 @@ export async function fastDiscoverEntries({ return; } - addImportParent(filePath, resolved); + addImportParent(state.importParents, filePath, resolved); if (!isJsTsFile(resolved) || isGeneratedBuildArtifactPath(resolved)) { return; } diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 425603e6a6..1711602651 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -20,6 +20,7 @@ export { } from './constants.js'; export { createDiscoverEntriesPlugin, + type ImportParents, parentHasChild, } from './discover-entries-esbuild-plugin.js'; export { diff --git a/packages/builders/src/step-source-registration.test.ts b/packages/builders/src/step-source-registration.test.ts index 6b55b707f9..1305dcd59a 100644 --- a/packages/builders/src/step-source-registration.test.ts +++ b/packages/builders/src/step-source-registration.test.ts @@ -105,6 +105,8 @@ describe('step source registration', () => { discoveredSteps: new Set([stepFile]), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set([serdeFile]), + discoveredFiles: new Set([stepFile, serdeFile]), + importParents: new Map(), }; const { manifest } = await createBuilder( diff --git a/packages/builders/src/swc-esbuild-plugin.test.ts b/packages/builders/src/swc-esbuild-plugin.test.ts index e21e7d0351..fc3121ca57 100644 --- a/packages/builders/src/swc-esbuild-plugin.test.ts +++ b/packages/builders/src/swc-esbuild-plugin.test.ts @@ -18,11 +18,15 @@ vi.mock('./apply-swc-transform.js', () => ({ applySwcTransform: applySwcTransformMock, })); +import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; import { - createDiscoverEntriesPlugin, - importParents, -} from './discover-entries-esbuild-plugin.js'; -import { createSwcPlugin } from './swc-esbuild-plugin.js'; + createSwcPlugin as createSwcPluginWithGraph, + type SwcPluginOptions, +} from './swc-esbuild-plugin.js'; + +const importParents = new Map>(); +const createSwcPlugin = (options: Omit) => + createSwcPluginWithGraph({ ...options, importParents }); const realTmpdir = realpathSync(tmpdir()); @@ -690,6 +694,7 @@ export const client = { provider: providerName };` discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; await esbuild.build({ entryPoints: [stepFile], @@ -748,6 +753,7 @@ export const client = { provider: providerName };` discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; await esbuild.build({ entryPoints: [stepFile], @@ -804,6 +810,7 @@ export const client = { provider: providerName };` discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; await esbuild.build({ entryPoints: [stepFile], @@ -861,6 +868,7 @@ export const client = { provider: providerName };` discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), + importParents, }; await esbuild.build({ entryPoints: [stepFile], diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index f4630e5d03..2130c0e54c 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -8,6 +8,7 @@ import { type WorkflowManifest, } from './apply-swc-transform.js'; import { + type ImportParents, jsTsRegex, parentHasChild, } from './discover-entries-esbuild-plugin.js'; @@ -34,6 +35,7 @@ export type WorkflowAfterTransformHook = ( export interface SwcPluginOptions { mode: 'step' | 'workflow'; + importParents: ImportParents; entriesToBundle?: string[]; outdir?: string; projectRoot?: string; @@ -285,7 +287,13 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { // if the current entry imports a child that needs // to be bundled then it needs to also be bundled so // that the child can have our transform applied - if (parentHasChild(normalizedResolvedPath, normalizedEntry)) { + if ( + parentHasChild( + options.importParents, + normalizedResolvedPath, + normalizedEntry + ) + ) { shouldBundle = true; break; } @@ -301,7 +309,11 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { normalizedResolvedPath, moduleSpecifierRoot ) && - parentHasChild(normalizedEntry, normalizedResolvedPath) + parentHasChild( + options.importParents, + normalizedEntry, + normalizedResolvedPath + ) ) { shouldBundle = true; break; diff --git a/packages/builders/src/workflow-bundle-boundary.test.ts b/packages/builders/src/workflow-bundle-boundary.test.ts index 8e5cc0066f..62c7571335 100644 --- a/packages/builders/src/workflow-bundle-boundary.test.ts +++ b/packages/builders/src/workflow-bundle-boundary.test.ts @@ -57,6 +57,8 @@ describe('workflow bundle boundary', () => { discoveredSteps: new Set(), discoveredWorkflows: new Set([inputFile]), discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([inputFile]), + importParents: new Map(), }; const { interimBundleMetafile } = await new TestBuilder( diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 39db21f468..e7189ffcfa 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -14,6 +14,7 @@ import { createSourceSnapshot, getRelevantFiles, readSourceSnapshots, + sourceSnapshotsMatch, type SourceSnapshot, } from './watch-rebuild.js'; @@ -313,18 +314,22 @@ export async function getNextBuilderEager( await writeManifest(newCombined.manifest); sourceSnapshots = nextSourceSnapshots; + return sourceSnapshotsMatch( + nextSourceSnapshots, + await snapshotSources() + ); }; + let relevantFiles = getRelevantFiles({ + discoveredEntries, + inputFiles: options.inputFiles, + normalizePath, + }); const isWatchableFile = (path: string) => - watchableExtensions.has(extname(path)); + watchableExtensions.has(extname(path)) || relevantFiles.has(path); const readKnownFileAliases = async () => { const aliases = new Map(); - const relevantFiles = getRelevantFiles({ - discoveredEntries, - inputFiles: options.inputFiles, - normalizePath, - }); const addKnownFile = async (filePath: string) => { let realFilePath = filePath; @@ -382,6 +387,7 @@ export async function getNextBuilderEager( }; await visit(this.config.workingDir); + await Promise.all([...relevantFiles].map(addKnownFile)); return { aliases, addKnownFile }; }; @@ -396,14 +402,23 @@ export async function getNextBuilderEager( await readKnownFileAliases(); const refreshKnownFiles = async () => { + relevantFiles = getRelevantFiles({ + discoveredEntries, + inputFiles: options.inputFiles, + normalizePath, + }); const nextKnown = await readKnownFileAliases(); knownFileAliases = nextKnown.aliases; rememberKnownFile = nextKnown.addKnownFile; + await watcher.add([...relevantFiles]); }; const runFullRebuild = async () => { - logDevHmr('workflow dev hmr: full rediscovery'); - await fullRebuild(); + // The build and watcher cannot read the filesystem atomically. Keep + // rebuilding until one complete build window observes no changes. + do { + logDevHmr('workflow dev hmr: full rediscovery'); + } while (!(await fullRebuild())); await refreshKnownFiles(); }; @@ -413,7 +428,13 @@ export async function getNextBuilderEager( discoveredEntries, inputFiles: options.inputFiles, normalizePath, - parentHasChild, + parentHasChild: (parent, child, options) => + parentHasChild( + discoveredEntries.importParents, + parent, + child, + options + ), readSnapshot: readSourceSnapshot, sourceSnapshots, }); @@ -486,18 +507,24 @@ export async function getNextBuilderEager( scheduleFileChange(canonicalPath); }; - const watcher = chokidar.watch(this.config.workingDir, { - ignoreInitial: true, - followSymlinks: true, - ignored: (pathname) => { - const normalizedPath = normalizePath(String(pathname)); - const extension = extname(normalizedPath); - if (extension && !watchableExtensions.has(extension)) { - return true; - } - return hasIgnoredPathFragment(normalizedPath); - }, - }); + const watcher = chokidar.watch( + [this.config.workingDir, ...relevantFiles], + { + ignoreInitial: true, + followSymlinks: true, + ignored: (pathname) => { + const normalizedPath = normalizePath(String(pathname)); + if (relevantFiles.has(normalizedPath)) { + return false; + } + const extension = extname(normalizedPath); + if (extension && !watchableExtensions.has(extension)) { + return true; + } + return hasIgnoredPathFragment(normalizedPath); + }, + } + ); watcher.on('add', handleFileWritten); watcher.on('change', handleFileWritten); diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 5f492a7d7d..b12c4f92f7 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -5,6 +5,7 @@ import { createRebuildScheduler, createSourceSnapshotFromSource, extractImportSignature, + sourceSnapshotsMatch, type SourceSnapshot, stripCommentsFromSource, } from './watch-rebuild.js'; @@ -106,6 +107,33 @@ export async function commentedStep() { 'use step'; } expect(snapshot.hasDirective).toBe(false); }); + test('detects files added or changed during a full rebuild', () => { + const before = createSourceSnapshotFromSource( + `export const value = 'before';\n`, + detectWorkflowPatterns + ); + const after = createSourceSnapshotFromSource( + `export const value = 'after';\n`, + detectWorkflowPatterns + ); + + expect( + sourceSnapshotsMatch( + new Map([['/app/workflow.ts', before]]), + new Map([['/app/workflow.ts', after]]) + ) + ).toBe(false); + expect( + sourceSnapshotsMatch( + new Map([['/app/workflow.ts', before]]), + new Map([ + ['/app/workflow.ts', before], + ['/app/helper.ts', before], + ]) + ) + ).toBe(false); + }); + test('commenting out a registry import requires full rediscovery', async () => { const registryFile = '/app/_workflows.ts'; const workflowFile = '/app/workflows/1_simple.ts'; @@ -275,6 +303,38 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); + test('fully rebuilds every serde file change', async () => { + const serdeFile = '/app/workflows/value.ts'; + const previousSnapshot = createSourceSnapshotFromSource( + `export class Value { + static [Symbol.for('workflow-serialize')]() { return 'before'; } +}\n`, + detectWorkflowPatterns + ); + const nextSnapshot = createSourceSnapshotFromSource( + `export class Value { + static [Symbol.for('workflow-serialize')]() { return 'after'; } +}\n`, + detectWorkflowPatterns + ); + + await expect( + classifyRebuild({ + discoveredEntries: { + discoveredSteps: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set([serdeFile]), + discoveredFiles: new Set([serdeFile]), + }, + files: [serdeFile], + inputFiles: [], + parentHasChild: () => false, + readSnapshot: async () => nextSnapshot, + sourceSnapshots: new Map([[serdeFile, previousSnapshot]]), + }) + ).resolves.toEqual({ kind: 'full' }); + }); + test('rebuilds new files that can introduce graph entries', async () => { const routeFile = '/app/app/new/route.ts'; diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index aa02df6531..f5f910774d 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -5,7 +5,7 @@ export interface DiscoveredEntriesLike { discoveredSteps: Set; discoveredWorkflows: Set; discoveredSerdeFiles: Set; - discoveredFiles?: Set; + discoveredFiles: Set; } export type ScheduledRebuild = @@ -15,7 +15,6 @@ export type ScheduledRebuild = export interface SourceSnapshot { sourceHash: string; importSignature: string; - serdeSignature: string; hasDirective: boolean; hasSerde: boolean; } @@ -40,9 +39,6 @@ const importSpecifierPatterns = [ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, ]; -const serdeClassPattern = - /\bclass\s+([A-Za-z_$][\w$]*)[\s\S]*?(?:static\s+classId\s*=\s*['"]([^'"]+)['"]|Symbol\.for\s*\(\s*['"]workflow-(?:serialize|deserialize)['"]\s*\)|\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\])/g; - const defaultNormalizePath = (pathname: string) => pathname.replace(/\\/g, '/'); const REGEX_PREFIX_CHARS = new Set([ @@ -189,15 +185,6 @@ export const extractImportSignature = (source: string) => ? collectImportSpecifiers(source) : ''; -export const extractSerdeSignature = (source: string) => { - const definitions: string[] = []; - serdeClassPattern.lastIndex = 0; - for (const match of source.matchAll(serdeClassPattern)) { - definitions.push(`serde:${match[2] ?? match[1]}`); - } - return definitions.sort().join('\n'); -}; - export const createSourceSnapshotFromSource = ( source: string, detectWorkflowPatterns: SourcePatternDetector @@ -208,7 +195,6 @@ export const createSourceSnapshotFromSource = ( return { sourceHash: createHash('sha256').update(source).digest('base64url'), importSignature: extractImportSignature(sourceWithoutComments), - serdeSignature: extractSerdeSignature(sourceWithoutComments), hasDirective: patterns.hasDirective, hasSerde: patterns.hasSerde, }; @@ -236,13 +222,7 @@ export const getRelevantFiles = ({ normalizePath?: (path: string) => string; }) => new Set( - [ - ...inputFiles, - ...discoveredEntries.discoveredSteps, - ...discoveredEntries.discoveredWorkflows, - ...discoveredEntries.discoveredSerdeFiles, - ...(discoveredEntries.discoveredFiles || []), - ].map(normalizePath) + [...inputFiles, ...discoveredEntries.discoveredFiles].map(normalizePath) ); export const readSourceSnapshots = async ({ @@ -276,12 +256,28 @@ export const readSourceSnapshots = async ({ const requiresFullRediscovery = ( previousSnapshot: SourceSnapshot, nextSnapshot: SourceSnapshot +) => { + if (previousSnapshot.hasDirective || nextSnapshot.hasDirective) { + return true; + } + + // Serde edits can change the generated class manifest. Rediscover all of + // them instead of approximating JavaScript class structure with a regex. + if (previousSnapshot.hasSerde || nextSnapshot.hasSerde) { + return true; + } + + return previousSnapshot.importSignature !== nextSnapshot.importSignature; +}; + +export const sourceSnapshotsMatch = ( + left: Map, + right: Map ) => - previousSnapshot.hasDirective || - nextSnapshot.hasDirective || - previousSnapshot.importSignature !== nextSnapshot.importSignature || - previousSnapshot.serdeSignature !== nextSnapshot.serdeSignature || - previousSnapshot.hasSerde !== nextSnapshot.hasSerde; + left.size === right.size && + [...left].every( + ([file, snapshot]) => snapshot.sourceHash === right.get(file)?.sourceHash + ); export const createRebuildScheduler = ( rebuild: (request: ScheduledRebuild) => Promise, From 8294a781f6a71c98a2432cc59e4998d067c3091d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:53:51 -0700 Subject: [PATCH 08/17] fix(builders): preserve discovery helper API --- .../discover-entries-esbuild-plugin.test.ts | 13 +++++++---- .../src/discover-entries-esbuild-plugin.ts | 23 +++++++++++++++---- packages/builders/src/fast-discovery.test.ts | 18 +++++++-------- packages/builders/src/index.ts | 1 + packages/builders/src/swc-esbuild-plugin.ts | 14 ++++++----- packages/next/src/builder-eager.ts | 4 ++-- 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/builders/src/discover-entries-esbuild-plugin.test.ts b/packages/builders/src/discover-entries-esbuild-plugin.test.ts index 6c1ae51a9a..4cb1d81665 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.test.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.test.ts @@ -20,6 +20,8 @@ vi.mock('./apply-swc-transform.js', () => ({ import { createDiscoverEntriesPlugin, + importGraphHasChild, + importParents as legacyImportParents, parentHasChild, } from './discover-entries-esbuild-plugin.js'; @@ -42,6 +44,7 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { beforeEach(() => { testRoot = mkdtempSync(join(realTmpdir, 'workflow-discover-plugin-')); importParents.clear(); + legacyImportParents.clear(); applySwcTransformMock.mockReset(); applySwcTransformMock.mockImplementation( async (filename: string, source: string) => { @@ -139,7 +142,6 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), - importParents, }; const result = await esbuild.build({ @@ -189,7 +191,6 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), - importParents, }; const result = await esbuild.build({ @@ -209,18 +210,20 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { const normalizedSerde = normalizeSlashes(serdeFile); // entry.ts -> bare-pkg/index.js should be tracked - const entryChildren = importParents.get(normalizedEntry); + const entryChildren = legacyImportParents.get(normalizedEntry); expect(entryChildren).toBeDefined(); expect(entryChildren!.has(normalizedPkgIndex)).toBe(true); // bare-pkg/index.js -> bare-pkg/serde.js should be tracked - const pkgChildren = importParents.get(normalizedPkgIndex); + const pkgChildren = legacyImportParents.get(normalizedPkgIndex); expect(pkgChildren).toBeDefined(); expect(pkgChildren!.has(normalizedSerde)).toBe(true); // parentHasChild should transitively find serde.js from entry.ts + expect(parentHasChild(normalizedEntry, normalizedSerde)).toBe(true); + expect( - parentHasChild(importParents, normalizedEntry, normalizedSerde) + importGraphHasChild(legacyImportParents, normalizedEntry, normalizedSerde) ).toBe(true); }); }); diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index df9311a311..eb9ef12f3b 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -54,10 +54,14 @@ function isGeneratedBuildArtifactPath(filePath: string): boolean { export type ImportParents = Map>; +// Kept for compatibility with the exported discovery helpers. BaseBuilder +// passes a per-discovery graph instead. +export const importParents: ImportParents = new Map(); + // check if a parent has a child in its import chain // e.g. if a dependency needs to be bundled because it has // a 'use workflow/'use step' directive in it -export function parentHasChild( +export function importGraphHasChild( importParents: ImportParents, parent: string, childToFind: string, @@ -98,18 +102,29 @@ export function parentHasChild( return false; } +export function parentHasChild( + parent: string, + childToFind: string, + options: { excludedRoots?: Iterable } = {} +): boolean { + return importGraphHasChild(importParents, parent, childToFind, options); +} + export function createDiscoverEntriesPlugin( state: { discoveredSteps: Set; discoveredWorkflows: Set; discoveredSerdeFiles: Set; - importParents: ImportParents; + importParents?: ImportParents; }, projectRoot?: string ): Plugin { return { name: 'discover-entries-esbuild-plugin', setup(build) { + const currentImportParents = state.importParents ?? importParents; + build.onStart(() => currentImportParents.clear()); + // Track parent→child import relationships for ALL imports (not just // those with file extensions) so that `parentHasChild()` can correctly // identify transitive parents of serde/step files even when the @@ -124,10 +139,10 @@ export function createDiscoverEntriesPlugin( const normalizedImporter = args.importer.replace(/\\/g, '/'); const normalizedResolved = resolved.replace(/\\/g, '/'); // A file can import multiple files, so we store a Set of children - let children = state.importParents.get(normalizedImporter); + let children = currentImportParents.get(normalizedImporter); if (!children) { children = new Set(); - state.importParents.set(normalizedImporter, children); + currentImportParents.set(normalizedImporter, children); } children.add(normalizedResolved); } diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index 65693413d3..b5f763883e 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BaseBuilder, type DiscoveredEntries } from './base-builder.js'; -import { parentHasChild } from './discover-entries-esbuild-plugin.js'; +import { importGraphHasChild } from './discover-entries-esbuild-plugin.js'; import type { StandaloneConfig } from './types.js'; class TestBuilder extends BaseBuilder { @@ -94,7 +94,7 @@ describe('fast workflow discovery', () => { expect(discovered.discoveredSteps).toEqual(new Set([normalize(stepFile)])); expect( - parentHasChild( + importGraphHasChild( discovered.importParents, normalize(entryFile), normalize(stepFile) @@ -118,7 +118,7 @@ describe('fast workflow discovery', () => { join(testRoot, 'out') ); expect( - parentHasChild( + importGraphHasChild( first.importParents, normalize(entryFile), normalize(workflowFile) @@ -132,7 +132,7 @@ describe('fast workflow discovery', () => { join(testRoot, 'out') ); expect( - parentHasChild( + importGraphHasChild( second.importParents, normalize(entryFile), normalize(workflowFile) @@ -163,7 +163,7 @@ describe('fast workflow discovery', () => { new Set([normalize(workflowFile)]) ); expect( - parentHasChild( + importGraphHasChild( discovered.importParents, normalize(entryFile), normalize(workflowFile) @@ -208,7 +208,7 @@ describe('fast workflow discovery', () => { new Set([normalize(packageWorkflow)]) ); expect( - parentHasChild( + importGraphHasChild( discovered.importParents, normalize(packageIndex), normalize(packageWorkflow) @@ -288,7 +288,7 @@ describe('fast workflow discovery', () => { expect(discovered.discoveredFiles.has(normalize(file))).toBe(false); } expect( - parentHasChild( + importGraphHasChild( discovered.importParents, normalize(entryFile), normalize(packageIndex) @@ -335,7 +335,7 @@ describe('fast workflow discovery', () => { true ); expect( - parentHasChild( + importGraphHasChild( discovered.importParents, normalize(packageIndex), normalize(packageWorkflow) @@ -385,7 +385,7 @@ export const allWorkflows = { workflow }; new Set([normalize(workflowFile)]) ); expect( - parentHasChild( + importGraphHasChild( discovered.importParents, normalize(entryFile), normalize(workflowFile) diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 1711602651..fa61f0292b 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -20,6 +20,7 @@ export { } from './constants.js'; export { createDiscoverEntriesPlugin, + importGraphHasChild, type ImportParents, parentHasChild, } from './discover-entries-esbuild-plugin.js'; diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index 2130c0e54c..d52d5d0412 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -8,9 +8,10 @@ import { type WorkflowManifest, } from './apply-swc-transform.js'; import { + importGraphHasChild, + importParents, type ImportParents, jsTsRegex, - parentHasChild, } from './discover-entries-esbuild-plugin.js'; import { hashManifestSource, @@ -35,7 +36,7 @@ export type WorkflowAfterTransformHook = ( export interface SwcPluginOptions { mode: 'step' | 'workflow'; - importParents: ImportParents; + importParents?: ImportParents; entriesToBundle?: string[]; outdir?: string; projectRoot?: string; @@ -122,6 +123,7 @@ function normalizePath(path: string): string { } export function createSwcPlugin(options: SwcPluginOptions): Plugin { + const currentImportParents = options.importParents ?? importParents; return { name: 'swc-workflow-plugin', setup(build) { @@ -288,8 +290,8 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { // to be bundled then it needs to also be bundled so // that the child can have our transform applied if ( - parentHasChild( - options.importParents, + importGraphHasChild( + currentImportParents, normalizedResolvedPath, normalizedEntry ) @@ -309,8 +311,8 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { normalizedResolvedPath, moduleSpecifierRoot ) && - parentHasChild( - options.importParents, + importGraphHasChild( + currentImportParents, normalizedEntry, normalizedResolvedPath ) diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index e7189ffcfa..7c440648f2 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -79,7 +79,7 @@ export async function getNextBuilderEager( BaseBuilder: BaseBuilderClass, getWorkflowQueueTrigger, detectWorkflowPatterns, - parentHasChild, + importGraphHasChild, writeFileIfChanged, } = buildersModule ?? (await importEsm('@workflow/builders')); @@ -429,7 +429,7 @@ export async function getNextBuilderEager( inputFiles: options.inputFiles, normalizePath, parentHasChild: (parent, child, options) => - parentHasChild( + importGraphHasChild( discoveredEntries.importParents, parent, child, From c5999038bebc4bdc64642a47dd1b3c39ee06ade6 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:25:13 -0700 Subject: [PATCH 09/17] fix(builders): preserve legacy import graph --- .../builders/src/discover-entries-esbuild-plugin.ts | 2 +- packages/builders/src/fast-discovery.test.ts | 11 ++++++++++- packages/builders/src/fast-discovery.ts | 10 +++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index eb9ef12f3b..0df81aede2 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -55,7 +55,7 @@ function isGeneratedBuildArtifactPath(filePath: string): boolean { export type ImportParents = Map>; // Kept for compatibility with the exported discovery helpers. BaseBuilder -// passes a per-discovery graph instead. +// replaces it with the last completed discovery graph. export const importParents: ImportParents = new Map(); // check if a parent has a child in its import chain diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index b5f763883e..21741d9d9e 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -9,7 +9,10 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BaseBuilder, type DiscoveredEntries } from './base-builder.js'; -import { importGraphHasChild } from './discover-entries-esbuild-plugin.js'; +import { + importGraphHasChild, + parentHasChild, +} from './discover-entries-esbuild-plugin.js'; import type { StandaloneConfig } from './types.js'; class TestBuilder extends BaseBuilder { @@ -100,6 +103,9 @@ describe('fast workflow discovery', () => { normalize(stepFile) ) ).toBe(true); + expect(parentHasChild(normalize(entryFile), normalize(stepFile))).toBe( + true + ); }); it('replaces the import graph on rediscovery', async () => { @@ -138,6 +144,9 @@ describe('fast workflow discovery', () => { normalize(workflowFile) ) ).toBe(false); + expect(parentHasChild(normalize(entryFile), normalize(workflowFile))).toBe( + false + ); }); it('discovers relative JS imports whose basename includes .step', async () => { diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index a7f459af17..2886467848 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -5,7 +5,10 @@ import { promisify } from 'node:util'; import enhancedResolveOriginal from 'enhanced-resolve'; import { findUp } from 'find-up'; import JSON5 from 'json5'; -import type { ImportParents } from './discover-entries-esbuild-plugin.js'; +import { + importParents as legacyImportParents, + type ImportParents, +} from './discover-entries-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; const FAST_DISCOVERY_SOURCE_EXTENSIONS = [ @@ -1045,4 +1048,9 @@ export async function fastDiscoverEntries({ } state.discoveredFiles = processedFiles; + + legacyImportParents.clear(); + for (const [parent, children] of state.importParents) { + legacyImportParents.set(parent, new Set(children)); + } } From 10d21d0a7769690a664007ee27e96c17efe40099 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:46:05 -0700 Subject: [PATCH 10/17] fix(builders): preserve discovery result compatibility --- packages/builders/src/base-builder.ts | 30 +++++++++++++------- packages/builders/src/fast-discovery.test.ts | 5 ++-- packages/builders/src/fast-discovery.ts | 11 +++++-- packages/builders/src/index.ts | 2 +- packages/builders/src/swc-esbuild-plugin.ts | 2 +- packages/core/e2e/dev.test.ts | 25 +++++++++------- packages/next/src/builder-eager.ts | 2 +- packages/next/src/watch-rebuild.test.ts | 2 +- 8 files changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index c3d4a52b6d..e1d9e4b2af 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -27,8 +27,10 @@ import { createWorkflowEntrypointOptionsCode, createWorkflowRouteHandlersCode, } from './constants.js'; +import { importParents as legacyImportParents } from './discover-entries-esbuild-plugin.js'; import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; import { + type CompleteDiscoveredEntries, type DiscoveredEntries, fastDiscoverEntries, } from './fast-discovery.js'; @@ -376,7 +378,7 @@ export abstract class BaseBuilder { * This cache is invalidated automatically when the inputs array reference changes * (e.g., when files are added/removed during watch mode). */ - private discoveredEntries: WeakMap = + private discoveredEntries: WeakMap = new WeakMap(); public clearDiscoveredEntriesCache(): void { @@ -499,13 +501,13 @@ export abstract class BaseBuilder { inputs: string[], outdir: string, tsconfigPath?: string - ): Promise { + ): Promise { const previousResult = this.discoveredEntries.get(inputs); if (previousResult) { return previousResult; } - const state: DiscoveredEntries = { + const state: CompleteDiscoveredEntries = { discoveredSteps: new Set(), discoveredWorkflows: new Set(), discoveredSerdeFiles: new Set(), @@ -1648,18 +1650,24 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr stepsContext?: esbuild.BuildContext; interimBundleCtx?: esbuild.BuildContext; bundleFinal?: (interimBundleResult: string) => Promise; - discoveredEntries: DiscoveredEntries; + discoveredEntries: CompleteDiscoveredEntries; stepsManifest: WorkflowManifest; workflowsManifest: WorkflowManifest; }> { this.startWorkflowBuildTimer(); - const effectiveDiscoveredEntries = - discoveredEntries ?? - (await this.discoverEntries( - inputFiles, - dirname(flowOutfile), - tsconfigPath - )); + const effectiveDiscoveredEntries: CompleteDiscoveredEntries = + discoveredEntries === undefined + ? await this.discoverEntries( + inputFiles, + dirname(flowOutfile), + tsconfigPath + ) + : { + ...discoveredEntries, + importParents: + discoveredEntries.importParents ?? legacyImportParents, + discoveredFiles: discoveredEntries.discoveredFiles ?? new Set(), + }; // 1. Build step registrations bundle (used as separate file for // bundleFinalOutput: false, or read back for inline content when true) diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index 21741d9d9e..abb5336084 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -8,11 +8,12 @@ import { import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BaseBuilder, type DiscoveredEntries } from './base-builder.js'; +import { BaseBuilder } from './base-builder.js'; import { importGraphHasChild, parentHasChild, } from './discover-entries-esbuild-plugin.js'; +import type { CompleteDiscoveredEntries } from './fast-discovery.js'; import type { StandaloneConfig } from './types.js'; class TestBuilder extends BaseBuilder { @@ -24,7 +25,7 @@ class TestBuilder extends BaseBuilder { inputs: string[], outdir: string, tsconfigPath?: string - ): Promise { + ): Promise { return this.discoverEntries(inputs, outdir, tsconfigPath); } diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index 2886467848..204d724765 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -6,8 +6,8 @@ import enhancedResolveOriginal from 'enhanced-resolve'; import { findUp } from 'find-up'; import JSON5 from 'json5'; import { - importParents as legacyImportParents, type ImportParents, + importParents as legacyImportParents, } from './discover-entries-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; @@ -52,14 +52,19 @@ export interface DiscoveredEntries { discoveredSteps: Set; discoveredWorkflows: Set; discoveredSerdeFiles: Set; - importParents: ImportParents; + importParents?: ImportParents; /** Source files visited by discovery or consumed by the workflow bundle. */ + discoveredFiles?: Set; +} + +export interface CompleteDiscoveredEntries extends DiscoveredEntries { + importParents: ImportParents; discoveredFiles: Set; } interface FastDiscoverEntriesOptions { entryPoints: string[]; - state: DiscoveredEntries; + state: CompleteDiscoveredEntries; defaultTsconfigPath: string | undefined; workingDir: string; /** diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index fa61f0292b..40cbf00d75 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -20,8 +20,8 @@ export { } from './constants.js'; export { createDiscoverEntriesPlugin, - importGraphHasChild, type ImportParents, + importGraphHasChild, parentHasChild, } from './discover-entries-esbuild-plugin.js'; export { diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index d52d5d0412..4f99a62b84 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -8,9 +8,9 @@ import { type WorkflowManifest, } from './apply-swc-transform.js'; import { + type ImportParents, importGraphHasChild, importParents, - type ImportParents, jsTsRegex, } from './discover-entries-esbuild-plugin.js'; import { diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 0e5968700b..3e7adff121 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -319,6 +319,7 @@ export function createDevTests(config?: DevTestConfig) { log.split(message).length - 1; type ExpectedHmrLogCount = | number + | { kind: 'at-least'; min: number } | { kind: 'range'; min: number; max: number }; type ExpectedHmrLogCounts = { skip?: ExpectedHmrLogCount; @@ -334,6 +335,9 @@ export function createDevTests(config?: DevTestConfig) { return; } switch (expected.kind) { + case 'at-least': + expect(actual).toBeGreaterThanOrEqual(expected.min); + return; case 'range': expect(actual).toBeGreaterThanOrEqual(expected.min); expect(actual).toBeLessThanOrEqual(expected.max); @@ -1270,7 +1274,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { { file: files.serde, kind: 'serde', - expectedLogCounts: { hot: 1 }, + expectedLogCounts: { full: 1 }, source: (iteration: number) => `export class HmrFuzzBox { static classId = 'HmrFuzzBox'; @@ -1335,10 +1339,15 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { await expectHmrLogCounts(logCursor, testCase.expectedLogCounts); } + // Watchers may deliver the same edit during consecutive builds. The + // manifest and execution assertions below verify eventual convergence. + const expectedFullRediscovery = { + full: { kind: 'at-least', min: 1 }, + } as const; const fullCases = [ { description: 'workflow import graph change', - expectedLogCounts: { full: 1 }, + expectedLogCounts: expectedFullRediscovery, write: async () => { await fs.writeFile( files.workflow, @@ -1372,7 +1381,7 @@ export async function hmrFuzzWorkflow() { }, { description: 'step definition added', - expectedLogCounts: { full: 1 }, + expectedLogCounts: expectedFullRediscovery, write: async (iteration: number) => { await fs.writeFile( files.step, @@ -1407,7 +1416,7 @@ export async function hmrFuzzAddedStep() { }, { description: 'workflow definition added', - expectedLogCounts: { full: 1 }, + expectedLogCounts: expectedFullRediscovery, write: async (iteration: number) => { await fs.writeFile( files.workflow, @@ -1449,9 +1458,7 @@ export async function hmrFuzzAddedWorkflow() { }, { description: 'workflow file added through API import', - expectedLogCounts: { - full: { kind: 'range', min: 1, max: 2 }, - }, + expectedLogCounts: expectedFullRediscovery, write: async (iteration: number) => { await fs.writeFile( files.addedWorkflow, @@ -1485,9 +1492,7 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: { - full: { kind: 'range', min: 1, max: 2 }, - }, + expectedLogCounts: expectedFullRediscovery, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 7c440648f2..f2d22c8f30 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -14,8 +14,8 @@ import { createSourceSnapshot, getRelevantFiles, readSourceSnapshots, - sourceSnapshotsMatch, type SourceSnapshot, + sourceSnapshotsMatch, } from './watch-rebuild.js'; let CachedNextBuilderEager: any; diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index b12c4f92f7..42f53a5865 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -5,8 +5,8 @@ import { createRebuildScheduler, createSourceSnapshotFromSource, extractImportSignature, - sourceSnapshotsMatch, type SourceSnapshot, + sourceSnapshotsMatch, stripCommentsFromSource, } from './watch-rebuild.js'; From 41471639ebd7696b618411334c101484f98eb260 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:28:39 -0700 Subject: [PATCH 11/17] fix(next): rebuild non-source workflow inputs --- packages/next/src/builder-eager.ts | 15 ++-------- packages/next/src/watch-rebuild.test.ts | 28 ++++++++++++++++++ packages/next/src/watch-rebuild.ts | 38 +++++++++++++++++++++---- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index f2d22c8f30..48a93b764e 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -13,6 +13,7 @@ import { createRebuildScheduler, createSourceSnapshot, getRelevantFiles, + isSourceFile, readSourceSnapshots, type SourceSnapshot, sourceSnapshotsMatch, @@ -193,16 +194,6 @@ export async function getNextBuilderEager( ).replace(/\\/g, '/'); let sourceSnapshots = new Map(); - const watchableExtensions = new Set([ - '.js', - '.jsx', - '.ts', - '.tsx', - '.mts', - '.cts', - '.cjs', - '.mjs', - ]); const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); const normalizedDistDir = normalizePath(this.config.distDir); @@ -326,7 +317,7 @@ export async function getNextBuilderEager( normalizePath, }); const isWatchableFile = (path: string) => - watchableExtensions.has(extname(path)) || relevantFiles.has(path); + isSourceFile(path) || relevantFiles.has(path); const readKnownFileAliases = async () => { const aliases = new Map(); @@ -518,7 +509,7 @@ export async function getNextBuilderEager( return false; } const extension = extname(normalizedPath); - if (extension && !watchableExtensions.has(extension)) { + if (extension && !isSourceFile(normalizedPath)) { return true; } return hasIgnoredPathFragment(normalizedPath); diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 42f53a5865..90a5b11fd5 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -5,6 +5,7 @@ import { createRebuildScheduler, createSourceSnapshotFromSource, extractImportSignature, + isSourceFile, type SourceSnapshot, sourceSnapshotsMatch, stripCommentsFromSource, @@ -71,6 +72,11 @@ describe('watch-rebuild scheduling', () => { }); describe('watch-rebuild source snapshots', () => { + test('only treats JavaScript and TypeScript files as source', () => { + expect(isSourceFile('/app/workflow.ts')).toBe(true); + expect(isSourceFile('/app/workflow.json')).toBe(false); + }); + test('ignores imports inside line and block comments', () => { const source = stripCommentsFromSource(` import * as active from './workflows/active'; @@ -218,6 +224,28 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); + test('non-source build inputs require full rediscovery', async () => { + const buildInput = '/app/workflow.json'; + + await expect( + classifyRebuild({ + discoveredEntries: { + discoveredSteps: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([buildInput]), + }, + files: [buildInput], + inputFiles: [], + parentHasChild: () => false, + readSnapshot: async () => { + throw new Error('Non-source inputs are not hot rebuilt'); + }, + sourceSnapshots: new Map(), + }) + ).resolves.toEqual({ kind: 'full' }); + }); + test('fully rebuilds byte-identical relevant notifications', async () => { const workflowFile = '/app/workflows/helper.ts'; const source = "export const value = 'unchanged';\n"; diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index f5f910774d..8827119747 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; export interface DiscoveredEntriesLike { discoveredSteps: Set; @@ -40,6 +41,19 @@ const importSpecifierPatterns = [ /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, ]; const defaultNormalizePath = (pathname: string) => pathname.replace(/\\/g, '/'); +const sourceExtensions = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mts', + '.cts', + '.cjs', + '.mjs', +]); + +export const isSourceFile = (file: string) => + sourceExtensions.has(extname(file)); const REGEX_PREFIX_CHARS = new Set([ '(', @@ -206,11 +220,21 @@ export const createSourceSnapshot = async ({ }: { file: string; detectWorkflowPatterns: SourcePatternDetector; -}): Promise => - createSourceSnapshotFromSource( - await readFile(file, 'utf8'), - detectWorkflowPatterns - ); +}): Promise => { + const contents = await readFile(file); + if (isSourceFile(file)) { + return createSourceSnapshotFromSource( + contents.toString('utf8'), + detectWorkflowPatterns + ); + } + return { + sourceHash: createHash('sha256').update(contents).digest('base64url'), + importSignature: '', + hasDirective: false, + hasSerde: false, + }; +}; export const getRelevantFiles = ({ discoveredEntries, @@ -416,6 +440,10 @@ export const classifyRebuild = async ({ }); const snapshots = new Map(); for (const file of files) { + if (relevantFiles.has(file) && !isSourceFile(file)) { + return { kind: 'full' }; + } + let nextSnapshot: SourceSnapshot; try { nextSnapshot = await readSnapshot(file); From 703cf38f818a79ba10963a89cb0c0e2ced78fa5f Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:37:13 -0700 Subject: [PATCH 12/17] fix(next): keep HMR rebuilds minimal --- packages/builders/src/fast-discovery.ts | 15 +- packages/builders/src/index.ts | 1 + packages/next/src/builder-eager.ts | 161 ++++--------- packages/next/src/watch-rebuild.test.ts | 67 +++--- packages/next/src/watch-rebuild.ts | 295 ++++++------------------ 5 files changed, 156 insertions(+), 383 deletions(-) diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index 204d724765..9910fec04d 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -381,8 +381,7 @@ function stripCommentsFromSource(source: string): string { return output; } -function extractImportSpecifiers(source: string): string[] { - const sourceWithoutComments = stripCommentsFromSource(source); +function collectImportSpecifiers(sourceWithoutComments: string): string[] { if ( !sourceWithoutComments.includes('import') && !sourceWithoutComments.includes('require') && @@ -405,6 +404,14 @@ function extractImportSpecifiers(source: string): string[] { return Array.from(specifiers); } +export function analyzeWorkflowSource(source: string) { + const sourceWithoutComments = stripCommentsFromSource(source); + return { + ...detectWorkflowPatterns(sourceWithoutComments), + importSpecifiers: collectImportSpecifiers(sourceWithoutComments), + }; +} + function hasWorkflowDependency(dependencies: unknown): boolean { if ( typeof dependencies !== 'object' || @@ -992,7 +999,7 @@ export async function fastDiscoverEntries({ return; } - const patterns = detectWorkflowPatterns(source); + const patterns = analyzeWorkflowSource(source); if (patterns.hasUseWorkflow) { state.discoveredWorkflows.add(filePath); } @@ -1012,7 +1019,7 @@ export async function fastDiscoverEntries({ return; } - const specifiers = extractImportSpecifiers(source); + const specifiers = patterns.importSpecifiers; if (specifiers.length === 0) { return; } diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 40cbf00d75..d91998c3ae 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -24,6 +24,7 @@ export { importGraphHasChild, parentHasChild, } from './discover-entries-esbuild-plugin.js'; +export { analyzeWorkflowSource } from './fast-discovery.js'; export { clearModuleSpecifierCache, getImportPath, diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 48a93b764e..b84edd2e46 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -1,5 +1,5 @@ -import { constants, type Dirent } from 'node:fs'; -import { access, mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { access, mkdir, realpath, rm, stat } from 'node:fs/promises'; import { extname, isAbsolute, join, relative, resolve } from 'node:path'; import type { NextConfig as BuilderNextConfig, @@ -13,6 +13,7 @@ import { createRebuildScheduler, createSourceSnapshot, getRelevantFiles, + type HotRebuildTarget, isSourceFile, readSourceSnapshots, type SourceSnapshot, @@ -78,8 +79,8 @@ export async function getNextBuilderEager( const { BaseBuilder: BaseBuilderClass, + analyzeWorkflowSource, getWorkflowQueueTrigger, - detectWorkflowPatterns, importGraphHasChild, writeFileIfChanged, } = buildersModule ?? @@ -171,9 +172,6 @@ export async function getNextBuilderEager( ); } - // Step registrations may be emitted as source imports without an - // esbuild context when externalizeNonSteps is enabled. - let stepsCtx = combinedResult.stepsContext; let workflowsCtx = { interimBundleCtx: combinedResult.interimBundleCtx, bundleFinal: combinedResult.bundleFinal, @@ -221,7 +219,7 @@ export async function getNextBuilderEager( }; const readSourceSnapshot = (file: string) => - createSourceSnapshot({ file, detectWorkflowPatterns }); + createSourceSnapshot({ file, analyzeWorkflowSource }); const snapshotSources = () => readSourceSnapshots({ @@ -245,29 +243,29 @@ export async function getNextBuilderEager( }, }); - const hotRebuild = async (refreshStepRegistrations: boolean) => { - if (refreshStepRegistrations) { - if (stepsCtx) { - await stepsCtx.rebuild(); - } else { - stepsManifest = await this.createStepSourceRegistrationFile({ - inputFiles: options.inputFiles, - outfile: stepsOutfile, - tsconfigPath, - discoveredEntries, - }); - } + const hotRebuild = async (target: HotRebuildTarget) => { + if (target !== 'workflows') { + stepsManifest = await this.createStepSourceRegistrationFile({ + inputFiles: options.inputFiles, + outfile: stepsOutfile, + tsconfigPath, + discoveredEntries, + }); } - const workflowResult = await workflowsCtx.interimBundleCtx.rebuild(); - const workflowOutput = workflowResult.outputFiles?.[0]?.text; - if (!workflowOutput) { - throw new Error( - 'Invariant: expected workflow output from hot rebuild' - ); + if (target !== 'steps') { + const workflowResult = + await workflowsCtx.interimBundleCtx.rebuild(); + const workflowOutput = workflowResult.outputFiles?.[0]?.text; + if (!workflowOutput) { + throw new Error( + 'Invariant: expected workflow output from hot rebuild' + ); + } + + await workflowsCtx.bundleFinal(workflowOutput); } - await workflowsCtx.bundleFinal(workflowOutput); await writeManifest(mergeCombinedManifest(stepsManifest)); }; @@ -287,9 +285,7 @@ export async function getNextBuilderEager( ); } - const previousStepsCtx = stepsCtx; const previousWorkflowsCtx = workflowsCtx.interimBundleCtx; - stepsCtx = newCombined.stepsContext; discoveredEntries = newCombined.discoveredEntries; stepsManifest = newCombined.stepsManifest; workflowsManifest = newCombined.workflowsManifest; @@ -298,10 +294,7 @@ export async function getNextBuilderEager( bundleFinal: newCombined.bundleFinal, }; - await Promise.all([ - previousStepsCtx?.dispose(), - previousWorkflowsCtx.dispose(), - ]); + await previousWorkflowsCtx.dispose(); await writeManifest(newCombined.manifest); sourceSnapshots = nextSourceSnapshots; @@ -319,69 +312,6 @@ export async function getNextBuilderEager( const isWatchableFile = (path: string) => isSourceFile(path) || relevantFiles.has(path); - const readKnownFileAliases = async () => { - const aliases = new Map(); - - const addKnownFile = async (filePath: string) => { - let realFilePath = filePath; - try { - realFilePath = normalizePath(await realpath(filePath)); - } catch {} - - const canonicalPath = relevantFiles.has(realFilePath) - ? realFilePath - : filePath; - aliases.set(filePath, canonicalPath); - aliases.set(realFilePath, canonicalPath); - return canonicalPath; - }; - - const visit = async (directory: string): Promise => { - let dirents: Dirent[]; - try { - dirents = await readdir(directory, { withFileTypes: true }); - } catch { - return; - } - - await Promise.all( - dirents.map(async (dirent) => { - const filePath = normalizePath(join(directory, dirent.name)); - if (hasIgnoredPathFragment(filePath)) { - return; - } - - if (dirent.isDirectory()) { - await visit(filePath); - return; - } - - let stats: Awaited>; - try { - stats = await stat(filePath); - } catch { - return; - } - - if (stats.isDirectory()) { - await visit(filePath); - return; - } - - if (!stats.isFile() || !isWatchableFile(filePath)) { - return; - } - - await addKnownFile(filePath); - }) - ); - }; - - await visit(this.config.workingDir); - await Promise.all([...relevantFiles].map(addKnownFile)); - return { aliases, addKnownFile }; - }; - const logDevHmr = (...args: unknown[]) => { if (process.env.WORKFLOW_DEV_HMR_LOGS === '1') { console.log(...args); @@ -389,8 +319,6 @@ export async function getNextBuilderEager( }; sourceSnapshots = await snapshotSources(); - let { aliases: knownFileAliases, addKnownFile: rememberKnownFile } = - await readKnownFileAliases(); const refreshKnownFiles = async () => { relevantFiles = getRelevantFiles({ @@ -398,9 +326,6 @@ export async function getNextBuilderEager( inputFiles: options.inputFiles, normalizePath, }); - const nextKnown = await readKnownFileAliases(); - knownFileAliases = nextKnown.aliases; - rememberKnownFile = nextKnown.addKnownFile; await watcher.add([...relevantFiles]); }; @@ -434,10 +359,8 @@ export async function getNextBuilderEager( logDevHmr('workflow dev hmr: skip'); break; case 'hot': - logDevHmr( - `workflow dev hmr: hot rebuild${decision.refreshStepRegistrations ? ' with step registration refresh' : ''}` - ); - await hotRebuild(decision.refreshStepRegistrations); + logDevHmr(`workflow dev hmr: hot rebuild ${decision.target}`); + await hotRebuild(decision.target); break; case 'full': await runFullRebuild(); @@ -471,31 +394,27 @@ export async function getNextBuilderEager( }, () => logDevHmr('workflow dev hmr: idle') ); - const scheduleFileChange = (file: string) => { - scheduleRebuild({ kind: 'files', files: [file] }); - }; - const handleFileWritten = async (pathname: string) => { + const handleFileChanged = async (pathname: string) => { const normalizedPath = normalizePath(pathname); if (!isWatchableFile(normalizedPath)) { return; } - const canonicalPath = - knownFileAliases.get(normalizedPath) ?? - (await rememberKnownFile(normalizedPath)); - scheduleFileChange(canonicalPath); + const realFilePath = normalizePath(await realpath(normalizedPath)); + scheduleRebuild({ + kind: 'files', + files: [ + relevantFiles.has(realFilePath) ? realFilePath : normalizedPath, + ], + }); }; - const handleFileRemoved = (pathname: string) => { + const scheduleFullRebuild = (pathname: string) => { const normalizedPath = normalizePath(pathname); if (!isWatchableFile(normalizedPath)) { return; } - - const canonicalPath = - knownFileAliases.get(normalizedPath) ?? normalizedPath; - knownFileAliases.delete(normalizedPath); - scheduleFileChange(canonicalPath); + scheduleRebuild({ kind: 'full' }); }; const watcher = chokidar.watch( @@ -517,9 +436,9 @@ export async function getNextBuilderEager( } ); - watcher.on('add', handleFileWritten); - watcher.on('change', handleFileWritten); - watcher.on('unlink', handleFileRemoved); + watcher.on('add', scheduleFullRebuild); + watcher.on('change', handleFileChanged); + watcher.on('unlink', scheduleFullRebuild); watcher.on('error', (error) => { console.error('Workflow dev watcher error', error); }); diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 90a5b11fd5..b8e719b5e1 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -1,14 +1,12 @@ -import { detectWorkflowPatterns } from '@workflow/builders'; +import { analyzeWorkflowSource as detectWorkflowPatterns } from '@workflow/builders'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { classifyRebuild, createRebuildScheduler, createSourceSnapshotFromSource, - extractImportSignature, isSourceFile, type SourceSnapshot, sourceSnapshotsMatch, - stripCommentsFromSource, } from './watch-rebuild.js'; afterEach(() => { @@ -40,7 +38,7 @@ describe('watch-rebuild scheduling', () => { }); }); - test('fully rebuilds after files change during a rebuild', async () => { + test('preserves file changes that arrive during a rebuild', async () => { vi.useFakeTimers(); const firstBuild = Promise.withResolvers(); const fullBuild = Promise.withResolvers(); @@ -66,9 +64,21 @@ describe('watch-rebuild scheduling', () => { await fullBuild.promise; await idle.promise; - expect(requests).toEqual(['files', 'full']); + expect(requests).toEqual(['files', 'files']); expect(onIdle).toHaveBeenCalledOnce(); }); + + test('lets a full rebuild replace pending file changes', async () => { + vi.useFakeTimers(); + const rebuild = vi.fn(async () => {}); + const schedule = createRebuildScheduler(rebuild, () => {}); + + schedule({ kind: 'files', files: ['/app/workflow.ts'] }); + schedule({ kind: 'full' }); + await vi.advanceTimersByTimeAsync(100); + + expect(rebuild).toHaveBeenCalledWith({ kind: 'full' }); + }); }); describe('watch-rebuild source snapshots', () => { @@ -77,28 +87,6 @@ describe('watch-rebuild source snapshots', () => { expect(isSourceFile('/app/workflow.json')).toBe(false); }); - test('ignores imports inside line and block comments', () => { - const source = stripCommentsFromSource(` -import * as active from './workflows/active'; -// import * as commentedLine from './workflows/commented-line'; -/* -import * as commentedBlock from './workflows/commented-block'; -*/ -`); - - expect(extractImportSignature(source)).toBe('./workflows/active'); - }); - - test('does not treat regex literals as comments', () => { - const source = stripCommentsFromSource(` -const commentStartChars = /[/*]/; -const protocol = /https?:\\/\\//; -import * as active from './workflows/active'; -`); - - expect(extractImportSignature(source)).toBe('./workflows/active'); - }); - test('ignores directives inside comments', () => { const snapshot = createSourceSnapshotFromSource( ` @@ -110,7 +98,8 @@ export async function commentedStep() { 'use step'; } detectWorkflowPatterns ); - expect(snapshot.hasDirective).toBe(false); + expect(snapshot.hasUseWorkflow).toBe(false); + expect(snapshot.hasUseStep).toBe(false); }); test('detects files added or changed during a full rebuild', () => { @@ -246,7 +235,7 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); - test('fully rebuilds byte-identical relevant notifications', async () => { + test('skips byte-identical relevant notifications', async () => { const workflowFile = '/app/workflows/helper.ts'; const source = "export const value = 'unchanged';\n"; const snapshot = createSourceSnapshotFromSource( @@ -268,7 +257,7 @@ export const allWorkflows = {} as const; readSnapshot: async () => snapshot, sourceSnapshots: new Map([[workflowFile, snapshot]]), }) - ).resolves.toEqual({ kind: 'full' }); + ).resolves.toEqual({ kind: 'skip', snapshots: new Map() }); }); test('rebuilds relevant files without snapshots', async () => { @@ -295,7 +284,7 @@ export const allWorkflows = {} as const; ).resolves.toEqual({ kind: 'full' }); }); - test('fully rebuilds every directive file change', async () => { + test('hot rebuilds step body changes', async () => { const stepFile = '/app/workflows/step.ts'; const previousSnapshot = createSourceSnapshotFromSource( `export let example = async () => { @@ -328,10 +317,14 @@ export const allWorkflows = {} as const; readSnapshot: async () => nextSnapshot, sourceSnapshots: new Map([[stepFile, previousSnapshot]]), }) - ).resolves.toEqual({ kind: 'full' }); + ).resolves.toEqual({ + kind: 'hot', + target: 'steps', + snapshots: new Map([[stepFile, nextSnapshot]]), + }); }); - test('fully rebuilds every serde file change', async () => { + test('hot rebuilds serde body changes in both contexts', async () => { const serdeFile = '/app/workflows/value.ts'; const previousSnapshot = createSourceSnapshotFromSource( `export class Value { @@ -360,7 +353,11 @@ export const allWorkflows = {} as const; readSnapshot: async () => nextSnapshot, sourceSnapshots: new Map([[serdeFile, previousSnapshot]]), }) - ).resolves.toEqual({ kind: 'full' }); + ).resolves.toEqual({ + kind: 'hot', + target: 'both', + snapshots: new Map([[serdeFile, nextSnapshot]]), + }); }); test('rebuilds new files that can introduce graph entries', async () => { @@ -415,7 +412,7 @@ export const allWorkflows = {} as const; }) ).resolves.toEqual({ kind: 'hot', - refreshStepRegistrations: false, + target: 'workflows', snapshots: new Map([[helperFile, nextHelperSnapshot]]), }); }); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 8827119747..679ccf6578 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -16,30 +16,29 @@ export type ScheduledRebuild = export interface SourceSnapshot { sourceHash: string; importSignature: string; - hasDirective: boolean; + hasUseWorkflow: boolean; + hasUseStep: boolean; hasSerde: boolean; } +export type HotRebuildTarget = 'workflows' | 'steps' | 'both'; + export type RebuildDecision = | { kind: 'skip'; snapshots: Map } | { kind: 'hot'; - refreshStepRegistrations: boolean; + target: HotRebuildTarget; snapshots: Map; } | { kind: 'full' }; -export type SourcePatternDetector = (source: string) => { - hasDirective: boolean; +export type SourceAnalyzer = (source: string) => { + hasUseWorkflow: boolean; + hasUseStep: boolean; hasSerde: boolean; + importSpecifiers: string[]; }; -const importSpecifierPatterns = [ - /\bfrom\s+['"]([^'"]+)['"]/g, - /(?:^|[;\n])\s*import\s+['"]([^'"]+)['"]/g, - /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, - /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, -]; const defaultNormalizePath = (pathname: string) => pathname.replace(/\\/g, '/'); const sourceExtensions = new Set([ '.js', @@ -55,183 +54,40 @@ const sourceExtensions = new Set([ export const isSourceFile = (file: string) => sourceExtensions.has(extname(file)); -const REGEX_PREFIX_CHARS = new Set([ - '(', - '{', - '[', - '=', - ':', - ',', - ';', - '!', - '?', - '&', - '|', - '+', - '-', - '*', - '~', - '^', - '<', - '>', - '%', -]); -const REGEX_PREFIX_KEYWORDS = - /\b(?:return|throw|case|delete|void|typeof|instanceof|in|yield|await)$/; - -const canStartRegexLiteral = (output: string) => { - const previous = output.trimEnd(); - if (previous.length === 0) { - return true; - } - const previousChar = previous[previous.length - 1]; - return ( - REGEX_PREFIX_CHARS.has(previousChar) || REGEX_PREFIX_KEYWORDS.test(previous) - ); -}; - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Keep the string/comment/regex scanner local and allocation-light. -export const stripCommentsFromSource = (source: string) => { - let output = ''; - let index = 0; - let quote: '"' | "'" | '`' | undefined; - let regex = false; - let regexCharClass = false; - let escaped = false; - - while (index < source.length) { - const char = source[index]; - const next = source[index + 1]; - - if (quote || regex) { - output += char; - index++; - - if (escaped) { - escaped = false; - } else if (char === '\\') { - escaped = true; - } else if (quote && char === quote) { - quote = undefined; - } else if (regex && char === '[') { - regexCharClass = true; - } else if (regex && char === ']') { - regexCharClass = false; - } else if (regex && char === '/' && !regexCharClass) { - regex = false; - } - continue; - } - - if (char === '"' || char === "'" || char === '`') { - quote = char; - output += char; - index++; - continue; - } - - if ( - char === '/' && - next !== '/' && - next !== '*' && - canStartRegexLiteral(output) - ) { - regex = true; - output += char; - index++; - continue; - } - - if (char === '/' && next === '/') { - output += ' '; - index += 2; - while (index < source.length && source[index] !== '\n') { - output += ' '; - index++; - } - continue; - } - - if (char === '/' && next === '*') { - output += ' '; - index += 2; - while (index < source.length) { - const blockChar = source[index]; - const blockNext = source[index + 1]; - if (blockChar === '*' && blockNext === '/') { - output += ' '; - index += 2; - break; - } - output += blockChar === '\n' ? '\n' : ' '; - index++; - } - continue; - } - - output += char; - index++; - } - - return output; -}; - -const sourceMayContainImportSpecifiers = (source: string) => - source.includes('import') || - source.includes('require') || - source.includes('from'); - -const collectImportSpecifiers = (source: string) => { - const specifiers = new Set(); - for (const pattern of importSpecifierPatterns) { - pattern.lastIndex = 0; - for (const match of source.matchAll(pattern)) { - if (match[1]) { - specifiers.add(match[1]); - } - } - } - return [...specifiers].sort().join('\n'); -}; - -export const extractImportSignature = (source: string) => - sourceMayContainImportSpecifiers(source) - ? collectImportSpecifiers(source) - : ''; - export const createSourceSnapshotFromSource = ( source: string, - detectWorkflowPatterns: SourcePatternDetector + analyzeWorkflowSource: SourceAnalyzer ): SourceSnapshot => { - const sourceWithoutComments = stripCommentsFromSource(source); - const patterns = detectWorkflowPatterns(sourceWithoutComments); + const analysis = analyzeWorkflowSource(source); return { sourceHash: createHash('sha256').update(source).digest('base64url'), - importSignature: extractImportSignature(sourceWithoutComments), - hasDirective: patterns.hasDirective, - hasSerde: patterns.hasSerde, + importSignature: [...analysis.importSpecifiers].sort().join('\n'), + hasUseWorkflow: analysis.hasUseWorkflow, + hasUseStep: analysis.hasUseStep, + hasSerde: analysis.hasSerde, }; }; export const createSourceSnapshot = async ({ file, - detectWorkflowPatterns, + analyzeWorkflowSource, }: { file: string; - detectWorkflowPatterns: SourcePatternDetector; + analyzeWorkflowSource: SourceAnalyzer; }): Promise => { const contents = await readFile(file); if (isSourceFile(file)) { return createSourceSnapshotFromSource( contents.toString('utf8'), - detectWorkflowPatterns + analyzeWorkflowSource ); } return { sourceHash: createHash('sha256').update(contents).digest('base64url'), importSignature: '', - hasDirective: false, + hasUseWorkflow: false, + hasUseStep: false, hasSerde: false, }; }; @@ -259,7 +115,7 @@ export const readSourceSnapshots = async ({ inputFiles: string[]; normalizePath?: (path: string) => string; readSnapshot: (file: string) => Promise; -}) => { +}): Promise> => { const snapshots = new Map(); await Promise.all( [ @@ -280,19 +136,11 @@ export const readSourceSnapshots = async ({ const requiresFullRediscovery = ( previousSnapshot: SourceSnapshot, nextSnapshot: SourceSnapshot -) => { - if (previousSnapshot.hasDirective || nextSnapshot.hasDirective) { - return true; - } - - // Serde edits can change the generated class manifest. Rediscover all of - // them instead of approximating JavaScript class structure with a regex. - if (previousSnapshot.hasSerde || nextSnapshot.hasSerde) { - return true; - } - - return previousSnapshot.importSignature !== nextSnapshot.importSignature; -}; +) => + previousSnapshot.importSignature !== nextSnapshot.importSignature || + previousSnapshot.hasUseWorkflow !== nextSnapshot.hasUseWorkflow || + previousSnapshot.hasUseStep !== nextSnapshot.hasUseStep || + previousSnapshot.hasSerde !== nextSnapshot.hasSerde; export const sourceSnapshotsMatch = ( left: Map, @@ -334,9 +182,7 @@ export const createRebuildScheduler = ( return (request: ScheduledRebuild) => { switch (request.kind) { case 'files': - if (rebuilding) { - pending = { kind: 'full' }; - } else if (pending?.kind !== 'full') { + if (pending?.kind !== 'full') { pending = { kind: 'files', files: [...new Set([...(pending?.files ?? []), ...request.files])], @@ -359,7 +205,7 @@ export const createRebuildScheduler = ( }; }; -const workflowEntryFilesChanged = ({ +const getHotRebuildTarget = ({ changedFiles, discoveredEntries, normalizePath, @@ -373,43 +219,52 @@ const workflowEntryFilesChanged = ({ child: string, options?: { excludedRoots?: Iterable } ) => boolean; -}) => { - const workflowEntryFiles = [ - ...discoveredEntries.discoveredWorkflows, - ...discoveredEntries.discoveredSerdeFiles, - ].map(normalizePath); +}): HotRebuildTarget | undefined => { + const workflowEntryFiles = [...discoveredEntries.discoveredWorkflows].map( + normalizePath + ); const stepEntryFiles = [...discoveredEntries.discoveredSteps].map( normalizePath ); + const serdeFiles = new Set( + [...discoveredEntries.discoveredSerdeFiles].map(normalizePath) + ); + let rebuildWorkflows = false; + let rebuildSteps = false; - return changedFiles.some((changedFile) => { + for (const changedFile of changedFiles) { + if (serdeFiles.has(changedFile)) { + rebuildWorkflows = true; + rebuildSteps = true; + continue; + } if (workflowEntryFiles.includes(changedFile)) { - return true; + rebuildWorkflows = true; } if (stepEntryFiles.includes(changedFile)) { - return false; + rebuildSteps = true; + continue; } - return workflowEntryFiles.some((workflowFile) => - parentHasChild(workflowFile, changedFile, { - excludedRoots: stepEntryFiles, - }) - ); - }); -}; + if ( + workflowEntryFiles.some((workflowFile) => + parentHasChild(workflowFile, changedFile, { + excludedRoots: stepEntryFiles, + }) + ) + ) { + rebuildWorkflows = true; + } + } -const stepRegistrationsNeedRefresh = ({ - changedFiles, - discoveredEntries, - normalizePath, -}: { - changedFiles: string[]; - discoveredEntries: DiscoveredEntriesLike; - normalizePath: (path: string) => string; -}) => { - const serdeFiles = new Set( - [...discoveredEntries.discoveredSerdeFiles].map(normalizePath) - ); - return changedFiles.some((file) => serdeFiles.has(file)); + if (rebuildWorkflows && rebuildSteps) { + return 'both'; + } + if (rebuildWorkflows) { + return 'workflows'; + } + if (rebuildSteps) { + return 'steps'; + } }; export const classifyRebuild = async ({ @@ -459,7 +314,8 @@ export const classifyRebuild = async ({ if ( relevantFiles.has(file) || nextSnapshot.importSignature || - nextSnapshot.hasDirective || + nextSnapshot.hasUseWorkflow || + nextSnapshot.hasUseStep || nextSnapshot.hasSerde ) { return { kind: 'full' }; @@ -470,26 +326,19 @@ export const classifyRebuild = async ({ return { kind: 'full' }; } if (previousSnapshot.sourceHash === nextSnapshot.sourceHash) { - return { kind: 'full' }; + continue; } snapshots.set(file, nextSnapshot); } const changedFiles = [...snapshots.keys()]; - return workflowEntryFilesChanged({ + const target = getHotRebuildTarget({ changedFiles, discoveredEntries, normalizePath, parentHasChild, - }) - ? { - kind: 'hot', - refreshStepRegistrations: stepRegistrationsNeedRefresh({ - changedFiles, - discoveredEntries, - normalizePath, - }), - snapshots, - } + }); + return target + ? { kind: 'hot', target, snapshots } : { kind: 'skip', snapshots }; }; From ce5a4a26c6a15d4cdff1307ffbd5372dd65b8fd0 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:54:50 -0700 Subject: [PATCH 13/17] fix(next): bound full rebuild convergence --- packages/core/e2e/dev.test.ts | 61 ++++++++++++------------- packages/next/src/builder-eager.ts | 31 ++++++++----- packages/next/src/watch-rebuild.test.ts | 38 +++++++++++++++ packages/next/src/watch-rebuild.ts | 17 ++----- 4 files changed, 90 insertions(+), 57 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 3e7adff121..22fac710b9 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -319,7 +319,6 @@ export function createDevTests(config?: DevTestConfig) { log.split(message).length - 1; type ExpectedHmrLogCount = | number - | { kind: 'at-least'; min: number } | { kind: 'range'; min: number; max: number }; type ExpectedHmrLogCounts = { skip?: ExpectedHmrLogCount; @@ -334,18 +333,8 @@ export function createDevTests(config?: DevTestConfig) { expect(actual).toBe(expected ?? 0); return; } - switch (expected.kind) { - case 'at-least': - expect(actual).toBeGreaterThanOrEqual(expected.min); - return; - case 'range': - expect(actual).toBeGreaterThanOrEqual(expected.min); - expect(actual).toBeLessThanOrEqual(expected.max); - return; - default: - expected satisfies never; - throw new Error('Unknown HMR log count expectation'); - } + expect(actual).toBeGreaterThanOrEqual(expected.min); + expect(actual).toBeLessThanOrEqual(expected.max); }; const expectHmrLogCounts = async ( cursor: number | undefined, @@ -1188,11 +1177,15 @@ ${apiFileContent}` }; let snapshot = await waitForGeneratedArtifactStability(); + const expectedHotRebuild: ExpectedHmrLogCounts = { + hot: { kind: 'range', min: 1, max: 2 }, + skip: { kind: 'range', min: 0, max: 1 }, + }; const cases = [ { file: files.step, - kind: 'full', - expectedLogCounts: { full: 1 }, + kind: 'step', + expectedLogCounts: expectedHotRebuild, expectedStepValue: (iteration: number) => `step-only-${iteration}`, source: ( iteration: number @@ -1207,8 +1200,8 @@ export async function hmrFuzzStep() { }, { file: files.stepHelper, - kind: 'skip', - expectedLogCounts: { skip: 1 }, + kind: 'workflow', + expectedLogCounts: expectedHotRebuild, expectedStepValue: (iteration: number) => `step-helper-only-${iteration}`, source: ( @@ -1220,8 +1213,8 @@ export async function hmrFuzzStep() { }, { file: files.workflow, - kind: 'full', - expectedLogCounts: { full: 1 }, + kind: 'workflow', + expectedLogCounts: expectedHotRebuild, expectedWorkflowValue: (iteration: number) => `workflow-body-${iteration}`, source: ( @@ -1244,7 +1237,7 @@ export async function hmrFuzzWorkflow() { { file: files.workflowHelper, kind: 'workflow', - expectedLogCounts: { hot: 1 }, + expectedLogCounts: expectedHotRebuild, expectedWorkflowValue: (iteration: number) => `workflow-helper-body-${iteration}`, source: ( @@ -1259,7 +1252,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { { file: files.sharedHelper, kind: 'workflow', - expectedLogCounts: { hot: 1 }, + expectedLogCounts: expectedHotRebuild, expectedStepValue: (iteration: number) => `shared-body-${iteration}`, expectedWorkflowValue: (iteration: number) => @@ -1274,7 +1267,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { { file: files.serde, kind: 'serde', - expectedLogCounts: { full: 1 }, + expectedLogCounts: expectedHotRebuild, source: (iteration: number) => `export class HmrFuzzBox { static classId = 'HmrFuzzBox'; @@ -1339,11 +1332,9 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { await expectHmrLogCounts(logCursor, testCase.expectedLogCounts); } - // Watchers may deliver the same edit during consecutive builds. The - // manifest and execution assertions below verify eventual convergence. - const expectedFullRediscovery = { - full: { kind: 'at-least', min: 1 }, - } as const; + const expectedFullRediscovery: ExpectedHmrLogCounts = { + full: { kind: 'range', min: 1, max: 3 }, + }; const fullCases = [ { description: 'workflow import graph change', @@ -1381,7 +1372,7 @@ export async function hmrFuzzWorkflow() { }, { description: 'step definition added', - expectedLogCounts: expectedFullRediscovery, + expectedLogCounts: expectedHotRebuild, write: async (iteration: number) => { await fs.writeFile( files.step, @@ -1416,7 +1407,7 @@ export async function hmrFuzzAddedStep() { }, { description: 'workflow definition added', - expectedLogCounts: expectedFullRediscovery, + expectedLogCounts: expectedHotRebuild, write: async (iteration: number) => { await fs.writeFile( files.workflow, @@ -1492,7 +1483,10 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: expectedFullRediscovery, + expectedLogCounts: { + skip: { kind: 'range', min: 0, max: 1 }, + full: { kind: 'range', min: 1, max: 2 }, + }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( @@ -1530,12 +1524,15 @@ ${apiFileContent}` const unrelatedLogCursor = await readDevServerLogCursor(); await fs.writeFile(files.unrelated, 'export const unrelated = true;\n'); snapshot = await expectGeneratedArtifactsUnchanged(snapshot); - await expectHmrLogCounts(unrelatedLogCursor, { skip: 1 }); + await expectHmrLogCounts(unrelatedLogCursor, expectedFullRediscovery); const unrelatedRemovalLogCursor = await readDevServerLogCursor(); await fs.unlink(files.unrelated); snapshot = await expectGeneratedArtifactsUnchanged(snapshot); - await expectHmrLogCounts(unrelatedRemovalLogCursor, { skip: 1 }); + await expectHmrLogCounts( + unrelatedRemovalLogCursor, + expectedFullRediscovery + ); } ); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index b84edd2e46..aef8cff954 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert'; import { constants } from 'node:fs'; import { access, mkdir, realpath, rm, stat } from 'node:fs/promises'; import { extname, isAbsolute, join, relative, resolve } from 'node:path'; @@ -319,6 +320,7 @@ export async function getNextBuilderEager( }; sourceSnapshots = await snapshotSources(); + let watchGeneration = 0; const refreshKnownFiles = async () => { relevantFiles = getRelevantFiles({ @@ -330,12 +332,11 @@ export async function getNextBuilderEager( }; const runFullRebuild = async () => { - // The build and watcher cannot read the filesystem atomically. Keep - // rebuilding until one complete build window observes no changes. - do { - logDevHmr('workflow dev hmr: full rediscovery'); - } while (!(await fullRebuild())); + const generation = watchGeneration; + logDevHmr('workflow dev hmr: full rediscovery'); + const buildWasStable = await fullRebuild(); await refreshKnownFiles(); + return buildWasStable && generation === watchGeneration; }; const processFileChanges = async (files: string[]) => { @@ -344,12 +345,11 @@ export async function getNextBuilderEager( discoveredEntries, inputFiles: options.inputFiles, normalizePath, - parentHasChild: (parent, child, options) => + parentHasChild: (parent, child) => importGraphHasChild( discoveredEntries.importParents, parent, - child, - options + child ), readSnapshot: readSourceSnapshot, sourceSnapshots, @@ -363,7 +363,7 @@ export async function getNextBuilderEager( await hotRebuild(decision.target); break; case 'full': - await runFullRebuild(); + scheduleRebuild({ kind: 'full' }); return; default: decision satisfies never; @@ -382,7 +382,9 @@ export async function getNextBuilderEager( await processFileChanges(request.files); return; case 'full': - await runFullRebuild(); + if (!(await runFullRebuild())) { + scheduleRebuild({ kind: 'full' }); + } return; default: request satisfies never; @@ -395,6 +397,7 @@ export async function getNextBuilderEager( () => logDevHmr('workflow dev hmr: idle') ); const handleFileChanged = async (pathname: string) => { + watchGeneration++; const normalizedPath = normalizePath(pathname); if (!isWatchableFile(normalizedPath)) { return; @@ -410,6 +413,7 @@ export async function getNextBuilderEager( }; const scheduleFullRebuild = (pathname: string) => { + watchGeneration++; const normalizedPath = normalizePath(pathname); if (!isWatchableFile(normalizedPath)) { return; @@ -518,7 +522,7 @@ export async function getNextBuilderEager( const flowRouteDir = join(workflowGeneratedDir, 'flow'); await mkdir(flowRouteDir, { recursive: true }); - return await this.createCombinedBundle({ + const result = await this.createCombinedBundle({ format: 'esm', inputFiles, stepsOutfile: join(flowRouteDir, '__step_registrations.js'), @@ -528,6 +532,11 @@ export async function getNextBuilderEager( sourceStepRegistrationImports: true, tsconfigPath, }); + assert( + result.stepsContext === undefined, + 'Invariant: source step registrations must not create an esbuild context' + ); + return result; } private async buildWebhookRoute({ diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index b8e719b5e1..19648c1045 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -324,6 +324,44 @@ export const allWorkflows = {} as const; }); }); + test('also rebuilds workflows that import a step module', async () => { + const stepFile = '/app/workflows/step.ts'; + const workflowFile = '/app/workflows/workflow.ts'; + const previousSnapshot = createSourceSnapshotFromSource( + `export const value = 'before'; +export async function step() { 'use step'; } +`, + detectWorkflowPatterns + ); + const nextSnapshot = createSourceSnapshotFromSource( + `export const value = 'after'; +export async function step() { 'use step'; } +`, + detectWorkflowPatterns + ); + + await expect( + classifyRebuild({ + discoveredEntries: { + discoveredSteps: new Set([stepFile]), + discoveredWorkflows: new Set([workflowFile]), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([stepFile, workflowFile]), + }, + files: [stepFile], + inputFiles: [], + parentHasChild: (parent, child) => + parent === workflowFile && child === stepFile, + readSnapshot: async () => nextSnapshot, + sourceSnapshots: new Map([[stepFile, previousSnapshot]]), + }) + ).resolves.toEqual({ + kind: 'hot', + target: 'both', + snapshots: new Map([[stepFile, nextSnapshot]]), + }); + }); + test('hot rebuilds serde body changes in both contexts', async () => { const serdeFile = '/app/workflows/value.ts'; const previousSnapshot = createSourceSnapshotFromSource( diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 679ccf6578..7d4ad3d03f 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -214,11 +214,7 @@ const getHotRebuildTarget = ({ changedFiles: string[]; discoveredEntries: DiscoveredEntriesLike; normalizePath: (path: string) => string; - parentHasChild: ( - parent: string, - child: string, - options?: { excludedRoots?: Iterable } - ) => boolean; + parentHasChild: (parent: string, child: string) => boolean; }): HotRebuildTarget | undefined => { const workflowEntryFiles = [...discoveredEntries.discoveredWorkflows].map( normalizePath @@ -243,13 +239,10 @@ const getHotRebuildTarget = ({ } if (stepEntryFiles.includes(changedFile)) { rebuildSteps = true; - continue; } if ( workflowEntryFiles.some((workflowFile) => - parentHasChild(workflowFile, changedFile, { - excludedRoots: stepEntryFiles, - }) + parentHasChild(workflowFile, changedFile) ) ) { rebuildWorkflows = true; @@ -280,11 +273,7 @@ export const classifyRebuild = async ({ discoveredEntries: DiscoveredEntriesLike; inputFiles: string[]; normalizePath?: (path: string) => string; - parentHasChild: ( - parent: string, - child: string, - options?: { excludedRoots?: Iterable } - ) => boolean; + parentHasChild: (parent: string, child: string) => boolean; readSnapshot: (file: string) => Promise; sourceSnapshots: Map; }): Promise => { From ac084705427a7a071b4fecaff45579c5c4a0b6b1 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:09:07 -0700 Subject: [PATCH 14/17] perf(next): precompute affected workflow files --- .../src/discover-entries-esbuild-plugin.ts | 8 +-- packages/next/src/builder-eager.ts | 21 ++++--- packages/next/src/watch-rebuild.test.ts | 45 ++++++++++---- packages/next/src/watch-rebuild.ts | 59 +++++++++++++------ 4 files changed, 89 insertions(+), 44 deletions(-) diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index 0df81aede2..b4f35b386b 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -62,7 +62,7 @@ export const importParents: ImportParents = new Map(); // e.g. if a dependency needs to be bundled because it has // a 'use workflow/'use step' directive in it export function importGraphHasChild( - importParents: ImportParents, + importGraph: ImportParents, parent: string, childToFind: string, { @@ -75,15 +75,15 @@ export function importGraphHasChild( const excluded = new Set(excludedRoots); const queue: string[] = [parent]; - while (queue.length > 0) { - const current = queue.shift()!; + for (let index = 0; index < queue.length; index++) { + const current = queue[index]; if (visited.has(current)) { continue; } visited.add(current); - const children = importParents.get(current); + const children = importGraph.get(current); if (!children) { continue; } diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index aef8cff954..9183c4fabd 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -13,6 +13,7 @@ import { classifyRebuild, createRebuildScheduler, createSourceSnapshot, + getAffectedWorkflowFiles, getRelevantFiles, type HotRebuildTarget, isSourceFile, @@ -82,7 +83,6 @@ export async function getNextBuilderEager( BaseBuilder: BaseBuilderClass, analyzeWorkflowSource, getWorkflowQueueTrigger, - importGraphHasChild, writeFileIfChanged, } = buildersModule ?? (await importEsm('@workflow/builders')); @@ -165,8 +165,6 @@ export async function getNextBuilderEager( await this.writeFunctionsConfig(outputDir); if (this.config.watch) { - // TODO: implement watch mode for combined bundle - // For now, fall back to full rebuild on file changes if (!combinedResult?.interimBundleCtx || !combinedResult.bundleFinal) { throw new Error( 'Invariant: expected workflow build context in watch mode' @@ -191,6 +189,11 @@ export async function getNextBuilderEager( ? pathname : resolve(this.config.workingDir, pathname) ).replace(/\\/g, '/'); + let affectedWorkflowFiles = getAffectedWorkflowFiles({ + discoveredEntries, + importGraph: discoveredEntries.importParents, + normalizePath, + }); let sourceSnapshots = new Map(); const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); @@ -288,6 +291,11 @@ export async function getNextBuilderEager( const previousWorkflowsCtx = workflowsCtx.interimBundleCtx; discoveredEntries = newCombined.discoveredEntries; + affectedWorkflowFiles = getAffectedWorkflowFiles({ + discoveredEntries, + importGraph: discoveredEntries.importParents, + normalizePath, + }); stepsManifest = newCombined.stepsManifest; workflowsManifest = newCombined.workflowsManifest; workflowsCtx = { @@ -341,16 +349,11 @@ export async function getNextBuilderEager( const processFileChanges = async (files: string[]) => { const decision = await classifyRebuild({ + affectedWorkflowFiles, files, discoveredEntries, inputFiles: options.inputFiles, normalizePath, - parentHasChild: (parent, child) => - importGraphHasChild( - discoveredEntries.importParents, - parent, - child - ), readSnapshot: readSourceSnapshot, sourceSnapshots, }); diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 19648c1045..bc031a859a 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -4,6 +4,7 @@ import { classifyRebuild, createRebuildScheduler, createSourceSnapshotFromSource, + getAffectedWorkflowFiles, isSourceFile, type SourceSnapshot, sourceSnapshotsMatch, @@ -82,6 +83,28 @@ describe('watch-rebuild scheduling', () => { }); describe('watch-rebuild source snapshots', () => { + test('precomputes files imported by workflows', () => { + const workflowFile = '/app/workflow.ts'; + const helperFile = '/app/helper.ts'; + const nestedFile = '/app/nested.ts'; + + expect( + getAffectedWorkflowFiles({ + discoveredEntries: { + discoveredSteps: new Set(), + discoveredWorkflows: new Set([workflowFile]), + discoveredSerdeFiles: new Set(), + discoveredFiles: new Set([workflowFile, helperFile, nestedFile]), + }, + importGraph: new Map([ + [workflowFile, new Set([helperFile])], + [helperFile, new Set([nestedFile])], + [nestedFile, new Set([workflowFile])], + ]), + }) + ).toEqual(new Set([workflowFile, helperFile, nestedFile])); + }); + test('only treats JavaScript and TypeScript files as source', () => { expect(isSourceFile('/app/workflow.ts')).toBe(true); expect(isSourceFile('/app/workflow.json')).toBe(false); @@ -164,6 +187,7 @@ export const allWorkflows = { await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set([workflowFile]), @@ -172,7 +196,6 @@ export const allWorkflows = { }, files: [registryFile], inputFiles: [pageFile], - parentHasChild: () => false, readSnapshot: async (file) => createSourceSnapshotFromSource( sources.get(file) ?? '', @@ -194,6 +217,7 @@ export const allWorkflows = {} as const; await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set(), @@ -202,7 +226,6 @@ export const allWorkflows = {} as const; }, files: [stepFile, registryFile], inputFiles: [registryFile], - parentHasChild: () => false, readSnapshot: async (file) => createSourceSnapshotFromSource( sources.get(file) ?? '', @@ -218,6 +241,7 @@ export const allWorkflows = {} as const; await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set(), @@ -226,7 +250,6 @@ export const allWorkflows = {} as const; }, files: [buildInput], inputFiles: [], - parentHasChild: () => false, readSnapshot: async () => { throw new Error('Non-source inputs are not hot rebuilt'); }, @@ -245,6 +268,7 @@ export const allWorkflows = {} as const; await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set([workflowFile]), @@ -253,7 +277,6 @@ export const allWorkflows = {} as const; }, files: [workflowFile], inputFiles: [workflowFile], - parentHasChild: () => false, readSnapshot: async () => snapshot, sourceSnapshots: new Map([[workflowFile, snapshot]]), }) @@ -265,6 +288,7 @@ export const allWorkflows = {} as const; await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set(), @@ -273,7 +297,6 @@ export const allWorkflows = {} as const; }, files: [helperFile], inputFiles: [], - parentHasChild: () => false, readSnapshot: async () => createSourceSnapshotFromSource( "export const value = 'helper';\n", @@ -305,6 +328,7 @@ export const allWorkflows = {} as const; await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set([stepFile]), discoveredWorkflows: new Set(), @@ -313,7 +337,6 @@ export const allWorkflows = {} as const; }, files: [stepFile], inputFiles: [], - parentHasChild: () => false, readSnapshot: async () => nextSnapshot, sourceSnapshots: new Map([[stepFile, previousSnapshot]]), }) @@ -342,6 +365,7 @@ export async function step() { 'use step'; } await expect( classifyRebuild({ + affectedWorkflowFiles: new Set([workflowFile, stepFile]), discoveredEntries: { discoveredSteps: new Set([stepFile]), discoveredWorkflows: new Set([workflowFile]), @@ -350,8 +374,6 @@ export async function step() { 'use step'; } }, files: [stepFile], inputFiles: [], - parentHasChild: (parent, child) => - parent === workflowFile && child === stepFile, readSnapshot: async () => nextSnapshot, sourceSnapshots: new Map([[stepFile, previousSnapshot]]), }) @@ -379,6 +401,7 @@ export async function step() { 'use step'; } await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set(), @@ -387,7 +410,6 @@ export async function step() { 'use step'; } }, files: [serdeFile], inputFiles: [], - parentHasChild: () => false, readSnapshot: async () => nextSnapshot, sourceSnapshots: new Map([[serdeFile, previousSnapshot]]), }) @@ -403,6 +425,7 @@ export async function step() { 'use step'; } await expect( classifyRebuild({ + affectedWorkflowFiles: new Set(), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set(), @@ -411,7 +434,6 @@ export async function step() { 'use step'; } }, files: [routeFile], inputFiles: [], - parentHasChild: () => false, readSnapshot: async () => createSourceSnapshotFromSource( "import './workflow';\n", @@ -435,6 +457,7 @@ export async function step() { 'use step'; } ); await expect( classifyRebuild({ + affectedWorkflowFiles: new Set([workflowFile, helperFile]), discoveredEntries: { discoveredSteps: new Set(), discoveredWorkflows: new Set([workflowFile]), @@ -443,8 +466,6 @@ export async function step() { 'use step'; } }, files: [helperFile], inputFiles: [workflowFile], - parentHasChild: (parent, child) => - parent === workflowFile && child === helperFile, readSnapshot: async () => nextHelperSnapshot, sourceSnapshots: new Map([[helperFile, previousHelperSnapshot]]), }) diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 7d4ad3d03f..6705b6d2b1 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -9,6 +9,8 @@ export interface DiscoveredEntriesLike { discoveredFiles: Set; } +type ImportGraph = ReadonlyMap>; + export type ScheduledRebuild = | { kind: 'files'; files: string[] } | { kind: 'full' }; @@ -105,6 +107,35 @@ export const getRelevantFiles = ({ [...inputFiles, ...discoveredEntries.discoveredFiles].map(normalizePath) ); +export const getAffectedWorkflowFiles = ({ + discoveredEntries, + importGraph, + normalizePath = defaultNormalizePath, +}: { + discoveredEntries: DiscoveredEntriesLike; + importGraph: ImportGraph; + normalizePath?: (path: string) => string; +}) => { + const affectedFiles = new Set( + [...discoveredEntries.discoveredWorkflows].map(normalizePath) + ); + const pendingFiles = [...affectedFiles]; + + for (let index = 0; index < pendingFiles.length; index++) { + const file = pendingFiles[index]; + for (const child of importGraph.get(file) ?? []) { + const normalizedChild = normalizePath(child); + if (affectedFiles.has(normalizedChild)) { + continue; + } + affectedFiles.add(normalizedChild); + pendingFiles.push(normalizedChild); + } + } + + return affectedFiles; +}; + export const readSourceSnapshots = async ({ discoveredEntries, inputFiles, @@ -206,21 +237,18 @@ export const createRebuildScheduler = ( }; const getHotRebuildTarget = ({ + affectedWorkflowFiles, changedFiles, discoveredEntries, normalizePath, - parentHasChild, }: { + affectedWorkflowFiles: ReadonlySet; changedFiles: string[]; discoveredEntries: DiscoveredEntriesLike; normalizePath: (path: string) => string; - parentHasChild: (parent: string, child: string) => boolean; }): HotRebuildTarget | undefined => { - const workflowEntryFiles = [...discoveredEntries.discoveredWorkflows].map( - normalizePath - ); - const stepEntryFiles = [...discoveredEntries.discoveredSteps].map( - normalizePath + const stepEntryFiles = new Set( + [...discoveredEntries.discoveredSteps].map(normalizePath) ); const serdeFiles = new Set( [...discoveredEntries.discoveredSerdeFiles].map(normalizePath) @@ -234,19 +262,12 @@ const getHotRebuildTarget = ({ rebuildSteps = true; continue; } - if (workflowEntryFiles.includes(changedFile)) { + if (affectedWorkflowFiles.has(changedFile)) { rebuildWorkflows = true; } - if (stepEntryFiles.includes(changedFile)) { + if (stepEntryFiles.has(changedFile)) { rebuildSteps = true; } - if ( - workflowEntryFiles.some((workflowFile) => - parentHasChild(workflowFile, changedFile) - ) - ) { - rebuildWorkflows = true; - } } if (rebuildWorkflows && rebuildSteps) { @@ -261,19 +282,19 @@ const getHotRebuildTarget = ({ }; export const classifyRebuild = async ({ + affectedWorkflowFiles, files, discoveredEntries, inputFiles, normalizePath = defaultNormalizePath, - parentHasChild, readSnapshot, sourceSnapshots, }: { + affectedWorkflowFiles: ReadonlySet; files: string[]; discoveredEntries: DiscoveredEntriesLike; inputFiles: string[]; normalizePath?: (path: string) => string; - parentHasChild: (parent: string, child: string) => boolean; readSnapshot: (file: string) => Promise; sourceSnapshots: Map; }): Promise => { @@ -322,10 +343,10 @@ export const classifyRebuild = async ({ const changedFiles = [...snapshots.keys()]; const target = getHotRebuildTarget({ + affectedWorkflowFiles, changedFiles, discoveredEntries, normalizePath, - parentHasChild, }); return target ? { kind: 'hot', target, snapshots } From 694b773e42e74b56320941ee3f1553bdcfc2259b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:30:11 -0700 Subject: [PATCH 15/17] fix(next): honor selected source directory --- packages/next/src/builder-eager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 9183c4fabd..48eca0c4b0 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -457,6 +457,12 @@ export async function getNextBuilderEager( protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); + const appDirectory = relative( + this.config.workingDir, + await this.findAppDirectory() + ).replaceAll('\\', '/'); + assert(appDirectory === 'app' || appDirectory === 'src/app'); + const sourceDirectory = appDirectory === 'app' ? '.' : 'src'; const isNextEntrypoint = createNextEntrypointMatcher( this.config.pageExtensions ); @@ -483,6 +489,9 @@ export async function getNextBuilderEager( if (rootModuleEntrypoint.test(rootModule)) { return rootModuleFiles.has(file); } + if (entry.startsWith('src/') !== (sourceDirectory === 'src')) { + return false; + } return isNextEntrypoint(entry); }); } From 4d207be923efbe27655a3b17d346268be01fe097 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:35:35 -0700 Subject: [PATCH 16/17] fix(next): match active entrypoint conventions --- packages/next/src/builder-eager.test.ts | 39 +++++++++++++++++++--- packages/next/src/builder-eager.ts | 44 ++++++++++++++++++------- packages/next/src/index.test.ts | 10 ++++++ packages/next/src/index.ts | 1 + 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts index 36b8f52f21..c0a7bf1a80 100644 --- a/packages/next/src/builder-eager.test.ts +++ b/packages/next/src/builder-eager.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import { createNextEntrypointMatcher } from './builder-eager.js'; const pageExtensions = ['js', 'jsx', 'ts', 'tsx', 'mts', 'cts']; -const isNextEntrypoint = createNextEntrypointMatcher(pageExtensions); +const isNextEntrypoint = createNextEntrypointMatcher({ + pageExtensions, + bundler: 'webpack', + globalNotFound: false, +}); describe('isNextEntrypoint', () => { it.each([ @@ -20,10 +24,7 @@ describe('isNextEntrypoint', () => { 'instrumentation-client.ts', 'instrumentation-client.mjs', 'proxy.ts', - 'mdx-components.tsx', - 'mdx-components.mjs', 'src/instrumentation-client.ts', - 'src/mdx-components.tsx', ])('includes %s', (entry) => { expect(isNextEntrypoint(entry)).toBe(true); }); @@ -35,6 +36,9 @@ describe('isNextEntrypoint', () => { 'app/blog/global-error.tsx', 'app/blog/robots.ts', 'app/blog/opengraph-image12.tsx', + 'app/global-not-found.tsx', + 'mdx-components.tsx', + 'src/mdx-components.tsx', 'pages/types.d.ts', 'pages/types.d.mts', 'pages/types.d.cts', @@ -46,7 +50,32 @@ describe('isNextEntrypoint', () => { it('supports compound page extensions', () => { expect( - createNextEntrypointMatcher(['tsx', 'page.tsx'])('app/error.page.tsx') + createNextEntrypointMatcher({ + pageExtensions: ['tsx', 'page.tsx'], + bundler: 'webpack', + globalNotFound: false, + })('app/error.page.tsx') ).toBe(true); }); + + it('supports Turbopack numbered metadata', () => { + const isTurbopackEntrypoint = createNextEntrypointMatcher({ + pageExtensions, + bundler: 'turbopack', + globalNotFound: false, + }); + + expect(isTurbopackEntrypoint('app/blog/opengraph-image12.tsx')).toBe(true); + }); + + it('includes enabled optional conventions', () => { + const isOptionalEntrypoint = createNextEntrypointMatcher({ + pageExtensions: [...pageExtensions, 'mdx'], + bundler: 'webpack', + globalNotFound: true, + }); + + expect(isOptionalEntrypoint('app/global-not-found.tsx')).toBe(true); + expect(isOptionalEntrypoint('mdx-components.tsx')).toBe(true); + }); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 48eca0c4b0..4ba23cf52f 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -27,17 +27,31 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( specifier: string ) => Promise; -const appEntrypoint = - /^(?:page|route|layout|default|error|loading|template|not-found|forbidden|unauthorized|sitemap|(?:icon|apple-icon|opengraph-image|twitter-image)\d?)$/; -const appRootEntrypoint = /^(?:global-error|global-not-found|robots|manifest)$/; const rootEntrypoint = /^(?:instrumentation|middleware|proxy)$/; -const rootModuleEntrypoint = +const rootModuleCandidate = /^(?:instrumentation-client|mdx-components)\.(?:mjs|[jt]sx?)$/; const rootModuleNames = ['instrumentation-client', 'mdx-components']; const rootModuleExtensions = ['js', 'mjs', 'tsx', 'ts', 'jsx']; -export function createNextEntrypointMatcher(pageExtensions: readonly string[]) { +export function createNextEntrypointMatcher({ + pageExtensions, + bundler, + globalNotFound, +}: { + pageExtensions: readonly string[]; + bundler: 'webpack' | 'turbopack'; + globalNotFound: boolean; +}) { const extensions = [...pageExtensions].sort((a, b) => b.length - a.length); + const appEntrypoint = new RegExp( + `^(?:page|route|layout|default|error|loading|template|not-found|forbidden|unauthorized|sitemap|(?:icon|apple-icon|opengraph-image|twitter-image)\\d${bundler === 'turbopack' ? '*' : '?'})$` + ); + const appRootEntrypoint = new RegExp( + `^(?:global-error${globalNotFound ? '|global-not-found' : ''}|robots|manifest)$` + ); + const rootModuleEntrypoint = new RegExp( + `^(?:instrumentation-client${pageExtensions.some((extension) => /(?:^|\.)mdx?$/.test(extension)) ? '|mdx-components' : ''})\\.(?:mjs|[jt]sx?)$` + ); return (entry: string): boolean => { if (/\.d\.(?:cts|mts|ts)$/.test(entry)) return false; @@ -91,6 +105,7 @@ export async function getNextBuilderEager( protected declare config: BuilderNextConfig & { pageExtensions: NonNullable; distDir: string; + globalNotFound: boolean; }; async build() { @@ -463,13 +478,20 @@ export async function getNextBuilderEager( ).replaceAll('\\', '/'); assert(appDirectory === 'app' || appDirectory === 'src/app'); const sourceDirectory = appDirectory === 'app' ? '.' : 'src'; - const isNextEntrypoint = createNextEntrypointMatcher( - this.config.pageExtensions - ); + const bundler = process.env.TURBOPACK ? 'turbopack' : 'webpack'; + const isNextEntrypoint = createNextEntrypointMatcher({ + pageExtensions: this.config.pageExtensions, + bundler, + globalNotFound: this.config.globalNotFound, + }); const inputFileSet = new Set(inputFiles); const rootModuleFiles = new Set( rootModuleNames.flatMap((name) => { - const file = ['src', ''] + const directories = + name === 'mdx-components' && bundler === 'turbopack' + ? ['', 'src'] + : ['src', '']; + const file = directories .flatMap((directory) => rootModuleExtensions.map((extension) => join(this.config.workingDir, directory, `${name}.${extension}`) @@ -486,8 +508,8 @@ export async function getNextBuilderEager( '/' ); const rootModule = entry.startsWith('src/') ? entry.slice(4) : entry; - if (rootModuleEntrypoint.test(rootModule)) { - return rootModuleFiles.has(file); + if (rootModuleCandidate.test(rootModule)) { + return rootModuleFiles.has(file) && isNextEntrypoint(entry); } if (entry.startsWith('src/') !== (sourceDirectory === 'src')) { return false; diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 89d7c980e6..740b2f7264 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -122,6 +122,16 @@ describe('withWorkflow builder config', () => { }); }); + it('passes the global-not-found setting to entry discovery', async () => { + const config = withWorkflow({ + experimental: { globalNotFound: true }, + }); + + await config('phase-production-build', { defaultConfig: {} }); + + expect(builderConfigs[0]).toMatchObject({ globalNotFound: true }); + }); + it.each([ 'phase-production-build', 'phase-development-server', diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index da0feac73c..8abb7b5e1e 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -593,6 +593,7 @@ export function withWorkflow( 'jsx', 'js', ], + globalNotFound: nextConfig.experimental?.globalNotFound === true, projectRoot, moduleSpecifierRoot: workingDir, workingDir, From 2150e2b92d0766c3457c2d2eeec09de52490ea00 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:41:22 -0700 Subject: [PATCH 17/17] fix(next): classify Rspack entrypoints --- packages/next/src/builder-eager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 4ba23cf52f..7ace153813 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -39,7 +39,7 @@ export function createNextEntrypointMatcher({ globalNotFound, }: { pageExtensions: readonly string[]; - bundler: 'webpack' | 'turbopack'; + bundler: 'webpack' | 'turbopack' | 'rspack'; globalNotFound: boolean; }) { const extensions = [...pageExtensions].sort((a, b) => b.length - a.length); @@ -478,7 +478,11 @@ export async function getNextBuilderEager( ).replaceAll('\\', '/'); assert(appDirectory === 'app' || appDirectory === 'src/app'); const sourceDirectory = appDirectory === 'app' ? '.' : 'src'; - const bundler = process.env.TURBOPACK ? 'turbopack' : 'webpack'; + const bundler = process.env.NEXT_RSPACK + ? 'rspack' + : process.env.TURBOPACK + ? 'turbopack' + : 'webpack'; const isNextEntrypoint = createNextEntrypointMatcher({ pageExtensions: this.config.pageExtensions, bundler,