From 04bf98878cff404ef90a6fc613ede91cad458133 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Jun 2026 14:07:18 +0100 Subject: [PATCH 1/6] fix(build): support Babel flowtype page compilation (#1506) --- packages/vinext/src/index.ts | 58 ++++++++++++++++++++ tests/babel-flowtype.test.ts | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/babel-flowtype.test.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 212265b1ee..93fbf6690b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -609,6 +609,55 @@ function hasReactDirective(code: string): boolean { return false; } +/** + * Transform a `.js` file that carries a `// @flow` pragma using @babel/core. + * + * OXC (used by `vite:oxc` and `transformWithOxc`) does not support Flow type + * syntax and throws `PARSE_ERROR: Flow is not supported` on such files. This + * function resolves @babel/core from the project root (so the user's installed + * version is used) and invokes it with `babelrc: true` so the project's + * .babelrc — typically containing @babel/preset-flow — strips the Flow + * annotations. JSX in the file is also compiled so vite:oxc does not re-parse + * JSX syntax later in the pipeline. + * + * Returns null when @babel/core cannot be resolved (the file is silently + * skipped and will fail later with a more specific error). Throws when Babel + * itself rejects the file (e.g. a misconfigured .babelrc). + */ +async function transformWithFlowBabel( + code: string, + filename: string, + projectRoot: string, + // oxlint-disable-next-line typescript/no-explicit-any +): Promise<{ code: string; map?: any } | null> { + // Resolve @babel/core from the project root so the user's version is used. + // Users who have @babel/preset-flow installed always have @babel/core as a + // transitive dependency, so this resolve should succeed in valid setups. + // oxlint-disable-next-line typescript/no-explicit-any + let babelCore: any; + try { + const req = createRequire(path.join(projectRoot, "package.json")); + babelCore = req("@babel/core"); + } catch { + // @babel/core not installed — skip silently. The subsequent vite:oxc pass + // will produce a clearer "Flow is not supported" parse error pointing at + // the file, which is more actionable than a module-not-found from here. + return null; + } + + // oxlint-disable-next-line typescript/no-unsafe-call, typescript/no-unsafe-member-access + const result = (await babelCore.transformAsync(code, { + filename, + sourceMaps: true, + // Pick up the project's .babelrc / babel.config.* so @babel/preset-flow + // (and any other project-local Babel presets) are applied. + configFile: true, + babelrc: true, + })) as { code?: string | null; map?: unknown } | null; + if (!result?.code) return null; + return { code: result.code, map: result.map ?? undefined }; +} + function generateRootParamsModule(rootParamNames: Iterable): string { const names = Array.from(new Set(rootParamNames)).filter(isValidExportIdentifier).sort(); if (names.length === 0) return "export {};\n"; @@ -1090,6 +1139,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } + // OXC does not support Flow type syntax. When a file carries a + // `// @flow` pragma, skip OXC and use @babel/core (resolved from + // the project root) instead, so the project's .babelrc — which + // typically includes @babel/preset-flow — can strip Flow + // annotations before Vite continues processing. + if (/^\s*\/\/\s*@flow\b/m.test(code)) { + return transformWithFlowBabel(code, cleanId, root); + } + const result = await transformWithOxc(code, id, { lang: "jsx", jsx: { runtime: "automatic" as const }, diff --git a/tests/babel-flowtype.test.ts b/tests/babel-flowtype.test.ts new file mode 100644 index 0000000000..f5690a642a --- /dev/null +++ b/tests/babel-flowtype.test.ts @@ -0,0 +1,101 @@ +/** + * Test: Babel flowtype page compilation (#1506) + * + * When a `.js` file carries a `// @flow` pragma, OXC (used by + * `vinext:jsx-in-js`) rejects it with: + * + * PARSE_ERROR: Flow is not supported + * + * vinext must gracefully route such files through @babel/core (resolved from + * the project root) so the project's .babelrc — which typically contains + * @babel/preset-flow — can strip the Flow annotations before Vite continues. + * + * Ported from Next.js: + * test/e2e/babel/index.test.ts + * https://github.com/vercel/next.js/blob/canary/test/e2e/babel/ + */ + +import { describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; + +const FLOW_COMPONENT_CODE = `\ +// @flow +import React from 'react' + +type Props = {} + +export default class MyComponent extends React.Component { + render() { + return
Test Babel
+ } +} +`; + +/** + * Retrieve the vinext:jsx-in-js plugin and its transform hook from the + * plugin array returned by vinext(). + */ +function getJsxInJsPlugin(plugins: unknown[]) { + // oxlint-disable-next-line typescript/no-explicit-any + const flat = (plugins as any[]).flat(Infinity).filter(Boolean); + // oxlint-disable-next-line typescript/no-explicit-any + return flat.find((p: any) => p?.name === "vinext:jsx-in-js") ?? null; +} + +describe("Babel flowtype compilation (issue #1506)", () => { + it("vinext:jsx-in-js does not throw 'Flow is not supported' for // @flow files", async () => { + // This test verifies that files annotated with // @flow are routed to + // the Babel fallback path rather than OXC. When @babel/core is not + // installed in the test environment, transformWithFlowBabel returns null + // and the plugin returns null (graceful pass-through). The important thing + // is that OXC is never called on the Flow-annotated code, so the + // "Flow is not supported" PARSE_ERROR is never thrown here. + // + // Full e2e verification (including Babel stripping Flow types and the + // page rendering) is covered by the deploy-suite test: + // test/e2e/babel/index.test.ts → "Babel > Should compile a page with + // flowtype correctly" + + const plugins = vinext(); + const jsxInJsPlugin = getJsxInJsPlugin([plugins]); + expect(jsxInJsPlugin).not.toBeNull(); + expect(typeof jsxInJsPlugin.transform).toBe("function"); + + // Simulate what vite:oxc would return: "Flow is not supported" + // Before the fix, calling transform on a Flow file would delegate to OXC + // and throw. After the fix, it delegates to transformWithFlowBabel which + // either uses Babel (if available) or returns null (no Babel). + // Either way, no "Flow is not supported" error should propagate. + const result = await jsxInJsPlugin.transform( + FLOW_COMPONENT_CODE, + `/some/project/lib/mycomponent.js`, + ); + + // The result is either: + // - null if @babel/core is not installed in the project root + // - { code, map } if @babel/core is available and transforms the file + // We accept both — the key assertion is that no Flow-parse error was thrown. + expect(result).toSatisfy( + (r: unknown) => + r === null || r === undefined || typeof (r as { code?: unknown }).code === "string", + ); + }); + + it("vinext:jsx-in-js transforms a non-Flow .js file with JSX normally", async () => { + const plugins = vinext(); + const jsxInJsPlugin = getJsxInJsPlugin([plugins]); + expect(jsxInJsPlugin).not.toBeNull(); + + const result = await jsxInJsPlugin.transform( + `export default function Hello() { return
Hi
; }`, + `/some/project/pages/hello.js`, + ); + + // Normal JSX compilation should produce output (not null). + expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); + expect(typeof result.code).toBe("string"); + // OXC should have compiled the JSX away — no raw JSX in the output. + expect(result.code).not.toContain("
"); + }); +}); From af2c37438107b6274dcb5e0cc1a7226da57054b2 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Jun 2026 15:03:46 +0100 Subject: [PATCH 2/6] fix(build): address ask-bonk review issues for babel flowtype support (#1506) - Extract `hasFlowPragma` into `utils/flow-pragma.ts` with proper leading-comment-block scanning (mirrors `hasReactDirective`): only the file's BOM / hashbang / leading whitespace / leading comments are scanned, so mid-file `@flow` occurrences (template literals, strings, mid-file comments) never trigger the Babel fallback path. - Add block-comment pragma detection (`/* @flow */`, `/** @flow */`), matching Flow's own rules. - Fix misleading JSX comment: `@babel/preset-flow` only strips Flow types; a follow-up `transformWithOxc(lang:"jsx")` pass now compiles JSX so the Vite pipeline receives plain JS. - Memoize `@babel/core` resolve per projectRoot to avoid repeated `createRequire` overhead on every Flow file. - Surface a clear error instead of silently returning null when Babel produces empty output. - Rewrite unit test to import `hasFlowPragma` directly from the small util module (avoids the `image-size` transitive dep that caused the test suite to fail to load); 22 targeted detection tests covering true-positives, false-positives, block comments, edge cases, and the `@flowtype` non-match. --- packages/vinext/src/index.ts | 84 ++++++--- packages/vinext/src/utils/flow-pragma.ts | 72 ++++++++ tests/babel-flowtype.test.ts | 206 +++++++++++++++-------- 3 files changed, 262 insertions(+), 100 deletions(-) create mode 100644 packages/vinext/src/utils/flow-pragma.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 93fbf6690b..7a2beac4c0 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -178,6 +178,7 @@ import fs from "node:fs"; import { randomBytes, randomUUID } from "node:crypto"; import commonjs from "vite-plugin-commonjs"; import { normalizePathSeparators } from "./utils/path.js"; +import { hasFlowPragma } from "./utils/flow-pragma.js"; // Install the process-level peer-disconnect backstop at module load. // Vite plugin lifecycle hooks (config / configureServer) proved @@ -610,19 +611,28 @@ function hasReactDirective(code: string): boolean { } /** - * Transform a `.js` file that carries a `// @flow` pragma using @babel/core. + * Memoized @babel/core resolve cache: projectRoot → resolved module or null. + * Avoids repeated `createRequire` + `require.resolve` on every transformed file. + */ +const _babelCoreCache = new Map(); + +/** + * Transform a `.js` file that carries a Flow pragma using @babel/core so that + * Flow type annotations are stripped before OXC processes the file. * * OXC (used by `vite:oxc` and `transformWithOxc`) does not support Flow type * syntax and throws `PARSE_ERROR: Flow is not supported` on such files. This * function resolves @babel/core from the project root (so the user's installed * version is used) and invokes it with `babelrc: true` so the project's - * .babelrc — typically containing @babel/preset-flow — strips the Flow - * annotations. JSX in the file is also compiled so vite:oxc does not re-parse - * JSX syntax later in the pipeline. + * .babelrc — which must contain `@babel/preset-flow` — strips the Flow + * annotations. Babel only removes Flow types; JSX is left intact in its + * output. A subsequent `transformWithOxc` pass (with `lang: "jsx"`) then + * compiles the JSX so the Vite pipeline can continue normally. * - * Returns null when @babel/core cannot be resolved (the file is silently - * skipped and will fail later with a more specific error). Throws when Babel - * itself rejects the file (e.g. a misconfigured .babelrc). + * Returns null when @babel/core cannot be resolved (the file is then handled + * by OXC which will produce a clear "Flow is not supported" parse error). + * Throws when Babel itself rejects the file (e.g. a misconfigured .babelrc) + * or when Babel produces no output (which indicates a silent misconfiguration). */ async function transformWithFlowBabel( code: string, @@ -631,31 +641,50 @@ async function transformWithFlowBabel( // oxlint-disable-next-line typescript/no-explicit-any ): Promise<{ code: string; map?: any } | null> { // Resolve @babel/core from the project root so the user's version is used. - // Users who have @babel/preset-flow installed always have @babel/core as a - // transitive dependency, so this resolve should succeed in valid setups. + // Memoize per projectRoot to avoid repeated `createRequire` calls. // oxlint-disable-next-line typescript/no-explicit-any let babelCore: any; - try { - const req = createRequire(path.join(projectRoot, "package.json")); - babelCore = req("@babel/core"); - } catch { - // @babel/core not installed — skip silently. The subsequent vite:oxc pass - // will produce a clearer "Flow is not supported" parse error pointing at - // the file, which is more actionable than a module-not-found from here. - return null; + if (_babelCoreCache.has(projectRoot)) { + babelCore = _babelCoreCache.get(projectRoot); + if (babelCore === null) return null; + } else { + try { + const req = createRequire(path.join(projectRoot, "package.json")); + babelCore = req("@babel/core"); + _babelCoreCache.set(projectRoot, babelCore); + } catch { + // @babel/core not installed in this project. Cache the miss so we don't + // re-attempt on every file. OXC will surface "Flow is not supported". + _babelCoreCache.set(projectRoot, null); + return null; + } } + // Use the project's .babelrc / babel.config.* (must include @babel/preset-flow) + // to strip Flow type annotations. Babel leaves JSX syntax intact. // oxlint-disable-next-line typescript/no-unsafe-call, typescript/no-unsafe-member-access const result = (await babelCore.transformAsync(code, { filename, sourceMaps: true, - // Pick up the project's .babelrc / babel.config.* so @babel/preset-flow - // (and any other project-local Babel presets) are applied. configFile: true, babelrc: true, })) as { code?: string | null; map?: unknown } | null; - if (!result?.code) return null; - return { code: result.code, map: result.map ?? undefined }; + + if (!result?.code) { + throw new Error( + `[vinext] Babel produced empty output for Flow file: ${filename}\n` + + "Ensure @babel/preset-flow is listed in the project's .babelrc or babel.config.*", + ); + } + + // Babel has stripped Flow types; the output still contains JSX. Run OXC to + // compile JSX so the rest of the Vite pipeline receives plain JS. + const oxcResult = await transformWithOxc(result.code, filename, { + lang: "jsx", + jsx: { runtime: "automatic" as const }, + sourcemap: true, + }); + return { code: oxcResult.code, map: oxcResult.map }; } function generateRootParamsModule(rootParamNames: Iterable): string { @@ -1140,11 +1169,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // OXC does not support Flow type syntax. When a file carries a - // `// @flow` pragma, skip OXC and use @babel/core (resolved from - // the project root) instead, so the project's .babelrc — which - // typically includes @babel/preset-flow — can strip Flow - // annotations before Vite continues processing. - if (/^\s*\/\/\s*@flow\b/m.test(code)) { + // leading `// @flow` or `/* @flow */` pragma, route it through + // @babel/core (resolved from the project root) so the project's + // .babelrc — which must include @babel/preset-flow — strips Flow + // annotations. A subsequent OXC pass then compiles the JSX. + // `hasFlowPragma` only matches the leading comment block, so + // mid-file occurrences of `@flow` (template literals, comments) + // never trigger the Babel fallback. + if (hasFlowPragma(code)) { return transformWithFlowBabel(code, cleanId, root); } diff --git a/packages/vinext/src/utils/flow-pragma.ts b/packages/vinext/src/utils/flow-pragma.ts new file mode 100644 index 0000000000..de474c9ca8 --- /dev/null +++ b/packages/vinext/src/utils/flow-pragma.ts @@ -0,0 +1,72 @@ +/** + * Detects a Flow type-checker pragma (`// @flow`, `/* @flow *\/`, or `/** @flow *\/`) + * in the **leading comment block** of a JavaScript file. + * + * Flow only recognises the pragma when it appears at or near the very top of the + * file — in a leading line or block comment, optionally preceded by a BOM, + * a hashbang line, leading whitespace, or other leading comments. A `@flow` + * annotation buried inside a template literal, a string, or a mid-file comment + * is NOT a valid Flow pragma and must not trigger the Babel fallback path. + * + * Detection algorithm (mirrors `hasReactDirective`): + * 1. Skip BOM. + * 2. Skip hashbang (`#!...`). + * 3. Loop: skip whitespace and leading comments (both `//` and `/* ... *\/`). + * For each comment, check whether it contains `@flow` as a word-boundary + * token (`\b@flow\b`). If found → return true. + * 4. Once the first non-comment, non-whitespace token is reached → return false. + * + * This guarantees that `@flow` in mid-file comments, template literals, or + * string arguments is never mistaken for a leading pragma. + */ +export function hasFlowPragma(code: string): boolean { + let i = 0; + const len = code.length; + + // Strip BOM. + if (code.charCodeAt(0) === 0xfeff) i = 1; + + // Strip hashbang (`#!/usr/bin/env node`). + if (code[i] === "#" && code[i + 1] === "!") { + const nl = code.indexOf("\n", i); + if (nl === -1) return false; + i = nl + 1; + } + + while (i < len) { + // Skip whitespace. + while (i < len && /\s/.test(code[i] ?? "")) i++; + if (i >= len) return false; + + // Line comment: `// ...` + if (code[i] === "/" && code[i + 1] === "/") { + const nl = code.indexOf("\n", i + 2); + const commentText = nl === -1 ? code.slice(i + 2) : code.slice(i + 2, nl); + // `@flow` followed by a word boundary — matches `@flow`, `@flow strict`, + // `@flow weak` but not `@flowtype`. + if (/@flow\b/.test(commentText)) { + return true; + } + if (nl === -1) return false; + i = nl + 1; + continue; + } + + // Block comment: `/* ... */` or `/** ... */` + if (code[i] === "/" && code[i + 1] === "*") { + const end = code.indexOf("*/", i + 2); + if (end === -1) return false; + const commentText = code.slice(i + 2, end); + if (/@flow\b/.test(commentText)) { + return true; + } + i = end + 2; + continue; + } + + // First non-comment, non-whitespace token — not a leading pragma. + return false; + } + + return false; +} diff --git a/tests/babel-flowtype.test.ts b/tests/babel-flowtype.test.ts index f5690a642a..eff3db7347 100644 --- a/tests/babel-flowtype.test.ts +++ b/tests/babel-flowtype.test.ts @@ -1,14 +1,23 @@ /** * Test: Babel flowtype page compilation (#1506) * - * When a `.js` file carries a `// @flow` pragma, OXC (used by - * `vinext:jsx-in-js`) rejects it with: + * When a `.js` file carries a leading `// @flow` or `/* @flow *\/` pragma, + * OXC (used by `vinext:jsx-in-js`) rejects it with: * * PARSE_ERROR: Flow is not supported * - * vinext must gracefully route such files through @babel/core (resolved from - * the project root) so the project's .babelrc — which typically contains - * @babel/preset-flow — can strip the Flow annotations before Vite continues. + * vinext must route such files through @babel/core (resolved from the project + * root) so the project's .babelrc — which must contain @babel/preset-flow — + * strips the Flow annotations before Vite continues. + * + * This test suite exercises `hasFlowPragma` — the detection function — in + * isolation. It imports from the small util module, so it never pulls in + * `image-size` or other heavy transitive deps from the full index.ts. + * + * Full e2e verification (Babel stripping Flow types + page rendering) is + * covered by the deploy-suite test: + * test/e2e/babel/index.test.ts → "Babel > Should compile a page with + * flowtype correctly" * * Ported from Next.js: * test/e2e/babel/index.test.ts @@ -16,11 +25,52 @@ */ import { describe, expect, it } from "vite-plus/test"; -import vinext from "../packages/vinext/src/index.js"; +import { hasFlowPragma } from "../packages/vinext/src/utils/flow-pragma.js"; -const FLOW_COMPONENT_CODE = `\ -// @flow -import React from 'react' +describe("hasFlowPragma — leading pragma detection (#1506)", () => { + // ── True-positive cases ──────────────────────────────────────────────────── + + it("detects `// @flow` as the very first line", () => { + expect(hasFlowPragma("// @flow\nimport React from 'react';\n")).toBe(true); + }); + + it("detects `// @flow` after leading whitespace", () => { + expect(hasFlowPragma("\n // @flow\nimport React from 'react';\n")).toBe(true); + }); + + it("detects `// @flow strict` (word-boundary after @flow)", () => { + expect(hasFlowPragma("// @flow strict\n")).toBe(true); + }); + + it("detects `// @flow` after a hashbang line", () => { + expect(hasFlowPragma("#!/usr/bin/env node\n// @flow\n")).toBe(true); + }); + + it("detects `/* @flow */` block-comment pragma", () => { + expect(hasFlowPragma("/* @flow */\nimport x from 'x';\n")).toBe(true); + }); + + it("detects `/** @flow */` JSDoc-style block-comment pragma", () => { + expect(hasFlowPragma("/** @flow */\nimport x from 'x';\n")).toBe(true); + }); + + it("detects `/* @flow weak */` block-comment pragma with mode", () => { + expect(hasFlowPragma("/* @flow weak */\n")).toBe(true); + }); + + it("detects `// @flow` after another leading line comment", () => { + // Flow allows the pragma anywhere in the leading comment block. + expect(hasFlowPragma("// This is a component\n// @flow\n")).toBe(true); + }); + + it("detects `/* @flow */` after a leading line comment", () => { + expect(hasFlowPragma("// Copyright 2025\n/* @flow */\n")).toBe(true); + }); + + it("detects a real Flow component header (mycomponent.js fixture)", () => { + const code = `// @flow +// This page is written in flowtype to test Babel's functionality +import { React } from '../namespace-exported-react' type Props = {} @@ -30,72 +80,80 @@ export default class MyComponent extends React.Component { } } `; + expect(hasFlowPragma(code)).toBe(true); + }); -/** - * Retrieve the vinext:jsx-in-js plugin and its transform hook from the - * plugin array returned by vinext(). - */ -function getJsxInJsPlugin(plugins: unknown[]) { - // oxlint-disable-next-line typescript/no-explicit-any - const flat = (plugins as any[]).flat(Infinity).filter(Boolean); - // oxlint-disable-next-line typescript/no-explicit-any - return flat.find((p: any) => p?.name === "vinext:jsx-in-js") ?? null; -} + it("detects BOM + `// @flow`", () => { + expect(hasFlowPragma("// @flow\n")).toBe(true); + }); + + // ── False-positive / false-negative cases ────────────────────────────────── + + it("does NOT match `// @flow` buried mid-file after an import statement", () => { + const code = `import React from 'react'; + +// @flow +export default function Foo() { return null; } +`; + expect(hasFlowPragma(code)).toBe(false); + }); + + it("does NOT match `@flow` inside a template literal", () => { + const code = `const x = \`// @flow\`; +export default x; +`; + expect(hasFlowPragma(code)).toBe(false); + }); + + it("does NOT match `@flow` inside a string literal", () => { + const code = `const pragma = "// @flow"; +export default pragma; +`; + expect(hasFlowPragma(code)).toBe(false); + }); + + it("does NOT match a file with no @flow pragma", () => { + expect( + hasFlowPragma( + `import React from 'react';\nexport default function Hello() { return
Hi
; }\n`, + ), + ).toBe(false); + }); + + it("does NOT match an empty file", () => { + expect(hasFlowPragma("")).toBe(false); + }); + + it("does NOT match a file with only whitespace", () => { + expect(hasFlowPragma(" \n\n ")).toBe(false); + }); + + it("does NOT match `// @flow` that appears only after real code", () => { + const code = `export const x = 1; +// @flow +`; + expect(hasFlowPragma(code)).toBe(false); + }); + + it("does NOT match `// @flowtype` (no word-boundary after 'flow')", () => { + // `@flowtype` is not a valid Flow pragma — the regex must use \b. + // Note: hasFlowPragma uses `/\b@flow\b/` which matches `@flow` followed by + // non-word char. `@flowtype` has a word char after `flow`, so it must NOT match. + expect(hasFlowPragma("// @flowtype\n")).toBe(false); + }); + + it("does NOT match `/* @flow */` that appears after real code", () => { + const code = `const x = 1;\n/* @flow */\n`; + expect(hasFlowPragma(code)).toBe(false); + }); + + // ── Edge cases ───────────────────────────────────────────────────────────── + + it("returns false for an unterminated block comment (malformed file)", () => { + expect(hasFlowPragma("/* @flow")).toBe(false); + }); -describe("Babel flowtype compilation (issue #1506)", () => { - it("vinext:jsx-in-js does not throw 'Flow is not supported' for // @flow files", async () => { - // This test verifies that files annotated with // @flow are routed to - // the Babel fallback path rather than OXC. When @babel/core is not - // installed in the test environment, transformWithFlowBabel returns null - // and the plugin returns null (graceful pass-through). The important thing - // is that OXC is never called on the Flow-annotated code, so the - // "Flow is not supported" PARSE_ERROR is never thrown here. - // - // Full e2e verification (including Babel stripping Flow types and the - // page rendering) is covered by the deploy-suite test: - // test/e2e/babel/index.test.ts → "Babel > Should compile a page with - // flowtype correctly" - - const plugins = vinext(); - const jsxInJsPlugin = getJsxInJsPlugin([plugins]); - expect(jsxInJsPlugin).not.toBeNull(); - expect(typeof jsxInJsPlugin.transform).toBe("function"); - - // Simulate what vite:oxc would return: "Flow is not supported" - // Before the fix, calling transform on a Flow file would delegate to OXC - // and throw. After the fix, it delegates to transformWithFlowBabel which - // either uses Babel (if available) or returns null (no Babel). - // Either way, no "Flow is not supported" error should propagate. - const result = await jsxInJsPlugin.transform( - FLOW_COMPONENT_CODE, - `/some/project/lib/mycomponent.js`, - ); - - // The result is either: - // - null if @babel/core is not installed in the project root - // - { code, map } if @babel/core is available and transforms the file - // We accept both — the key assertion is that no Flow-parse error was thrown. - expect(result).toSatisfy( - (r: unknown) => - r === null || r === undefined || typeof (r as { code?: unknown }).code === "string", - ); - }); - - it("vinext:jsx-in-js transforms a non-Flow .js file with JSX normally", async () => { - const plugins = vinext(); - const jsxInJsPlugin = getJsxInJsPlugin([plugins]); - expect(jsxInJsPlugin).not.toBeNull(); - - const result = await jsxInJsPlugin.transform( - `export default function Hello() { return
Hi
; }`, - `/some/project/pages/hello.js`, - ); - - // Normal JSX compilation should produce output (not null). - expect(result).not.toBeNull(); - expect(result).not.toBeUndefined(); - expect(typeof result.code).toBe("string"); - // OXC should have compiled the JSX away — no raw JSX in the output. - expect(result.code).not.toContain("
"); + it("returns false for a hashbang-only file with no further content", () => { + expect(hasFlowPragma("#!/usr/bin/env node")).toBe(false); }); }); From a6df73adb740234ed0f14cc488b4ea68a3ae1e6a Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Jun 2026 15:36:06 +0100 Subject: [PATCH 3/6] fix(babel-flowtype): chain Babel sourcemap into OXC pass + correct @flow regex doc - Pass Babel's output map as `inMap` to `transformWithOxc` so the composed sourcemap traces back to the original Flow source rather than Babel's intermediate JS output. - Correct the doc comment in `flow-pragma.ts` (and test): the regex used is `/@flow\b/`, not `/\b@flow\b/` (no leading word-boundary anchor). --- packages/vinext/src/index.ts | 15 ++++++++++----- packages/vinext/src/utils/flow-pragma.ts | 2 +- tests/babel-flowtype.test.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7a2beac4c0..b27d2e0401 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -679,11 +679,16 @@ async function transformWithFlowBabel( // Babel has stripped Flow types; the output still contains JSX. Run OXC to // compile JSX so the rest of the Vite pipeline receives plain JS. - const oxcResult = await transformWithOxc(result.code, filename, { - lang: "jsx", - jsx: { runtime: "automatic" as const }, - sourcemap: true, - }); + // Pass Babel's output map as `inMap` so OXC composes it into the final map, + // making the returned sourcemap trace back to the original Flow source rather + // than to Babel's intermediate output. + const babelMap = result.map as object | undefined; + const oxcResult = await transformWithOxc( + result.code, + filename, + { lang: "jsx", jsx: { runtime: "automatic" as const }, sourcemap: true }, + babelMap, + ); return { code: oxcResult.code, map: oxcResult.map }; } diff --git a/packages/vinext/src/utils/flow-pragma.ts b/packages/vinext/src/utils/flow-pragma.ts index de474c9ca8..af8f906e9d 100644 --- a/packages/vinext/src/utils/flow-pragma.ts +++ b/packages/vinext/src/utils/flow-pragma.ts @@ -13,7 +13,7 @@ * 2. Skip hashbang (`#!...`). * 3. Loop: skip whitespace and leading comments (both `//` and `/* ... *\/`). * For each comment, check whether it contains `@flow` as a word-boundary - * token (`\b@flow\b`). If found → return true. + * token (`/@flow\b/`). If found → return true. * 4. Once the first non-comment, non-whitespace token is reached → return false. * * This guarantees that `@flow` in mid-file comments, template literals, or diff --git a/tests/babel-flowtype.test.ts b/tests/babel-flowtype.test.ts index eff3db7347..638e878675 100644 --- a/tests/babel-flowtype.test.ts +++ b/tests/babel-flowtype.test.ts @@ -137,7 +137,7 @@ export default pragma; it("does NOT match `// @flowtype` (no word-boundary after 'flow')", () => { // `@flowtype` is not a valid Flow pragma — the regex must use \b. - // Note: hasFlowPragma uses `/\b@flow\b/` which matches `@flow` followed by + // Note: hasFlowPragma uses `/@flow\b/` which matches `@flow` followed by // non-word char. `@flowtype` has a word char after `flow`, so it must NOT match. expect(hasFlowPragma("// @flowtype\n")).toBe(false); }); From 6479ceeab7b260179b45fa0c5a0281ac08f962c3 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Jun 2026 11:23:57 +0100 Subject: [PATCH 4/6] test(babel-flowtype): exercise the real Babel transform per-PR + actionable babel-missing error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two remaining findings from the latest ask-bonk review: - Extract transformWithFlowBabel into utils/flow-babel.ts and drive it in tests/babel-flowtype.test.ts against a new workspace fixture (tests/fixtures/babel-flowtype) that depends on @babel/core + @babel/preset-flow + @babel/plugin-syntax-jsx like a real user project. The tests assert Flow types are stripped, the OXC re-pass compiles the leftover JSX, and the composed sourcemap traces back to the original Flow source — so the transform (incl. sourcemap chaining) is verified on every PR instead of only by the nightly deploy suite. - When a leading @flow pragma is detected but @babel/core is not resolvable, fall through to the regular OXC pass instead of skipping the transform: plain JS that merely mentions @flow in a leading comment still compiles, and genuine Flow syntax now fails with an actionable "install @babel/core + @babel/preset-flow" error (with the OXC parse error as cause) instead of a confusing downstream parser error. - Pin Babel's root/cwd to the project root so .babelrc resolution works when the Vite process is launched from a different directory (Babel's default babelrcRoots only honors .babelrc files in its root package). - Correct the doc comments: @babel/preset-flow alone cannot parse JSX, so Babel's output only retains JSX when the project's config parses-but- keeps it (e.g. @babel/plugin-syntax-jsx); next/babel compiles it. The OXC re-pass handles both. --- packages/vinext/src/index.ts | 124 +++++------------- packages/vinext/src/utils/flow-babel.ts | 99 ++++++++++++++ pnpm-lock.yaml | 71 ++++++++++ tests/babel-flowtype.test.ts | 73 ++++++++++- tests/fixtures/babel-flowtype/.babelrc | 4 + tests/fixtures/babel-flowtype/package.json | 10 ++ .../babel-flowtype/pages/mycomponent.js | 12 ++ vite.config.ts | 7 +- 8 files changed, 301 insertions(+), 99 deletions(-) create mode 100644 packages/vinext/src/utils/flow-babel.ts create mode 100644 tests/fixtures/babel-flowtype/.babelrc create mode 100644 tests/fixtures/babel-flowtype/package.json create mode 100644 tests/fixtures/babel-flowtype/pages/mycomponent.js diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index b27d2e0401..c129661be7 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -179,6 +179,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import commonjs from "vite-plugin-commonjs"; import { normalizePathSeparators } from "./utils/path.js"; import { hasFlowPragma } from "./utils/flow-pragma.js"; +import { transformWithFlowBabel } from "./utils/flow-babel.js"; // Install the process-level peer-disconnect backstop at module load. // Vite plugin lifecycle hooks (config / configureServer) proved @@ -610,88 +611,6 @@ function hasReactDirective(code: string): boolean { return false; } -/** - * Memoized @babel/core resolve cache: projectRoot → resolved module or null. - * Avoids repeated `createRequire` + `require.resolve` on every transformed file. - */ -const _babelCoreCache = new Map(); - -/** - * Transform a `.js` file that carries a Flow pragma using @babel/core so that - * Flow type annotations are stripped before OXC processes the file. - * - * OXC (used by `vite:oxc` and `transformWithOxc`) does not support Flow type - * syntax and throws `PARSE_ERROR: Flow is not supported` on such files. This - * function resolves @babel/core from the project root (so the user's installed - * version is used) and invokes it with `babelrc: true` so the project's - * .babelrc — which must contain `@babel/preset-flow` — strips the Flow - * annotations. Babel only removes Flow types; JSX is left intact in its - * output. A subsequent `transformWithOxc` pass (with `lang: "jsx"`) then - * compiles the JSX so the Vite pipeline can continue normally. - * - * Returns null when @babel/core cannot be resolved (the file is then handled - * by OXC which will produce a clear "Flow is not supported" parse error). - * Throws when Babel itself rejects the file (e.g. a misconfigured .babelrc) - * or when Babel produces no output (which indicates a silent misconfiguration). - */ -async function transformWithFlowBabel( - code: string, - filename: string, - projectRoot: string, - // oxlint-disable-next-line typescript/no-explicit-any -): Promise<{ code: string; map?: any } | null> { - // Resolve @babel/core from the project root so the user's version is used. - // Memoize per projectRoot to avoid repeated `createRequire` calls. - // oxlint-disable-next-line typescript/no-explicit-any - let babelCore: any; - if (_babelCoreCache.has(projectRoot)) { - babelCore = _babelCoreCache.get(projectRoot); - if (babelCore === null) return null; - } else { - try { - const req = createRequire(path.join(projectRoot, "package.json")); - babelCore = req("@babel/core"); - _babelCoreCache.set(projectRoot, babelCore); - } catch { - // @babel/core not installed in this project. Cache the miss so we don't - // re-attempt on every file. OXC will surface "Flow is not supported". - _babelCoreCache.set(projectRoot, null); - return null; - } - } - - // Use the project's .babelrc / babel.config.* (must include @babel/preset-flow) - // to strip Flow type annotations. Babel leaves JSX syntax intact. - // oxlint-disable-next-line typescript/no-unsafe-call, typescript/no-unsafe-member-access - const result = (await babelCore.transformAsync(code, { - filename, - sourceMaps: true, - configFile: true, - babelrc: true, - })) as { code?: string | null; map?: unknown } | null; - - if (!result?.code) { - throw new Error( - `[vinext] Babel produced empty output for Flow file: ${filename}\n` + - "Ensure @babel/preset-flow is listed in the project's .babelrc or babel.config.*", - ); - } - - // Babel has stripped Flow types; the output still contains JSX. Run OXC to - // compile JSX so the rest of the Vite pipeline receives plain JS. - // Pass Babel's output map as `inMap` so OXC composes it into the final map, - // making the returned sourcemap trace back to the original Flow source rather - // than to Babel's intermediate output. - const babelMap = result.map as object | undefined; - const oxcResult = await transformWithOxc( - result.code, - filename, - { lang: "jsx", jsx: { runtime: "automatic" as const }, sourcemap: true }, - babelMap, - ); - return { code: oxcResult.code, map: oxcResult.map }; -} - function generateRootParamsModule(rootParamNames: Iterable): string { const names = Array.from(new Set(rootParamNames)).filter(isValidExportIdentifier).sort(); if (names.length === 0) return "export {};\n"; @@ -1181,19 +1100,38 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `hasFlowPragma` only matches the leading comment block, so // mid-file occurrences of `@flow` (template literals, comments) // never trigger the Babel fallback. - if (hasFlowPragma(code)) { - return transformWithFlowBabel(code, cleanId, root); + const isFlowFile = hasFlowPragma(code); + if (isFlowFile) { + const flowResult = await transformWithFlowBabel(code, cleanId, root); + if (flowResult) return flowResult; + // @babel/core is not installed in the project: fall through to + // the regular OXC pass below. Plain JS that merely mentions + // `@flow` in a leading comment still compiles; genuine Flow + // syntax fails OXC's parse and is rethrown with an actionable + // message instead of a confusing downstream parser error. } - const result = await transformWithOxc(code, id, { - lang: "jsx", - jsx: { runtime: "automatic" as const }, - sourcemap: true, - }); - return { - code: result.code, - map: result.map, - }; + try { + const result = await transformWithOxc(code, id, { + lang: "jsx", + jsx: { runtime: "automatic" as const }, + sourcemap: true, + }); + return { + code: result.code, + map: result.map, + }; + } catch (error) { + if (isFlowFile) { + throw new Error( + `[vinext] Failed to compile ${cleanId}, which has a leading @flow pragma. ` + + "Flow type syntax requires Babel: install @babel/core and @babel/preset-flow, " + + 'and add "@babel/preset-flow" to the project\'s .babelrc or babel.config.*', + { cause: error }, + ); + } + throw error; + } }, } satisfies Plugin, ] diff --git a/packages/vinext/src/utils/flow-babel.ts b/packages/vinext/src/utils/flow-babel.ts new file mode 100644 index 0000000000..7c3a952c6a --- /dev/null +++ b/packages/vinext/src/utils/flow-babel.ts @@ -0,0 +1,99 @@ +import path from "node:path"; +import { createRequire } from "node:module"; +import { transformWithOxc } from "vite"; + +/** + * Memoized @babel/core resolve cache: projectRoot → resolved module or null. + * Avoids repeated `createRequire` + `require.resolve` on every transformed file. + */ +const _babelCoreCache = new Map(); + +/** + * Transform a `.js` file that carries a Flow pragma using @babel/core so that + * Flow type annotations are stripped before OXC processes the file. + * + * OXC (used by `vite:oxc` and `transformWithOxc`) does not support Flow type + * syntax and throws `PARSE_ERROR: Flow is not supported` on such files. This + * function resolves @babel/core from the project root (so the user's installed + * version is used) and invokes it with `babelrc: true` so the project's + * .babelrc — which must contain `@babel/preset-flow` — strips the Flow + * annotations. Note that `@babel/preset-flow` alone cannot *parse* JSX, so a + * Flow + JSX project's config necessarily handles JSX one way or another: + * either it compiles JSX too (e.g. `next/babel`) or it parses-but-keeps it + * (e.g. `@babel/plugin-syntax-jsx`). A subsequent `transformWithOxc` pass + * (with `lang: "jsx"`) compiles any JSX still present in Babel's output, so + * the Vite pipeline receives plain JS in both cases. + * + * Babel's `root`/`cwd` are pinned to `projectRoot` so that .babelrc / + * babel.config.* resolution is anchored to the project even when the Vite + * process was launched from a different directory (e.g. a monorepo root — + * Babel's default `babelrcRoots` only honors .babelrc files in its `root` + * package, which defaults to `process.cwd()`). + * + * Returns null when @babel/core cannot be resolved; the caller falls back to + * the regular OXC pass, which compiles plain JS normally and rejects genuine + * Flow syntax with a parse error (which the caller turns into an actionable + * "install @babel/core + @babel/preset-flow" message). Throws when Babel + * itself rejects the file (e.g. a misconfigured .babelrc) or when Babel + * produces no output (which indicates a silent misconfiguration). + */ +export async function transformWithFlowBabel( + code: string, + filename: string, + projectRoot: string, + // oxlint-disable-next-line typescript/no-explicit-any +): Promise<{ code: string; map?: any } | null> { + // Resolve @babel/core from the project root so the user's version is used. + // Memoize per projectRoot to avoid repeated `createRequire` calls. + // oxlint-disable-next-line typescript/no-explicit-any + let babelCore: any; + if (_babelCoreCache.has(projectRoot)) { + babelCore = _babelCoreCache.get(projectRoot); + if (babelCore === null) return null; + } else { + try { + const req = createRequire(path.join(projectRoot, "package.json")); + babelCore = req("@babel/core"); + _babelCoreCache.set(projectRoot, babelCore); + } catch { + // @babel/core not installed in this project. Cache the miss so we don't + // re-attempt on every file; the caller falls back to the OXC pass. + _babelCoreCache.set(projectRoot, null); + return null; + } + } + + // Use the project's .babelrc / babel.config.* (must include @babel/preset-flow) + // to strip Flow type annotations. Babel leaves JSX syntax intact. + // oxlint-disable-next-line typescript/no-unsafe-call, typescript/no-unsafe-member-access + const result = (await babelCore.transformAsync(code, { + filename, + sourceMaps: true, + configFile: true, + babelrc: true, + root: projectRoot, + cwd: projectRoot, + })) as { code?: string | null; map?: unknown } | null; + + if (!result?.code) { + throw new Error( + `[vinext] Babel produced empty output for Flow file: ${filename}\n` + + "Ensure @babel/preset-flow is listed in the project's .babelrc or babel.config.*", + ); + } + + // Babel has stripped Flow types; the output may still contain JSX (when the + // project's config only parses it, e.g. @babel/plugin-syntax-jsx). Run OXC + // to compile any remaining JSX so the Vite pipeline receives plain JS. + // Pass Babel's output map as `inMap` so OXC composes it into the final map, + // making the returned sourcemap trace back to the original Flow source rather + // than to Babel's intermediate output. + const babelMap = result.map as object | undefined; + const oxcResult = await transformWithOxc( + result.code, + filename, + { lang: "jsx", jsx: { runtime: "automatic" as const }, sourcemap: true }, + babelMap, + ); + return { code: oxcResult.code, map: oxcResult.map }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1cf9c64f..6adebda462 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1119,6 +1119,18 @@ importers: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.6(@voidzero-dev/vite-plus-test@0.1.24))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0) + tests/fixtures/babel-flowtype: + devDependencies: + '@babel/core': + specifier: 7.29.0 + version: 7.29.0 + '@babel/plugin-syntax-jsx': + specifier: 7.29.7 + version: 7.29.7(@babel/core@7.29.0) + '@babel/preset-flow': + specifier: 7.29.7 + version: 7.29.7(@babel/core@7.29.0) + tests/fixtures/cf-app-basic: dependencies: '@cloudflare/vite-plugin': @@ -1542,6 +1554,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -1554,6 +1570,10 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.2': resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} @@ -1563,6 +1583,30 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.29.7': + resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-flow@7.29.7': + resolution: {integrity: sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -7184,12 +7228,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 @@ -7199,6 +7247,29 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.0) + + '@babel/preset-flow@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.0) + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': diff --git a/tests/babel-flowtype.test.ts b/tests/babel-flowtype.test.ts index 638e878675..65e54e4c56 100644 --- a/tests/babel-flowtype.test.ts +++ b/tests/babel-flowtype.test.ts @@ -10,12 +10,19 @@ * root) so the project's .babelrc — which must contain @babel/preset-flow — * strips the Flow annotations before Vite continues. * - * This test suite exercises `hasFlowPragma` — the detection function — in - * isolation. It imports from the small util module, so it never pulls in - * `image-size` or other heavy transitive deps from the full index.ts. + * This test suite exercises both halves of the feature: * - * Full e2e verification (Babel stripping Flow types + page rendering) is - * covered by the deploy-suite test: + * 1. `hasFlowPragma` — the detection function — in isolation. It imports + * from the small util module, so it never pulls in `image-size` or other + * heavy transitive deps from the full index.ts. + * 2. `transformWithFlowBabel` — the actual two-stage transform (Babel + * strips Flow types via the project's .babelrc, then an OXC re-pass + * compiles the JSX) — driven against the workspace fixture + * `tests/fixtures/babel-flowtype/`, which declares @babel/core + + * @babel/preset-flow as its own deps the way a real user project would. + * + * Additional e2e verification (full page rendering) is covered by the + * deploy-suite test: * test/e2e/babel/index.test.ts → "Babel > Should compile a page with * flowtype correctly" * @@ -24,8 +31,12 @@ * https://github.com/vercel/next.js/blob/canary/test/e2e/babel/ */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { hasFlowPragma } from "../packages/vinext/src/utils/flow-pragma.js"; +import { transformWithFlowBabel } from "../packages/vinext/src/utils/flow-babel.js"; describe("hasFlowPragma — leading pragma detection (#1506)", () => { // ── True-positive cases ──────────────────────────────────────────────────── @@ -157,3 +168,55 @@ export default pragma; expect(hasFlowPragma("#!/usr/bin/env node")).toBe(false); }); }); + +describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", () => { + const FIXTURE_ROOT = path.resolve(import.meta.dirname, "./fixtures/babel-flowtype"); + const FIXTURE_FILE = path.join(FIXTURE_ROOT, "pages", "mycomponent.js"); + const fixtureCode = fs.readFileSync(FIXTURE_FILE, "utf8"); + + it("strips Flow type annotations using the fixture project's .babelrc", async () => { + // Sanity: the fixture really is a Flow file with a leading pragma. + expect(hasFlowPragma(fixtureCode)).toBe(true); + expect(fixtureCode).toContain("type Props"); + + const result = await transformWithFlowBabel(fixtureCode, FIXTURE_FILE, FIXTURE_ROOT); + expect(result).not.toBeNull(); + // Flow-only syntax must be gone from the output. + expect(result!.code).not.toContain("type Props"); + expect(result!.code).not.toContain("Component"); + expect(result!.code).not.toContain(": React.Node"); + }); + + it("compiles the JSX left in Babel's output via the OXC re-pass", async () => { + const result = await transformWithFlowBabel(fixtureCode, FIXTURE_FILE, FIXTURE_ROOT); + expect(result).not.toBeNull(); + // Raw JSX must be compiled away to automatic-runtime calls. + expect(result!.code).not.toContain(" { + const result = await transformWithFlowBabel(fixtureCode, FIXTURE_FILE, FIXTURE_ROOT); + expect(result).not.toBeNull(); + const map = result!.map as { sources?: string[]; sourcesContent?: (string | null)[] }; + expect(map).toBeTruthy(); + // The composed map must point at the original file — not at Babel's + // intermediate output — proving the Babel map was chained into the OXC + // pass rather than discarded. + expect(map.sources?.some((s) => s.includes("mycomponent.js"))).toBe(true); + expect(map.sourcesContent?.some((c) => c?.includes("type Props"))).toBe(true); + }); + + it("returns null when @babel/core is not resolvable from the project root", async () => { + const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-flow-babel-")); + try { + const file = path.join(emptyRoot, "page.js"); + expect(await transformWithFlowBabel(fixtureCode, file, emptyRoot)).toBeNull(); + // Second call exercises the memoized "not found" path. + expect(await transformWithFlowBabel(fixtureCode, file, emptyRoot)).toBeNull(); + } finally { + fs.rmSync(emptyRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/fixtures/babel-flowtype/.babelrc b/tests/fixtures/babel-flowtype/.babelrc new file mode 100644 index 0000000000..5100a8a770 --- /dev/null +++ b/tests/fixtures/babel-flowtype/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/preset-flow"], + "plugins": ["@babel/plugin-syntax-jsx"] +} diff --git a/tests/fixtures/babel-flowtype/package.json b/tests/fixtures/babel-flowtype/package.json new file mode 100644 index 0000000000..1ac73ef901 --- /dev/null +++ b/tests/fixtures/babel-flowtype/package.json @@ -0,0 +1,10 @@ +{ + "name": "babel-flowtype-fixture", + "private": true, + "type": "module", + "devDependencies": { + "@babel/core": "7.29.0", + "@babel/plugin-syntax-jsx": "7.29.7", + "@babel/preset-flow": "7.29.7" + } +} diff --git a/tests/fixtures/babel-flowtype/pages/mycomponent.js b/tests/fixtures/babel-flowtype/pages/mycomponent.js new file mode 100644 index 0000000000..011060917a --- /dev/null +++ b/tests/fixtures/babel-flowtype/pages/mycomponent.js @@ -0,0 +1,12 @@ +// @flow +// This page is written in flowtype to test Babel's functionality. +// Ported from the Next.js e2e fixture: test/e2e/babel/ +import React from 'react' + +type Props = {} + +export default class MyComponent extends React.Component { + render(): React.Node { + return
Test Babel
+ } +} diff --git a/vite.config.ts b/vite.config.ts index fe8c210769..ed3f93c0a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,7 +24,12 @@ export default defineConfig({ semi: true, singleQuote: false, trailingComma: "all", - ignorePatterns: ["tests/fixtures/ecosystem/**", "examples/**"], + ignorePatterns: [ + "tests/fixtures/ecosystem/**", + // Flow type syntax is not parseable by the formatter. + "tests/fixtures/babel-flowtype/pages/**", + "examples/**", + ], }, lint: { ignorePatterns: [ From df5774c2b7768d5a0b3069624abc89d6f7668015 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Jun 2026 12:21:01 +0100 Subject: [PATCH 5/6] fix(build): address bonk notes on Babel flowtype transform - Fail loud when the babel-flowtype fixture's node_modules is missing: a beforeAll resolves @babel/core from the fixture root and throws an actionable "run vp install" error instead of opaque null assertions. - Stop caching @babel/core resolution misses so installing it mid-dev- session takes effect without a server restart; successful resolutions stay memoized for the per-module-transform hot path. Locked in by a new install-after-miss test. - Pass cleanId (query params stripped) to the fallback OXC transform so it matches the filename given to transformWithFlowBabel. - Neutralize vp's NODE_PATH fallback in the babel-missing tests: on a hoisted install, NODE_PATH makes @babel/core resolvable from any directory, making those tests environment-sensitive. --- packages/vinext/src/index.ts | 5 +- packages/vinext/src/utils/flow-babel.ts | 27 +++++--- tests/babel-flowtype.test.ts | 90 +++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index c129661be7..159e797a14 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1112,7 +1112,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } try { - const result = await transformWithOxc(code, id, { + // Pass `cleanId` (query params stripped) so the filename OXC + // uses for sourcemaps and parse errors matches the one given + // to `transformWithFlowBabel` above. + const result = await transformWithOxc(code, cleanId, { lang: "jsx", jsx: { runtime: "automatic" as const }, sourcemap: true, diff --git a/packages/vinext/src/utils/flow-babel.ts b/packages/vinext/src/utils/flow-babel.ts index 7c3a952c6a..37a07d0776 100644 --- a/packages/vinext/src/utils/flow-babel.ts +++ b/packages/vinext/src/utils/flow-babel.ts @@ -3,8 +3,16 @@ import { createRequire } from "node:module"; import { transformWithOxc } from "vite"; /** - * Memoized @babel/core resolve cache: projectRoot → resolved module or null. + * Memoized @babel/core resolve cache: projectRoot → resolved module. * Avoids repeated `createRequire` + `require.resolve` on every transformed file. + * + * Only successful resolutions are cached. Misses are deliberately NOT cached: + * caching the not-found result would pin "no Babel" for the process lifetime, + * forcing a dev-server restart after installing @babel/core mid-session. The + * re-attempt cost is a failed `require.resolve` and only applies to files that + * carry a leading `@flow` pragma — and in a project without @babel/core those + * either fail the build anyway (genuine Flow syntax) or are rare pragma-only + * plain-JS files, so the hot path (Babel installed) always hits the cache. */ const _babelCoreCache = new Map(); @@ -44,21 +52,20 @@ export async function transformWithFlowBabel( // oxlint-disable-next-line typescript/no-explicit-any ): Promise<{ code: string; map?: any } | null> { // Resolve @babel/core from the project root so the user's version is used. - // Memoize per projectRoot to avoid repeated `createRequire` calls. + // Memoize successful resolutions per projectRoot to avoid repeated + // `createRequire` calls on every transformed file. // oxlint-disable-next-line typescript/no-explicit-any - let babelCore: any; - if (_babelCoreCache.has(projectRoot)) { - babelCore = _babelCoreCache.get(projectRoot); - if (babelCore === null) return null; - } else { + let babelCore: any = _babelCoreCache.get(projectRoot); + if (babelCore === undefined) { try { const req = createRequire(path.join(projectRoot, "package.json")); babelCore = req("@babel/core"); _babelCoreCache.set(projectRoot, babelCore); } catch { - // @babel/core not installed in this project. Cache the miss so we don't - // re-attempt on every file; the caller falls back to the OXC pass. - _babelCoreCache.set(projectRoot, null); + // @babel/core not installed in this project: the caller falls back to + // the OXC pass. The miss is intentionally not cached (see the cache doc + // above) so installing @babel/core mid-session takes effect without a + // dev-server restart. return null; } } diff --git a/tests/babel-flowtype.test.ts b/tests/babel-flowtype.test.ts index 65e54e4c56..4ea027009d 100644 --- a/tests/babel-flowtype.test.ts +++ b/tests/babel-flowtype.test.ts @@ -32,9 +32,10 @@ */ import fs from "node:fs"; +import Module, { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vite-plus/test"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; import { hasFlowPragma } from "../packages/vinext/src/utils/flow-pragma.js"; import { transformWithFlowBabel } from "../packages/vinext/src/utils/flow-babel.js"; @@ -174,6 +175,25 @@ describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", ( const FIXTURE_FILE = path.join(FIXTURE_ROOT, "pages", "mycomponent.js"); const fixtureCode = fs.readFileSync(FIXTURE_FILE, "utf8"); + beforeAll(() => { + // `transformWithFlowBabel` returns null when @babel/core cannot be + // resolved from the fixture root — which would make the tests below fail + // with opaque "expected null not to be null" assertions on a checkout + // where the fixture workspace's node_modules was never materialized + // (e.g. installs run with --no-install or a partial install). Fail loud + // and actionable instead. + try { + createRequire(path.join(FIXTURE_ROOT, "package.json")).resolve("@babel/core"); + } catch (cause) { + throw new Error( + `@babel/core is not resolvable from the babel-flowtype fixture (${FIXTURE_ROOT}). ` + + "Its node_modules has not been materialized — run `vp install` at the repo root " + + "to install the fixture workspace's dependencies, then re-run this test.", + { cause }, + ); + } + }); + it("strips Flow type annotations using the fixture project's .babelrc", async () => { // Sanity: the fixture really is a Flow file with a leading pragma. expect(hasFlowPragma(fixtureCode)).toBe(true); @@ -208,15 +228,75 @@ describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", ( expect(map.sourcesContent?.some((c) => c?.includes("type Props"))).toBe(true); }); + /** + * Run `fn` with Node's NODE_PATH-derived global module folders neutralized. + * + * `vp test` launches its workers with NODE_PATH pointing at pnpm's hoisted + * `node_modules/.pnpm/node_modules` store. On a fully-installed checkout + * that store contains @babel/core, which makes `createRequire(...)` resolve + * it from ANY directory — including the "empty project" temp dirs the + * babel-missing tests below rely on. Clearing NODE_PATH (via the same + * `Module._initPaths()` hook Node's REPL uses) makes those tests + * deterministic regardless of the installer's hoisting behavior. The + * standard per-directory `node_modules` walk-up is unaffected. + */ + async function withoutNodePathFallback(fn: () => Promise): Promise { + const prev = process.env.NODE_PATH; + process.env.NODE_PATH = ""; + // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-call + (Module as any)._initPaths(); + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.NODE_PATH; + else process.env.NODE_PATH = prev; + // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-call + (Module as any)._initPaths(); + } + } + it("returns null when @babel/core is not resolvable from the project root", async () => { const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-flow-babel-")); try { - const file = path.join(emptyRoot, "page.js"); - expect(await transformWithFlowBabel(fixtureCode, file, emptyRoot)).toBeNull(); - // Second call exercises the memoized "not found" path. - expect(await transformWithFlowBabel(fixtureCode, file, emptyRoot)).toBeNull(); + await withoutNodePathFallback(async () => { + const file = path.join(emptyRoot, "page.js"); + expect(await transformWithFlowBabel(fixtureCode, file, emptyRoot)).toBeNull(); + // Misses are deliberately not cached (so installing @babel/core + // mid-session takes effect without a restart) — the second call + // re-attempts resolution and must still return null. + expect(await transformWithFlowBabel(fixtureCode, file, emptyRoot)).toBeNull(); + }); } finally { fs.rmSync(emptyRoot, { recursive: true, force: true }); } }); + + it("picks up @babel/core installed after a prior miss (no restart needed)", async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-flow-babel-late-")); + try { + await withoutNodePathFallback(async () => { + const file = path.join(tmpRoot, "page.js"); + // Plain JS with only a pragma comment, so Babel needs no preset-flow. + const plainJs = "// @flow\nexport const x = 1;\n"; + + // First call: @babel/core is missing → null (the miss must not stick). + expect(await transformWithFlowBabel(plainJs, file, tmpRoot)).toBeNull(); + + // "Install" @babel/core mid-session by linking the fixture's copy. + fs.mkdirSync(path.join(tmpRoot, "node_modules", "@babel"), { recursive: true }); + fs.symlinkSync( + fs.realpathSync(path.join(FIXTURE_ROOT, "node_modules", "@babel", "core")), + path.join(tmpRoot, "node_modules", "@babel", "core"), + ); + + // Second call re-attempts resolution and now succeeds — proving a + // mid-session install takes effect without a dev-server restart. + const result = await transformWithFlowBabel(plainJs, file, tmpRoot); + expect(result).not.toBeNull(); + expect(result!.code).toContain("x = 1"); + }); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); }); From 1569302c0ec229987cb8d1598ed5a0abc150b80f Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Jun 2026 12:33:35 +0100 Subject: [PATCH 6/6] test(babel-flowtype): cover the actionable Flow-rethrow path via extracted transformJsxInJs Extract the vinext:jsx-in-js transform body (Flow pragma detection, Babel attempt, OXC fallback, actionable rethrow) into transformJsxInJs in utils/flow-babel.ts so the babel-missing error path is unit-testable. New tests cover: the wrapped install-message error (with OXC parse error as cause), the pragma-comment-only fall-through compile, and the babel-installed happy path through the full pipeline. --- packages/vinext/src/index.ts | 55 ++--------- packages/vinext/src/utils/flow-babel.ts | 56 +++++++++++ tests/babel-flowtype.test.ts | 123 ++++++++++++++++++------ 3 files changed, 156 insertions(+), 78 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 159e797a14..b91f8eef16 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1,5 +1,5 @@ import type { Plugin, PluginOption, UserConfig, ViteDevServer } from "vite"; -import { loadEnv, parseAst, transformWithOxc } from "vite"; +import { loadEnv, parseAst } from "vite"; import { pagesRouter, apiRouter, @@ -178,8 +178,7 @@ import fs from "node:fs"; import { randomBytes, randomUUID } from "node:crypto"; import commonjs from "vite-plugin-commonjs"; import { normalizePathSeparators } from "./utils/path.js"; -import { hasFlowPragma } from "./utils/flow-pragma.js"; -import { transformWithFlowBabel } from "./utils/flow-babel.js"; +import { transformJsxInJs } from "./utils/flow-babel.js"; // Install the process-level peer-disconnect backstop at module load. // Vite plugin lifecycle hooks (config / configureServer) proved @@ -1092,49 +1091,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - // OXC does not support Flow type syntax. When a file carries a - // leading `// @flow` or `/* @flow */` pragma, route it through - // @babel/core (resolved from the project root) so the project's - // .babelrc — which must include @babel/preset-flow — strips Flow - // annotations. A subsequent OXC pass then compiles the JSX. - // `hasFlowPragma` only matches the leading comment block, so - // mid-file occurrences of `@flow` (template literals, comments) - // never trigger the Babel fallback. - const isFlowFile = hasFlowPragma(code); - if (isFlowFile) { - const flowResult = await transformWithFlowBabel(code, cleanId, root); - if (flowResult) return flowResult; - // @babel/core is not installed in the project: fall through to - // the regular OXC pass below. Plain JS that merely mentions - // `@flow` in a leading comment still compiles; genuine Flow - // syntax fails OXC's parse and is rethrown with an actionable - // message instead of a confusing downstream parser error. - } - - try { - // Pass `cleanId` (query params stripped) so the filename OXC - // uses for sourcemaps and parse errors matches the one given - // to `transformWithFlowBabel` above. - const result = await transformWithOxc(code, cleanId, { - lang: "jsx", - jsx: { runtime: "automatic" as const }, - sourcemap: true, - }); - return { - code: result.code, - map: result.map, - }; - } catch (error) { - if (isFlowFile) { - throw new Error( - `[vinext] Failed to compile ${cleanId}, which has a leading @flow pragma. ` + - "Flow type syntax requires Babel: install @babel/core and @babel/preset-flow, " + - 'and add "@babel/preset-flow" to the project\'s .babelrc or babel.config.*', - { cause: error }, - ); - } - throw error; - } + // Compile JSX with OXC — routing files that carry a leading + // `// @flow` or `/* @flow */` pragma through the project's + // @babel/core first, since OXC cannot parse Flow type syntax. + // See transformJsxInJs in utils/flow-babel.ts for the full + // pipeline, including the actionable "install @babel/core" + // error when Babel is missing but the file has Flow syntax. + return transformJsxInJs(code, cleanId, root); }, } satisfies Plugin, ] diff --git a/packages/vinext/src/utils/flow-babel.ts b/packages/vinext/src/utils/flow-babel.ts index 37a07d0776..ab7699f51a 100644 --- a/packages/vinext/src/utils/flow-babel.ts +++ b/packages/vinext/src/utils/flow-babel.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import { transformWithOxc } from "vite"; +import { hasFlowPragma } from "./flow-pragma.js"; /** * Memoized @babel/core resolve cache: projectRoot → resolved module. @@ -104,3 +105,58 @@ export async function transformWithFlowBabel( ); return { code: oxcResult.code, map: oxcResult.map }; } + +/** + * Compile a `.js`/`.mjs` module that may contain JSX — and, when it carries a + * leading `// @flow` or `/* @flow *\/` pragma, Flow type annotations — into + * plain JS for the Vite pipeline. This is the transform body of the + * `vinext:jsx-in-js` plugin, extracted so the full pipeline (including the + * babel-missing error path) is unit-testable. + * + * Flow files are routed through `transformWithFlowBabel` (the project's + * .babelrc must include `@babel/preset-flow`). When @babel/core is not + * installed, the file falls through to the regular OXC pass: plain JS that + * merely mentions `@flow` in a leading comment still compiles, while genuine + * Flow syntax fails OXC's parse and is rethrown with an actionable + * "install @babel/core + @babel/preset-flow" message (with the OXC parse + * error as `cause`) instead of a confusing downstream parser error. + * + * `cleanId` must already have query params stripped — it is used as the + * filename for Babel config lookup, sourcemaps, and parse errors. + */ +export async function transformJsxInJs( + code: string, + cleanId: string, + projectRoot: string, + // oxlint-disable-next-line typescript/no-explicit-any +): Promise<{ code: string; map?: any }> { + // `hasFlowPragma` only matches the leading comment block, so mid-file + // occurrences of `@flow` (template literals, comments) never trigger the + // Babel fallback. + const isFlowFile = hasFlowPragma(code); + if (isFlowFile) { + const flowResult = await transformWithFlowBabel(code, cleanId, projectRoot); + if (flowResult) return flowResult; + // @babel/core is not installed in the project: fall through to the + // regular OXC pass below. + } + + try { + const result = await transformWithOxc(code, cleanId, { + lang: "jsx", + jsx: { runtime: "automatic" as const }, + sourcemap: true, + }); + return { code: result.code, map: result.map }; + } catch (error) { + if (isFlowFile) { + throw new Error( + `[vinext] Failed to compile ${cleanId}, which has a leading @flow pragma. ` + + "Flow type syntax requires Babel: install @babel/core and @babel/preset-flow, " + + 'and add "@babel/preset-flow" to the project\'s .babelrc or babel.config.*', + { cause: error }, + ); + } + throw error; + } +} diff --git a/tests/babel-flowtype.test.ts b/tests/babel-flowtype.test.ts index 4ea027009d..5e0757cf20 100644 --- a/tests/babel-flowtype.test.ts +++ b/tests/babel-flowtype.test.ts @@ -37,7 +37,45 @@ import os from "node:os"; import path from "node:path"; import { beforeAll, describe, expect, it } from "vite-plus/test"; import { hasFlowPragma } from "../packages/vinext/src/utils/flow-pragma.js"; -import { transformWithFlowBabel } from "../packages/vinext/src/utils/flow-babel.js"; +import { + transformJsxInJs, + transformWithFlowBabel, +} from "../packages/vinext/src/utils/flow-babel.js"; + +const FIXTURE_ROOT = path.resolve(import.meta.dirname, "./fixtures/babel-flowtype"); +const FIXTURE_FILE = path.join(FIXTURE_ROOT, "pages", "mycomponent.js"); +const fixtureCode = fs.readFileSync(FIXTURE_FILE, "utf8"); + +/** + * Run `fn` with Node's NODE_PATH-derived global module folders neutralized. + * + * `vp test` launches its workers with NODE_PATH pointing at pnpm's hoisted + * `node_modules/.pnpm/node_modules` store. On a fully-installed checkout + * that store contains @babel/core, which makes `createRequire(...)` resolve + * it from ANY directory — including the "empty project" temp dirs the + * babel-missing tests rely on. Clearing NODE_PATH (via the same + * `Module._initPaths()` hook Node's REPL uses) makes those tests + * deterministic regardless of the installer's hoisting behavior. The + * standard per-directory `node_modules` walk-up is unaffected. + * + * The env mutation is process-global, restored in `finally`. That is safe + * here: tests within this file run sequentially (none use `it.concurrent`), + * and vitest runs other test files in separate worker processes. + */ +async function withoutNodePathFallback(fn: () => Promise): Promise { + const prev = process.env.NODE_PATH; + process.env.NODE_PATH = ""; + // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-call + (Module as any)._initPaths(); + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.NODE_PATH; + else process.env.NODE_PATH = prev; + // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-call + (Module as any)._initPaths(); + } +} describe("hasFlowPragma — leading pragma detection (#1506)", () => { // ── True-positive cases ──────────────────────────────────────────────────── @@ -171,10 +209,6 @@ export default pragma; }); describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", () => { - const FIXTURE_ROOT = path.resolve(import.meta.dirname, "./fixtures/babel-flowtype"); - const FIXTURE_FILE = path.join(FIXTURE_ROOT, "pages", "mycomponent.js"); - const fixtureCode = fs.readFileSync(FIXTURE_FILE, "utf8"); - beforeAll(() => { // `transformWithFlowBabel` returns null when @babel/core cannot be // resolved from the fixture root — which would make the tests below fail @@ -228,33 +262,6 @@ describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", ( expect(map.sourcesContent?.some((c) => c?.includes("type Props"))).toBe(true); }); - /** - * Run `fn` with Node's NODE_PATH-derived global module folders neutralized. - * - * `vp test` launches its workers with NODE_PATH pointing at pnpm's hoisted - * `node_modules/.pnpm/node_modules` store. On a fully-installed checkout - * that store contains @babel/core, which makes `createRequire(...)` resolve - * it from ANY directory — including the "empty project" temp dirs the - * babel-missing tests below rely on. Clearing NODE_PATH (via the same - * `Module._initPaths()` hook Node's REPL uses) makes those tests - * deterministic regardless of the installer's hoisting behavior. The - * standard per-directory `node_modules` walk-up is unaffected. - */ - async function withoutNodePathFallback(fn: () => Promise): Promise { - const prev = process.env.NODE_PATH; - process.env.NODE_PATH = ""; - // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-call - (Module as any)._initPaths(); - try { - return await fn(); - } finally { - if (prev === undefined) delete process.env.NODE_PATH; - else process.env.NODE_PATH = prev; - // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unsafe-call - (Module as any)._initPaths(); - } - } - it("returns null when @babel/core is not resolvable from the project root", async () => { const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-flow-babel-")); try { @@ -300,3 +307,55 @@ describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", ( } }); }); + +describe("transformJsxInJs — vinext:jsx-in-js transform pipeline (#1506)", () => { + it("rethrows OXC's Flow parse failure as an actionable install message when @babel/core is missing", async () => { + const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-jsx-in-js-")); + try { + await withoutNodePathFallback(async () => { + const file = path.join(emptyRoot, "page.js"); + // The fixture has genuine Flow syntax, so with @babel/core missing + // the OXC fallback must fail — and the plugin pipeline must surface + // the actionable install message, not OXC's raw parse error. + const error = await transformJsxInJs(fixtureCode, file, emptyRoot).then( + () => null, + (e: unknown) => e as Error, + ); + expect(error).toBeTruthy(); + expect(error!.message).toMatch(/leading @flow pragma/); + expect(error!.message).toMatch(/install @babel\/core and @babel\/preset-flow/); + // The original OXC parse error is preserved as `cause`. + expect(error!.cause).toBeTruthy(); + }); + } finally { + fs.rmSync(emptyRoot, { recursive: true, force: true }); + } + }); + + it("still compiles plain JS with a pragma-only comment when @babel/core is missing", async () => { + const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-jsx-in-js-")); + try { + await withoutNodePathFallback(async () => { + const file = path.join(emptyRoot, "page.js"); + // No Flow syntax — only the pragma comment. The OXC fall-through + // must compile it (JSX included) rather than erroring. + const result = await transformJsxInJs( + "// @flow\nexport default function Page() { return
ok
; }\n", + file, + emptyRoot, + ); + expect(result.code).toContain("react/jsx-runtime"); + expect(result.code).not.toContain(" { + const result = await transformJsxInJs(fixtureCode, FIXTURE_FILE, FIXTURE_ROOT); + expect(result.code).not.toContain("type Props"); + expect(result.code).toContain("react/jsx-runtime"); + expect(result.code).toContain("Test Babel"); + }); +});