diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 212265b1ee..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,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 { transformJsxInJs } from "./utils/flow-babel.js"; // Install the process-level peer-disconnect backstop at module load. // Vite plugin lifecycle hooks (config / configureServer) proved @@ -1090,15 +1091,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - const result = await transformWithOxc(code, id, { - lang: "jsx", - jsx: { runtime: "automatic" as const }, - sourcemap: true, - }); - return { - code: result.code, - map: result.map, - }; + // 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 new file mode 100644 index 0000000000..ab7699f51a --- /dev/null +++ b/packages/vinext/src/utils/flow-babel.ts @@ -0,0 +1,162 @@ +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. + * 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(); + +/** + * 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 successful resolutions per projectRoot to avoid repeated + // `createRequire` calls on every transformed file. + // oxlint-disable-next-line typescript/no-explicit-any + 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: 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; + } + } + + // 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 }; +} + +/** + * 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/packages/vinext/src/utils/flow-pragma.ts b/packages/vinext/src/utils/flow-pragma.ts new file mode 100644 index 0000000000..af8f906e9d --- /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 (`/@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/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 new file mode 100644 index 0000000000..5e0757cf20 --- /dev/null +++ b/tests/babel-flowtype.test.ts @@ -0,0 +1,361 @@ +/** + * Test: Babel flowtype page compilation (#1506) + * + * 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 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 both halves of the feature: + * + * 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" + * + * Ported from Next.js: + * test/e2e/babel/index.test.ts + * https://github.com/vercel/next.js/blob/canary/test/e2e/babel/ + */ + +import fs from "node:fs"; +import Module, { createRequire } from "node:module"; +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 { + 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 ──────────────────────────────────────────────────── + + 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 = {} + +export default class MyComponent extends React.Component { + render() { + return
Test Babel
+ } +} +`; + expect(hasFlowPragma(code)).toBe(true); + }); + + 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 `/@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); + }); + + it("returns false for a hashbang-only file with no further content", () => { + expect(hasFlowPragma("#!/usr/bin/env node")).toBe(false); + }); +}); + +describe("transformWithFlowBabel — Babel + OXC two-stage transform (#1506)", () => { + 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); + 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 { + 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 }); + } + }); +}); + +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"); + }); +}); 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: [