diff --git a/packages/compiler/src/analysis.ts b/packages/compiler/src/analysis.ts index da0a4f16..0401d3f5 100644 --- a/packages/compiler/src/analysis.ts +++ b/packages/compiler/src/analysis.ts @@ -1,4 +1,5 @@ import type { PageAst, ViewNode } from "@wrnexus/syntax"; +import type { ResolvedImport } from "./import-resolver.ts"; export type RouteExecutionKind = | "static" @@ -12,6 +13,8 @@ export interface RuntimeRequirements { kind: RouteExecutionKind; canPrerender: boolean; needsClientRuntime: boolean; + /** True when the route mounts a React island and must ship the island runtime. */ + needsIslandRuntime: boolean; needsServerRuntime: boolean; hydrationStrategy: string | null; reasons: string[]; @@ -210,8 +213,21 @@ function hasEvent(nodes: ViewNode[]): boolean { return false; } -export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements { +/** + * A route containing a React island ships JavaScript and can no longer be + * classified as zero-JS static, so island presence must reach the classifier. + */ +export function routeNeedsIslands(imports: ResolvedImport[]): boolean { + return imports.some((entry) => entry.kind === "island"); +} + +export function analyzeRuntimeRequirements( + ast: PageAst, + options: { hasIslands?: boolean } = {}, +): RuntimeRequirements { + const hasIslands = options.hasIslands ?? false; const reasons: string[] = []; + if (hasIslands) reasons.push("react island"); const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server"); const clientState = ast.states.some((state) => state.runtime !== "server"); const interactive = @@ -260,12 +276,17 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements { reasons.push("partial-static shell with streamed dynamic regions"); } + // An island ships JavaScript, so a would-be zero-JS static route must be + // reported as static-interactive. Explicit render modes still win above. + if (hasIslands && kind === "static") kind = "static-interactive"; + const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server"; const serverDisabled = ast.renderMode === "client"; return { kind, canPrerender: kind === "static" || kind === "static-interactive", + needsIslandRuntime: hasIslands, needsClientRuntime: !clientDisabled && (interactive || ast.renderMode === "client") && diff --git a/packages/compiler/test/island-classification.test.ts b/packages/compiler/test/island-classification.test.ts new file mode 100644 index 00000000..868b4b90 --- /dev/null +++ b/packages/compiler/test/island-classification.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { analyzeRuntimeRequirements, routeNeedsIslands } from "../src/analysis.ts"; + +test("a route with an island import needs client JavaScript", () => { + expect( + routeNeedsIslands([ + { declaration: { source: "./a" } as any, resolved: "/app/a.ts" }, + { declaration: { source: "./Chart" } as any, resolved: "/app/Chart.tsx", kind: "island" }, + ]), + ).toBe(true); +}); + +test("a route with no island imports stays zero-JS", () => { + expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe( + false, + ); +}); + +test("an empty import list stays zero-JS", () => { + expect(routeNeedsIslands([])).toBe(false); +}); + +test("an unresolved import does not count as an island", () => { + expect( + routeNeedsIslands([ + { + declaration: { source: "./missing" } as any, + diagnostic: { code: "WRN-IMPORT-NOT-FOUND", message: "nope", severity: "warning" }, + }, + ]), + ).toBe(false); +}); + +test("an island promotes a static route to static-interactive", () => { + const source = `page Home { view {
hello
} }`; + const ast = parse(source); + + const plain = analyzeRuntimeRequirements(ast); + expect(plain.kind).toBe("static"); + expect(plain.needsIslandRuntime).toBe(false); + + const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true }); + expect(withIsland.kind).toBe("static-interactive"); + expect(withIsland.needsIslandRuntime).toBe(true); + expect(withIsland.reasons).toContain("react island"); +}); + +test("an island does not turn on the WRNexus reactive runtime", () => { + const ast = parse(`page Home { view {
hello
} }`); + const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true }); + + // Islands ship the island runtime, not WRNexus's own client runtime. + expect(withIsland.needsClientRuntime).toBe(false); + expect(withIsland.needsIslandRuntime).toBe(true); +});