From f4288465bf79c79e391e9b5d65af4cb8d469e3a5 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 11:36:11 +0100 Subject: [PATCH 1/2] refactor(config): rely on native Vite tsconfig paths --- packages/vinext/src/config/next-config.ts | 28 +- packages/vinext/src/config/tsconfig-paths.ts | 216 ------------- packages/vinext/src/index.ts | 314 +------------------ tests/helpers.ts | 3 +- tests/tsconfig-path-alias-build.test.ts | 178 ++++------- tests/tsconfig-paths-config-loader.test.ts | 233 -------------- tests/tsconfig-paths-vite8.test.ts | 129 +------- 7 files changed, 74 insertions(+), 1027 deletions(-) delete mode 100644 packages/vinext/src/config/tsconfig-paths.ts delete mode 100644 tests/tsconfig-paths-config-loader.test.ts diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index fcfb13c237..3ac4288baf 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -15,7 +15,6 @@ import { normalizePageExtensions } from "../routing/file-matcher.js"; import { getHtmlLimitedBotRegex } from "../utils/html-limited-bots.js"; import { isUnknownRecord } from "../utils/record.js"; import { applyLocaleToRoutes, isExternalUrl } from "./config-matchers.js"; -import { loadTsconfigResolutionForRoot } from "./tsconfig-paths.js"; import { loadCommonJsModule, shouldRetryAsCommonJs } from "../utils/commonjs-loader.js"; /** @@ -955,23 +954,9 @@ export async function loadNextConfig( const filename = path.basename(configPath); const isTypeScriptConfig = /\.[cm]?ts$/.test(configPath); - // Mirror Next.js: read `compilerOptions.paths` from the project's - // tsconfig.json so aliased imports inside next.config.ts (e.g. - // `import { foo } from '@/foo'`) resolve at config-load time. Next.js - // passes `paths` and `baseUrl` to SWC; we thread both into Vite's resolver. - // See packages/next/src/build/next-config-ts/transpile-config.ts. - const tsconfigResolution = loadTsconfigResolutionForRoot(root); - const tsconfigBaseUrl = isTypeScriptConfig ? tsconfigResolution.baseUrl : null; - - // Vite 8 (Rolldown) resolves tsconfig `baseUrl` bare imports natively via - // `resolve.tsconfigPaths` (oxc-resolver). `paths` aliases are materialized - // into `resolve.alias` so import.meta.glob and dynamic imports can see them. - // - // Note: installed packages stay externalized (so CJS config plugins like - // `@next/mdx` that call `require`/`require.resolve` at runtime keep working). - // baseUrl resolves bare imports that have no installed package of the same - // name; it does not shadow an installed package with a baseUrl-local file. - const useNativeTsconfigPaths = !!tsconfigBaseUrl; + // Mirror Next.js tsconfig path resolution for imports inside next.config.ts + // by delegating to Vite's native per-importer tsconfig resolver. + const useNativeTsconfigPaths = isTypeScriptConfig; // Symlink-resolved config path, used by the `commonjs()` filter below to // exclude the config file itself. macOS uses /private/var symlinks, so @@ -986,13 +971,6 @@ export async function loadNextConfig( logLevel: "error", clearScreen: false, resolve: { - alias: tsconfigResolution.aliases, - // On Vite 8, use native tsconfig resolution (oxc-resolver - // `tsconfig: 'auto'`), which mirrors Next.js's SWC `paths` + `baseUrl` - // handling: it follows `extends` and resolves baseUrl-local bare imports - // via per-importer tsconfig discovery. Installed packages stay - // externalized, so a baseUrl-local file does not shadow a package of the - // same name. ...(useNativeTsconfigPaths ? { tsconfigPaths: true } : {}), // Include `.cjs` and `.cts` so `vite-plugin-commonjs` recognises // those extensions (the plugin keys off `config.resolve.extensions`, diff --git a/packages/vinext/src/config/tsconfig-paths.ts b/packages/vinext/src/config/tsconfig-paths.ts deleted file mode 100644 index c5812dbf37..0000000000 --- a/packages/vinext/src/config/tsconfig-paths.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * tsconfig.json `compilerOptions.paths` loader. - * - * Used to make tsconfig path aliases (e.g. `@/foo` mapping to `./src/foo`) - * and `baseUrl` bare imports available when vinext loads `next.config.ts` - * through Vite's `runnerImport`. - * - * Next.js's own `next.config.ts` loader (packages/next/src/build/next-config-ts/ - * transpile-config.ts) reads `compilerOptions.paths` and - * `compilerOptions.baseUrl` from the project's `tsconfig.json` and passes them - * to SWC so that imports like - * `import { foo } from '@/foo'` and `import { bar } from 'bar'` resolve at - * config load time. We do the same here with Vite resolver settings. - * - * The implementation is intentionally minimal: - * - Static JSON-style parse of tsconfig.json (handles trailing commas / - * comments via the shared `parseStaticObjectLiteral` helper) - * - `extends` is followed up to a small recursion depth, with cycle - * detection — matches the subset Next.js supports - * - Only the common `"@/*": ["./src/*"]` / `"@/*": ["src/*"]` pattern is - * supported; non-wildcard paths and exact aliases also work - * - Returned alias values are always absolute paths so they work with - * `runnerImport`'s inline environment (which has its own root). - */ -import fs from "node:fs"; -import path from "node:path"; -import { createRequire } from "node:module"; -import { parseStaticObjectLiteral } from "../plugins/fonts.js"; -import { isUnknownRecord as isRecord } from "../utils/record.js"; - -const TSCONFIG_FILES = ["tsconfig.json", "jsconfig.json"]; - -type TsconfigPathResolution = { - aliases: Record; - baseUrl: string | null; -}; - -function resolveTsconfigPathCandidate(candidate: string): string | null { - const candidates = candidate.endsWith(".json") - ? [candidate] - : [candidate, `${candidate}.json`, path.join(candidate, "tsconfig.json")]; - - for (const item of candidates) { - if (fs.existsSync(item) && fs.statSync(item).isFile()) { - return item; - } - } - - return null; -} - -/** - * Normalize a tsconfig `extends` field into a list of specifier strings. - * - * TypeScript 5.0+ allows `extends` to be either a string or an array of - * strings. Matches Next.js's handling in - * packages/next/src/build/next-config-ts/transpile-config.ts, where parents - * are iterated in order and later entries override earlier ones. - */ -function normalizeExtends(extendsField: unknown): string[] { - if (typeof extendsField === "string") return [extendsField]; - if (Array.isArray(extendsField)) { - return extendsField.filter((value): value is string => typeof value === "string"); - } - return []; -} - -function resolveTsconfigExtends(configPath: string, specifier: string): string | null { - const fromDir = path.dirname(configPath); - if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("\\")) { - return resolveTsconfigPathCandidate(path.resolve(fromDir, specifier)); - } - - const requireFromConfig = createRequire(configPath); - const candidates = [specifier, `${specifier}.json`, path.join(specifier, "tsconfig.json")]; - - for (const item of candidates) { - try { - return requireFromConfig.resolve(item); - } catch {} - } - - return null; -} - -function materializeAliases( - pathsConfig: Record, - baseUrl: string, -): Record { - const aliases: Record = {}; - - for (const [find, rawTargets] of Object.entries(pathsConfig)) { - const target = Array.isArray(rawTargets) - ? rawTargets.find((value): value is string => typeof value === "string") - : typeof rawTargets === "string" - ? rawTargets - : null; - if (!target) continue; - - if (find.includes("*") || target.includes("*")) { - // Only support trailing wildcard (the common `"@/*": ["./src/*"]` form). - if (!find.endsWith("/*") || !target.endsWith("/*")) continue; - if (find.indexOf("*") !== find.length - 1 || target.indexOf("*") !== target.length - 1) { - continue; - } - - const aliasKey = find.slice(0, -2); - const targetDir = target.slice(0, -2); - if (!aliasKey || !targetDir) continue; - - aliases[aliasKey] = path.resolve(baseUrl, targetDir); - continue; - } - - aliases[find] = path.resolve(baseUrl, target); - } - - return aliases; -} - -function emptyResolution(): TsconfigPathResolution { - return { aliases: {}, baseUrl: null }; -} - -function loadResolutionFromTsconfigFile( - configPath: string, - seen: Set, -): TsconfigPathResolution { - if (seen.has(configPath)) return emptyResolution(); - seen.add(configPath); - - let parsed: Record | null = null; - try { - parsed = parseStaticObjectLiteral(fs.readFileSync(configPath, "utf-8")); - } catch { - return emptyResolution(); - } - if (!parsed) return emptyResolution(); - - let resolution = emptyResolution(); - // TypeScript 5.0+ allows `extends` to be an array of specifiers (later - // entries override earlier ones). Normalize both forms to a string list. - const extendsList = normalizeExtends(parsed.extends); - - for (const extendsSpecifier of extendsList) { - const extendedPath = resolveTsconfigExtends(configPath, extendsSpecifier); - if (extendedPath) { - const parent = loadResolutionFromTsconfigFile(extendedPath, seen); - resolution = { - aliases: { ...resolution.aliases, ...parent.aliases }, - baseUrl: parent.baseUrl ?? resolution.baseUrl, - }; - } - } - - const compilerOptions = isRecord(parsed.compilerOptions) ? parsed.compilerOptions : null; - const ownBaseUrl = - compilerOptions && typeof compilerOptions.baseUrl === "string" - ? path.resolve(path.dirname(configPath), compilerOptions.baseUrl) - : null; - const baseUrl = ownBaseUrl ?? resolution.baseUrl; - - const pathsConfig = - compilerOptions && isRecord(compilerOptions.paths) ? compilerOptions.paths : null; - if (!pathsConfig) { - return { - aliases: resolution.aliases, - baseUrl, - }; - } - - const pathsBaseUrl = baseUrl ?? path.dirname(configPath); - - return { - aliases: { - ...resolution.aliases, - ...materializeAliases(pathsConfig, pathsBaseUrl), - }, - baseUrl, - }; -} - -/** - * Read the project's tsconfig.json (or jsconfig.json) and return its - * path-resolution settings as absolute paths. - * - * Returns an empty resolution if no config is found or no relevant compiler - * options are configured. - * Errors during parsing are swallowed — this is a best-effort helper that - * must not break config loading. - */ -export function loadTsconfigResolutionForRoot(projectRoot: string): TsconfigPathResolution { - for (const name of TSCONFIG_FILES) { - const candidate = path.join(projectRoot, name); - if (!fs.existsSync(candidate)) continue; - const resolution = loadResolutionFromTsconfigFile(candidate, new Set()); - return { - // TypeScript matches `paths` by longest prefix regardless of declaration - // order, while Vite's alias plugin picks the first matching entry. Order - // overlapping patterns (e.g. `@/*` + `@/public/*`) longest-first so the - // specific pattern is not shadowed by the general one. - aliases: Object.fromEntries( - Object.entries(resolution.aliases).sort((a, b) => b[0].length - a[0].length), - ), - baseUrl: resolution.baseUrl, - }; - } - return emptyResolution(); -} - -/** - * Back-compat helper for call sites that only need `compilerOptions.paths`. - */ -export function loadTsconfigPathAliasesForRoot(projectRoot: string): Record { - return loadTsconfigResolutionForRoot(projectRoot).aliases; -} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 967074dfe4..344f26b45e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1,16 +1,14 @@ import type { - Alias, CSSModulesOptions, Logger, Plugin, PluginOption, ResolvedConfig, - ResolverFunction, SassPreprocessorOptions, UserConfig, ViteDevServer, } from "vite"; -import { createLogger, loadEnv, parseAst, transformWithOxc } from "vite"; +import { loadEnv, parseAst, transformWithOxc } from "vite"; import { pagesRouter, apiRouter, @@ -135,7 +133,6 @@ import { formatMissingCloudflarePluginError, hasWranglerConfig, } from "./utils/project.js"; -import { isUnknownRecord as isRecord } from "./utils/record.js"; import { VIRTUAL_MODULE_ID_RE, VIRTUAL_PREFIX } from "./utils/virtual-module.js"; import { ASSET_PREFIX_URL_DIR, resolveAssetsDir } from "./utils/asset-prefix.js"; import { renderVinextBuiltUrl } from "./utils/built-asset-url.js"; @@ -166,7 +163,6 @@ import { createServerExternalsManifestPlugin } from "./plugins/server-externals- import { VIRTUAL_GOOGLE_FONTS, RESOLVED_VIRTUAL_GOOGLE_FONTS, - parseStaticObjectLiteral, generateGoogleFontsVirtualModule, createGoogleFontsPlugin, createLocalFontsPlugin, @@ -207,7 +203,6 @@ import { import { augmentSsrManifestFromBundle, tryRealpathSync, - relativeWithinRoot, type BundleBackfillChunk, } from "./build/ssr-manifest.js"; import { @@ -590,114 +585,6 @@ async function collectDevPagesAppStylesheetAssets( return stylesheetAssets; } -const TSCONFIG_FILES = ["tsconfig.json", "jsconfig.json"]; - -function resolveTsconfigPathCandidate(candidate: string): string | null { - const candidates = candidate.endsWith(".json") - ? [candidate] - : [candidate, `${candidate}.json`, path.join(candidate, "tsconfig.json")]; - - for (const item of candidates) { - if (fs.existsSync(item) && fs.statSync(item).isFile()) { - return item; - } - } - - return null; -} - -/** - * Normalize a tsconfig `extends` field into a list of specifier strings. - * - * TypeScript 5.0+ allows `extends` to be either a string or an array of - * strings. Matches Next.js's handling in - * packages/next/src/build/next-config-ts/transpile-config.ts, where parents - * are iterated in order and later entries override earlier ones. - */ -function normalizeTsconfigExtends(extendsField: unknown): string[] { - if (typeof extendsField === "string") return [extendsField]; - if (Array.isArray(extendsField)) { - return extendsField.filter((value): value is string => typeof value === "string"); - } - return []; -} - -function resolveTsconfigExtends(configPath: string, specifier: string): string | null { - const fromDir = path.dirname(configPath); - if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("\\")) { - return resolveTsconfigPathCandidate(path.resolve(fromDir, specifier)); - } - - const requireFromConfig = createRequire(configPath); - const candidates = [specifier, `${specifier}.json`, path.join(specifier, "tsconfig.json")]; - - for (const item of candidates) { - try { - return requireFromConfig.resolve(item); - } catch {} - } - - return null; -} - -function materializeTsconfigPathAliases( - pathsConfig: Record, - baseUrl: string, - projectRoot: string, -): Record { - const aliases: Record = {}; - - for (const [find, rawTargets] of Object.entries(pathsConfig)) { - const target = Array.isArray(rawTargets) - ? rawTargets.find((value): value is string => typeof value === "string") - : typeof rawTargets === "string" - ? rawTargets - : null; - if (!target) continue; - - if (find.includes("*") || target.includes("*")) { - if (!find.endsWith("/*") || !target.endsWith("/*")) continue; - if (find.indexOf("*") !== find.length - 1 || target.indexOf("*") !== target.length - 1) { - continue; - } - - const aliasKey = find.slice(0, -2); - const targetDir = target.slice(0, -2); - if (!aliasKey || !targetDir) continue; - - aliases[aliasKey] = toViteAliasReplacement(path.resolve(baseUrl, targetDir), projectRoot); - continue; - } - - aliases[find] = toViteAliasReplacement(path.resolve(baseUrl, target), projectRoot); - } - - return aliases; -} - -function toViteAliasReplacement(absolutePath: string, projectRoot: string): string { - const normalizedPath = absolutePath.replace(/\\/g, "/"); - const rootCandidates = new Set([projectRoot]); - const realRoot = tryRealpathSync(projectRoot); - if (realRoot) rootCandidates.add(realRoot); - - const pathCandidates = new Set([absolutePath]); - const realPath = tryRealpathSync(absolutePath); - if (realPath) pathCandidates.add(realPath); - - for (const rootCandidate of rootCandidates) { - for (const pathCandidate of pathCandidates) { - if (pathCandidate === rootCandidate) { - return normalizedPath; - } - const relativeId = relativeWithinRoot(rootCandidate, pathCandidate); - if (relativeId) return "/" + relativeId; - } - } - - return normalizedPath; -} - function resolveSwcHelpersAlias(root: string): string | undefined { const rootRequire = createRequire(path.join(root, "package.json")); const resolvers: NodeRequire[] = []; @@ -724,48 +611,6 @@ function resolveSwcHelpersAlias(root: string): string | undefined { return undefined; } -function loadTsconfigPathAliases( - configPath: string, - projectRoot: string, - seen = new Set(), -): Record { - const normalizedPath = tryRealpathSync(configPath) ?? configPath; - if (seen.has(normalizedPath)) return {}; - seen.add(normalizedPath); - - let parsed: Record | null = null; - try { - parsed = parseStaticObjectLiteral(fs.readFileSync(normalizedPath, "utf-8")); - } catch { - return {}; - } - if (!parsed) return {}; - - let aliases: Record = {}; - // `extends` may be a string or (TypeScript 5.0+) an array; iterate parents in - // order so later entries override earlier ones (matching Next.js). - for (const extendsSpecifier of normalizeTsconfigExtends(parsed.extends)) { - const extendedPath = resolveTsconfigExtends(normalizedPath, extendsSpecifier); - if (extendedPath) { - aliases = { ...aliases, ...loadTsconfigPathAliases(extendedPath, projectRoot, seen) }; - } - } - - const compilerOptions = isRecord(parsed.compilerOptions) ? parsed.compilerOptions : null; - const pathsConfig = - compilerOptions && isRecord(compilerOptions.paths) ? compilerOptions.paths : null; - if (!pathsConfig) return aliases; - - const baseUrl = - compilerOptions && typeof compilerOptions.baseUrl === "string" ? compilerOptions.baseUrl : "."; - const resolvedBaseUrl = path.resolve(path.dirname(normalizedPath), baseUrl); - - return { - ...aliases, - ...materializeTsconfigPathAliases(pathsConfig, resolvedBaseUrl, projectRoot), - }; -} - /** * Read the vinext package version once at plugin load. Surfaced via * `process.env.__NEXT_VERSION` define so `window.next.version` lands a @@ -816,130 +661,6 @@ function suppressOptionalOptimizeDepsWarnings(logger: Logger): void { marker[VINEXT_FILTERED_OPTIMIZE_DEPS_WARN] = true; } -// Cache materialized tsconfig/jsconfig aliases so Vite's glob and dynamic-import -// transforms can see them via resolve.alias without re-reading config files per env. -const _tsconfigAliasCache = new Map>(); - -/** - * Order materialized tsconfig path aliases by descending prefix length. - * - * TypeScript (and Next.js) match `paths` patterns by longest matched prefix, - * regardless of declaration order, while Vite's alias plugin picks the first - * matching entry. Overlapping patterns like `@/*` + `@/public/*` must - * therefore be materialized longest-first or the general pattern shadows the - * specific one (`@/public/foo.svg` would resolve into `src/public/`). - */ -function sortTsconfigAliasesBySpecificity(aliases: Record): Record { - return Object.fromEntries(Object.entries(aliases).sort((a, b) => b[0].length - a[0].length)); -} - -function resolveTsconfigAliases(projectRoot: string): Record { - if (_tsconfigAliasCache.has(projectRoot)) { - return _tsconfigAliasCache.get(projectRoot)!; - } - - let aliases: Record = {}; - for (const name of TSCONFIG_FILES) { - const candidate = path.join(projectRoot, name); - if (!fs.existsSync(candidate)) continue; - aliases = sortTsconfigAliasesBySpecificity(loadTsconfigPathAliases(candidate, projectRoot)); - break; - } - - _tsconfigAliasCache.set(projectRoot, aliases); - return aliases; -} - -/** - * Stylesheet importer contexts as seen by Vite's internal CSS resolvers. - * - * Vite resolves CSS `@import`/`composes`/`url()` specifiers through a - * dedicated resolver container that only runs the alias plugin plus Vite's - * own resolver — user plugins never participate. The importer is either the - * stylesheet's own path (sass, CSS modules `composes`, url() rewriting) or a - * synthetic `/*` (postcss-import, less). - */ -const STYLESHEET_IMPORTER_RE = /\.(?:css|scss|sass|less|styl|stylus|pcss|sss)$/i; - -function isStylesheetImporter(importer: string | undefined): boolean { - if (!importer) return false; - if (importer.endsWith("/*") || importer.endsWith("\\*")) return true; - return STYLESHEET_IMPORTER_RE.test(stripViteModuleQuery(importer)); -} - -/** - * Alias resolver for tsconfig-derived path aliases that keeps them out of - * stylesheet resolution. - * - * TypeScript `paths` never apply to CSS in Next.js — `@import` specifiers in - * stylesheets use standard bundler resolution, including package.json - * `exports` maps. A blind prefix-replacement alias breaks that: with - * `"@scope/ui/*": ["../../packages/ui/src/*"]` in tsconfig, an - * `@import "@scope/ui/globals.css"` whose real target is `exports`-mapped - * gets rewritten to a nonexistent source path and fails with ENOENT. - * - * Returning `null` for stylesheet importers makes the alias plugin fall - * through, so Vite's own resolver handles the original specifier. JS/TS - * importers keep the default alias behavior (Next.js applies `paths` there, - * including for `import "@/styles/globals.css"` from a layout), and Vite's - * glob/dynamic-import transforms — which require blind prefix replacement - * because their patterns never exist on disk — keep working. - */ -// `ResolverFunction` is typed with rolldown's synchronous resolveId signature, -// but the alias plugin awaits resolver results, so an async resolver is fine — -// hence the cast. -const tsconfigAliasCustomResolver = async function ( - this: { resolve: ResolveFromImporter }, - updatedId: string, - importer: string | undefined, - options?: { skipSelf?: boolean }, -) { - if (isStylesheetImporter(importer)) return null; - // Mirror the alias plugin's default resolution for every other importer. - const resolved = await this.resolve(updatedId, importer, { ...options, skipSelf: true }); - return resolved ?? { id: updatedId }; -} as unknown as ResolverFunction; - -/** - * Convert the merged alias map into Vite alias entries, attaching the - * stylesheet-scoping resolver to entries that came from tsconfig `paths`. - * Array order preserves the map's first-match ordering. - */ -function buildResolveAliasEntries( - aliasMap: Record, - tsconfigPathAliases: Record, -): Alias[] { - return Object.entries(aliasMap).map(([find, replacement]) => - tsconfigPathAliases[find] === replacement - ? { find, replacement, customResolver: tsconfigAliasCustomResolver } - : { find, replacement }, - ); -} - -// Vite 8 logs a deprecation warning when `resolve.alias` contains a -// `customResolver`. vinext uses one deliberately (see -// `tsconfigAliasCustomResolver`): the aliases must stay in `resolve.alias` -// for Vite's glob/dynamic-import transforms and internal resolvers, but must -// not apply inside stylesheet resolution — and only a `customResolver` can -// observe the importer there. Filter the warning so every project with -// tsconfig `paths` doesn't boot with it. -const ALIAS_CUSTOM_RESOLVER_DEPRECATION_RE = - /`resolve\.alias` contains an alias with `customResolver` option/; -const VINEXT_FILTERED_ALIAS_DEPRECATION_WARN = Symbol.for("vinext.filteredAliasDeprecationWarn"); - -function suppressAliasCustomResolverDeprecationWarning(logger: Logger): Logger { - const marker = logger as Logger & { [VINEXT_FILTERED_ALIAS_DEPRECATION_WARN]?: true }; - if (marker[VINEXT_FILTERED_ALIAS_DEPRECATION_WARN]) return logger; - - const warn = logger.warn.bind(logger); - logger.warn = (msg, warnOptions) => { - if (ALIAS_CUSTOM_RESOLVER_DEPRECATION_RE.test(stripAnsi(msg))) return; - warn(msg, warnOptions); - }; - marker[VINEXT_FILTERED_ALIAS_DEPRECATION_WARN] = true; - return logger; -} - // Virtual module IDs for Pages Router production build const VIRTUAL_WORKER_ENTRY = "virtual:vinext-worker-entry"; const RESOLVED_WORKER_ENTRY = VIRTUAL_PREFIX + VIRTUAL_WORKER_ENTRY; @@ -1752,21 +1473,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { root = normalizePathSeparators(config.root ?? process.cwd()); const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined; const shouldEnableNativeTsconfigPaths = userResolve?.tsconfigPaths === undefined; - const tsconfigPathAliases = resolveTsconfigAliases(root); const swcHelpersAlias = resolveSwcHelpersAlias(root); - // tsconfig-derived alias entries carry a customResolver, which Vite 8 - // reports as deprecated during config resolution. Filter that warning - // (see suppressAliasCustomResolverDeprecationWarning). Mutating - // config.customLogger (rather than returning it) keeps mergeConfig - // from deep-cloning the logger and flattening its hasWarned getter. - if (Object.keys(tsconfigPathAliases).length > 0) { - config.customLogger = suppressAliasCustomResolverDeprecationWarning( - config.customLogger ?? - createLogger(config.logLevel, { allowClearScreen: config.clearScreen }), - ); - } - // Load .env files into process.env before anything else. // Next.js loads .env files before evaluating next.config.js, so // env vars are available in config, server-side code, and as @@ -2584,20 +2292,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }), resolve: { - // Materialize simple tsconfig/jsconfig path aliases into resolve.alias - // so Vite can transform import.meta.glob("@/...") and import(`@/...`). - // tsconfig-derived entries carry a customResolver that keeps them out - // of stylesheet resolution (see tsconfigAliasCustomResolver). - alias: buildResolveAliasEntries( - { - ...(swcHelpersAlias ? { "@swc/helpers/_": swcHelpersAlias } : {}), - ...tsconfigPathAliases, - ...nextConfig.aliases, - ...nextShimMap, - "vinext/server/pages-client-assets": _pagesClientAssetsPath, - }, - tsconfigPathAliases, - ), + alias: { + ...(swcHelpersAlias ? { "@swc/helpers/_": swcHelpersAlias } : {}), + ...nextConfig.aliases, + ...nextShimMap, + "vinext/server/pages-client-assets": _pagesClientAssetsPath, + }, // Dedupe React packages to prevent dual-instance errors. // When vinext is linked (npm link / bun link) or any dependency // brings its own React copy, multiple React instances can load, diff --git a/tests/helpers.ts b/tests/helpers.ts index 57c1f3d1fa..be5d0bdd85 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -117,8 +117,7 @@ export async function startFixtureServer( /** * Normalize a config-hook `resolve.alias` value to a find→replacement record. - * vinext emits alias entry arrays (so tsconfig-derived entries can carry a - * customResolver); tests assert on the object view. + * Vite accepts both entry arrays and object maps; tests assert on one shape. */ export function aliasEntriesToRecord(alias: unknown): Record { if (Array.isArray(alias)) { diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 272a60f3ec..9e44b4eef2 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -152,180 +152,118 @@ async function buildCloudflareAppFixture(root: string) { await builder.buildApp(); } -describe("App Router tsconfig path aliases in production builds", () => { +describe("native Vite tsconfig paths build gaps", () => { afterEach(() => { for (const dir of tmpDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); - it("transforms alias-based import.meta.glob and alias-based dynamic import in Cloudflare builds", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-alias-build-")); + it("expands a tsconfig-aliased import.meta.glob in a bundled RSC build", async () => { + // Vite's JavaScript import-glob path calls the plugin resolver, but the + // bundled Rolldown import-glob plugin does not currently apply + // resolve.tsconfigPaths to the glob's static prefix. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-glob-gap-")); tmpDirs.push(root); - writeCloudflareAppFixture(root, "vinext-tsconfig-alias-build"); + writeCloudflareAppFixture(root, "vinext-tsconfig-glob-gap"); writeFixtureFile( root, "app/page.tsx", - `import { getGlobPostCount } from "../lib/mdx-loader"; + `import { postCount } from "../lib/posts"; export default function HomePage() { - return
home {getGlobPostCount()}
; + return
posts: {postCount}
; } `, ); writeFixtureFile( root, - "app/mdx-probe/page.mdx", - `# Probe - -This file exists only to trigger vinext's MDX auto-detection in the fixture. + "lib/posts.ts", + `export const posts = import.meta.glob("@/content/posts/**/*.mdx", { eager: true }); +export const postCount = Object.keys(posts).length; `, ); - writeFixtureFile( - root, - "lib/mdx-loader.ts", - `type MdxModule = { - default: React.ComponentType; -}; + writeFixtureFile(root, "content/posts/first.mdx", "# Glob gap sentinel\n"); -export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", { - eager: true, -}) as Record; - -export function getGlobPostCount(): number { - return Object.keys(mdxModules).length; -} -`, - ); - writeFixtureFile( - root, - "lib/mdx-dynamic.ts", - `export async function loadDynamicPost(year: string, month: string, day: string, slug: string) { - return await import(\`@/content/posts/\${year}/\${month}/\${day}/\${slug}/index.mdx\`); -} -`, - ); - writeFixtureFile( - root, - "app/dynamic-posts/[year]/[month]/[day]/[slug]/page.tsx", - `import { notFound } from "next/navigation"; -import { loadDynamicPost } from "../../../../../../lib/mdx-dynamic"; - -export default async function DynamicPostPage({ - params, -}: { - params: Promise<{ year: string; month: string; day: string; slug: string }>; -}) { - const { year, month, day, slug } = await params; - const mod = await loadDynamicPost(year, month, day, slug).catch(() => null); - - if (!mod) { - notFound(); - } + await buildCloudflareAppFixture(root); - const Content = mod.default; + const buildOutput = readTextFilesRecursive(path.join(root, "dist")); + expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"'); + expect(buildOutput).toContain("Glob gap sentinel"); + }, 60_000); - return ( -
-

Dynamic Post

- -
- ); -} -`, - ); + it("expands a tsconfig-aliased variable dynamic import in a bundled RSC build", async () => { + // Vite's bundled dynamic-import-vars transform resolves the static prefix + // internally. User resolveId hooks and resolve.tsconfigPaths do not turn + // the aliased template into a relative pattern before analysis. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-dynamic-gap-")); + tmpDirs.push(root); + writeCloudflareAppFixture(root, "vinext-tsconfig-dynamic-gap"); writeFixtureFile( root, - "content/posts/2024/01/02/glob-post/index.mdx", - `# Globbed-Only MDX Post + "app/page.tsx", + `import { loadPost } from "../lib/load-post"; -This content came from an alias-based MDX import. +export default async function HomePage() { + const post = await loadPost("first"); + return
{post.default({})}
; +} `, ); writeFixtureFile( root, - "content/posts/2024/01/02/dynamic-post/index.mdx", - `# Dynamic MDX Post - -This content came from an alias-based dynamic MDX import. -`, + "lib/load-post.ts", + "export function loadPost(slug: string) {\n" + + " return import(`@/content/posts/${slug}.mdx`);\n" + + "}\n", ); + writeFixtureFile(root, "content/posts/first.mdx", "# Dynamic import gap sentinel\n"); await buildCloudflareAppFixture(root); const buildOutput = readTextFilesRecursive(path.join(root, "dist")); - expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"'); expect(buildOutput).not.toContain("@/content/posts/"); + expect(buildOutput).toContain("Dynamic import gap sentinel"); }, 60_000); - it("import.meta.glob with MDX files containing frontmatter does not cause parse errors (issue #659)", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mdx-frontmatter-build-")); + it("uses the nearest importer tsconfig when expanding aliased glob patterns", async () => { + // Any fallback must preserve Vite's per-importer tsconfig discovery. A + // root-level alias map silently expands this pattern against the wrong + // config when a nested project overrides the same paths key. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-nested-gap-")); tmpDirs.push(root); - writeCloudflareAppFixture(root, "vinext-mdx-frontmatter-test"); + writeCloudflareAppFixture(root, "vinext-tsconfig-nested-gap"); writeFixtureFile( root, - "app/page.tsx", - `import { getGlobPostCount } from "../lib/mdx-loader"; - -export default function HomePage() { - return
home {getGlobPostCount()}
; -} -`, + "lib/tsconfig.json", + JSON.stringify({ + compilerOptions: { paths: { "@/*": ["../content/*"] } }, + include: ["**/*.ts"], + }), ); writeFixtureFile( root, - "lib/mdx-loader.ts", - `type MdxModule = { - default: React.ComponentType; - frontmatter?: { - title: string; - date: string; - }; -}; - -export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", { - eager: true, -}) as Record; + "app/page.tsx", + `import { postCount } from "../lib/posts"; -export function getGlobPostCount(): number { - return Object.keys(mdxModules).length; +export default function HomePage() { + return
nested posts: {postCount}
; } `, ); writeFixtureFile( root, - "content/posts/2025/08/20/second-post/index.mdx", - `--- -title: "Second Post" -date: "2025-08-20" ---- - -This is a post with frontmatter and JSX. + "lib/posts.ts", + `export const posts = import.meta.glob("@/posts/**/*.mdx", { eager: true }); +export const postCount = Object.keys(posts).length; `, ); + writeFixtureFile(root, "content/posts/nested.mdx", "# Nested tsconfig gap sentinel\n"); await buildCloudflareAppFixture(root); const buildOutput = readTextFilesRecursive(path.join(root, "dist")); - // Runs against the PRODUCTION DEFAULT (server minification ON). - expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"'); - expect(buildOutput).not.toContain("@/content/posts/"); - // Issue #659 was a build-time *parse failure*: the MDX frontmatter `---` - // fence broke compilation, so the module never produced runnable JSX. The - // regression guard is therefore "the MDX compiled to JSX". The fixture - // configures no remark-frontmatter plugin, so MDX legitimately renders the - // `---` block as an `
` + heading text — that rendered text (including - // `title: "Second Post"`) is EXPECTED content, present identically in - // minified and unminified output, and is NOT a leak. (The old - // `not.toContain('title: "Second Post"')` check only ever passed by accident: - // unminified JS escaped the inner quotes as `title: \"Second Post\"`, so the - // bare substring never matched. Minification emits the same string inside a - // backtick template without escaping, exposing that the assertion was a - // quote-style artifact rather than a real signal — hence its removal.) - // - // The real leak we still guard against is the RAW YAML frontmatter surviving - // verbatim as a top-of-module `---` fence (the unparsed #659 shape). - expect(buildOutput).toContain("text-red-500"); - expect(buildOutput).not.toMatch(/^---\s*$[\s\S]*?title:/m); + expect(buildOutput).not.toContain('import.meta.glob("@/posts/**/*.mdx"'); + expect(buildOutput).toContain("Nested tsconfig gap sentinel"); }, 60_000); }); diff --git a/tests/tsconfig-paths-config-loader.test.ts b/tests/tsconfig-paths-config-loader.test.ts deleted file mode 100644 index 8d341e077b..0000000000 --- a/tests/tsconfig-paths-config-loader.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vite-plus/test"; -import { loadTsconfigPathAliasesForRoot } from "../packages/vinext/src/config/tsconfig-paths.js"; - -function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-paths-test-")); -} - -describe("loadTsconfigPathAliasesForRoot", () => { - let tmpDir: string; - - afterEach(() => { - if (tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("returns absolute alias paths for wildcard mapping without baseUrl", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - paths: { - "@/*": ["./src/*"], - }, - }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "src")); - }); - - it("supports baseUrl with relative path values", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - baseUrl: ".", - paths: { - "@/*": ["src/*"], - }, - }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "src")); - }); - - it("follows 'extends' for inherited paths", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.base.json"), - JSON.stringify({ - compilerOptions: { paths: { "@/*": ["./lib/*"] } }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ extends: "./tsconfig.base.json" }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "lib")); - }); - - it("child paths override extended paths", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.base.json"), - JSON.stringify({ - compilerOptions: { paths: { "@/*": ["./lib/*"] } }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - extends: "./tsconfig.base.json", - compilerOptions: { paths: { "@/*": ["./src/*"] } }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "src")); - }); - - // Ported from Next.js: packages/next/src/build/next-config-ts/transpile-config.ts (array extends) - it("follows array-form 'extends' for inherited paths", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.base.json"), - JSON.stringify({ - compilerOptions: { paths: { "@/*": ["./lib/*"] } }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ extends: ["./tsconfig.base.json"] }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "lib")); - }); - - // Ported from Next.js: packages/next/src/build/next-config-ts/transpile-config.ts (array extends) - it("array-form 'extends': later entries override earlier ones", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.first.json"), - JSON.stringify({ - compilerOptions: { paths: { "@/*": ["./first/*"] } }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.second.json"), - JSON.stringify({ - compilerOptions: { paths: { "@/*": ["./second/*"] } }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ extends: ["./tsconfig.first.json", "./tsconfig.second.json"] }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "second")); - }); - - it("child paths override array-form extended paths", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.base.json"), - JSON.stringify({ - compilerOptions: { paths: { "@/*": ["./lib/*"] } }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - extends: ["./tsconfig.base.json"], - compilerOptions: { paths: { "@/*": ["./src/*"] } }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "src")); - }); - - it("supports non-wildcard exact alias", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - paths: { - "@app": ["./src/app.ts"], - }, - }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@app"]).toBe(path.join(tmpDir, "src", "app.ts")); - }); - - it("orders overlapping aliases longest-prefix-first regardless of declaration order", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - paths: { - // General pattern declared first; TypeScript matches by longest - // prefix, so consumers applying first-match semantics (Vite's - // alias plugin) need `@/public` ordered before `@`. - "@/*": ["./src/*"], - "@/public/*": ["./public/*"], - }, - }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(Object.keys(aliases)).toEqual(["@/public", "@"]); - expect(aliases["@/public"]).toBe(path.join(tmpDir, "public")); - expect(aliases["@"]).toBe(path.join(tmpDir, "src")); - }); - - it("returns empty object when tsconfig.json is missing", () => { - tmpDir = makeTempDir(); - expect(loadTsconfigPathAliasesForRoot(tmpDir)).toEqual({}); - }); - - it("falls back to jsconfig.json when tsconfig.json is absent", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "jsconfig.json"), - JSON.stringify({ - compilerOptions: { - paths: { "@/*": ["./src/*"] }, - }, - }), - ); - - const aliases = loadTsconfigPathAliasesForRoot(tmpDir); - expect(aliases["@"]).toBe(path.join(tmpDir, "src")); - }); - - it("ignores malformed wildcard patterns", () => { - tmpDir = makeTempDir(); - fs.writeFileSync( - path.join(tmpDir, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - // Invalid: wildcard not at end - paths: { "@*x/*": ["./src/*"] }, - }, - }), - ); - expect(loadTsconfigPathAliasesForRoot(tmpDir)).toEqual({}); - }); - - it("returns empty object on unparseable tsconfig.json", () => { - tmpDir = makeTempDir(); - fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), "not valid json{{{"); - expect(loadTsconfigPathAliasesForRoot(tmpDir)).toEqual({}); - }); -}); diff --git a/tests/tsconfig-paths-vite8.test.ts b/tests/tsconfig-paths-vite8.test.ts index b27239c2e0..74092402f9 100644 --- a/tests/tsconfig-paths-vite8.test.ts +++ b/tests/tsconfig-paths-vite8.test.ts @@ -97,23 +97,12 @@ describe("Vite tsconfig paths support", () => { expect(resolvedConfig?.resolve?.tsconfigPaths).toBe(true); }); - it("materializes simple tsconfig path aliases into resolve.alias on Vite 8", async () => { + it("does not materialize tsconfig paths into resolve.alias", async () => { const root = setupProject({ name: "vite", version: "8.0.0" }); process.chdir(root); fs.writeFileSync( path.join(root, "tsconfig.json"), - JSON.stringify( - { - compilerOptions: { - baseUrl: ".", - paths: { - "@/*": ["./*"], - }, - }, - }, - null, - 2, - ), + JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), ); const plugins = vinext({ appDir: root }); @@ -121,9 +110,7 @@ describe("Vite tsconfig paths support", () => { config?: ( config: { root: string }, env: { command: "serve"; mode: string }, - ) => Promise<{ - resolve?: Record; - }>; + ) => Promise<{ resolve?: Record }>; }; const resolvedConfig = await configPlugin.config?.( { root }, @@ -131,114 +118,8 @@ describe("Vite tsconfig paths support", () => { ); const alias = aliasEntriesToRecord(resolvedConfig?.resolve?.alias); - expect(alias["@"]).toBeDefined(); - expect(path.isAbsolute(alias["@"])).toBe(true); - expect(alias["@"].replace(/\\/g, "/")).toContain(root.replace(/\\/g, "/")); - }); - - it("orders overlapping tsconfig path aliases longest-prefix-first on Vite 8", async () => { - const root = setupProject({ name: "vite", version: "8.0.0" }); - process.chdir(root); - fs.writeFileSync( - path.join(root, "tsconfig.json"), - JSON.stringify( - { - compilerOptions: { - paths: { - // Declaration order intentionally puts the general pattern - // first. TypeScript matches by longest prefix, so the - // materialized alias entries must order `@/public` before `@`. - "@/*": ["./src/*"], - "@/public/*": ["./public/*"], - }, - }, - }, - null, - 2, - ), - ); - - const plugins = vinext({ appDir: root }); - const configPlugin = (await findNamedPlugin(plugins, "vinext:config")) as { - config?: ( - config: { root: string }, - env: { command: "serve"; mode: string }, - ) => Promise<{ - resolve?: Record; - }>; - }; - const resolvedConfig = await configPlugin.config?.( - { root }, - { command: "serve", mode: "development" }, - ); - - const alias = resolvedConfig?.resolve?.alias as Array<{ - find: string; - replacement: string; - customResolver?: unknown; - }>; - expect(Array.isArray(alias)).toBe(true); - const finds = alias.map((entry) => entry.find); - expect(finds.indexOf("@/public")).toBeGreaterThanOrEqual(0); - expect(finds.indexOf("@/public")).toBeLessThan(finds.indexOf("@")); - - // tsconfig-derived entries carry the stylesheet-scoping customResolver. - const publicEntry = alias.find((entry) => entry.find === "@/public"); - expect(typeof publicEntry?.customResolver).toBe("function"); - // Non-tsconfig entries (the next/* shims) do not. - const shimEntry = alias.find((entry) => entry.find === "next/link"); - expect(shimEntry?.customResolver).toBeUndefined(); - }); - - it("materializes path aliases inherited via tsconfig extends on Vite 8", async () => { - const root = setupProject({ name: "vite", version: "8.0.0" }); - process.chdir(root); - fs.mkdirSync(path.join(root, "src"), { recursive: true }); - fs.writeFileSync( - path.join(root, "tsconfig.base.json"), - JSON.stringify( - { - compilerOptions: { - baseUrl: ".", - paths: { - "@/*": ["src/*"], - }, - }, - }, - null, - 2, - ), - ); - fs.writeFileSync( - path.join(root, "tsconfig.json"), - JSON.stringify( - { - extends: "./tsconfig.base.json", - }, - null, - 2, - ), - ); - - const plugins = vinext({ appDir: root }); - const configPlugin = (await findNamedPlugin(plugins, "vinext:config")) as { - config?: ( - config: { root: string }, - env: { command: "serve"; mode: string }, - ) => Promise<{ - resolve?: Record; - }>; - }; - const resolvedConfig = await configPlugin.config?.( - { root }, - { command: "serve", mode: "development" }, - ); - - expect(aliasEntriesToRecord(resolvedConfig?.resolve?.alias)).toEqual( - expect.objectContaining({ - "@": "/src", - }), - ); + expect(alias["@"]).toBeUndefined(); + expect(resolvedConfig?.resolve?.tsconfigPaths).toBe(true); }); it("does not override user-defined resolve.tsconfigPaths on Vite 8", async () => { From 8077699c53dd775e1f9047bee018a8624d6a19ba Mon Sep 17 00:00:00 2001 From: James Date: Mon, 6 Jul 2026 11:56:32 +0100 Subject: [PATCH 2/2] test(config): preserve native tsconfig gap coverage --- tests/next-config.test.ts | 58 +++++++++++++++++++++++++ tests/tsconfig-path-alias-build.test.ts | 40 +++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 7b4f1952b2..945b98c239 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -663,6 +663,64 @@ describe("loadNextConfig with tsconfig path aliases", () => { expect(config?.env?.BAZ).toBe("baz"); }); + it("follows array-form tsconfig extends when resolving paths", async () => { + tmpDir = makeTempDir(); + + fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "src", "value.ts"), `export const value = "array";\n`); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.base.json"), + JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + ); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify({ extends: ["./tsconfig.base.json"] }), + ); + fs.writeFileSync( + path.join(tmpDir, "next.config.ts"), + `import { value } from "@/value";\nexport default { env: { VALUE: value } };\n`, + ); + + const config = await loadNextConfig(tmpDir); + expect(config?.env?.VALUE).toBe("array"); + }); + + it("resolves exact non-wildcard tsconfig path aliases", async () => { + tmpDir = makeTempDir(); + + fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "src", "value.ts"), `export const value = "exact";\n`); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify({ compilerOptions: { paths: { "@value": ["./src/value.ts"] } } }), + ); + fs.writeFileSync( + path.join(tmpDir, "next.config.ts"), + `import { value } from "@value";\nexport default { env: { VALUE: value } };\n`, + ); + + const config = await loadNextConfig(tmpDir); + expect(config?.env?.VALUE).toBe("exact"); + }); + + it("resolves next.config.ts imports through jsconfig paths", async () => { + tmpDir = makeTempDir(); + + fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "src", "value.ts"), `export const value = "jsconfig";\n`); + fs.writeFileSync( + path.join(tmpDir, "jsconfig.json"), + JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + ); + fs.writeFileSync( + path.join(tmpDir, "next.config.ts"), + `import { value } from "@/value";\nexport default { env: { VALUE: value } };\n`, + ); + + const config = await loadNextConfig(tmpDir); + expect(config?.env?.VALUE).toBe("jsconfig"); + }); + it("follows extended tsconfig baseUrl when resolving bare imports", async () => { // Ported from Next.js: test/e2e/app-dir/next-config-ts/tsconfig-extends/ // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts/tsconfig-extends/next-config-ts-tsconfig-extends-cjs.test.ts diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 9e44b4eef2..37155e040e 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -266,4 +266,44 @@ export const postCount = Object.keys(posts).length; expect(buildOutput).not.toContain('import.meta.glob("@/posts/**/*.mdx"'); expect(buildOutput).toContain("Nested tsconfig gap sentinel"); }, 60_000); + + it("compiles globbed MDX files containing frontmatter (issue #659)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mdx-frontmatter-build-")); + tmpDirs.push(root); + writeCloudflareAppFixture(root, "vinext-mdx-frontmatter-test"); + writeFixtureFile( + root, + "app/page.tsx", + `import { postCount } from "../lib/posts"; + +export default function HomePage() { + return
posts: {postCount}
; +} +`, + ); + writeFixtureFile( + root, + "lib/posts.ts", + `export const posts = import.meta.glob("../content/posts/**/*.mdx", { eager: true }); +export const postCount = Object.keys(posts).length; +`, + ); + writeFixtureFile( + root, + "content/posts/second.mdx", + `--- +title: "Second Post" +date: "2025-08-20" +--- + +This is a post with frontmatter and JSX. +`, + ); + + await buildCloudflareAppFixture(root); + + const buildOutput = readTextFilesRecursive(path.join(root, "dist")); + expect(buildOutput).toContain("text-red-500"); + expect(buildOutput).not.toMatch(/^---\s*$[\s\S]*?title:/m); + }, 60_000); });