Files
WRNexusJS/packages/compiler/src/analysis.ts
T
ClintchizandClaude Opus 5 01a3b3b4e9 feat(compiler): classify island routes as static-interactive
A route mounting an island ships JavaScript, so reporting it as zero-JS
static would make the framework's performance accounting wrong.

Adds a separate needsIslandRuntime flag rather than reusing
needsClientRuntime: an island needs the island runtime, not WRNexus's
reactive runtime, and conflating them would ship the wrong bundle.

analyzeRuntimeRequirements takes island presence as an optional second
argument, so existing callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:30:19 +05:30

309 lines
10 KiB
TypeScript

import type { PageAst, ViewNode } from "@wrnexus/syntax";
import type { ResolvedImport } from "./import-resolver.ts";
export type RouteExecutionKind =
| "static"
| "static-interactive"
| "request-ssr"
| "authenticated-ssr"
| "streaming-ssr"
| "dynamic";
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[];
optimization: OptimizationReport;
cachePolicy: Record<string, string>;
requiredPermission: string | null;
}
export interface OptimizationReport {
staticNodes: number;
reactiveRegions: number;
eliminatedBranches: number;
unusedState: string[];
unusedHandlers: string[];
constantProps: string[];
unusedLocalCssClasses: string[];
batchableStateUpdates: number;
memoizableComponents: string[];
preloadDependencies: string[];
serverOnlyModules: string[];
}
function identifiers(value: string): Set<string> {
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
}
function literalBoolean(expression: string | null): boolean | undefined {
if (expression === null) return true;
const value = expression.trim();
if (value === "true") return true;
if (
value === "false" ||
value === "null" ||
value === "undefined" ||
value === "0" ||
value === "''" ||
value === '""'
)
return false;
if (/^-?(?:[1-9]\d*|0?\.\d+)$/.test(value) || /^(['"]).+\1$/.test(value)) return true;
return undefined;
}
function optimizeNodes(nodes: ViewNode[], report: { eliminated: number }): ViewNode[] {
const output: ViewNode[] = [];
for (const node of nodes) {
if (node.type === "element")
output.push({
...node,
attrs: node.attrs.map((attribute) => ({ ...attribute })),
children: optimizeNodes(node.children, report),
});
else if (node.type === "each")
output.push({
...node,
body: optimizeNodes(node.body, report),
empty: optimizeNodes(node.empty, report),
});
else if (node.type === "if") {
let selected: ViewNode[] | undefined;
let dynamic = false;
for (const branch of node.branches) {
const value = literalBoolean(branch.cond);
if (value === undefined) {
dynamic = true;
break;
}
report.eliminated++;
if (value) {
selected = branch.body;
break;
}
}
if (dynamic)
output.push({
...node,
branches: node.branches.map((branch) => ({
...branch,
body: optimizeNodes(branch.body, report),
})),
});
else if (selected) output.push(...optimizeNodes(selected, report));
} else output.push({ ...node });
}
return output;
}
/** Safe compile-time folding for literal conditional branches. */
export function optimizeAst(ast: PageAst): { ast: PageAst; eliminatedBranches: number } {
const report = { eliminated: 0 };
return {
ast: { ...ast, view: optimizeNodes(ast.view, report) },
eliminatedBranches: report.eliminated,
};
}
export function analyzeOptimizations(ast: PageAst): OptimizationReport {
const used = new Set<string>();
let staticNodes = 0;
let reactiveRegions = 0;
const componentNames = new Set<string>();
const staticClasses = new Set<string>();
const visit = (nodes: ViewNode[]) => {
for (const node of nodes) {
if (node.type === "text") {
const refs = identifiers(node.value);
refs.forEach((name) => used.add(name));
if (node.value.includes("{")) reactiveRegions++;
else staticNodes++;
} else if (node.type === "element") {
if (/^[A-Z]/.test(node.tag)) componentNames.add(node.tag);
let reactive = false;
for (const attribute of node.attrs) {
identifiers(attribute.value).forEach((name) => used.add(name));
reactive ||= attribute.event || attribute.value.includes("{");
if (attribute.name === "class" && !attribute.value.includes("{"))
for (const name of attribute.value.split(/\s+/)) if (name) staticClasses.add(name);
}
if (reactive) reactiveRegions++;
else staticNodes++;
visit(node.children);
} else if (node.type === "each") {
identifiers(`${node.list} ${node.key ?? ""}`).forEach((name) => used.add(name));
reactiveRegions++;
visit(node.body);
visit(node.empty);
} else {
for (const branch of node.branches) {
identifiers(branch.cond ?? "").forEach((name) => used.add(name));
visit(branch.body);
}
reactiveRegions++;
}
}
};
visit(ast.view);
const handlerReferences = new Set(used);
const executable = [
...ast.runtimeFunctions.map((fn) => fn.body),
...ast.functions,
...ast.effects.map((effect) => effect.body),
...ast.watches.map((watch) => watch.body),
...ast.actions.map((action) => action.body),
].join("\n");
identifiers(executable).forEach((name) => used.add(name));
const localCss = new Set(
ast.styles.flatMap((style) =>
[...style.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map((match) => match[1]!),
),
);
const optimized = optimizeAst(ast);
const assignmentCounts = ast.runtimeFunctions.map(
(fn) =>
ast.states.filter((state) =>
new RegExp(`\\b${state.name}\\s*(?:[+*/-]?=|\\+\\+|--)`).test(fn.body),
).length,
);
return {
staticNodes,
reactiveRegions,
eliminatedBranches: optimized.eliminatedBranches,
unusedState: ast.states.filter((state) => !used.has(state.name)).map((state) => state.name),
unusedHandlers: ast.runtimeFunctions
.filter((fn) => fn.runtime !== "server" && !handlerReferences.has(fn.name))
.map((fn) => fn.name),
constantProps: ast.props
.filter((prop) =>
/^(?:-?\d+(?:\.\d+)?|true|false|null|(['"]).*\1)$/.test(prop.default.trim()),
)
.map((prop) => prop.name),
unusedLocalCssClasses: [...localCss].filter((name) => !staticClasses.has(name)).sort(),
batchableStateUpdates: assignmentCounts
.filter((count) => count > 1)
.reduce((sum, count) => sum + count - 1, 0),
memoizableComponents: [...componentNames].sort(),
preloadDependencies: ast.structuredImports
.filter((entry) => !entry.typeOnly && !entry.source.startsWith("node:"))
.map((entry) => entry.source),
serverOnlyModules: ast.structuredImports
.filter((entry) => entry.source.startsWith("node:") || ast.runtime === "server")
.map((entry) => entry.source),
};
}
function hasEvent(nodes: ViewNode[]): boolean {
for (const node of nodes) {
if (node.type === "element") {
if (node.attrs.some((attribute) => attribute.event)) return true;
if (hasEvent(node.children)) return true;
} else if (node.type === "each") {
if (hasEvent(node.body) || hasEvent(node.empty)) return true;
} else if (node.type === "if") {
if (node.branches.some((branch) => hasEvent(branch.body))) return true;
}
}
return false;
}
/**
* 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 =
clientFunctions ||
clientState ||
ast.effects.length > 0 ||
ast.watches.length > 0 ||
hasEvent(ast.view);
if (interactive) reasons.push("client interactivity");
const requestData =
ast.loads.length > 0 ||
ast.actions.length > 0 ||
ast.dataApis.length > 0 ||
ast.apis.length > 0 ||
ast.realtimes.length > 0 ||
ast.runtimeFunctions.some((fn) => fn.runtime === "server") ||
ast.states.some((state) => state.runtime === "server");
if (requestData) reasons.push("server/request data");
const authenticated = /^(?:required|true)$/i.test(ast.security.auth ?? "");
if (authenticated) reasons.push("authentication required");
const streaming = /^(?:true|required)$/i.test(ast.security.streaming ?? "");
if (streaming) reasons.push("streaming enabled");
let kind: RouteExecutionKind;
if (streaming) kind = "streaming-ssr";
else if (authenticated) kind = "authenticated-ssr";
else if (requestData && interactive) kind = "dynamic";
else if (requestData) kind = "request-ssr";
else if (interactive) kind = "static-interactive";
else kind = "static";
if (ast.renderMode === "static") {
kind = "static";
reasons.push("explicit static rendering");
} else if (ast.renderMode === "server") {
kind = requestData ? "request-ssr" : "static";
reasons.push("explicit server rendering");
} else if (ast.renderMode === "client") {
kind = "static-interactive";
reasons.push("explicit client rendering");
} else if (ast.renderMode === "partial-static") {
kind = "streaming-ssr";
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") &&
ast.hydrate !== "none" &&
ast.runtime !== "server",
needsServerRuntime:
!serverDisabled &&
(requestData ||
authenticated ||
streaming ||
ast.renderMode === "server" ||
["server", "edge", "worker", "service-worker"].includes(ast.runtime ?? "")),
hydrationStrategy: clientDisabled ? null : interactive ? (ast.hydrate ?? "load") : null,
reasons,
optimization: analyzeOptimizations(ast),
cachePolicy: { ...(ast.cache ?? {}) },
requiredPermission: ast.security.permission ?? null,
};
}