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

@@ -364,6 +364,13 @@ Selected bounded text is then parsed as inert data to recover:
relative to the calling module; string worker and service-worker paths use
the document base URL or Node's working directory, which static analysis
does not establish, so they keep their expression and stay unresolved);
- Vite preload dependency tables (`__vite__mapDeps`): `./` entries resolve
relative to the importing chunk; base-relative entries such as
`assets/chunk.js` carry a `vite-output:` specifier and resolve under the one
ancestor directory of the importer that holds them, the build output
directory. A file found there but not analyzed, such as a stylesheet, stays
`unavailable` with its path. Selecting only the assets directory leaves them
`not-found`;
- route, network endpoint, storage, and vendor-marker observations;
- local source-map declarations and original
source names and content digests;
@@ -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,
@@ -7,7 +7,11 @@ import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js";
import {
hasScheme,
htmlUrlText,
stripQueryAndFragment,
} from "../artifactPathSyntax.js";
import { sanitizeEndpointCandidate } from "../browserObservation.js";
import {
addFindingOnce,
@@ -36,6 +40,7 @@ import type {
JavaScriptFindingContext as FindingContext,
JavaScriptReferenceInput as ReferenceInput,
} from "./javascriptStaticAnalysisState.js";
import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js";
import {
resolveSemanticBindingState,
semanticStableInitializer,
@@ -392,10 +397,26 @@ const vitePreloadDependencySpecifiers = (
return indexes.map((element) => {
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0 ? table[index] : undefined;
return Number.isSafeInteger(index) && index >= 0
? viteDependencySpecifier(table[index])
: undefined;
});
};
// A relative base writes importer-relative `./` paths, which remain module
// URLs. Without a scheme or leading dot or slash, the entry names a file under
// the build output directory.
const viteDependencySpecifier = (
dependency: string | undefined,
): string | undefined =>
dependency === undefined ||
dependency === "" ||
dependency.startsWith(".") ||
dependency.startsWith("/") ||
hasScheme(dependency)
? dependency
: `${VITE_OUTPUT_DEPENDENCY_PREFIX}${dependency}`;
const isExplicitDefaultArgument = (
argument: t.CallExpression["arguments"][number],
bindings: JavaScriptSemanticAnalysisState,
@@ -5,6 +5,14 @@ import type { JavaScriptStaticCoverage } from "./javascriptStaticCoverage.js";
/** Token semantics shared by bundle producers and worker admission. */
export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v3";
/**
* Marks a Vite preload dependency written relative to the build output
* directory (Vite's absolute-base form, such as `assets/chunk.js`). The
* runtime helper prefixes the configured base, so the path is not a module
* specifier and not relative to the importing chunk.
*/
export const VITE_OUTPUT_DEPENDENCY_PREFIX = "vite-output:";
/** Static syntax context retained for later artifact path resolution. */
export type JavaScriptStaticPathContext =
| "module-specifier"
@@ -121,4 +121,25 @@ describe("Vite preload dependency recovery", () => {
.map(({ specifier }) => specifier),
).toEqual(["./first.js", null]);
});
it("marks build-output paths apart from importer-relative module URLs", () => {
const analysis = analyzeJavaScriptStaticSource(`
const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["assets/lazy.js", "./near.js", "https://cdn.example/x.js", "/abs.js"]))) =>
indexes.map((index) => dependencies[index]);
__vite__mapDeps([0, 1, 2, 3]);
`);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier)
.sort(),
).toEqual([
"./near.js",
"/abs.js",
"https://cdn.example/x.js",
"vite-output:assets/lazy.js",
]);
});
});
@@ -0,0 +1,99 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import {
reconstructJavaScriptFixture,
writeFixtureFiles,
} from "../../../support/javascriptApplicationFixture.js";
// Vite's absolute-base build output: the helper table names files under the
// output directory, and the real import is relative to the importing chunk.
const viteBuild = async (): Promise<string> => {
const root = await createTestTempDirectory("rea-vite-output-");
await writeFixtureFiles(root, {
"index.html":
'<!doctype html><script type="module" src="/assets/index.js"></script>',
"assets/index.js": `
const __vite__mapDeps = (i, m = __vite__mapDeps, d =
(m.f || (m.f = ["assets/lazy.js", "assets/missing.js"]))) => i.map((i) => d[i]);
const preload = (load, deps) => load();
export const route = () =>
preload(() => import("./lazy.js"), __vite__mapDeps([0, 1]));
`,
"assets/lazy.js": "export const lazy = true;",
});
return root;
};
const preloadEdges = (
graph: Awaited<ReturnType<typeof reconstructJavaScriptFixture>>["graph"],
) =>
graph.edges
.filter(
({ relation, properties }) =>
relation === "imports" &&
typeof properties.specifier === "string" &&
properties.specifier.startsWith("vite-output:"),
)
.map(({ properties }) => ({
specifier: properties.specifier,
resolution_status: properties.resolution_status,
resolved_path: properties.resolved_path,
}));
describe("Vite build-output preload dependencies", () => {
it("resolves a dependency under the selected build output directory", async () => {
const result = await reconstructJavaScriptFixture(await viteBuild());
expect(preloadEdges(result.graph)).toContainEqual({
specifier: "vite-output:assets/lazy.js",
resolution_status: "resolved",
resolved_path: "assets/lazy.js",
});
expect(JSON.stringify(result.graph)).toContain(
"no ancestor of the importing file holds it",
);
});
it("does not report a dependency as an external package when only the assets directory is selected", async () => {
const root = await viteBuild();
const result = await reconstructJavaScriptFixture(join(root, "assets"));
const serialized = JSON.stringify(result.graph);
expect(serialized).not.toContain('"resolution_status":"external"');
expect(serialized).toContain(
"A Vite preload dependency is relative to the build output directory",
);
});
it("keeps an inventoried but unanalyzed dependency under a nested build output directory", async () => {
const root = await createTestTempDirectory("rea-vite-output-css-");
await writeFixtureFiles(root, {
"dist/index.html":
'<!doctype html><script type="module" src="/assets/index.js"></script>',
"dist/assets/index.js": `
const __vite__mapDeps = (i, m = __vite__mapDeps, d =
(m.f || (m.f = ["assets/lazy.css"]))) => i.map((i) => d[i]);
export const route = () => __vite__mapDeps([0]);
`,
"dist/assets/lazy.css": ".lazy { color: green; }",
});
const result = await reconstructJavaScriptFixture(root);
const edge = result.graph.edges.find(
({ properties }) =>
properties.specifier === "vite-output:assets/lazy.css",
);
expect(edge?.properties).toMatchObject({
resolution_status: "unavailable",
candidate_path: "dist/assets/lazy.css",
});
const serialized = JSON.stringify(edge);
expect(serialized).toContain("dist/assets/lazy.css");
expect(serialized).not.toContain(
"no ancestor of the importing file holds it",
);
});
});