Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/vinext/src/client/vinext-next-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,53 @@
import type { NEXT_DATA } from "vinext/shims/internal/utils";
import { isUnknownRecord } from "../utils/record.js";

declare global {
// Window must use interface merging for this browser bootstrap flag.
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
interface Window {
__VINEXT_PREFETCH_VARY_ENABLED__?: boolean;
}
}

export type VinextPrefetchVaryMetadata = {
loadingParamNames: string[];
metadataParamNames: string[];
metadataSearchParams: boolean;
/** Treat every preserved page Suspense child as runtime-only. */
pageAllSuspenseDynamic?: boolean;
pageDynamicSuspenseOrdinals: number[];
pageDynamicSuspenseOrdinalsByElementId?: Record<string, number[]>;
pageParamNames: string[];
pageSearchParams: boolean;
/**
* Conservative wire fallback used when the precise dependency set cannot be
* represented within the completion-metadata framing limit. The client must
* then key every route and parallel-slot param by value.
*/
varyAllParams?: boolean;
};

export type VinextLinkPrefetchRoute = {
canPrefetchLoadingShell: boolean;
canPrefetchFullStaticRoute?: boolean;
canPrefetchRuntimeShell?: boolean;
canPrefetchStaticRoute?: boolean;
documentOnly?: boolean;
isDynamic: boolean;
loadingShellVaryParamNames?: string[];
loadingShellVarySearchParams?: boolean;
metadataVaryParamNames?: string[];
metadataVarySearchParams?: boolean;
patternParts: string[];
prefetchVaryParamNames?: string[];
prefetchVarySearchParams?: boolean;
requiresDynamicNavigationRequest?: boolean;
runtimePrefetchVaryParamNames?: string[];
runtimePrefetchVarySearchParams?: boolean;
slotParamPatterns?: Array<{
paramNames: string[];
patternParts: string[];
}>;
/** The route has dynamic params above its root layout. */
hasRootParams?: true;
};
Expand Down
6 changes: 6 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ export type ResolvedNextConfig = {
serverResolveExtensions: string[] | null;
instrumentationClientInject: string[];
cacheComponents: boolean;
optimisticRouting: boolean;
varyParams: boolean;
appNavFailHandling: boolean;
/**
* Enables the experimental App Router gesture transition API:
Expand Down Expand Up @@ -1561,6 +1563,8 @@ export async function resolveNextConfig(
resolveExtensions: null,
serverResolveExtensions: null,
cacheComponents: false,
optimisticRouting: false,
varyParams: false,
appNavFailHandling: false,
gestureTransition: false,
prefetchInlining: false,
Expand Down Expand Up @@ -1926,6 +1930,8 @@ export async function resolveNextConfig(
)
: [],
cacheComponents: config.cacheComponents ?? false,
optimisticRouting: experimental?.optimisticRouting === true,
varyParams: experimental?.varyParams === true,
appNavFailHandling: experimental?.appNavFailHandling === true,
gestureTransition: experimental?.gestureTransition === true,
prefetchInlining,
Expand Down
50 changes: 39 additions & 11 deletions packages/vinext/src/entries/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { toClientRewrites } from "../client/client-rewrites.js";
import type { AppRoute } from "../routing/app-router.js";
import { patternsStructurallyEquivalent, type RouteManifest } from "../routing/app-route-graph.js";
import type { NextRewrite } from "../config/next-config.js";
import { analyzeAppPrefetchCapabilities } from "../server/app-prefetch-vary-analysis.js";

/**
* Generate the virtual browser entry module.
Expand All @@ -24,17 +25,19 @@ export function generateBrowserEntry(
beforeFiles: [],
fallback: [],
},
prefetchVaryEnabled = 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, { prefetchVaryEnabled });
const clientRewrites = toClientRewrites(rewrites);

return `import ${JSON.stringify(reactInstanceBootstrapPath)};
import { registerNavigationRuntimeBootstrap } from ${JSON.stringify(navigationRuntimePath)};

window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(prefetchRoutes)};
window.__VINEXT_PREFETCH_VARY_ENABLED__ = ${JSON.stringify(prefetchVaryEnabled)};
// Pages route manifest for hybrid ownership decisions. In a hybrid
// app+pages build the user can land on an App page, so the App browser
// entry must also expose the Pages manifest (the Pages client entry does
Expand Down Expand Up @@ -107,23 +110,48 @@ function hasLoadingBoundary(route: AppRoute, hasSiblingInterceptLoading: boolean
/** Project an `AppRoute` down to the public `VinextLinkPrefetchRoute` shape. */
export function toLinkPrefetchRoute(
route: AppRoute,
hasSiblingInterceptLoading = route.siblingIntercepts.some(
(intercept) =>
interceptTargetsRoute(intercept.targetPattern, route) &&
(intercept.loadingPaths?.length ?? 0) > 0,
),
options: {
hasSiblingInterceptLoading?: boolean;
prefetchVaryEnabled?: boolean;
} = {},
): VinextLinkPrefetchRoute {
const hasSiblingInterceptLoading =
options.hasSiblingInterceptLoading ??
route.siblingIntercepts.some(
(intercept) =>
interceptTargetsRoute(intercept.targetPattern, route) &&
(intercept.loadingPaths?.length ?? 0) > 0,
);
const capabilities =
options.prefetchVaryEnabled !== false ? analyzeAppPrefetchCapabilities(route) : null;
const slotParamPatterns = route.parallelSlots.flatMap((slot) =>
slot.slotPatternParts && slot.slotParamNames
? [
{
paramNames: [...slot.slotParamNames],
patternParts: [...slot.slotPatternParts],
},
]
: [],
);
return {
canPrefetchLoadingShell: hasLoadingBoundary(route, hasSiblingInterceptLoading),
...(capabilities?.canPrefetchFullStaticRoute ? { canPrefetchFullStaticRoute: true } : {}),
...(capabilities?.canPrefetchRuntimeShell ? { canPrefetchRuntimeShell: true } : {}),
...(capabilities?.canPrefetchStaticRoute ? { canPrefetchStaticRoute: true } : {}),
patternParts: [...route.patternParts],
isDynamic: route.isDynamic,
...(requiresDynamicNavigationRequest(route) ? { requiresDynamicNavigationRequest: true } : {}),
...(slotParamPatterns.length > 0 ? { slotParamPatterns } : {}),
...((route.rootParamNames?.length ?? 0) > 0 ? { hasRootParams: 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[],
{ prefetchVaryEnabled = true }: { prefetchVaryEnabled?: boolean } = {},
): VinextLinkPrefetchRoute[] {
const siblingInterceptLoadingTargets: string[][] = [];
for (const route of routes) {
for (const intercept of route.siblingIntercepts) {
Expand All @@ -135,12 +163,12 @@ export function toLinkPrefetchRoutes(routes: readonly AppRoute[]): VinextLinkPre

return routes.map((route) =>
isLinkPrefetchRoute(route)
? toLinkPrefetchRoute(
route,
siblingInterceptLoadingTargets.some((targetParts) =>
? toLinkPrefetchRoute(route, {
hasSiblingInterceptLoading: siblingInterceptLoadingTargets.some((targetParts) =>
patternsStructurallyEquivalent(targetParts, route.patternParts),
),
)
prefetchVaryEnabled,
})
: toDocumentOnlyAppRoute(route),
);
}
Expand Down
53 changes: 47 additions & 6 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ type AppRouterConfig = {
globalNotFound?: boolean;
/** Enables Next.js Cache Components semantics for App Router document HTML. */
cacheComponents?: boolean;
/** Enable render-observed segment prefetch variation metadata. */
prefetchVaryEnabled?: boolean;
/** Resolved `experimental.prefetchInlining` thresholds. */
prefetchInlining?: PrefetchInliningConfig;
/** Whether the RSC build discovered any server references. Defaults to true. */
Expand Down Expand Up @@ -230,6 +232,7 @@ export function generateRscEntry(
const cacheMaxMemorySize = config?.cacheMaxMemorySize;
const inlineCss = config?.inlineCss === true;
const cacheComponents = config?.cacheComponents === true;
const prefetchVaryEnabled = cacheComponents && config?.prefetchVaryEnabled === true;
const prefetchInlining = config?.prefetchInlining ?? false;
const hasServerActions = config?.hasServerActions !== false;
const i18nConfig = config?.i18n ?? null;
Expand Down Expand Up @@ -366,10 +369,17 @@ import {
} from ${JSON.stringify(appElementsPath)};
import {
probeAppPageLayoutWithTracking as __probeAppPageLayoutWithTracking,
getAppPageSerializedSlotProbeElements as __getAppPageSerializedSlotProbeElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from ${JSON.stringify(appPageRouteWiringPath)};
import { buildPageElements as __buildPageElements } from ${JSON.stringify(appPageElementBuilderPath)};
import { buildAppPageProbes as __buildAppPageProbes } from ${JSON.stringify(appPageProbePath)};
import {
buildPageElements as __buildPageElements,
resolveSlotParamOverrides as __resolveSlotParamOverrides,
} from ${JSON.stringify(appPageElementBuilderPath)};
import {
buildAppPageProbes as __buildAppPageProbes,
resolveAppPageProbeMainElementId as __resolveAppPageProbeMainElementId,
} from ${JSON.stringify(appPageProbePath)};
import {
dispatchAppPage as __dispatchAppPage,
} from ${JSON.stringify(appPageDispatchPath)};
Expand Down Expand Up @@ -807,7 +817,6 @@ export default createAppRscHandler({
routePatternParts: route.patternParts,
routeSegments: route.routeSegments,
});
const _asyncRouteParams = makeThenableParams(params);
return __dispatchAppPage({
basePath: __basePath,
ensureRouteLoaded: __ensureRouteLoaded,
Expand Down Expand Up @@ -841,6 +850,7 @@ export default createAppRscHandler({
dynamicConfig: __segmentConfig.dynamicConfig,
dynamicStaleTimeSeconds: __segmentConfig.dynamicStaleTimeSeconds,
dynamicParamsConfig: __segmentConfig.dynamicParamsConfig,
prefetchVaryEnabled: ${JSON.stringify(prefetchVaryEnabled)},
fetchCache: __segmentConfig.fetchCache ?? null,
isEdgeRuntime: __isEdgeRuntime(__segmentConfig.runtime),
findIntercept(pathname) {
Expand Down Expand Up @@ -898,7 +908,7 @@ export default createAppRscHandler({
route,
});
},
async probePage(probeSearchParams = searchParams) {
async probePage(probeSearchParams = searchParams, layoutParamAccess, elements) {
const __probeIntercept = findIntercept(interceptionPathname, interceptionContext);
// The intercepting-route page module is lazy (page: null + __pageLoader).
// Resolve it before probing so buildAppPageProbes inspects the real page
Expand All @@ -908,15 +918,46 @@ export default createAppRscHandler({
// observes the page's searchParams/headers access. Shared loader, so
// the import is isolated from the request context here too.
if (__probeIntercept) await __loadAppInterceptPage(__probeIntercept);
const __probeMainPageElementId = __resolveAppPageProbeMainElementId(
route,
cleanPathname,
interceptionContext,
);
return Promise.all(__buildAppPageProbes({
route,
elements:
renderMode === "navigation" || renderMode === "prefetch-dynamic-shell"
? __getAppPageSerializedSlotProbeElements(elements)
: undefined,
pageComponent: PageComponent,
asyncRouteParams: _asyncRouteParams,
asyncRouteParams: makeThenableParams(
params,
layoutParamAccess?.createPageParamsObserver(
route.params ?? undefined,
__probeMainPageElementId,
),
),
mainPageElementId: __probeMainPageElementId,
searchParams: probeSearchParams,
intercept: __probeIntercept,
isRscRequest,
matchedParams: params,
makeThenableParams,
makeThenableParams(value, pageElementId, paramNames) {
return makeThenableParams(
value,
layoutParamAccess?.createPageParamsObserver(
paramNames ?? route.params ?? undefined,
pageElementId,
),
);
},
onSearchParamsAccess(pageElementId) {
layoutParamAccess?.observePageSearchParams(pageElementId);
},
onDynamicSuspenseBoundary(pageElementId, ordinal) {
layoutParamAccess?.observePageDynamicSuspenseBoundary(pageElementId, ordinal);
},
slotParamOverrides: __resolveSlotParamOverrides(route, cleanPathname),
}));
},
renderErrorBoundaryPage(renderErr, errorOrigin) {
Expand Down
3 changes: 3 additions & 0 deletions packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ window.__VINEXT_APP_LOADER__ = appLoader;
// when the user lands on an App Router page (see app-browser-entry.ts) — the
// two writes do not race because only one entry executes per page load.
window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(appPrefetchRoutes)};
window.__VINEXT_PREFETCH_VARY_ENABLED__ = ${JSON.stringify(
nextConfig.cacheComponents && nextConfig.varyParams && nextConfig.optimisticRouting,
)};
// Pages route manifest, exposed so the App Router runtime can decide when
// a soft-navigated URL is actually owned by Pages (and must hard-navigate
// instead of issuing an RSC request). Set here AND in app-browser-entry.ts
Expand Down
16 changes: 15 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1519,7 +1519,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// with `{ __appRouter: true }`. See `pages-client-entry.ts` and issue
// #1526 for the Next.js parity rationale.
const appPrefetchRoutes = hasAppDir
? toLinkPrefetchRoutes(await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher))
? toLinkPrefetchRoutes(await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), {
prefetchVaryEnabled:
nextConfig?.cacheComponents === true &&
nextConfig?.varyParams === true &&
nextConfig?.optimisticRouting === true,
})
: [];
return _generateClientEntry(pagesDir, nextConfig, fileMatcher, {
appPrefetchRoutes,
Expand Down Expand Up @@ -2361,6 +2366,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
defines["process.env.__NEXT_CACHE_COMPONENTS"] = JSON.stringify(
nextConfig.cacheComponents ?? false,
);
defines["process.env.__VINEXT_OPTIMISTIC_ROUTING"] = JSON.stringify(
nextConfig.optimisticRouting,
);
defines["process.env.__VINEXT_VARY_PARAMS"] = JSON.stringify(nextConfig.varyParams);

// User-defined compile-time constants from `compiler.define` in
// next.config. Applied to BOTH client and server bundles via Vite's
Expand Down Expand Up @@ -3816,6 +3825,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
inlineCss: nextConfig?.inlineCss,
globalNotFound: nextConfig?.globalNotFound,
cacheComponents: nextConfig?.cacheComponents,
prefetchVaryEnabled:
nextConfig?.cacheComponents === true &&
nextConfig?.varyParams === true &&
nextConfig?.optimisticRouting === true,
prefetchInlining: nextConfig?.prefetchInlining,
hasServerActions,
i18n: nextConfig?.i18n,
Expand Down Expand Up @@ -3888,6 +3901,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
graph.routeManifest,
pagesPrefetchRoutes,
nextConfig.rewrites,
nextConfig.cacheComponents && nextConfig.varyParams && nextConfig.optimisticRouting,
);
}
if (id === RESOLVED_APP_CAPABILITIES && hasAppDir) {
Expand Down
Loading
Loading