fix(javascript): resolve Vite build-output preload dependencies (#7745)

* fix(javascript): resolve Vite build-output preload dependencies

With an absolute base, Vite writes __vite__mapDeps entries relative to the
build output directory (for example assets/chunk.js), and its preload helper
prefixes the configured base at runtime. REA emitted these entries as
dynamic-import module specifiers, so a scheme-less path without a leading
dot resolved as a bare package and every entry was reported as an
unresolved "external" module, even when the build output directory was
selected.

The static analyzer now marks such entries with a vite-output: specifier,
as Webpack chunk loads are marked with chunk:. Entries starting with ./,
../ or /, and scheme-qualified entries, keep their previous handling. The
graph resolves a marked entry as a URL path under each ancestor directory
of the importing file; exactly one ancestor holding the file establishes
the build output directory. Otherwise the entry is not-found (or
unavailable when several ancestors match) with a limitation that names
the build output directory.

On a synthetic 7-file Vite build, the 12 preload entries that were
"external" now resolve when the build output directory is selected, and
are not-found with that guidance when only assets/ is selected.

* fix(javascript): keep unanalyzed Vite output candidates

resolveViteOutputDependency considered only resolved ancestors. A file
that exists under an ancestor but was not analyzed (for example
dist/assets/lazy.css for the entry assets/lazy.css) resolves there as
unavailable, and that result was replaced by the root's not-found with a
limitation saying that no ancestor holds the file.

Keep resolved and unavailable ancestor candidates together. One candidate
is returned with its own path and limitations; several are reported as
unavailable with every candidate path and limitation. Only when no
ancestor can hold the file does the root's not-found result stand.
This commit is contained in:
JG Tromp authored and GitHub committed 2026-10-28 19:46:45 +08:00
1 parent 33698e6b31
commit 7aa943c14b
6 files changed
+275 -35

No files matched your search

@@ -3,7 +3,10 @@ import { posix } from "node:path";
import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import {
VITE_OUTPUT_DEPENDENCY_PREFIX,
type JavaScriptStaticAnalysis,
} from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addStaticInferenceEdge,
@@ -665,9 +668,13 @@ const resolveReference = (
posix.join(posix.dirname(sourcePath), reference.resolution_base_path),
)
: sourcePath;
const chunk = isWorker
? null
: resolveBundlerChunkReference(context, sourcePath, reference);
const viteDependency = specifier.startsWith(VITE_OUTPUT_DEPENDENCY_PREFIX)
? specifier.slice(VITE_OUTPUT_DEPENDENCY_PREFIX.length)
: undefined;
const chunk =
isWorker || viteDependency !== undefined
? null
: resolveBundlerChunkReference(context, sourcePath, reference);
if (chunk !== null) return chunk;
const module =
isWorker || reference.module_runtime === undefined
@@ -687,35 +694,38 @@ const resolveReference = (
};
}
// Worker scripts are URLs relative to the script, not module specifiers.
const resolution = isWorker
? resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath,
context: "url-reference",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
})
: resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath: resolutionSourcePath,
context: "module-specifier",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
...("require" === reference.kind ||
"static-import" === reference.kind ||
"dynamic-import" === reference.kind
? {
moduleKind: moduleResolutionModeForJavaScriptReference(
resolutionSourcePath,
reference.kind,
),
}
: {}),
});
const resolution =
viteDependency !== undefined
? resolveViteOutputDependency(context, sourcePath, viteDependency)
: isWorker
? resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath,
context: "url-reference",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
})
: resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath: resolutionSourcePath,
context: "module-specifier",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
...("require" === reference.kind ||
"static-import" === reference.kind ||
"dynamic-import" === reference.kind
? {
moduleKind: moduleResolutionModeForJavaScriptReference(
resolutionSourcePath,
reference.kind,
),
}
: {}),
});
const path = resolution.resolved_path;
const targetPath = path ?? resolution.candidate_path;
const targetKind =
@@ -773,6 +783,80 @@ const resolveReference = (
};
};
/**
* Vite's preload helper prefixes the configured base to a path under the
* build output directory, which is an ancestor of the importing chunk. Only
* one ancestor holding the file establishes that directory.
*/
const resolveViteOutputDependency = (
context: JavaScriptArtifactGraphContext,
sourcePath: string,
dependency: string,
): ArtifactPathResolution => {
const resolutions = ancestorDirectories(sourcePath).map((directory) =>
resolveArtifactPathByContext({
declaredPath: dependency,
sourcePath: posix.join(directory, posix.basename(sourcePath)),
context: "url-reference",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
}),
);
// An ancestor whose candidate exists but was not analyzed, or whose absence
// is unknown, may still be the build output directory. Keep it.
const candidates = resolutions.filter(
({ resolution_status: status }) =>
status === "resolved" || status === "unavailable",
);
const [single] = candidates;
if (candidates.length === 1 && single !== undefined) return single;
const root = resolutions.at(-1);
if (root === undefined)
throw new TypeError("A source path has at least the root directory");
if (candidates.length > 1) {
const { candidate_path: _rootCandidate, ...rootFacts } = root;
const paths = candidates.map(
(candidate) => candidate.resolved_path ?? candidate.candidate_path,
);
return {
...rootFacts,
resolved_path: null,
resolution_status: "unavailable",
limitations: [
...new Set([
...candidates.flatMap(({ limitations }) => limitations),
`Several ancestors of the importing file may hold this Vite build-output path (${paths.join(", ")}); the build output directory is not established.`,
]),
],
};
}
return {
...root,
resolved_path: null,
resolution_status:
root.resolution_status === "resolved"
? "not-found"
: root.resolution_status,
limitations: [
...root.limitations,
"A Vite preload dependency is relative to the build output directory, and no ancestor of the importing file holds it. Select the build output directory, which holds index.html and the assets directory.",
],
};
};
/** The source's directory and each ancestor up to the selected root. */
const ancestorDirectories = (sourcePath: string): string[] => {
const directories: string[] = [];
for (let directory = posix.dirname(sourcePath); ;) {
directories.push(directory === "." ? "" : directory);
if (directory === "." || directory === "/" || directory === "") break;
directory = posix.dirname(directory);
}
return directories;
};
const resolveBundlerChunkReference = (
context: JavaScriptArtifactGraphContext,
sourcePath: string,