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
21 changes: 14 additions & 7 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,7 +1295,8 @@ type NitroSetupContext = {
};

export default function vinext(options: VinextOptions = {}): PluginOption[] {
assertSupportedViteVersion();
const { supportsNativeTypeofWindowFolding: useNativeTypeofWindowFolding } =
assertSupportedViteVersion();
const prerenderConfig = normalizeVinextPrerenderConfig(options.prerender);
let root: string;
let pagesDir: string;
Expand Down Expand Up @@ -5420,25 +5421,31 @@ export const loadServerActionClient = ${
{
name: "vinext:typeof-window",
configEnvironment(_name, environment) {
if (!useNativeTypeofWindowFolding) return null;
return {
define: {
"typeof window": environment.consumer === "client" ? '"object"' : '"undefined"',
},
};
},
},
// plugin-RSC's analysis builds replace each module with lexer-discovered
// imports before Rolldown's native define folding runs. Fold only in those
// write-less analysis builds so dead browser/server imports are absent from
// the synthetic graph; real builds continue to use native Oxc folding.
// Toolchains before Vite 8.1.4 / Rolldown 1.1.4 can run native define
// folding too late to prune dead imports, so retain the custom fold for
// every build. Newer toolchains only need it for plugin-RSC's write-less
// analysis builds, which replace modules with lexer-discovered imports
// before native folding runs.
{
name: "vinext:typeof-window-scan",
apply: "build",
apply(_config, environment) {
return !useNativeTypeofWindowFolding || environment.command === "build";
},
enforce: "post",
transform: {
filter: { code: /\btypeof\s+window\b/ },
handler(code, id) {
if (this.environment.config.build.write !== false) return null;
if (useNativeTypeofWindowFolding && this.environment.config.build.write !== false) {
return null;
}
const cacheDir = `${toSlash(this.environment.config.cacheDir).replace(/\/$/, "")}/`;
if (toSlash(id).startsWith(cacheDir)) return null;
return replaceTypeofWindow(code, getTypeofWindowReplacement(this.environment), id);
Expand Down
86 changes: 69 additions & 17 deletions packages/vinext/src/utils/vite-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,19 @@ export function getDepOptimizeNodeEnvOptions(nodeEnvDefine: string): {
}

/**
* Detect Vite major version at runtime. Prefer the project cwd, then fall back
* to vinext's own dependency graph for tests and linked source checkouts.
* Detect the Vite toolchain version at runtime. Prefer the project cwd, then
* fall back to vinext's own dependency graph for tests and linked checkouts.
*/
function getViteMajorVersion(): number {
type ViteToolchainVersion = {
vite: string;
rolldown?: string;
};

function getViteToolchainVersion(): ViteToolchainVersion {
try {
return getViteMajorVersionFromRequire(createRequire(path.join(process.cwd(), "package.json")));
return getViteToolchainVersionFromRequire(
createRequire(path.join(process.cwd(), "package.json")),
);
} catch (error) {
if (!isModuleNotFoundError(error)) {
const message = error instanceof Error ? error.message : String(error);
Expand All @@ -69,7 +76,7 @@ function getViteMajorVersion(): number {
}

try {
return getViteMajorVersionFromRequire(createRequire(import.meta.url));
return getViteToolchainVersionFromRequire(createRequire(import.meta.url));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
Expand All @@ -78,31 +85,76 @@ function getViteMajorVersion(): number {
}
}

function getViteMajorVersionFromRequire(require: NodeRequire): number {
function getViteToolchainVersionFromRequire(require: NodeRequire): ViteToolchainVersion {
const vitePkg = require("vite/package.json");
const viteMajor = parseInt(vitePkg?.version, 10);
if (vitePkg?.name === "vite" && Number.isFinite(viteMajor)) {
return viteMajor;
if (vitePkg?.name === "vite" && parseViteVersion(vitePkg.version)) {
return { vite: vitePkg.version };
}

const bundledViteMajor = parseInt(vitePkg?.bundledVersions?.vite, 10);
if (Number.isFinite(bundledViteMajor)) {
return bundledViteMajor;
const bundledViteVersion = vitePkg?.bundledVersions?.vite;
if (parseViteVersion(bundledViteVersion)) {
const bundledRolldownVersion = vitePkg?.bundledVersions?.rolldown;
return {
vite: bundledViteVersion,
rolldown: parseViteVersion(bundledRolldownVersion) ? bundledRolldownVersion : undefined,
};
}

throw new Error(`could not determine Vite version from ${vitePkg?.name ?? "vite/package.json"}`);
}

function parseViteVersion(
version: unknown,
): [major: number, minor: number, patch: number, prerelease: boolean] | null {
if (typeof version !== "string") return null;
const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3]), !!match[4]];
}

function isModuleNotFoundError(error: unknown): boolean {
return (
!!error && typeof error === "object" && "code" in error && error.code === "MODULE_NOT_FOUND"
);
}

export function assertSupportedViteVersion(): number {
const viteMajorVersion = getViteMajorVersion();
if (viteMajorVersion < 8) {
throw new Error(`[vinext] Vite 8 or newer is required. Detected Vite ${viteMajorVersion}.`);
export function supportsNativeTypeofWindowFolding(
viteVersion: string,
bundledRolldownVersion?: string,
): boolean {
if (bundledRolldownVersion !== undefined) {
return isVersionAtLeast(bundledRolldownVersion, 1, 1, 4);
}
return viteMajorVersion;
return isVersionAtLeast(viteVersion, 8, 1, 4);
}

function isVersionAtLeast(
version: string,
requiredMajor: number,
requiredMinor: number,
requiredPatch: number,
): boolean {
const parsedVersion = parseViteVersion(version);
if (!parsedVersion) return false;
const [major, minor, patch, prerelease] = parsedVersion;
if (major !== requiredMajor) return major > requiredMajor;
if (minor !== requiredMinor) return minor > requiredMinor;
if (patch !== requiredPatch) return patch > requiredPatch;
return !prerelease;
}

export function assertSupportedViteVersion(): {
supportsNativeTypeofWindowFolding: boolean;
} {
const toolchainVersion = getViteToolchainVersion();
const [major] = parseViteVersion(toolchainVersion.vite)!;
if (major < 8) {
throw new Error(`[vinext] Vite 8 or newer is required. Detected Vite ${major}.`);
}
return {
supportsNativeTypeofWindowFolding: supportsNativeTypeofWindowFolding(
toolchainVersion.vite,
toolchainVersion.rolldown,
),
};
}
149 changes: 149 additions & 0 deletions tests/tsconfig-paths-vite8.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,155 @@ describe("Vite tsconfig paths support", () => {
expect(resolvedConfig?.resolve?.tsconfigPaths).toBe(true);
});

it("uses custom typeof window folding before Vite 8.1.4", async () => {
const root = setupProject({ name: "vite", version: "8.1.3" });
process.chdir(root);

const plugins = vinext({ appDir: root });
const definePlugin = await findNamedPlugin(plugins, "vinext:typeof-window");
const scanPlugin = await findNamedPlugin(plugins, "vinext:typeof-window-scan");
if (typeof definePlugin?.configEnvironment !== "function") {
throw new Error("vinext:typeof-window configEnvironment hook not found");
}
if (!scanPlugin?.transform || typeof scanPlugin.transform === "function") {
throw new Error("vinext:typeof-window-scan transform hook not found");
}

expect(typeof scanPlugin.apply).toBe("function");
expect((scanPlugin.apply as Function)({}, { command: "serve" })).toBe(true);
expect(
definePlugin.configEnvironment.call(
{} as never,
"server",
{ consumer: "server" },
{} as never,
),
).toBeNull();
expect(
await scanPlugin.transform.handler.call(
{
environment: {
config: {
build: { write: true },
cacheDir: path.join(root, ".vite"),
consumer: "server",
},
},
} as never,
`export const browser = typeof window !== "undefined"`,
path.join(root, "app.js"),
),
).not.toBeNull();
});

it("uses native typeof window folding from Vite 8.1.4", async () => {
const root = setupProject({ name: "vite", version: "8.1.4" });
process.chdir(root);

const plugins = vinext({ appDir: root });
const definePlugin = await findNamedPlugin(plugins, "vinext:typeof-window");
const scanPlugin = await findNamedPlugin(plugins, "vinext:typeof-window-scan");
if (typeof definePlugin?.configEnvironment !== "function") {
throw new Error("vinext:typeof-window configEnvironment hook not found");
}
if (!scanPlugin?.transform || typeof scanPlugin.transform === "function") {
throw new Error("vinext:typeof-window-scan transform hook not found");
}

expect(typeof scanPlugin.apply).toBe("function");
expect((scanPlugin.apply as Function)({}, { command: "serve" })).toBe(false);
expect(
definePlugin.configEnvironment.call(
{} as never,
"server",
{ consumer: "server" },
{} as never,
),
).toEqual({ define: { "typeof window": '"undefined"' } });
expect(
await scanPlugin.transform.handler.call(
{
environment: {
config: {
build: { write: true },
cacheDir: path.join(root, ".vite"),
consumer: "server",
},
},
} as never,
`export const browser = typeof window !== "undefined"`,
path.join(root, "app.js"),
),
).toBeNull();
});

it("uses bundled Rolldown capability from npm alias packages", async () => {
const root = setupProject({
name: "@voidzero-dev/vite-plus-core",
version: "0.2.2",
bundledVersions: { vite: "8.1.2", rolldown: "1.1.4" },
});
process.chdir(root);

const plugins = vinext({ appDir: root });
const definePlugin = await findNamedPlugin(plugins, "vinext:typeof-window");
const scanPlugin = await findNamedPlugin(plugins, "vinext:typeof-window-scan");
if (typeof definePlugin?.configEnvironment !== "function") {
throw new Error("vinext:typeof-window configEnvironment hook not found");
}
if (!scanPlugin?.transform || typeof scanPlugin.transform === "function") {
throw new Error("vinext:typeof-window-scan transform hook not found");
}

expect(
definePlugin.configEnvironment.call(
{} as never,
"server",
{ consumer: "server" },
{} as never,
),
).toEqual({ define: { "typeof window": '"undefined"' } });
expect(
await scanPlugin.transform.handler.call(
{
environment: {
config: {
build: { write: true },
cacheDir: path.join(root, ".vite"),
consumer: "server",
},
},
} as never,
`export const browser = typeof window !== "undefined"`,
path.join(root, "app.js"),
),
).toBeNull();
});

it("keeps custom folding for npm alias packages with older bundled Rolldown", async () => {
const root = setupProject({
name: "@voidzero-dev/vite-plus-core",
version: "0.2.1",
bundledVersions: { vite: "8.1.2", rolldown: "1.1.3" },
});
process.chdir(root);

const plugins = vinext({ appDir: root });
const definePlugin = await findNamedPlugin(plugins, "vinext:typeof-window");
if (typeof definePlugin?.configEnvironment !== "function") {
throw new Error("vinext:typeof-window configEnvironment hook not found");
}

expect(
definePlugin.configEnvironment.call(
{} as never,
"server",
{ consumer: "server" },
{} as never,
),
).toBeNull();
});

it("materializes simple tsconfig path aliases into resolve.alias on Vite 8", async () => {
const root = setupProject({ name: "vite", version: "8.0.0" });
process.chdir(root);
Expand Down
31 changes: 28 additions & 3 deletions tests/type-of-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getTypeofWindowReplacement,
replaceTypeofWindow,
} from "../packages/vinext/src/plugins/typeof-window.js";
import { supportsNativeTypeofWindowFolding } from "../packages/vinext/src/utils/vite-version.js";

const temporaryDirectories: string[] = [];

Expand All @@ -24,7 +25,7 @@ afterEach(async () => {
});

describe("typeof window compilation", () => {
it("configures native typeof window folding per environment", async () => {
it("configures the installed Vite typeof window folding strategy", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-typeof-window-define-"));
temporaryDirectories.push(root);
const builder = await createBuilder({
Expand All @@ -37,8 +38,32 @@ describe("typeof window compilation", () => {
plugins: [vinext({ react: false, rsc: false })],
});

expect(builder.environments.client?.config.define?.["typeof window"]).toBe('"object"');
expect(builder.environments.server?.config.define?.["typeof window"]).toBe('"undefined"');
const vitePackage = (await import("vite/package.json", { with: { type: "json" } })).default;
const viteVersion = vitePackage.bundledVersions?.vite ?? (await import("vite")).version;
const usesNativeFolding = supportsNativeTypeofWindowFolding(
viteVersion,
vitePackage.bundledVersions?.rolldown,
);

expect(builder.environments.client?.config.define?.["typeof window"]).toBe(
usesNativeFolding ? '"object"' : undefined,
);
expect(builder.environments.server?.config.define?.["typeof window"]).toBe(
usesNativeFolding ? '"undefined"' : undefined,
);
});

it("uses native folding only from the stable Vite 8.1.4 release", () => {
expect(supportsNativeTypeofWindowFolding("8.1.3")).toBe(false);
expect(supportsNativeTypeofWindowFolding("8.1.4-beta.1")).toBe(false);
expect(supportsNativeTypeofWindowFolding("8.1.4")).toBe(true);
expect(supportsNativeTypeofWindowFolding("8.1.4+build.1")).toBe(true);
expect(supportsNativeTypeofWindowFolding("8.2.0-beta.1")).toBe(true);
expect(supportsNativeTypeofWindowFolding("9.0.0-beta.1")).toBe(true);
expect(supportsNativeTypeofWindowFolding("8.1.2", "1.1.3")).toBe(false);
expect(supportsNativeTypeofWindowFolding("8.1.2", "1.1.4-beta.1")).toBe(false);
expect(supportsNativeTypeofWindowFolding("8.1.2", "1.1.4")).toBe(true);
expect(supportsNativeTypeofWindowFolding("8.2.0", "1.1.3")).toBe(false);
});

it("skips custom scan folding for modules in the Vite cache directory", async () => {
Expand Down
Loading