Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/next-dev-watch-unresolved-imports.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 57 additions & 0 deletions packages/builders/src/fast-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
};

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');
Expand Down
140 changes: 105 additions & 35 deletions packages/builders/src/fast-discovery.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -58,6 +58,14 @@ export interface DiscoveredEntries {
* unrelated application file edits.
*/
discoveredFiles?: Set<string>;
/**
* 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<string>;
}

interface FastDiscoverEntriesOptions {
Expand Down Expand Up @@ -569,6 +577,8 @@ export async function fastDiscoverEntries({
queue.push(normalizedPath);
};

const unresolvedImportCandidates = new Set<string>();

const readSource = async (filePath: string): Promise<string | null> => {
return await readLimit(async () => {
try {
Expand Down Expand Up @@ -597,6 +607,51 @@ export async function fastDiscoverEntries({
return promise;
};

const isExistingFile = (filePath: string): Promise<boolean> =>
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<void> => {
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<string | undefined> => {
Expand Down Expand Up @@ -638,23 +693,9 @@ export async function fastDiscoverEntries({
importer: string,
specifier: string
): Promise<string | null> => {
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);
}
Expand All @@ -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<string | null> => {
): 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) {
Expand All @@ -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 = (
Expand Down Expand Up @@ -766,6 +814,29 @@ export async function fastDiscoverEntries({
return false;
};

const resolveRelativeImport = async (
importer: string,
specifier: string
): Promise<string | null> => {
const resolved = await resolvePathLikeSpecifier(importer, specifier);
if (!resolved) {
await recordUnresolved([resolveBasePath(importer, specifier)]);
}
return resolved;
};

const resolveBareSpecifier = async (
importer: string,
specifier: string
): Promise<string | null> => {
try {
const resolved = await fastDiscoveryResolve(dirname(importer), specifier);
return typeof resolved === 'string' ? normalizePath(resolved) : null;
} catch {
return null;
}
};

const resolveImport = (
importer: string,
specifier: string
Expand All @@ -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;
Expand Down Expand Up @@ -935,4 +1004,5 @@ export async function fastDiscoverEntries({
}

state.discoveredFiles = processedFiles;
state.unresolvedImportCandidates = unresolvedImportCandidates;
}
Loading
Loading