Repository navigation
fix(next): watch before initial build #3470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
240e393
e272571
ca2854d
8eb6173
388ec67
0a60628
43bf5b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@workflow/next': patch | ||
| --- | ||
|
|
||
| Start the workflow watcher before the initial development build. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,8 @@ | ||
| import assert from 'node:assert'; | ||
| import { once } from 'node:events'; | ||
| 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 { dirname, isAbsolute, join, relative, resolve } from 'node:path'; | ||
| import type { | ||
| NextConfig as BuilderNextConfig, | ||
| WorkflowManifest, | ||
|
|
@@ -123,8 +124,101 @@ export async function getNextBuilderEager( | |
| } | ||
| await writeFileIfChanged(join(workflowGeneratedDir, '.gitignore'), '*'); | ||
|
|
||
| const inputFiles = await this.getInputFiles(); | ||
| const tsconfigPath = await this.findTsConfigPath(); | ||
| const normalizePath = (pathname: string) => | ||
| (isAbsolute(pathname) | ||
| ? pathname | ||
| : resolve(this.config.workingDir, pathname) | ||
| ).replace(/\\/g, '/'); | ||
| const normalizedWorkingDir = normalizePath(this.config.workingDir); | ||
| let relevantFiles = new Set<string>(); | ||
| const isWatchableFile = (path: string) => | ||
| isSourceFile(path) || relevantFiles.has(path); | ||
| const normalizedDistDir = normalizePath(this.config.distDir); | ||
| const isIgnoredWatchPath = createWatchIgnorePredicate({ | ||
| workingDir: this.config.workingDir, | ||
| projectRoot: this.transformProjectRoot, | ||
| extraFragments: [workflowGeneratedDir.replace(/\\/g, '/')], | ||
| }); | ||
| const sourceWatcherCovers = (file: string) => | ||
| file.startsWith(`${normalizedWorkingDir}/`) && | ||
| !file.startsWith(`${normalizedDistDir}/`) && | ||
| !isIgnoredWatchPath(file) && | ||
| isSourceFile(file); | ||
| const logDevHmr = (...args: unknown[]) => { | ||
| if (process.env.WORKFLOW_DEV_HMR_LOGS === '1') { | ||
| console.log(...args); | ||
| } | ||
| }; | ||
|
|
||
| type WatchEvent = { | ||
| kind: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; | ||
| pathname: string; | ||
| }; | ||
| let watchGeneration = 0; | ||
| let handleWatchEvent = async (_event: WatchEvent) => {}; | ||
| const attachWatchEvents = ( | ||
| currentWatcher: ReturnType<typeof chokidar.watch>, | ||
| mode: 'source' | 'dependencies' | ||
| ) => { | ||
| currentWatcher.on('all', (kind, pathname) => { | ||
| if ( | ||
| kind === 'all' || | ||
| kind === 'error' || | ||
| kind === 'raw' || | ||
| kind === 'ready' | ||
| ) { | ||
| throw new Error(`Unknown watch event: ${kind}`); | ||
| } | ||
| if ( | ||
| mode === 'source' && | ||
| (kind === 'addDir' || kind === 'unlinkDir') | ||
| ) { | ||
| return; | ||
| } | ||
| watchGeneration++; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Blocking
The dependency watcher created in That matters specifically during the startup window: The events are not hypothetical. Instrumenting the listener on A control run appending to an unrelated Separately, one uninstrumented run of the same app reached four Two cheap fixes, and I'd take both:
This also has a downstream effect on the test PR stacked on top of this one, which asserts an exact zero full-rediscovery count for hot-rebuild cases.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (AI) Re-verified against 43bf5b3. Still stands.
The source watcher is largely protected by its own Smallest fix is to gate the increment on the same predicate the handlers already use, e.g. skip when |
||
| void handleWatchEvent({ kind, pathname }).catch((error) => { | ||
| console.error('Failed to process file change', error); | ||
| }); | ||
| }); | ||
| }; | ||
| // Chokidar 4 registers an fs.watch per directory, so prune ignored trees | ||
| // before it walks the project. | ||
| const watcher = this.config.watch | ||
| ? chokidar.watch(this.config.workingDir, { | ||
| ignoreInitial: true, | ||
| followSymlinks: true, | ||
| ignored: (pathname, stats) => { | ||
| const normalizedPath = normalizePath(String(pathname)); | ||
| if ( | ||
| normalizedPath === normalizedDistDir || | ||
| normalizedPath.startsWith(`${normalizedDistDir}/`) || | ||
| isIgnoredWatchPath(normalizedPath) | ||
| ) { | ||
| return true; | ||
| } | ||
| return stats?.isFile() === true && !isSourceFile(normalizedPath); | ||
| }, | ||
| }) | ||
| : undefined; | ||
| let dependencyWatcher: ReturnType<typeof chokidar.watch> | undefined; | ||
| const closeWatcherOnError = async <T>(promise: Promise<T>) => { | ||
| try { | ||
| return await promise; | ||
| } catch (error) { | ||
| await Promise.all([watcher?.close(), dependencyWatcher?.close()]); | ||
| throw error; | ||
| } | ||
| }; | ||
| if (watcher) { | ||
| attachWatchEvents(watcher, 'source'); | ||
| watcher.on('error', (error) => { | ||
| console.error('Workflow dev watcher error', error); | ||
| }); | ||
| await closeWatcherOnError(once(watcher, 'ready')); | ||
|
vercel[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| const inputFiles = await closeWatcherOnError(this.getInputFiles()); | ||
| const tsconfigPath = await closeWatcherOnError(this.findTsConfigPath()); | ||
|
|
||
| const options = { | ||
| inputFiles, | ||
|
|
@@ -133,8 +227,12 @@ export async function getNextBuilderEager( | |
| }; | ||
|
|
||
| // V2: Build combined route (replaces separate step + flow routes) | ||
| const combinedResult = await this.buildCombinedFunction(options); | ||
| await this.buildWebhookRoute({ workflowGeneratedDir }); | ||
| const combinedResult = await closeWatcherOnError( | ||
| this.buildCombinedFunction(options) | ||
| ); | ||
| await closeWatcherOnError( | ||
| this.buildWebhookRoute({ workflowGeneratedDir }) | ||
| ); | ||
|
|
||
| const writeManifest = async ( | ||
| sourceManifest: WorkflowManifest | undefined | ||
|
|
@@ -175,12 +273,14 @@ export async function getNextBuilderEager( | |
| } | ||
| }; | ||
|
|
||
| await writeManifest(combinedResult?.manifest); | ||
| await closeWatcherOnError(writeManifest(combinedResult?.manifest)); | ||
|
|
||
| await this.writeFunctionsConfig(outputDir); | ||
| await closeWatcherOnError(this.writeFunctionsConfig(outputDir)); | ||
|
|
||
| if (this.config.watch) { | ||
| assert(watcher, 'Invariant: expected workflow watcher in watch mode'); | ||
| if (!combinedResult?.interimBundleCtx || !combinedResult.bundleFinal) { | ||
| await watcher.close(); | ||
| throw new Error( | ||
| 'Invariant: expected workflow build context in watch mode' | ||
| ); | ||
|
|
@@ -199,43 +299,53 @@ export async function getNextBuilderEager( | |
| '__step_registrations.js' | ||
| ); | ||
|
|
||
| const normalizePath = (pathname: string) => | ||
| (isAbsolute(pathname) | ||
| ? pathname | ||
| : resolve(this.config.workingDir, pathname) | ||
| ).replace(/\\/g, '/'); | ||
| let affectedWorkflowFiles = getAffectedWorkflowFiles({ | ||
| discoveredEntries, | ||
| importGraph: discoveredEntries.importParents, | ||
| normalizePath, | ||
| }); | ||
| let sourceSnapshots = new Map<string, SourceSnapshot>(); | ||
|
|
||
| const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); | ||
| const normalizedDistDir = normalizePath(this.config.distDir); | ||
|
|
||
| // Prune the dev watch set to keep chokidar from registering an | ||
| // fs.watch per directory across the whole project tree (chokidar 4 | ||
| // dropped fsevents, so on macOS that exhausts the fd limit -> EMFILE | ||
| // on large monorepos). This honors `.gitignore` and the | ||
| // WORKFLOW_DEV_WATCH_IGNORED_PATHS env var in addition to the | ||
| // built-in fragments. The generated workflow dir is passed as an | ||
| // extra fragment so it is pruned regardless of `.gitignore`. | ||
| const isIgnoredWatchPath = createWatchIgnorePredicate({ | ||
| workingDir: this.config.workingDir, | ||
| projectRoot: this.transformProjectRoot, | ||
| extraFragments: [normalizedGeneratedDir], | ||
| relevantFiles = getRelevantFiles({ | ||
| discoveredEntries, | ||
| inputFiles: options.inputFiles, | ||
| normalizePath, | ||
| }); | ||
|
|
||
| const hasIgnoredPathFragment = (normalizedPath: string) => { | ||
| if ( | ||
| normalizedPath === normalizedDistDir || | ||
| normalizedPath.startsWith(`${normalizedDistDir}/`) | ||
| ) { | ||
| return true; | ||
| const replaceDependencyWatcher = async () => { | ||
| const dependencyDirectories = new Set( | ||
| [...relevantFiles] | ||
| .filter((file) => !sourceWatcherCovers(file)) | ||
| .map(dirname) | ||
| ); | ||
| if (dependencyDirectories.size === 0) { | ||
| const previousWatcher = dependencyWatcher; | ||
| dependencyWatcher = undefined; | ||
| await previousWatcher?.close(); | ||
| return; | ||
| } | ||
|
|
||
| const nextWatcher = chokidar.watch([...dependencyDirectories], { | ||
| depth: 0, | ||
| ignoreInitial: true, | ||
| followSymlinks: true, | ||
| }); | ||
| attachWatchEvents(nextWatcher, 'dependencies'); | ||
| nextWatcher.on('error', (error) => { | ||
| console.error('Workflow dev watcher error', error); | ||
| }); | ||
| try { | ||
| await once(nextWatcher, 'ready'); | ||
| } catch (error) { | ||
| await nextWatcher.close(); | ||
| throw error; | ||
| } | ||
| return isIgnoredWatchPath(normalizedPath); | ||
|
|
||
| const previousWatcher = dependencyWatcher; | ||
| dependencyWatcher = nextWatcher; | ||
| await previousWatcher?.close(); | ||
| }; | ||
| await closeWatcherOnError(replaceDependencyWatcher()); | ||
|
|
||
| let sourceSnapshots = new Map<string, SourceSnapshot>(); | ||
|
|
||
| const readSourceSnapshot = (file: string) => | ||
| createSourceSnapshot({ file, analyzeWorkflowSource }); | ||
|
|
@@ -317,6 +427,11 @@ export async function getNextBuilderEager( | |
| interimBundleCtx: newCombined.interimBundleCtx, | ||
| bundleFinal: newCombined.bundleFinal, | ||
| }; | ||
| relevantFiles = getRelevantFiles({ | ||
| discoveredEntries, | ||
| inputFiles: options.inputFiles, | ||
| normalizePath, | ||
| }); | ||
|
|
||
| await previousWorkflowsCtx.dispose(); | ||
|
|
||
|
|
@@ -328,38 +443,17 @@ export async function getNextBuilderEager( | |
| ); | ||
| }; | ||
|
|
||
| let relevantFiles = getRelevantFiles({ | ||
| discoveredEntries, | ||
| inputFiles: options.inputFiles, | ||
| normalizePath, | ||
| }); | ||
| const isWatchableFile = (path: string) => | ||
| isSourceFile(path) || relevantFiles.has(path); | ||
|
|
||
| const logDevHmr = (...args: unknown[]) => { | ||
| if (process.env.WORKFLOW_DEV_HMR_LOGS === '1') { | ||
| console.log(...args); | ||
| } | ||
| }; | ||
|
|
||
| sourceSnapshots = await snapshotSources(); | ||
| let watchGeneration = 0; | ||
|
|
||
| const refreshKnownFiles = async () => { | ||
| relevantFiles = getRelevantFiles({ | ||
| discoveredEntries, | ||
| inputFiles: options.inputFiles, | ||
| normalizePath, | ||
| }); | ||
| await watcher.add([...relevantFiles]); | ||
| }; | ||
|
|
||
| const runFullRebuild = async () => { | ||
| const generation = watchGeneration; | ||
| logDevHmr('workflow dev hmr: full rediscovery'); | ||
| const buildWasStable = await fullRebuild(); | ||
| await refreshKnownFiles(); | ||
| return buildWasStable && generation === watchGeneration; | ||
| await replaceDependencyWatcher(); | ||
| const finalSnapshots = await snapshotSources(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Note Every full rebuild now stats the source tree three times: once at the top of The second and third reads are separated only by If the intent is to also catch writes that land during |
||
| return ( | ||
| buildWasStable && | ||
| sourceSnapshotsMatch(sourceSnapshots, finalSnapshots) && | ||
| generation === watchGeneration | ||
| ); | ||
| }; | ||
|
|
||
| const processFileChanges = async (files: string[]) => { | ||
|
|
@@ -415,7 +509,6 @@ export async function getNextBuilderEager( | |
| () => logDevHmr('workflow dev hmr: idle') | ||
| ); | ||
| const handleFileChanged = async (pathname: string) => { | ||
| watchGeneration++; | ||
| const normalizedPath = normalizePath(pathname); | ||
| if (!isWatchableFile(normalizedPath)) { | ||
| return; | ||
|
|
@@ -431,42 +524,39 @@ export async function getNextBuilderEager( | |
| }; | ||
|
|
||
| const scheduleFullRebuild = (pathname: string) => { | ||
| watchGeneration++; | ||
| const normalizedPath = normalizePath(pathname); | ||
| if (!isWatchableFile(normalizedPath)) { | ||
| return; | ||
| } | ||
| scheduleRebuild({ kind: 'full' }); | ||
| }; | ||
|
|
||
| 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 && !isSourceFile(normalizedPath)) { | ||
| return true; | ||
| } | ||
| return hasIgnoredPathFragment(normalizedPath); | ||
| }, | ||
| } | ||
| ); | ||
| const startupWasStable = await closeWatcherOnError(runFullRebuild()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: BlockingThe startup rediscovery is unconditional, so every Before this PR the startup path built once and then seeded the baseline with a single Measured on The user-visible effect is a duplicated The second build is only needed if something changed while the first one ran, and you already track that: const startupWasStable =
watchGeneration === 0
? ((sourceSnapshots = await snapshotSources()), true)
: await closeWatcherOnError(runFullRebuild());(or equivalent). That keeps the race fix and drops the cost when there is no race, which is the common case.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (AI) Re-verified against the current branch head (43bf5b3, after the merge of the updated base). This still stands, unchanged.
A note on what makes this harder to fix than it looks, since the author is no longer available to: the obvious fix (build once, then seed the baseline with So the correct fix is to route the initial build through the same snapshot-then-build ordering |
||
|
|
||
| watcher.on('add', scheduleFullRebuild); | ||
| watcher.on('change', handleFileChanged); | ||
| watcher.on('unlink', scheduleFullRebuild); | ||
| watcher.on('error', (error) => { | ||
| console.error('Workflow dev watcher error', error); | ||
| }); | ||
| watcher.on('ready', () => { | ||
| logDevHmr('workflow dev hmr: ready'); | ||
| }); | ||
| handleWatchEvent = async (event) => { | ||
| switch (event.kind) { | ||
| case 'add': | ||
| case 'unlink': | ||
| scheduleFullRebuild(event.pathname); | ||
| return; | ||
| case 'addDir': | ||
| case 'unlinkDir': | ||
| scheduleRebuild({ kind: 'full' }); | ||
| return; | ||
| case 'change': | ||
| await handleFileChanged(event.pathname); | ||
| return; | ||
| default: | ||
| event.kind satisfies never; | ||
| throw new Error('Unknown watch event'); | ||
| } | ||
| }; | ||
| logDevHmr('workflow dev hmr: ready'); | ||
| if (startupWasStable) { | ||
| logDevHmr('workflow dev hmr: idle'); | ||
| } else { | ||
| scheduleRebuild({ kind: 'full' }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI Review: Nit
createWatchIgnorePredicatemoved out of theif (this.config.watch)block, so production builds now construct it too. It callsloadGitignoreMatchers, which walks up to the project root doing synchronousreadFileSyncon each.gitignore.The cost is small (a handful of sync reads) and it is only build-time, so this isn't worth reworking the control flow for. But it is a behavior change outside dev mode that the PR doesn't mention, and it makes a production build's output depend on
.gitignoreparsing that previously never ran there.sourceWatcherCoversis the only non-watch consumer; if it is itself only reachable underwatch, the predicate can stay inside the block.