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

/**
* Returns whether a named export is statically known to be truthy.
*
* Object/array/function-like initializers are treated as truthy without
* evaluating user code. External re-exports are also treated as truthy because
* their runtime value cannot be determined safely.
*/
export function hasTruthyNamedExport(code: string, name: 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 true;

const expression = unwrapStaticExpression(initializer);
if (expression.type !== "Literal") return true;
return Boolean(expression.value);
}

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 = unwrapStaticExpression(initializer);
if (expression.type !== "ObjectExpression") return false;
for (const candidate of expression.properties) {
if (
candidate.type !== "Property" ||
candidate.computed ||
propertyName(candidate.key) !== property
) {
continue;
}
const value = unwrapStaticExpression(candidate.value);
return value.type === "Literal" && value.value === expectedValue;
}
return false;
}

function findExportedLocalNameInProgram(program: Program, name: string): string | null {
for (const node of program.body) {
if (node.type !== "ExportNamedDeclaration") continue;
if (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;
}

function hasNamedExportInProgram(program: Program, name: string): boolean {
for (const node of program.body) {
if (node.type !== "ExportNamedDeclaration") continue;
Expand Down Expand Up @@ -184,6 +257,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
63 changes: 63 additions & 0 deletions packages/vinext/src/client/app-route-prefetch-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { VinextLinkPrefetchRoute } from "./vinext-next-data.js";
import { createRouteTrieCache, matchRouteWithTrie } from "../routing/route-matching.js";
import { stripBasePath } from "../utils/base-path.js";

export type AppRoutePrefetchPolicy = {
cacheForNavigation: boolean;
prefetchInstantShell: boolean;
prefetchShellFirst: boolean;
prefetchSuspenseShell: boolean;
shouldPrefetch: boolean;
};

const routeTrieCache = createRouteTrieCache<VinextLinkPrefetchRoute>();
const unavailablePolicy: AppRoutePrefetchPolicy = {
cacheForNavigation: false,
prefetchInstantShell: false,
prefetchShellFirst: false,
prefetchSuspenseShell: false,
shouldPrefetch: false,
};

export function resolveAppRoutePrefetchPolicy(options: {
basePath: string;
currentHref: string;
href: string;
routes: VinextLinkPrefetchRoute[] | undefined;
}): AppRoutePrefetchPolicy {
if (!options.routes) return unavailablePolicy;

let url: URL;
let currentUrl: URL;
try {
currentUrl = new URL(options.currentHref);
url = new URL(options.href, currentUrl);
} catch {
return unavailablePolicy;
}
if (url.origin !== currentUrl.origin) return unavailablePolicy;

const routeHref = `${stripBasePath(url.pathname, options.basePath)}${url.search}`;
const match = matchRouteWithTrie(routeHref, options.routes, routeTrieCache);
if (!match) return unavailablePolicy;

const route = match.route;
const hasLoadingShell = route.canPrefetchLoadingShell;
if (route.hasInstant) {
return {
cacheForNavigation: true,
prefetchInstantShell: true,
prefetchSuspenseShell: true,
prefetchShellFirst: false,
shouldPrefetch: true,
};
}

return {
cacheForNavigation: !hasLoadingShell,
prefetchInstantShell: false,
prefetchSuspenseShell: route.isDynamic && !hasLoadingShell,
prefetchShellFirst: !route.isDynamic,
shouldPrefetch: true,
};
}
1 change: 1 addition & 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,7 @@ import { isUnknownRecord } from "../utils/record.js";
export type VinextLinkPrefetchRoute = {
canPrefetchLoadingShell: boolean;
documentOnly?: boolean;
hasInstant?: boolean;
isDynamic: boolean;
patternParts: string[];
};
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/entries/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function toDocumentOnlyAppRoute(route: AppRoute): VinextLinkPrefetchRoute
export function toLinkPrefetchRoute(route: AppRoute): VinextLinkPrefetchRoute {
return {
canPrefetchLoadingShell: route.loadingPath !== null,
...(route.hasInstant ? { hasInstant: true } : {}),
patternParts: [...route.patternParts],
isDynamic: route.isDynamic,
};
Expand Down
38 changes: 36 additions & 2 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
invalidateAppRouteCache,
matchAppRoute,
} from "./routing/app-router.js";
import { routeModuleHasInstant } from "./routing/app-route-graph.js";
import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js";
import {
buildViteResolveExtensions,
Expand Down Expand Up @@ -3424,9 +3425,12 @@ export const loadServerActionClient = ${
}

function invalidateHybridClientEntries() {
if (!hasAppDir || !hasPagesDir) return;
if (!hasAppDir) return;
for (const env of Object.values(server.environments)) {
for (const id of [RESOLVED_CLIENT_ENTRY, RESOLVED_APP_BROWSER_ENTRY]) {
const ids = hasPagesDir
? [RESOLVED_CLIENT_ENTRY, RESOLVED_APP_BROWSER_ENTRY]
: [RESOLVED_APP_BROWSER_ENTRY];
for (const id of ids) {
const mod = env.moduleGraph.getModuleById(id);
if (mod) env.moduleGraph.invalidateModule(mod);
}
Expand Down Expand Up @@ -3531,6 +3535,25 @@ export const loadServerActionClient = ${
regenerateAppRouteTypes();
revalidateHybridRoutes();

const instantConfigByFile = new Map<string, boolean>();
if (hasAppDir) {
void appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher).then((graph) => {
const files = new Set<string>();
for (const route of graph.routes) {
if (route.pagePath) files.add(route.pagePath);
for (const layoutPath of route.layouts) files.add(layoutPath);
for (const slot of route.parallelSlots) {
if (slot.pagePath) files.add(slot.pagePath);
if (slot.layoutPath) files.add(slot.layoutPath);
for (const layoutPath of slot.configLayoutPaths ?? []) files.add(layoutPath);
}
}
for (const filePath of files) {
instantConfigByFile.set(filePath, routeModuleHasInstant(filePath));
}
});
}

// Node throws on unhandled 'error' events on sockets. When a browser
// drops the connection mid-response (common in dev: HMR triggers a
// reload while an RSC stream is still flushing), the next res.write
Expand All @@ -3550,6 +3573,7 @@ export const loadServerActionClient = ${
routeChanged = true;
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
instantConfigByFile.set(filePath, routeModuleHasInstant(filePath));
invalidateAppRoutingModules();
regenerateAppRouteTypes();
routeChanged = true;
Expand All @@ -3567,6 +3591,7 @@ export const loadServerActionClient = ${
routeChanged = true;
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
instantConfigByFile.delete(filePath);
invalidateAppRoutingModules();
regenerateAppRouteTypes();
routeChanged = true;
Expand All @@ -3577,6 +3602,15 @@ export const loadServerActionClient = ${
revalidateHybridRoutes();
}
});
server.watcher.on("change", (filePath: string) => {
if (!hasAppDir || !shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) return;
const nextHasInstant = routeModuleHasInstant(filePath);
const previousHasInstant = instantConfigByFile.get(filePath) ?? false;
instantConfigByFile.set(filePath, nextHasInstant);
if (nextHasInstant === previousHasInstant) return;
invalidateAppRouteCache();
invalidateHybridClientEntries();
});

// ── Dev request origin check ─────────────────────────────────────
// Registered directly (not in the returned function) so it runs
Expand Down
41 changes: 41 additions & 0 deletions packages/vinext/src/routing/app-route-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { findFileWithExts, scanWithExtensions, type ValidFileMatcher } from "./f
import { validateRoutePatterns } from "./route-validation.js";
import { compareStrings } from "../utils/compare.js";
import { normalizePathSeparators } from "../utils/path.js";
import { hasNamedExportObjectStringProperty } from "../build/report.js";

type InterceptingRoute = {
/** The interception convention: "." | ".." | "../.." | "..." */
Expand Down Expand Up @@ -191,8 +192,41 @@ export type AppRoute = {
rootParamNames?: string[];
/** Pre-split pattern segments (computed once at scan time, reused per request) */
patternParts: string[];
/** Whether the page or any active ancestor layout opts into unstable_instant. */
hasInstant?: boolean;
};

export function routeModuleHasInstant(filePath: string | null): boolean {
if (filePath === null) return false;
try {
return hasNamedExportObjectStringProperty(
fs.readFileSync(filePath, "utf8"),
"unstable_instant",
"prefetch",
"runtime",
);
} catch {
return false;
}
}

function routeHasInstant(options: {
layouts: readonly string[];
pagePath: string | null;
parallelSlots: readonly ParallelSlot[];
}): boolean {
return (
routeModuleHasInstant(options.pagePath) ||
options.layouts.some((layoutPath) => routeModuleHasInstant(layoutPath)) ||
options.parallelSlots.some(
(slot) =>
routeModuleHasInstant(slot.pagePath) ||
routeModuleHasInstant(slot.layoutPath ?? null) ||
(slot.configLayoutPaths ?? []).some((layoutPath) => routeModuleHasInstant(layoutPath)),
)
);
}

export type AppRouteSemanticIds = {
route: string;
page: string | null;
Expand Down Expand Up @@ -1100,6 +1134,7 @@ function discoverSlotSubRoutes(
}
return slot;
});
route.hasInstant = routeHasInstant(route);
};

// Iterate real routes first so that later ghost-parent passes can detect
Expand Down Expand Up @@ -1300,6 +1335,11 @@ function discoverSlotSubRoutes(
params: [...parentRoute.params, ...subParams],
rootParamNames: parentRoute.rootParamNames,
patternParts: [...parentRoute.patternParts, ...urlParts],
hasInstant: routeHasInstant({
layouts: parentRoute.layouts,
pagePath: childrenFallback,
parallelSlots: subSlots,
}),
siblingIntercepts: [],
};
syntheticRoutes.push(newRoute);
Expand Down Expand Up @@ -1564,6 +1604,7 @@ function directoryToAppRoute(
params,
rootParamNames: computeRootParamNames(segments, layoutTreePositions),
patternParts: urlSegments,
hasInstant: routeHasInstant({ layouts, pagePath, parallelSlots }),
siblingIntercepts: [],
};
}
Expand Down
Loading
Loading