diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index 78580148..72819ff7 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -28,6 +28,8 @@ * } */ +import { Buffer } from "node:buffer"; + import type { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "../shims/cache.js"; import { getRequestExecutionContext, type ExecutionContextLike } from "../shims/request-context.js"; @@ -66,6 +68,9 @@ const ENTRY_PREFIX = "cache:"; /** Max tag length to prevent KV key abuse. */ const MAX_TAG_LENGTH = 256; +/** Matches a valid base64 string (standard alphabet with optional padding). */ +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; + /** * Validate a cache tag. Returns null if invalid. * Note: `:` is rejected because TAG_PREFIX and ENTRY_PREFIX use `:` as a @@ -435,26 +440,25 @@ function restoreArrayBuffers(value: IncrementalCacheValue): boolean { } function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); + return Buffer.from(buffer).toString("base64"); } +/** + * Decode a base64 string to an ArrayBuffer. + * Validates the input against the base64 alphabet before decoding, + * since Buffer.from(str, "base64") silently ignores invalid characters. + */ function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); + if (!BASE64_RE.test(base64) || base64.length % 4 !== 0) { + throw new Error("Invalid base64 string"); } - return bytes.buffer; + const buf = Buffer.from(base64, "base64"); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } /** * Safely decode base64 to ArrayBuffer. Returns null on invalid input - * instead of throwing a DOMException. + * instead of throwing. */ function safeBase64ToArrayBuffer(base64: string): ArrayBuffer | null { try { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index da966df5..8283b7b9 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -272,6 +272,13 @@ const POSTCSS_CONFIG_FILES = [ ".postcssrc.yml", ]; +/** + * Module-level cache for resolvePostcssStringPlugins — avoids re-scanning per Vite environment. + * Stores the Promise itself so concurrent calls (RSC/SSR/Client config() hooks firing in + * parallel) all await the same in-flight scan rather than each starting their own. + */ +const _postcssCache = new Map>(); + /** * Resolve PostCSS string plugin names in a project's PostCSS config. * @@ -284,7 +291,15 @@ const POSTCSS_CONFIG_FILES = [ * Returns the resolved PostCSS config object to inject into Vite's * `css.postcss`, or `undefined` if no resolution is needed. */ -async function resolvePostcssStringPlugins( +function resolvePostcssStringPlugins(projectRoot: string): Promise<{ plugins: any[] } | undefined> { + if (_postcssCache.has(projectRoot)) return _postcssCache.get(projectRoot)!; + + const promise = _resolvePostcssStringPluginsUncached(projectRoot); + _postcssCache.set(projectRoot, promise); + return promise; +} + +async function _resolvePostcssStringPluginsUncached( projectRoot: string, ): Promise<{ plugins: any[] } | undefined> { // Find the PostCSS config file @@ -296,7 +311,9 @@ async function resolvePostcssStringPlugins( break; } } - if (!configPath) return undefined; + if (!configPath) { + return undefined; + } // Load the config file let config: any; @@ -326,11 +343,15 @@ async function resolvePostcssStringPlugins( // Only process array-form plugins that contain string entries // (either bare strings or tuple form ["plugin-name", { options }]) - if (!config || !Array.isArray(config.plugins)) return undefined; + if (!config || !Array.isArray(config.plugins)) { + return undefined; + } const hasStringPlugins = config.plugins.some( (p: any) => typeof p === "string" || (Array.isArray(p) && typeof p[0] === "string"), ); - if (!hasStringPlugins) return undefined; + if (!hasStringPlugins) { + return undefined; + } // Resolve string plugin names to actual plugin functions const req = createRequire(path.join(projectRoot, "package.json")); @@ -3547,14 +3568,23 @@ function findFileWithExts( return null; } +/** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ +const _mdxScanCache = new Map(); + /** * Check if the project has .mdx files in app/ or pages/ directories. */ function hasMdxFiles(root: string, appDir: string | null, pagesDir: string | null): boolean { + const cacheKey = `${root}\0${appDir ?? ""}\0${pagesDir ?? ""}`; + if (_mdxScanCache.has(cacheKey)) return _mdxScanCache.get(cacheKey)!; const dirs = [appDir, pagesDir].filter(Boolean) as string[]; for (const dir of dirs) { - if (fs.existsSync(dir) && scanDirForMdx(dir)) return true; + if (fs.existsSync(dir) && scanDirForMdx(dir)) { + _mdxScanCache.set(cacheKey, true); + return true; + } } + _mdxScanCache.set(cacheKey, false); return false; } @@ -3592,6 +3622,9 @@ export type { NextConfig } from "./config/next-config.js"; export { clientManualChunks, clientOutputConfig, clientTreeshakeConfig, computeLazyChunks }; export { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle }; export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins }; +export { _postcssCache }; +export { hasMdxFiles as _hasMdxFiles }; +export { _mdxScanCache }; export { parseStaticObjectLiteral as _parseStaticObjectLiteral }; export { stripServerExports as _stripServerExports }; export { asyncHooksStubPlugin as _asyncHooksStubPlugin }; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index bbffc4a0..b7c5f7bb 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -357,15 +357,21 @@ const trustProxy = process.env.VINEXT_TRUST_PROXY === "1" || trustedHosts.size > /** * Convert a Node.js IncomingMessage to a Web Request object. + * + * When `urlOverride` is provided, it is used as the path + query string + * instead of `req.url`. This avoids redundant path normalization when the + * caller has already decoded and normalized the pathname (e.g. the App + * Router prod server normalizes before static-asset lookup, and can pass + * the result here so the downstream RSC handler doesn't re-normalize). */ -function nodeToWebRequest(req: IncomingMessage): Request { +function nodeToWebRequest(req: IncomingMessage, urlOverride?: string): Request { const rawProto = trustProxy ? (req.headers["x-forwarded-proto"] as string)?.split(",")[0]?.trim() : undefined; const proto = rawProto === "https" || rawProto === "http" ? rawProto : "http"; const host = resolveHost(req, "localhost"); const origin = `${proto}://${host}`; - const url = new URL(req.url ?? "/", origin); + const url = new URL(urlOverride ?? req.url ?? "/", origin); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { @@ -601,9 +607,9 @@ async function startAppRouterServer(options: AppRouterServerOptions) { const rscHandler = resolveAppRouterHandler(rscModule.default); const server = createServer(async (req, res) => { - const url = req.url ?? "/"; + const rawUrl = req.url ?? "/"; // Normalize backslashes (browsers treat /\ as //), then decode and normalize path. - const rawPathname = url.split("?")[0].replaceAll("\\", "/"); + const rawPathname = rawUrl.split("?")[0].replaceAll("\\", "/"); let pathname: string; try { pathname = normalizePath(decodeURIComponent(rawPathname)); @@ -630,7 +636,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // Image optimization passthrough (Node.js prod server has no Images binding; // serves the original file with cache headers and security headers) if (pathname === IMAGE_OPTIMIZATION_PATH) { - const parsedUrl = new URL(url, "http://localhost"); + const parsedUrl = new URL(rawUrl, "http://localhost"); const defaultAllowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES]; const params = parseImageParams(parsedUrl, defaultAllowedWidths); if (!params) { @@ -663,8 +669,14 @@ async function startAppRouterServer(options: AppRouterServerOptions) { } try { + // Build the normalized URL (pathname + original query string) so the + // RSC handler receives an already-canonical path and doesn't need to + // re-normalize. This deduplicates the normalizePath work done above. + const qs = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : ""; + const normalizedUrl = pathname + qs; + // Convert Node.js request to Web Request and call the RSC handler - const request = nodeToWebRequest(req); + const request = nodeToWebRequest(req, normalizedUrl); const response = await rscHandler(request); // Stream the Web Response back to the Node.js response diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 99bb4180..e0847a08 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -422,6 +422,69 @@ describe("KVCacheHandler", () => { }); }); + // ------------------------------------------------------------------------- + // ArrayBuffer base64 roundtrip edge cases + // ------------------------------------------------------------------------- + + describe("ArrayBuffer base64 roundtrip edge cases", () => { + it("round-trips a large buffer (1 MiB)", async () => { + const size = 1024 * 1024; // 1 MiB + const original = new Uint8Array(size); + for (let i = 0; i < size; i++) { + original[i] = i % 256; + } + + await handler.set("large-buf", { + kind: "APP_ROUTE", + body: original.buffer as ArrayBuffer, + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + + const result = await handler.get("large-buf"); + expect(result).not.toBeNull(); + const restored = new Uint8Array((result!.value as any).body); + expect(restored.byteLength).toBe(size); + // Verify every byte survived the roundtrip + expect(restored).toEqual(original); + }); + + it("round-trips a buffer containing null bytes", async () => { + const original = new Uint8Array([0, 0, 0, 72, 101, 108, 108, 111, 0, 0, 0]); + + await handler.set("null-bytes", { + kind: "APP_ROUTE", + body: original.buffer as ArrayBuffer, + status: 200, + headers: {}, + }); + + const result = await handler.get("null-bytes"); + expect(result).not.toBeNull(); + const restored = new Uint8Array((result!.value as any).body); + expect(restored).toEqual(original); + }); + + it("round-trips a buffer with all 256 byte values", async () => { + const original = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + original[i] = i; + } + + await handler.set("all-bytes", { + kind: "APP_ROUTE", + body: original.buffer as ArrayBuffer, + status: 200, + headers: {}, + }); + + const result = await handler.get("all-bytes"); + expect(result).not.toBeNull(); + const restored = new Uint8Array((result!.value as any).body); + expect(restored).toEqual(original); + }); + }); + // ------------------------------------------------------------------------- // ctx.waitUntil registration // ------------------------------------------------------------------------- diff --git a/tests/node-to-web-request.test.ts b/tests/node-to-web-request.test.ts new file mode 100644 index 00000000..e77760a5 --- /dev/null +++ b/tests/node-to-web-request.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; + +/** + * Tests for the nodeToWebRequest helper in prod-server.ts. + * + * Verifies the urlOverride parameter, which allows the App Router prod server + * to pass an already-normalized URL to avoid redundant normalization by the + * RSC handler downstream. + */ + +/** Minimal mock that satisfies nodeToWebRequest's usage of IncomingMessage. */ +function mockReq(overrides: Partial = {}): IncomingMessage { + return { + headers: { host: "localhost:3000" }, + url: "/", + method: "GET", + ...overrides, + } as unknown as IncomingMessage; +} + +describe("nodeToWebRequest", () => { + let nodeToWebRequest: (typeof import("../packages/vinext/src/server/prod-server.js"))["nodeToWebRequest"]; + + beforeAll(async () => { + const mod = await import("../packages/vinext/src/server/prod-server.js"); + nodeToWebRequest = mod.nodeToWebRequest; + }); + + it("uses req.url when no urlOverride is provided", () => { + const req = mockReq({ url: "/test/page?q=1" }); + + const webReq = nodeToWebRequest(req); + + const parsed = new URL(webReq.url); + // Without override, the raw req.url is used as the path+query source + expect(parsed.pathname).toBe("/test/page"); + expect(parsed.searchParams.get("q")).toBe("1"); + }); + + it("uses urlOverride when provided instead of req.url", () => { + const req = mockReq({ url: "/raw/unnormalized//path?q=1" }); + + // After normalization, the prod server would pass the clean URL + const webReq = nodeToWebRequest(req, "/normalized/path?q=1"); + + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/normalized/path"); + expect(parsed.searchParams.get("q")).toBe("1"); + }); + + it("urlOverride replaces the entire path+query from req.url", () => { + const req = mockReq({ url: "/original/path?old=param" }); + + const webReq = nodeToWebRequest(req, "/overridden/path?new=param"); + + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/overridden/path"); + expect(parsed.searchParams.get("new")).toBe("param"); + // The old query param should NOT be present + expect(parsed.searchParams.has("old")).toBe(false); + }); + + it("preserves headers and host when urlOverride is used", () => { + const req = mockReq({ + url: "/raw/url", + method: "GET", + headers: { + host: "example.com", + "x-custom": "value", + }, + }); + + const webReq = nodeToWebRequest(req, "/normalized/url"); + + expect(webReq.method).toBe("GET"); + expect(webReq.headers.get("x-custom")).toBe("value"); + const parsed = new URL(webReq.url); + expect(parsed.hostname).toBe("example.com"); + expect(parsed.pathname).toBe("/normalized/url"); + }); + + it("uses req.url fallback '/' when req.url is undefined and no override", () => { + const req = mockReq({ url: undefined }); + + const webReq = nodeToWebRequest(req); + + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/"); + }); + + it("urlOverride works for POST requests without affecting the body stream", async () => { + const bodyContent = JSON.stringify({ hello: "world" }); + // Build a real Readable and graft the IncomingMessage properties onto it + // so that Readable.toWeb() (used internally for POST bodies) works correctly. + const readable = Readable.from([Buffer.from(bodyContent)]) as unknown as IncomingMessage; + readable.headers = { host: "localhost:3000", "content-type": "application/json" }; + readable.url = "/raw/unnormalized//api/submit"; + readable.method = "POST"; + + const webReq = nodeToWebRequest(readable, "/api/submit"); + + // URL is overridden correctly + const parsed = new URL(webReq.url); + expect(parsed.pathname).toBe("/api/submit"); + expect(webReq.method).toBe("POST"); + + // Body stream is present and readable + expect(webReq.body).not.toBeNull(); + const text = await webReq.text(); + expect(text).toBe(bodyContent); + }); +}); diff --git a/tests/startup-cache.test.ts b/tests/startup-cache.test.ts new file mode 100644 index 00000000..0e9ccb40 --- /dev/null +++ b/tests/startup-cache.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for startup-time caching of expensive filesystem operations. + * + * Task 1a: hasMdxFiles() should cache its result per (root, appDir, pagesDir) combination. + * Task 1b: resolvePostcssStringPlugins() should cache its result per project root. + */ +import { afterEach, describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// --------------------------------------------------------------------------- +// Task 1a: hasMdxFiles caching +// --------------------------------------------------------------------------- + +describe("hasMdxFiles caching", () => { + let hasMdxFiles: (typeof import("../packages/vinext/src/index.js"))["_hasMdxFiles"]; + let mdxScanCache: (typeof import("../packages/vinext/src/index.js"))["_mdxScanCache"]; + + beforeAll(async () => { + const mod = await import("../packages/vinext/src/index.js"); + hasMdxFiles = mod._hasMdxFiles; + mdxScanCache = mod._mdxScanCache; + }); + + beforeEach(() => { + // Clear the cache between tests to ensure isolation + mdxScanCache.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns true when .mdx files exist in appDir", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.mdx"), "# Hello"); + + try { + const result = hasMdxFiles(tmpDir, appDir, null); + expect(result).toBe(true); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns false when no .mdx files exist", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.tsx"), "export default () => null"); + + try { + const result = hasMdxFiles(tmpDir, appDir, null); + expect(result).toBe(false); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not rescan the filesystem on second call with same root", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-cache-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.mdx"), "# Hello"); + + try { + // First call — populates cache + const result1 = hasMdxFiles(tmpDir, appDir, null); + expect(result1).toBe(true); + + // Spy on readdirSync after first call + const readdirSpy = vi.spyOn(fs, "readdirSync"); + + // Second call — should use cache, no filesystem reads + const result2 = hasMdxFiles(tmpDir, appDir, null); + expect(result2).toBe(true); + expect(readdirSpy).not.toHaveBeenCalled(); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("caches false results too (no redundant scans for missing mdx)", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-nomdx-")); + const appDir = path.join(tmpDir, "app"); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile(path.join(appDir, "page.tsx"), "export default () => null"); + + try { + // First call + const result1 = hasMdxFiles(tmpDir, appDir, null); + expect(result1).toBe(false); + + const readdirSpy = vi.spyOn(fs, "readdirSync"); + + // Second call — should hit cache + const result2 = hasMdxFiles(tmpDir, appDir, null); + expect(result2).toBe(false); + expect(readdirSpy).not.toHaveBeenCalled(); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("caches independently per root directory", async () => { + const fsp = await import("node:fs/promises"); + const tmpDir1 = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-root1-")); + const tmpDir2 = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-root2-")); + const appDir1 = path.join(tmpDir1, "app"); + const appDir2 = path.join(tmpDir2, "app"); + await fsp.mkdir(appDir1, { recursive: true }); + await fsp.mkdir(appDir2, { recursive: true }); + await fsp.writeFile(path.join(appDir1, "page.mdx"), "# Hello"); + await fsp.writeFile(path.join(appDir2, "page.tsx"), "export default () => null"); + + try { + expect(hasMdxFiles(tmpDir1, appDir1, null)).toBe(true); + expect(hasMdxFiles(tmpDir2, appDir2, null)).toBe(false); + } finally { + await fsp.rm(tmpDir1, { recursive: true, force: true }); + await fsp.rm(tmpDir2, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Task 1b: resolvePostcssStringPlugins caching +// --------------------------------------------------------------------------- + +describe("resolvePostcssStringPlugins caching", () => { + let resolvePostcssStringPlugins: (typeof import("../packages/vinext/src/index.js"))["_resolvePostcssStringPlugins"]; + let postcssCache: (typeof import("../packages/vinext/src/index.js"))["_postcssCache"]; + + beforeAll(async () => { + const mod = await import("../packages/vinext/src/index.js"); + resolvePostcssStringPlugins = mod._resolvePostcssStringPlugins; + postcssCache = mod._postcssCache; + }); + + beforeEach(() => { + postcssCache.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createTmpProject(configFileName: string, configContent: string): Promise { + const fsp = await import("node:fs/promises"); + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-cache-")); + + // Create a mock PostCSS plugin + const pluginDir = path.join(dir, "node_modules", "mock-postcss-plugin"); + await fsp.mkdir(pluginDir, { recursive: true }); + await fsp.writeFile( + path.join(pluginDir, "index.js"), + `module.exports = function mockPlugin(opts) { + return { postcssPlugin: "mock-postcss-plugin", Once(root) {} }; +}; +module.exports.postcss = true;`, + ); + await fsp.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ name: "mock-postcss-plugin", version: "1.0.0", main: "index.js" }), + ); + + await fsp.writeFile(path.join(dir, configFileName), configContent); + return dir; + } + + async function cleanupDir(dir: string) { + const fsp = await import("node:fs/promises"); + await fsp.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + + it("does not re-scan filesystem on second call with same projectRoot", async () => { + const dir = await createTmpProject( + "postcss.config.cjs", + `module.exports = { plugins: ["mock-postcss-plugin"] };`, + ); + + try { + // First call — populates cache + const result1 = await resolvePostcssStringPlugins(dir); + expect(result1).toBeDefined(); + expect(result1!.plugins).toHaveLength(1); + + // Spy on existsSync after first call + const existsSpy = vi.spyOn(fs, "existsSync"); + + // Second call — should use cache + const result2 = await resolvePostcssStringPlugins(dir); + expect(result2).toBeDefined(); + expect(result2!.plugins).toHaveLength(1); + expect(existsSpy).not.toHaveBeenCalled(); + } finally { + await cleanupDir(dir); + } + }); + + it("caches undefined results (no config file)", async () => { + const fsp = await import("node:fs/promises"); + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-noconf-")); + + try { + // First call — no config, returns undefined + const result1 = await resolvePostcssStringPlugins(dir); + expect(result1).toBeUndefined(); + + const existsSpy = vi.spyOn(fs, "existsSync"); + + // Second call — should use cache + const result2 = await resolvePostcssStringPlugins(dir); + expect(result2).toBeUndefined(); + expect(existsSpy).not.toHaveBeenCalled(); + } finally { + await cleanupDir(dir); + } + }); + + it("returns the exact same object reference from cache", async () => { + const dir = await createTmpProject( + "postcss.config.cjs", + `module.exports = { plugins: ["mock-postcss-plugin"] };`, + ); + + try { + const result1 = await resolvePostcssStringPlugins(dir); + const result2 = await resolvePostcssStringPlugins(dir); + // Same reference — not re-computed + expect(result1).toBe(result2); + } finally { + await cleanupDir(dir); + } + }); + + it("caches independently per project root", async () => { + const dir1 = await createTmpProject( + "postcss.config.cjs", + `module.exports = { plugins: ["mock-postcss-plugin"] };`, + ); + const fsp = await import("node:fs/promises"); + const dir2 = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-noconf2-")); + + try { + const result1 = await resolvePostcssStringPlugins(dir1); + const result2 = await resolvePostcssStringPlugins(dir2); + expect(result1).toBeDefined(); + expect(result2).toBeUndefined(); + } finally { + await cleanupDir(dir1); + await cleanupDir(dir2); + } + }); +});