Skip to content
Open
253 changes: 237 additions & 16 deletions packages/vinext/src/build/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,178 @@ export function hasNamedExport(code: string, name: string): boolean {
return hasNamedExportInProgram(program, name);
}

/**
* Returns whether a named export is statically known to use an object property
* with the requested string value. Local aliases are followed, while external
* re-exports remain unknown because evaluating user modules during route scans
* would run application code.
*/
export function hasNamedExportObjectStringProperty(
code: string,
name: string,
property: string,
expectedValue: string,
): boolean {
const program = parseRouteModule(code);
if (!program) return false;

const localName = findExportedLocalNameInProgram(program, name);
if (localName === null) return false;
const initializer =
findExportedConstInitializerInProgram(program, name) ??
findLocalConstInitializerInProgram(program, localName);
if (initializer === null) return false;

const expression = resolveLocalConstExpression(program, initializer, new Set());
if (expression.type !== "ObjectExpression") return false;
for (const candidate of expression.properties) {
if (
candidate.type !== "Property" ||
candidate.computed ||
propertyKeyName(candidate.key) !== property
) {
continue;
}
const value = resolveLocalConstExpression(program, candidate.value, new Set());
return value.type === "Literal" && value.value === expectedValue;
}
return false;
}

export type NamedExternalReexport = {
importedName: string;
source: string;
};

export type NamedExportObjectStringPropertyAnalysis = {
hasExport: boolean;
hasUseClientDirective: boolean;
hasStaticValue: boolean;
staticValue: unknown;
propertyValue: string | null;
reexport: NamedExternalReexport | null;
};

/** Analyze an object-valued named export with one AST parse. */
export function analyzeNamedExportObjectStringProperty(
code: string,
name: string,
property: string,
): NamedExportObjectStringPropertyAnalysis {
const program = parseRouteModule(code);
if (!program) {
return {
hasExport: false,
hasUseClientDirective: false,
hasStaticValue: false,
staticValue: undefined,
propertyValue: null,
reexport: null,
};
}

const reexport = findNamedExternalReexportInProgram(program, name);
const localName = findExportedLocalNameInProgram(program, name);
let hasStaticValue = false;
let staticValue: unknown;
let propertyValue: string | null = null;
if (localName !== null) {
const initializer =
findExportedConstInitializerInProgram(program, name) ??
findLocalConstInitializerInProgram(program, localName);
if (initializer !== null) {
const expression = resolveLocalConstExpression(program, initializer, new Set());
const extractedValue = extractStaticJsonValue(expression, (candidate) =>
resolveLocalConstExpression(program, candidate, new Set()),
);
if (extractedValue !== UNSUPPORTED_STATIC_VALUE) {
hasStaticValue = true;
staticValue = extractedValue;
}
if (expression.type === "ObjectExpression") {
for (const candidate of expression.properties) {
if (
candidate.type !== "Property" ||
candidate.computed ||
propertyKeyName(candidate.key) !== property
) {
continue;
}
const value = resolveLocalConstExpression(program, candidate.value, new Set());
if (value.type === "Literal" && typeof value.value === "string") {
propertyValue = value.value;
}
break;
}
}
}
}

return {
hasExport: localName !== null || reexport !== null,
hasUseClientDirective: program.body.some(
(node) => node.type === "ExpressionStatement" && node.directive === "use client",
),
hasStaticValue,
staticValue,
propertyValue,
reexport,
};
}

/** Returns the local module specifier that supplies a named re-export. */
export function findNamedExternalReexport(
code: string,
name: string,
): NamedExternalReexport | null {
const program = parseRouteModule(code);
if (!program) return null;

return findNamedExternalReexportInProgram(program, name);
}

function findNamedExternalReexportInProgram(
program: Program,
name: string,
): NamedExternalReexport | null {
for (const node of program.body) {
if (
node.type !== "ExportNamedDeclaration" ||
node.exportKind === "type" ||
node.source === null ||
typeof node.source.value !== "string"
) {
continue;
}
for (const specifier of node.specifiers) {
if (specifier.exportKind !== "type" && moduleExportNameValue(specifier.exported) === name) {
const importedName = moduleExportNameValue(specifier.local);
if (importedName !== null) {
return { importedName, source: node.source.value };
}
}
}
}

return null;
}

function findExportedLocalNameInProgram(program: Program, name: string): string | null {
for (const node of program.body) {
if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue;
if (declarationHasBindingName(node.declaration, name)) return name;

for (const specifier of node.specifiers) {
if (specifier.exportKind === "type") continue;
if (moduleExportNameValue(specifier.exported) === name) {
return moduleExportNameValue(specifier.local);
}
}
}

return null;
}

/** Returns true when Next.js' analyzer recognizes the requested export name. */
export function hasExportedName(code: string, name: string): boolean {
const program = parseRouteModule(code);
Expand Down Expand Up @@ -191,6 +363,20 @@ function unwrapStaticExpression(expression: Expression): Expression {
return current;
}

function resolveLocalConstExpression(
program: Program,
expression: Expression,
visited: Set<string>,
): Expression {
const unwrapped = unwrapStaticExpression(expression);
if (unwrapped.type !== "Identifier" || visited.has(unwrapped.name)) return unwrapped;

const initializer = findLocalConstInitializerInProgram(program, unwrapped.name);
if (initializer === null) return unwrapped;
visited.add(unwrapped.name);
return resolveLocalConstExpression(program, initializer, visited);
}

function findExportedConstInitializer(code: string, name: string): Expression | null {
const program = parseRouteModule(code);
if (!program) return null;
Expand All @@ -213,6 +399,17 @@ function findExportedConstInitializerInProgram(program: Program, name: string):
return null;
}

function findLocalConstInitializerInProgram(program: Program, name: string): Expression | null {
for (const node of program.body) {
if (node.type !== "VariableDeclaration" || node.kind !== "const") continue;
for (const declarator of node.declarations) {
if (bindingName(declarator.id) === name) return declarator.init;
}
}

return null;
}

/**
* Extracts the string value of `export const <name> = "value"`.
* Handles TypeScript annotations/assertions and no-substitution template literals.
Expand Down Expand Up @@ -290,8 +487,12 @@ function propertyKeyName(key: PropertyKey): string | null {
return null;
}

function extractStaticJsonValue(expression: Expression): unknown {
const value = unwrapStaticExpression(expression);
function extractStaticJsonValue(
expression: Expression,
resolveExpression: (expression: Expression) => Expression = unwrapStaticExpression,
visiting: Set<Expression> = new Set(),
): unknown {
const value = resolveExpression(expression);

if (value.type === "Literal") {
if (
Expand All @@ -309,28 +510,48 @@ function extractStaticJsonValue(expression: Expression): unknown {
return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw ?? "";
}

if (value.type === "Identifier" && value.name === "undefined") {
return undefined;
}

if (value.type === "ArrayExpression") {
if (visiting.has(value)) return UNSUPPORTED_STATIC_VALUE;
visiting.add(value);
const items: unknown[] = [];
for (const element of value.elements) {
if (!element || element.type === "SpreadElement") return UNSUPPORTED_STATIC_VALUE;
const item = extractStaticJsonValue(element);
if (item === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE;
items.push(item);
try {
for (const element of value.elements) {
if (!element) {
items.push(undefined);
continue;
}
if (element.type === "SpreadElement") return UNSUPPORTED_STATIC_VALUE;
const item = extractStaticJsonValue(element, resolveExpression, visiting);
if (item === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE;
items.push(item);
}
return items;
} finally {
visiting.delete(value);
}
return items;
}

if (value.type === "ObjectExpression") {
if (visiting.has(value)) return UNSUPPORTED_STATIC_VALUE;
visiting.add(value);
const object: Record<string, unknown> = {};
for (const property of value.properties) {
if (property.type !== "Property" || property.computed) return UNSUPPORTED_STATIC_VALUE;
const key = propertyKeyName(property.key);
if (!key) return UNSUPPORTED_STATIC_VALUE;
const propertyValue = extractStaticJsonValue(property.value);
if (propertyValue === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE;
object[key] = propertyValue;
try {
for (const property of value.properties) {
if (property.type !== "Property" || property.computed) return UNSUPPORTED_STATIC_VALUE;
const key = propertyKeyName(property.key);
if (!key) return UNSUPPORTED_STATIC_VALUE;
const propertyValue = extractStaticJsonValue(property.value, resolveExpression, visiting);
if (propertyValue === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE;
object[key] = propertyValue;
}
return object;
} finally {
visiting.delete(value);
}
return object;
}

return UNSUPPORTED_STATIC_VALUE;
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/client/vinext-next-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { isUnknownRecord } from "../utils/record.js";
export type VinextLinkPrefetchRoute = {
canPrefetchLoadingShell: boolean;
documentOnly?: boolean;
hasInstant?: boolean;
hasRuntimeInstant?: boolean;
isDynamic: boolean;
patternParts: string[];
requiresDynamicNavigationRequest?: boolean;
Expand Down
24 changes: 22 additions & 2 deletions packages/vinext/src/entries/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ export function generateBrowserEntry(
beforeFiles: [],
fallback: [],
},
cacheComponents = false,
): string {
const entryPath = resolveRuntimeEntryModule("app-browser-entry");
const reactInstanceBootstrapPath = resolveClientRuntimeModule("react-instance-bootstrap");
const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime");
const prefetchRoutes = toLinkPrefetchRoutes(routes);
const prefetchRoutes = toLinkPrefetchRoutes(routes, cacheComponents);
const clientRewrites = toClientRewrites(rewrites);

return `import ${JSON.stringify(reactInstanceBootstrapPath)};
Expand Down Expand Up @@ -115,14 +116,33 @@ export function toLinkPrefetchRoute(
): VinextLinkPrefetchRoute {
return {
canPrefetchLoadingShell: hasLoadingBoundary(route, hasSiblingInterceptLoading),
...(route.hasInstant ? { hasInstant: true } : {}),
...(route.hasRuntimeInstant ? { hasRuntimeInstant: true } : {}),
patternParts: [...route.patternParts],
isDynamic: route.isDynamic,
...(requiresDynamicNavigationRequest(route) ? { requiresDynamicNavigationRequest: true } : {}),
};
}

/** Project App routes together so sibling-intercept loading is applied to its target route. */
export function toLinkPrefetchRoutes(routes: readonly AppRoute[]): VinextLinkPrefetchRoute[] {
export function toLinkPrefetchRoutes(
routes: readonly AppRoute[],
cacheComponents = false,
): VinextLinkPrefetchRoute[] {
const clientInstantRoute = routes.find((route) => route.hasInstantConfigInClientModule);
if (clientInstantRoute) {
throw new Error(
`Page "${clientInstantRoute.pattern}" cannot export "unstable_instant" from a Client Component module. To use this API, convert this module to a Server Component by removing the "use client" directive.`,
);
}
if (!cacheComponents) {
const instantRoute = routes.find((route) => route.hasInstantConfig);
if (instantRoute) {
throw new Error(
`Page "${instantRoute.pattern}" cannot use \`export const unstable_instant = ...\` without enabling \`cacheComponents\`.`,
);
}
}
const siblingInterceptLoadingTargets: string[][] = [];
for (const route of routes) {
for (const intercept of route.siblingIntercepts) {
Expand Down
Loading
Loading