From 946a666127f44a06ce67a3f3db477e06696e6e78 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 30 Sep 2026 17:38:30 -0700 Subject: [PATCH] [next] Bundle workflows created after their import, or deleted and restored The graph-scoped dev watcher (#4396) only watches modules discovery reached, the directories they live in, and the route roots. Two edits fell outside that scope and left a workflow unbundled until the dev server restarted: - writing an import before creating its target in a directory the graph does not occupy yet (or directly in the project root or `src/`) - deleting a graph module while its importer is unchanged, then restoring it Discovery now records where each unresolved relative or tsconfig-alias import would resolve, and the Next watcher tracks those paths as `missing`. Creating one forces a rediscovery. When the importer is a workflow or step module the bundle build throws on the unresolved import, so the failed attempt's candidates are kept in scope until a rediscovery succeeds. Co-Authored-By: Claude Opus 5.5 --- .../next-dev-watch-unresolved-imports.md | 6 + .../configuration/build-and-diagnostics.mdx | 1 + packages/builders/src/fast-discovery.test.ts | 57 +++++++ packages/builders/src/fast-discovery.ts | 140 +++++++++++++----- packages/core/e2e/dev.test.ts | 127 ++++++++++++++++ packages/next/src/builder-eager.ts | 36 ++++- packages/next/src/watch-rebuild.test.ts | 4 +- packages/next/src/watch-rebuild.ts | 24 +-- packages/next/src/watch-scope.test.ts | 65 +++++++- packages/next/src/watch-scope.ts | 27 +++- 10 files changed, 430 insertions(+), 57 deletions(-) create mode 100644 .changeset/next-dev-watch-unresolved-imports.md diff --git a/.changeset/next-dev-watch-unresolved-imports.md b/.changeset/next-dev-watch-unresolved-imports.md new file mode 100644 index 0000000000..2bef3ae5ad --- /dev/null +++ b/.changeset/next-dev-watch-unresolved-imports.md @@ -0,0 +1,6 @@ +--- +'@workflow/next': patch +'@workflow/builders': patch +--- + +Fix `next dev` not bundling a workflow that is created after its import, or deleted and restored diff --git a/docs/content/docs/v5/configuration/build-and-diagnostics.mdx b/docs/content/docs/v5/configuration/build-and-diagnostics.mdx index a1fd0305c8..dbb5d0130c 100644 --- a/docs/content/docs/v5/configuration/build-and-diagnostics.mdx +++ b/docs/content/docs/v5/configuration/build-and-diagnostics.mdx @@ -83,6 +83,7 @@ In `next dev`, Workflow watches the modules your app actually imports, not your - Every file the workflow build reached from your Next.js entrypoints. Editing one rebuilds the affected workflow bundles. - The directories those files live in, so a module you add under an import you have already written is picked up. +- The paths behind imports that do not resolve yet. Write `import './billing/workflow'` before creating the file, or delete a workflow module and restore it, and the module is bundled as soon as it exists, wherever it lives. - The `app` and `pages` directories (`src/` variants included), so a route you create is noticed even though nothing imports it yet, along with root entrypoints such as `middleware.ts` and `instrumentation.ts`. A directory your app never imports is not watched at all, so files there never produce workflow bundles or rebuilds. Import one from a page and the next rebuild brings it into the watched set. Files are only ever watched through the directory that contains them, so the watcher's cost scales with the number of watched directories rather than the number of source files. diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index dc9673a8b4..1db4b7e1f4 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -337,6 +337,63 @@ export const allWorkflows = { workflow }; ); }); + describe('unresolved import candidates', () => { + const discover = async (entrySource: string) => { + const entryFile = join(testRoot, 'src', 'entry.ts'); + const tsconfigFile = join(testRoot, 'tsconfig.json'); + writeFile( + tsconfigFile, + JSON.stringify({ compilerOptions: { paths: { '@/*': ['./src/*'] } } }) + ); + writeFile(entryFile, entrySource); + const discovered = await createBuilder(testRoot).discoverEntriesPublic( + [entryFile], + join(testRoot, 'out'), + tsconfigFile + ); + return discovered.unresolvedImportCandidates ?? new Set(); + }; + + const src = (...segments: string[]) => + normalize(join(testRoot, 'src', ...segments)); + + it('records where a missing relative import would resolve', async () => { + const candidates = await discover(`import './billing/workflow';\n`); + + expect(candidates).toContain(src('billing', 'workflow.ts')); + expect(candidates).toContain(src('billing', 'workflow', 'index.ts')); + }); + + it('records where a missing tsconfig alias import would resolve', async () => { + const candidates = await discover(`import '@/billing/workflow';\n`); + + expect(candidates).toContain(src('billing', 'workflow.ts')); + }); + + it('records a missing import written with a source extension as-is', async () => { + const candidates = await discover(`import './billing/workflow.ts';\n`); + + expect([...candidates]).toEqual([src('billing', 'workflow.ts')]); + }); + + it('records nothing for imports that resolve', async () => { + writeFile(join(testRoot, 'src', 'helper.ts'), 'export const x = 1;\n'); + writeFile(join(testRoot, 'src', 'styles.css'), 'body {}\n'); + + const candidates = await discover( + `import './helper';\nimport '@/helper';\nimport './styles.css';\n` + ); + + expect([...candidates]).toEqual([]); + }); + + it('records nothing for an unresolved bare package import', async () => { + const candidates = await discover(`import 'not-installed-package';\n`); + + expect([...candidates]).toEqual([]); + }); + }); + it('discovers dotted files reached through tsconfig path aliases', async () => { const entryFile = join(testRoot, 'src', 'entry.ts'); const registryFile = join(testRoot, 'src', 'workflows', 'hello.index.ts'); diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index 7e2b3250f2..6c3d508053 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -1,4 +1,4 @@ -import { access, readFile } from 'node:fs/promises'; +import { access, readFile, stat } from 'node:fs/promises'; import { builtinModules, createRequire } from 'node:module'; import { dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -58,6 +58,14 @@ export interface DiscoveredEntries { * unrelated application file edits. */ discoveredFiles?: Set; + /** + * Paths an import in the graph would resolve to if the file existed, for + * relative and tsconfig-alias imports that did not resolve. An import can be + * written before its target is created, or outlive a target that was deleted: + * either way the graph stops at the importer, and only the appearance of one + * of these paths can extend it again. Watch-mode integrations track them. + */ + unresolvedImportCandidates?: Set; } interface FastDiscoverEntriesOptions { @@ -569,6 +577,8 @@ export async function fastDiscoverEntries({ queue.push(normalizedPath); }; + const unresolvedImportCandidates = new Set(); + const readSource = async (filePath: string): Promise => { return await readLimit(async () => { try { @@ -597,6 +607,51 @@ export async function fastDiscoverEntries({ return promise; }; + const isExistingFile = (filePath: string): Promise => + readLimit(async () => { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } + }); + + const resolveBasePath = (importer: string, specifier: string) => { + const strippedSpecifier = stripImportSpecifierQuery(specifier); + return isAbsolute(strippedSpecifier) + ? strippedSpecifier + : resolve(dirname(importer), strippedSpecifier); + }; + + /** Every source path `resolvePathLikeSpecifier` would accept for `basePath`. */ + const pathLikeCandidates = (basePath: string): string[] => + FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extname(basePath)) + ? [basePath] + : [ + ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map( + (candidateExtension) => `${basePath}${candidateExtension}` + ), + ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map((candidateExtension) => + join(basePath, `index${candidateExtension}`) + ), + ]; + + const recordUnresolved = async (basePaths: string[]): Promise => { + for (const basePath of basePaths) { + // An existing non-source file (a stylesheet, an image) is an import that + // resolved to something discovery does not follow, not a missing module. + if ( + !FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extname(basePath)) && + (await isExistingFile(basePath)) + ) { + continue; + } + for (const candidate of pathLikeCandidates(basePath)) { + unresolvedImportCandidates.add(normalizePath(candidate)); + } + } + }; + const findTsconfigPathForImporter = ( importer: string ): Promise => { @@ -638,23 +693,9 @@ export async function fastDiscoverEntries({ importer: string, specifier: string ): Promise => { - const strippedSpecifier = stripImportSpecifierQuery(specifier); - const basePath = isAbsolute(strippedSpecifier) - ? strippedSpecifier - : resolve(dirname(importer), strippedSpecifier); - const extension = extname(basePath); - if (FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extension)) { - return (await fileExists(basePath)) ? normalizePath(basePath) : null; - } - - for (const candidate of [ - ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map( - (candidateExtension) => `${basePath}${candidateExtension}` - ), - ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map((candidateExtension) => - join(basePath, `index${candidateExtension}`) - ), - ]) { + for (const candidate of pathLikeCandidates( + resolveBasePath(importer, specifier) + )) { if (await fileExists(candidate)) { return normalizePath(candidate); } @@ -663,18 +704,24 @@ export async function fastDiscoverEntries({ return null; }; + /** + * Resolve through the importer's tsconfig `paths`. `basePaths` lists every + * alias target that matched, so a caller that ends up with no resolution + * knows where the module was expected to be. + */ const resolveWithTsconfigPaths = async ( importer: string, specifier: string - ): Promise => { + ): Promise<{ resolved: string | null; basePaths: string[] }> => { + const basePaths: string[] = []; if (specifier.startsWith('.') || isAbsolute(specifier)) { - return null; + return { resolved: null, basePaths }; } const tsconfigPath = await findTsconfigPathForImporter(importer); const tsconfigPathAliases = await loadAliasesForTsconfig(tsconfigPath); if (tsconfigPathAliases.length === 0) { - return null; + return { resolved: null, basePaths }; } for (const alias of tsconfigPathAliases) { @@ -685,16 +732,17 @@ export async function fastDiscoverEntries({ for (const target of alias.targets) { const targetPath = applyTsconfigPathTarget(target, captures); + basePaths.push(resolveBasePath(importer, targetPath)); try { const resolved = await resolvePathLikeSpecifier(importer, targetPath); if (resolved) { - return resolved; + return { resolved, basePaths }; } } catch {} } } - return null; + return { resolved: null, basePaths }; }; const findPackageInfoBySpecifier = ( @@ -766,6 +814,29 @@ export async function fastDiscoverEntries({ return false; }; + const resolveRelativeImport = async ( + importer: string, + specifier: string + ): Promise => { + const resolved = await resolvePathLikeSpecifier(importer, specifier); + if (!resolved) { + await recordUnresolved([resolveBasePath(importer, specifier)]); + } + return resolved; + }; + + const resolveBareSpecifier = async ( + importer: string, + specifier: string + ): Promise => { + try { + const resolved = await fastDiscoveryResolve(dirname(importer), specifier); + return typeof resolved === 'string' ? normalizePath(resolved) : null; + } catch { + return null; + } + }; + const resolveImport = ( importer: string, specifier: string @@ -778,27 +849,25 @@ export async function fastDiscoverEntries({ const resolvedPromise = resolveLimit(async () => { if (isRelativeOrAbsoluteSpecifier(specifier)) { - return resolvePathLikeSpecifier(importer, specifier); + return resolveRelativeImport(importer, specifier); } - const resolvedAlias = await resolveWithTsconfigPaths(importer, specifier); - if (resolvedAlias) { - return resolvedAlias; + const alias = await resolveWithTsconfigPaths(importer, specifier); + if (alias.resolved) { + return alias.resolved; } + // An installed package discovery chooses not to follow is resolved as + // far as the graph is concerned, even under a catch-all alias like `*`. if (!(await shouldResolveBareSpecifier(specifier))) { return null; } - try { - const resolved = await fastDiscoveryResolve( - dirname(importer), - specifier - ); - return typeof resolved === 'string' ? normalizePath(resolved) : null; - } catch { - return null; + const resolved = await resolveBareSpecifier(importer, specifier); + if (!resolved) { + await recordUnresolved(alias.basePaths); } + return resolved; }); resolveCache.set(cacheKey, resolvedPromise); return resolvedPromise; @@ -935,4 +1004,5 @@ export async function fastDiscoverEntries({ } state.discoveredFiles = processedFiles; + state.unresolvedImportCandidates = unresolvedImportCandidates; } diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index be5ac87dc5..2c2d7297cd 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -554,6 +554,133 @@ export async function hmrPageWorkflow() { } ); + /** + * The import is written before the module exists, in a directory nothing + * in the graph lives in yet. Rediscovery cannot resolve it, so the module + * is not in the graph, and creating it changes no file that is already + * watched. Only watching the unresolved import's target catches it. + */ + test.runIf(shouldRunNextFlowRouteHmrTests)( + 'should bundle a workflow created after its import was written', + { timeout: multiPhaseHmrTestTimeoutMs }, + async () => { + await waitForHmrReady(); + + const lateDir = path.join(appPath, 'dev-test-late-import'); + const lateFile = path.join(lateDir, 'late-workflow.ts'); + const apiFile = path.join(appPath, finalConfig.apiFilePath); + const apiFileContent = await fs.readFile(apiFile, 'utf8'); + restoreFiles.push({ path: apiFile, content: apiFileContent }); + restoreFiles.push({ path: lateFile, content: '' }); + restoreDirectories.push(lateDir); + // Let the previous test's teardown rebuild finish first, like every + // other HMR case here, so the writes below land on an idle pipeline. + await readDevServerLogCursor(); + + await fs.writeFile( + apiFile, + `import '${finalConfig.apiFileImportPath}/dev-test-late-import/late-workflow'; +${apiFileContent}` + ); + // The rediscovery the import edit triggers has to be over before the + // module appears, or it would find the module itself. + await readDevServerLogCursor(); + if (!shouldAssertDevHmrLogs) { + await sleep(5_000); + } + expect(await readManifestWorkflowFunctionNames()).not.toContain( + 'devTestLateWorkflow' + ); + + await fs.mkdir(lateDir, { recursive: true }); + await fs.writeFile( + lateFile, + `export async function devTestLateWorkflow() { + 'use workflow'; + return 'late'; +} +` + ); + + await pollUntil({ + description: 'late-created workflow to appear in manifest', + timeoutMs: hmrRediscoveryTimeoutMs, + intervalMs: 500, + check: async () => { + await prewarm(); + expect(await readManifestWorkflowFunctionNames()).toContain( + 'devTestLateWorkflow' + ); + }, + }); + } + ); + + /** + * Deleting a module while its importer is left alone drops it from the + * graph, and with it the directory it was the only module in, so restoring + * it changes nothing the graph still watches. + */ + test.runIf(shouldRunNextFlowRouteHmrTests)( + 'should bundle a workflow again after it is deleted and restored', + { timeout: multiPhaseHmrTestTimeoutMs }, + async () => { + await waitForHmrReady(); + + const restoredDir = path.join(appPath, 'dev-test-restored'); + const workflowFile = path.join(restoredDir, 'restored-workflow.ts'); + const workflowContent = `export async function devTestRestoredWorkflow() { + 'use workflow'; + return 'restored'; +} +`; + const apiFile = path.join(appPath, finalConfig.apiFilePath); + const apiFileContent = await fs.readFile(apiFile, 'utf8'); + restoreFiles.push({ path: apiFile, content: apiFileContent }); + restoreFiles.push({ path: workflowFile, content: '' }); + restoreDirectories.push(restoredDir); + await readDevServerLogCursor(); + + const expectInManifest = async ( + present: boolean, + description: string + ) => + pollUntil({ + description, + timeoutMs: hmrRediscoveryTimeoutMs, + intervalMs: 500, + check: async () => { + await prewarm(); + const names = await readManifestWorkflowFunctionNames(); + if (present) { + expect(names).toContain('devTestRestoredWorkflow'); + } else { + expect(names).not.toContain('devTestRestoredWorkflow'); + } + }, + }); + + await fs.mkdir(restoredDir, { recursive: true }); + await fs.writeFile(workflowFile, workflowContent); + await fs.writeFile( + apiFile, + `import '${finalConfig.apiFileImportPath}/dev-test-restored/restored-workflow'; +${apiFileContent}` + ); + await expectInManifest(true, 'fixture workflow to appear in manifest'); + + await fs.rm(workflowFile); + await expectInManifest(false, 'deleted workflow to leave the manifest'); + await readDevServerLogCursor(); + + await fs.writeFile(workflowFile, workflowContent); + await expectInManifest( + true, + 'restored workflow to reappear in manifest' + ); + } + ); + test.runIf( shouldRunNextFlowRouteHmrTests && process.env.APP_NAME === 'nextjs-turbopack' diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 0ab7955edd..90fc765c89 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -321,6 +321,21 @@ export async function getNextBuilderEager( }); setActiveDevWatcher(watcher); + /** + * Unresolved imports seen by the discovery pass of a rediscovery whose + * bundle then failed. esbuild rejects an import it cannot resolve, so + * when the importer is itself a workflow or step module the rebuild + * throws and `discoveredEntries` keeps describing the previous graph, + * which never mentioned the new import. The target still has to be + * watched, or creating it is the one edit that cannot end the failure. + * Cleared by the next rediscovery that succeeds. + */ + let failedRebuildUnresolvedImports: ReadonlySet = new Set(); + + const isUnresolvedImport = (file: string) => + discoveredEntries.unresolvedImportCandidates?.has(file) === true || + failedRebuildUnresolvedImports.has(file); + /** * Attach the watcher to the scope the current graph implies. * @@ -343,6 +358,10 @@ export async function getNextBuilderEager( inputFiles: options.inputFiles, normalizePath, }), + unresolvedImports: [ + ...(discoveredEntries.unresolvedImportCandidates ?? []), + ...failedRebuildUnresolvedImports, + ], pageExtensions: this.config.pageExtensions, isIgnored: hasIgnoredPathFragment, }); @@ -589,7 +608,8 @@ export async function getNextBuilderEager( discoveredEntries, fileChanges, inputFiles: options.inputFiles, - isEntrypoint: isNextEntrypointPath, + forcesRediscovery: (file) => + isNextEntrypointPath(file) || isUnresolvedImport(file), normalizePath, parentHasChild, readSnapshot: readSourceSnapshot, @@ -606,10 +626,24 @@ export async function getNextBuilderEager( logDevHmr('workflow dev hmr: full rediscovery'); try { await fullRebuild(); + failedRebuildUnresolvedImports = new Set(); refreshKnownFiles(); // Rediscovery is what moves files in and out of the graph, so the // watch set is only correct once it follows. applyWatchScope(batchStartedAt); + } catch (error) { + // Discovery runs before the bundle and records where every + // unresolved import was expected; it is cached for these input + // files, so reading it back here does not walk the graph again. + const attempted = await this.discoverEntries( + options.inputFiles, + join(workflowGeneratedDir, 'flow'), + options.tsconfigPath + ).catch(() => undefined); + failedRebuildUnresolvedImports = + attempted?.unresolvedImportCandidates ?? new Set(); + applyWatchScope(batchStartedAt); + throw error; } finally { // Lets a log reader tell "quiet" from "rebuild in flight". // The e2e HMR tests drain-to-quiet before counting lines. diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index cde72f1ee3..0837175689 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -410,13 +410,13 @@ export default function Page() { const classifyAddedFile = ( file: string, - isEntrypoint?: (candidate: string) => boolean + forcesRediscovery?: (candidate: string) => boolean ) => classifyRebuild({ discoveredEntries, fileChanges: { addedFiles: [file], modifiedFiles: [], removedFiles: [] }, inputFiles: [pageFile], - isEntrypoint, + forcesRediscovery, parentHasChild: () => false, readSnapshot: async () => createSourceSnapshotFromSource(newPageSource, detectWorkflowPatterns), diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 120a66eb97..4a8d51a981 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -447,18 +447,19 @@ const removedFilesRequireFullRebuild = ({ const addedFilesRequireFullRebuild = async ({ addedFiles, - isEntrypoint, + forcesRediscovery, readSnapshot, }: { addedFiles: string[]; - isEntrypoint: (file: string) => boolean; + forcesRediscovery: (file: string) => boolean; readSnapshot: (file: string) => Promise; }) => { for (const file of addedFiles) { - // A new framework entrypoint changes the set discovery starts from, so it - // has to be rediscovered even when the file declares nothing itself: the - // workflow it imports may be reachable for the first time. - if (isEntrypoint(file)) { + // Some files extend the graph just by existing (a new framework + // entrypoint, or the target of an import that did not resolve), so they + // have to be rediscovered even when they declare nothing themselves: a + // workflow they import may be reachable for the first time. + if (forcesRediscovery(file)) { return true; } try { @@ -644,7 +645,7 @@ export const classifyRebuild = async ({ discoveredEntries, fileChanges, inputFiles, - isEntrypoint = () => false, + forcesRediscovery = () => false, normalizePath = defaultNormalizePath, parentHasChild, readSnapshot, @@ -654,10 +655,11 @@ export const classifyRebuild = async ({ fileChanges: FileChanges; inputFiles: string[]; /** - * Whether a path is an entrypoint the framework picks up by convention. - * Creating one changes the input set, which only rediscovery can act on. + * Whether creating this path changes what discovery would reach: an + * entrypoint the framework picks up by convention, or the target of an + * import that did not resolve. Only rediscovery can act on either. */ - isEntrypoint?: (file: string) => boolean; + forcesRediscovery?: (file: string) => boolean; normalizePath?: (path: string) => string; parentHasChild: ( parent: string, @@ -686,7 +688,7 @@ export const classifyRebuild = async ({ }) || (await addedFilesRequireFullRebuild({ addedFiles: normalizedFileChanges.addedFiles, - isEntrypoint, + forcesRediscovery, readSnapshot, })) || (await modifiedFilesRequireFullRebuild({ diff --git a/packages/next/src/watch-scope.test.ts b/packages/next/src/watch-scope.test.ts index 31601797d6..a2866fdf98 100644 --- a/packages/next/src/watch-scope.test.ts +++ b/packages/next/src/watch-scope.test.ts @@ -43,14 +43,17 @@ describe('createWatchScope', () => { // the watcher needs; compare in the canonical form the rest of the suite uses. const scope = ({ relevantFiles = [] as string[], + unresolvedImports = [] as string[], isIgnored = () => false, }: { relevantFiles?: string[]; + unresolvedImports?: string[]; isIgnored?: (path: string) => boolean; } = {}) => { const result = createWatchScope({ workingDir: root, relevantFiles, + unresolvedImports, pageExtensions, isIgnored, }); @@ -175,6 +178,22 @@ describe('createWatchScope', () => { expect(scope().missing).toContain(p('src/middleware.ts')); }); + test('waits for the targets of unresolved imports, without following their directories', () => { + const page = write('app/page.tsx'); + const target = p('lib/billing/workflow.ts'); + + const result = scope({ + relevantFiles: [page], + unresolvedImports: [target, p('node_modules/pkg/index.ts')], + isIgnored: (path) => path.includes('/node_modules/'), + }); + + expect(result.missing).toContain(target); + expect(result.missing).not.toContain(p('node_modules/pkg/index.ts')); + expect(result.directories).not.toContain(p('lib')); + expect(result.directories).not.toContain(p('lib/billing')); + }); + test('never lists a root entrypoint as both tracked and missing', () => { const middleware = write('middleware.ts'); @@ -247,19 +266,21 @@ describe('watching a scope', () => { const changes: string[] = []; const removals: string[] = []; - const buildScope = (relevantFiles: string[]) => + const buildScope = (relevantFiles: string[], unresolvedImports: string[]) => createWatchScope({ workingDir: root, relevantFiles, + unresolvedImports, pageExtensions, isIgnored: (path) => path.includes('/node_modules/'), }); const startWatching = ( relevantFiles: string[], - startTime: number = Date.now() + startTime: number = Date.now(), + unresolvedImports: string[] = [] ) => { - const scope = buildScope(relevantFiles); + const scope = buildScope(relevantFiles, unresolvedImports); if (!watcher) { watcher = new Watchpack({ followSymlinks: false }); @@ -432,4 +453,42 @@ describe('watching a scope', () => { expect(await waitFor(() => changes.includes(created))).toBe(true); }); + + /** + * The import is written before its target exists, in a directory that does + * not exist either. Nothing in the graph changes when the file is created, + * so only watching the unresolved target catches it. + */ + test('reports an unresolved import target created in a new directory', async () => { + write('app/page.tsx'); + const target = p('lib/billing/workflow.ts'); + + const { changes } = startWatching([p('app/page.tsx')], Date.now(), [ + target, + ]); + const created = write('lib/billing/workflow.ts'); + + expect(created).toBe(target); + expect(await waitFor(() => changes.includes(target))).toBe(true); + }); + + /** + * A graph module is deleted while its importer is left alone. Rediscovery + * drops it from the graph and reports it as an unresolved target instead, + * so restoring it is still seen. + */ + test('reports a deleted module that is restored after leaving the graph', async () => { + write('app/page.tsx'); + const workflow = write('workflows/order.ts'); + startWatching([p('app/page.tsx'), workflow]); + + rmSync(join(root, 'workflows'), { recursive: true }); + expect(await waitFor(() => removals.includes(workflow))).toBe(true); + startWatching([p('app/page.tsx')], Date.now(), [workflow]); + await new Promise((resolve) => setTimeout(resolve, 200)); + + write('workflows/order.ts'); + + expect(await waitFor(() => changes.includes(workflow))).toBe(true); + }); }); diff --git a/packages/next/src/watch-scope.ts b/packages/next/src/watch-scope.ts index 8bcd5dcf53..5f38c53a16 100644 --- a/packages/next/src/watch-scope.ts +++ b/packages/next/src/watch-scope.ts @@ -65,7 +65,8 @@ export interface WatchScope { */ directories: string[]; /** - * Paths that do not exist yet but would become entrypoints if they appeared. + * Paths that do not exist yet but would change the graph if they appeared: + * root entrypoints, and the targets of imports discovery could not resolve. * Tracked through their parent directory, never as a watch of their own. */ missing: string[]; @@ -79,6 +80,14 @@ export interface WatchScopeOptions { * framework entrypoints (`inputFiles` plus everything discovery walked into). */ relevantFiles: Iterable; + /** + * POSIX-normalized absolute paths an unresolved import in the graph would + * resolve to. The importer is watched, but the graph stops there: when the + * target is created later (the import was written first) or restored (it was + * deleted and came back), nothing already watched changes, so these paths + * have to be watched on their own. Their directories may not exist yet. + */ + unresolvedImports?: Iterable; /** The project's resolved `pageExtensions`. */ pageExtensions: readonly string[]; /** Predicate from `createWatchIgnorePredicate`, plus the caller's dist dir. */ @@ -204,6 +213,7 @@ function graphDirectories({ export function createWatchScope({ workingDir, relevantFiles, + unresolvedImports = [], pageExtensions, isIgnored, pathExists = existsSync, @@ -231,8 +241,8 @@ export function createWatchScope({ const watchedFiles = new Set(files); const missing = [ - ...new Set( - rootEntrypointCandidates({ + ...new Set([ + ...rootEntrypointCandidates({ workingDir: root, pageExtensions, pathExists, @@ -241,8 +251,15 @@ export function createWatchScope({ !isIgnored(candidate) && !watchedFiles.has(candidate) && !pathExists(candidate) - ) - ), + ), + // Kept even when the path exists by now: it was created after discovery + // ran, and the attach's `startTime` replays that creation as a change. + ...[...unresolvedImports] + .map(toPosix) + .filter( + (candidate) => !isIgnored(candidate) && !watchedFiles.has(candidate) + ), + ]), ].sort(); return {