Skip to content

Commit d4f6d13

Browse files
committed
fix(plugin-vite): make resolveId URLs consistent with Vite when
optimizeDeps is enabled (#3895)
1 parent fe855b5 commit d4f6d13

4 files changed

Lines changed: 189 additions & 0 deletions

File tree

packages/plugin-vite/src/plugins/deno.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as path from "@std/path";
1010
import * as babel from "@babel/core";
1111
import { httpAbsolute } from "./patches/http_absolute.ts";
1212
import { cleanId, JS_REG, JSX_REG } from "../utils.ts";
13+
import { depsOptimizerOf, ensureVersionQuery } from "./version_query.ts";
1314
import { builtinModules } from "node:module";
1415

1516
// @ts-ignore Workaround for https://github.com/denoland/deno/issues/30850
@@ -155,6 +156,12 @@ export function deno(): Plugin {
155156
resolved = path.fromFileUrl(resolved);
156157
}
157158

159+
const depsOptimizer = depsOptimizerOf(this.environment);
160+
if (depsOptimizer !== undefined) {
161+
// When `optimizeDeps` is enabled, ensure resolved URLs include versions to match Vite.
162+
resolved = ensureVersionQuery(resolved, depsOptimizer);
163+
}
164+
158165
return {
159166
id: resolved,
160167
meta: {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { cleanId } from "../utils.ts";
2+
3+
/** The parts of Vite's `DepsOptimizer` we rely on. */
4+
export interface DepsOptimizerLike {
5+
metadata: { browserHash: string };
6+
options: { extensions?: string[] };
7+
}
8+
9+
const DEP_VERSION_REG = /[?&]v=/;
10+
/** Mirrors Vite's `OPTIMIZABLE_ENTRY_RE`, which also excludes `.jsx`/`.tsx`. */
11+
const OPTIMIZABLE_REG = /\.[cm]?[jt]s$/;
12+
13+
/**
14+
* The dependency optimizer only exists on a dev environment, and is absent
15+
* from the base `Environment` type that plugin hooks are handed.
16+
*/
17+
export function depsOptimizerOf(
18+
environment: unknown,
19+
): DepsOptimizerLike | undefined {
20+
return (environment as { depsOptimizer?: DepsOptimizerLike }).depsOptimizer;
21+
}
22+
23+
/**
24+
* Mirrors `ensureVersionQuery()` from Vite's `vite:resolve` plugin.
25+
*
26+
* That is where Vite attaches the optimizer's `?v=<hash>` to a dependency,
27+
* but `vite:resolve` never runs for the specifiers we resolve ourselves. Both
28+
* resolvers have to produce the same url for a given file: the browser keys
29+
* modules on the url it was told to fetch, so a file reachable under two urls
30+
* is loaded twice as two independent instances.
31+
*/
32+
export function ensureVersionQuery(
33+
resolved: string,
34+
depsOptimizer: DepsOptimizerLike,
35+
): string {
36+
// Only dependencies are versioned; app source uses `?t=` for HMR instead.
37+
if (!resolved.includes("node_modules")) return resolved;
38+
if (DEP_VERSION_REG.test(resolved)) return resolved;
39+
40+
// Empty until the optimizer has finished initialising.
41+
const { browserHash } = depsOptimizer.metadata;
42+
if (!browserHash) return resolved;
43+
44+
// Vite only versions what it could pre-bundle, so we must match.
45+
const file = cleanId(resolved);
46+
const { extensions } = depsOptimizer.options;
47+
const optimizable = OPTIMIZABLE_REG.test(file) ||
48+
(extensions?.some((ext) => file.endsWith(ext)) ?? false);
49+
if (!optimizable) return resolved;
50+
51+
return injectVersionQuery(resolved, browserHash);
52+
}
53+
54+
function injectVersionQuery(id: string, browserHash: string): string {
55+
const hashIndex = id.indexOf("#");
56+
const fragment = hashIndex >= 0 ? id.slice(hashIndex) : "";
57+
const rest = hashIndex >= 0 ? id.slice(0, hashIndex) : id;
58+
59+
const queryIndex = rest.indexOf("?");
60+
if (queryIndex >= 0) {
61+
const pathname = rest.slice(0, queryIndex);
62+
const query = rest.slice(queryIndex + 1);
63+
return `${pathname}?v=${browserHash}&${query}${fragment}`;
64+
}
65+
66+
return `${rest}?v=${browserHash}${fragment}`;
67+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect } from "@std/expect/expect";
2+
import {
3+
type DepsOptimizerLike,
4+
depsOptimizerOf,
5+
ensureVersionQuery,
6+
} from "./version_query.ts";
7+
8+
const DEP = "/app/node_modules/preact/dist/preact.mjs";
9+
10+
function optimizer(
11+
options: { browserHash?: string; extensions?: string[] } = {},
12+
): DepsOptimizerLike {
13+
return {
14+
metadata: { browserHash: options.browserHash ?? "abc123" },
15+
options: { extensions: options.extensions },
16+
};
17+
}
18+
19+
Deno.test("version query - versions a dependency", () => {
20+
expect(ensureVersionQuery(DEP, optimizer())).toEqual(`${DEP}?v=abc123`);
21+
});
22+
23+
Deno.test("version query - ignores app source", () => {
24+
const id = "/app/islands/Counter.ts";
25+
expect(ensureVersionQuery(id, optimizer())).toEqual(id);
26+
});
27+
28+
Deno.test("version query - is idempotent", () => {
29+
const id = `${DEP}?v=abc123`;
30+
expect(ensureVersionQuery(id, optimizer())).toEqual(id);
31+
});
32+
33+
Deno.test("version query - ignores extensions Vite cannot pre-bundle", () => {
34+
const id = "/app/node_modules/some-dep/Widget.tsx";
35+
expect(ensureVersionQuery(id, optimizer())).toEqual(id);
36+
37+
// ...unless the optimizer was configured to handle them.
38+
expect(ensureVersionQuery(id, optimizer({ extensions: [".tsx"] })))
39+
.toEqual(`${id}?v=abc123`);
40+
});
41+
42+
Deno.test("version query - waits for the optimizer to initialise", () => {
43+
expect(ensureVersionQuery(DEP, optimizer({ browserHash: "" }))).toEqual(DEP);
44+
});
45+
46+
Deno.test("version query - keeps an existing query and fragment", () => {
47+
expect(ensureVersionQuery(`${DEP}?foo=1`, optimizer()))
48+
.toEqual(`${DEP}?v=abc123&foo=1`);
49+
50+
expect(ensureVersionQuery(`${DEP}#bar`, optimizer()))
51+
.toEqual(`${DEP}?v=abc123#bar`);
52+
});
53+
54+
Deno.test("version query - depsOptimizerOf tolerates a missing optimizer", () => {
55+
expect(depsOptimizerOf({})).toBeUndefined();
56+
expect(depsOptimizerOf({ depsOptimizer: optimizer() })).toBeDefined();
57+
});

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,64 @@ export default defineConfig({
623623
});
624624
});
625625

626+
// With `optimizeDeps` active, Vite's import analysis appends `?v=<hash>` to
627+
// the imports it rewrites, while imports from inside a `\0deno::` virtual
628+
// module are resolved by the Deno plugin and emitted unhashed. The browser
629+
// keys modules on the url it fetched, so one file under two urls becomes two
630+
// instances. This is a resolve-time problem, not a load-time one.
631+
integrationTest(
632+
"vite dev - optimizeDeps does not duplicate dependency instances",
633+
async () => {
634+
const fixture = path.join(FIXTURE_DIR, "remote_island");
635+
await using tmp = await prepareDevServer(fixture, {
636+
config: `import { defineConfig } from "vite";
637+
import { fresh } from "@fresh/plugin-vite";
638+
639+
export default defineConfig({
640+
plugins: [fresh({ islandSpecifiers: ["@marvinh-test/fresh-island"] })],
641+
optimizeDeps: { include: ["preact"] },
642+
});
643+
`,
644+
});
645+
646+
await launchDevServer(tmp.dir, async (address) => {
647+
await withBrowser(async (page) => {
648+
await page.goto(address, { waitUntil: "networkidle2" });
649+
650+
// A path fetched under more than one query string is loaded twice.
651+
const urls: string[] = await page.evaluate(() =>
652+
performance.getEntriesByType("resource").map((entry) => entry.name)
653+
);
654+
655+
const queriesByPath = new Map<string, Set<string>>();
656+
for (const url of urls) {
657+
const parsed = new URL(url);
658+
if (!/\.[mc]?[tj]sx?$/.test(parsed.pathname)) continue;
659+
660+
let queries = queriesByPath.get(parsed.pathname);
661+
if (queries === undefined) {
662+
queries = new Set();
663+
queriesByPath.set(parsed.pathname, queries);
664+
}
665+
queries.add(parsed.search);
666+
}
667+
668+
const duplicated = Array.from(queriesByPath)
669+
.filter(([, queries]) => queries.size > 1)
670+
.map(([pathname]) => pathname);
671+
672+
expect(duplicated).toEqual([]);
673+
674+
// A duplicated Preact makes hydration throw, so the island never
675+
// becomes interactive.
676+
await page.locator(".remote-island").wait();
677+
await page.locator(".increment").click();
678+
await waitForText(page, ".result", "Count: 1");
679+
});
680+
});
681+
},
682+
);
683+
626684
// issue: https://github.com/denoland/fresh/issues/3666
627685
integrationTest(
628686
"vite dev - basePath does not intercept Vite URLs",

0 commit comments

Comments
 (0)