Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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: 40 additions & 1 deletion packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ import {
renderAppPageHtmlStreamWithRecovery,
type AppPageSsrHandler,
} from "./app-page-stream.js";
import type { AppRscRenderMode } from "./app-rsc-render-mode.js";
import {
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL,
type AppRscRenderMode,
} from "./app-rsc-render-mode.js";
import {
createArtifactCompatibilityEnvelope,
createArtifactCompatibilityGraphVersion,
Expand Down Expand Up @@ -509,6 +512,36 @@ function createRenderLifecycleSkipDisposition(input: {
return plan.skipDisposition;
}

function createDynamicPrefetchAfterShellSkipDisposition(input: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reuses ClientReuseManifestSkipDisposition and returns SKIP_STATIC_LAYOUT_VERIFIED directly from current layoutFlags, but this path is not the same invariant as the existing manifest/cache-proof verifier in skip-cache-proof.ts; it depends on the client having a fresh shell payload, not on server-side cache proof. Reusing the verified disposition muddies the boundary and makes later readers think the normal proof pipeline ran. Can we add an explicit shell-delta skip model/code, or move this behind a helper in the skip/cache-proof layer with a name that states the prerequisite?

element: ReactNode | Readonly<Record<string, ReactNode>>;
isRscRequest: boolean;
layoutFlags: Readonly<Record<string, "s" | "d">>;
renderMode: AppRscRenderMode | undefined;
}): ClientReuseManifestSkipDisposition | undefined {
if (
!input.isRscRequest ||
input.renderMode !== APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL ||
!isAppElementsRecord(input.element)
) {
return undefined;
}

const skippedEntryIds = Object.entries(input.layoutFlags)
.filter(([id, flag]) => flag === "s" && AppElementsWire.parseElementKey(id)?.kind === "layout")
.map(([id]) => id);

if (skippedEntryIds.length === 0) {
return undefined;
}

return {
code: "SKIP_STATIC_LAYOUT_VERIFIED",
enabled: true,
mode: "skipStaticLayout",
skippedEntryIds,
};
}

function isSkipTransportEnabled(
skipDisposition: ClientReuseManifestSkipDisposition | undefined,
): boolean {
Expand Down Expand Up @@ -660,6 +693,12 @@ export async function renderAppPageLifecycle(
});
const skipDisposition =
options.skipDisposition ??
createDynamicPrefetchAfterShellSkipDisposition({
element: options.element,
isRscRequest: options.isRscRequest,
layoutFlags,
renderMode: options.renderMode,
}) ??
createRenderLifecycleSkipDisposition({
artifactCompatibility,
cleanPathname: options.cleanPathname,
Expand Down
12 changes: 4 additions & 8 deletions packages/vinext/src/server/app-page-route-wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ export function buildAppPageElements<
elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY] = "LoadingBoundary";
}

elements[pageElementId] = isPrefetchLoadingShell
elements[pageElementId] = shouldRenderPrefetchLoadingShell
? null
: renderAfterAppDependencies(options.element, pageDependencies);

Expand Down Expand Up @@ -936,17 +936,13 @@ export function buildAppPageElements<
</LayoutSegmentProvider>
);

if (isPrefetchLoadingShell) {
if (isPrefetchLoadingShell && routeLoadingComponent !== null) {
// A prefetch loading shell is a cached payload, not a committed navigation,
// so it intentionally does not mount AppRouterScrollTarget — the scroll/focus
// effect belongs to the real render that replaces this shell (handled in the
// else branch below).
if (routeLoadingComponent === null) {
routeChildren = null;
} else {
const RouteLoadingComponent = routeLoadingComponent;
routeChildren = <RouteLoadingComponent />;
}
const RouteLoadingComponent = routeLoadingComponent;
routeChildren = <RouteLoadingComponent />;
} else {
// Wrap the page slot in a per-segment RedirectBoundary so that a
// redirect() thrown from a server component (or a client component
Expand Down
8 changes: 8 additions & 0 deletions packages/vinext/src/server/app-rsc-render-mode.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
export type AppRscRenderMode =
| "navigation"
| "prefetch-loading-shell"
| "prefetch-dynamic-after-shell"
| "refresh-preserve-ui"
| "action-rerender-preserve-ui";

export const APP_RSC_RENDER_MODE_NAVIGATION = "navigation" satisfies AppRscRenderMode;
export const APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL =
"prefetch-loading-shell" satisfies AppRscRenderMode;
export const APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL =
"prefetch-dynamic-after-shell" satisfies AppRscRenderMode;
export const APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI =
"refresh-preserve-ui" satisfies AppRscRenderMode;
export const APP_RSC_RENDER_MODE_ACTION_RERENDER_PRESERVE_UI =
Expand All @@ -27,6 +30,9 @@ export function getRscRenderModeCacheVariant(mode: AppRscRenderMode): string | n
if (mode === APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL) {
return "prefetch-loading-shell";
}
if (mode === APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL) {
return "prefetch-dynamic-after-shell";
}

return shouldUsePreserveUiCacheVariant(mode) ? "preserve-ui" : null;
}
Expand All @@ -35,6 +41,8 @@ export function parseAppRscRenderMode(value: string | null): AppRscRenderMode {
switch (value) {
case APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL:
return APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL;
case APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL:
return APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL;
case APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI:
return APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI;
case APP_RSC_RENDER_MODE_ACTION_RERENDER_PRESERVE_UI:
Expand Down
71 changes: 51 additions & 20 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ type LinkProps = {
children?: React.ReactNode;
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">;

type LinkPrefetchMode = "disabled" | "auto" | "full" | "full-after-shell";
type LinkPrefetchMode = "disabled" | "auto" | "auto-shell" | "full" | "full-after-shell";

declare global {
// Window is an ambient interface from lib.dom; interface merging is required
Expand Down Expand Up @@ -395,8 +395,9 @@ function resolveFullAppRoutePrefetch(): {
* For Pages Router: warms the page chunk, prefetches data only for SSG pages,
* and falls back to a document prefetch hint when no page loader matches.
*
* Uses `requestIdleCallback` (or `setTimeout` fallback) to avoid blocking
* the main thread during initial page load.
* App Router and high-priority prefetches start immediately. Low-priority
* Pages Router fallback prefetches use `requestIdleCallback` (or `setTimeout`
* fallback) to avoid blocking the main thread during initial page load.
*/
function prefetchUrl(
href: string,
Expand Down Expand Up @@ -436,14 +437,7 @@ function prefetchUrl(
return;
}

const schedule =
priority === "high"
? (fn: () => void) => {
fn();
}
: (window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100)));

schedule(() => {
const runPrefetch = () => {
void (async () => {
if (hasAppNavigationRuntime()) {
if (isBotUserAgent(window.navigator?.userAgent ?? "")) return;
Expand All @@ -452,7 +446,10 @@ function prefetchUrl(
navigation,
{ AppElementsWire },
rscCacheBusting,
{ APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL },
{
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL,
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
},
headersModule,
{ resolveHybridClientRouteOwner },
] = await Promise.all([
Expand All @@ -473,7 +470,11 @@ function prefetchUrl(
PREFETCH_CACHE_TTL,
} = navigation;
const { createRscRequestHeaders, createRscRequestUrl } = rscCacheBusting;
const { NEXT_ROUTER_PREFETCH_HEADER, VINEXT_MOUNTED_SLOTS_HEADER } = headersModule;
const {
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
VINEXT_MOUNTED_SLOTS_HEADER,
} = headersModule;
// Hybrid ownership: skip the App RSC prefetch when Pages owns the
// URL. The App's `__VINEXT_LINK_PREFETCH_ROUTES__` may include an
// App catch-all that also matches the same path, so a naive
Expand All @@ -488,9 +489,11 @@ function prefetchUrl(
const autoPrefetch =
mode === "auto"
? resolveAutoAppRoutePrefetch(prefetchHref)
: mode === "full-after-shell"
? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true }
: resolveFullAppRoutePrefetch();
: mode === "auto-shell"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds auto-shell as an unconditional prefetch plan, which bypasses the route-manifest eligibility check that normal auto gets through resolveAutoAppRoutePrefetch(). Since visible unstable_dynamicOnHover links are switched to this mode, any same-origin App-runtime link can now issue a shell RSC prefetch even when it has no __VINEXT_LINK_PREFETCH_ROUTES__ match. This is exactly the kind of mode-string branching that makes link.tsx harder to reason about. Can we push this back into a single typed prefetch-plan helper that always does route eligibility first, then chooses shell-only vs navigation-cache behavior for matched routes?

? { cacheForNavigation: false, prefetchShellFirst: true, shouldPrefetch: true }
: mode === "full-after-shell"
? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true }
: resolveFullAppRoutePrefetch();
if (!autoPrefetch.shouldPrefetch) return;

const interceptionContext = getPrefetchInterceptionContext(fullHref);
Expand All @@ -501,13 +504,16 @@ function prefetchUrl(
interceptionContext,
renderMode: isOptimisticRouteShellPrefetch
? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL
: undefined,
: mode === "full-after-shell"
? APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL
: undefined,
});
if (mountedSlotsHeader) {
headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader);
}
if (isOptimisticRouteShellPrefetch) {
headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree");
}
// Distinguish the same visible URL when it is prefetched from different
// request contexts such as /feed vs /gallery or different mounted slots.
Expand Down Expand Up @@ -549,6 +555,7 @@ function prefetchUrl(
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
shellHeaders.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
shellHeaders.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/_tree");
if (mountedSlotsHeader) {
shellHeaders.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader);
}
Expand Down Expand Up @@ -583,6 +590,14 @@ function prefetchUrl(
shellEntry = shellCache.get(shellCacheKey);
}
await shellEntry?.pending?.catch(() => {});
const settledShellEntry = shellCache.get(shellCacheKey);
if (
settledShellEntry?.outcome !== "cache-seeded" ||
settledShellEntry.expiresAt === undefined ||
settledShellEntry.expiresAt <= Date.now()
) {
throw new Error("Unable to upgrade prefetch without a fresh shell payload");
}
return fetch(rscUrl, {
headers,
credentials: "include",
Expand Down Expand Up @@ -650,7 +665,15 @@ function prefetchUrl(
})().catch((error) => {
console.error("[vinext] RSC prefetch setup error:", error);
});
});
};

if (priority === "high" || hasAppNavigationRuntime()) {
runPrefetch();
return;
}

const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100));
schedule(runPrefetch);
}

async function promotePrefetchEntriesForNavigation(href: string): Promise<void> {
Expand Down Expand Up @@ -1027,10 +1050,12 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
if (!observer) return;

registerVisibleLinkPing();
const viewportPrefetchMode =
unstable_dynamicOnHover && prefetchMode === "auto" ? "auto-shell" : prefetchMode;
const instance: LinkPrefetchInstance = {
href: hrefToPrefetch,
isVisible: false,
mode: prefetchMode,
mode: viewportPrefetchMode,
pagesRouteHref:
normalizedRouteHref === normalizedHref
? undefined
Expand All @@ -1050,7 +1075,13 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
observedLinkPrefetches.delete(node);
visibleLinkPrefetches.delete(instance);
};
}, [shouldViewportPrefetch, prefetchMode, normalizedHref, normalizedRouteHref]);
}, [
shouldViewportPrefetch,
prefetchMode,
normalizedHref,
normalizedRouteHref,
unstable_dynamicOnHover,
]);

const prefetchOnIntent = useCallback(() => {
if (
Expand Down
49 changes: 49 additions & 0 deletions tests/app-page-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import type { LayoutClassificationOptions } from "../packages/vinext/src/server/
import { createClientReuseManifestHeaderFromVisibleAppState } from "../packages/vinext/src/server/app-browser-client-reuse-manifest.js";
import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js";
import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js";
import {
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL,
type AppRscRenderMode,
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
import {
parseClientReuseManifestHeader,
type ClientReuseManifestParseResult,
Expand Down Expand Up @@ -1308,6 +1312,7 @@ describe("layoutFlags injection into RSC payload", () => {
classification?: LayoutClassificationOptions | null;
routePattern?: string;
skipDisposition?: ClientReuseManifestSkipDisposition;
renderMode?: AppRscRenderMode;
}) {
let capturedElement: Record<string, unknown> | null = null;

Expand Down Expand Up @@ -1355,6 +1360,7 @@ describe("layoutFlags injection into RSC payload", () => {
runWithSuppressedHookWarning: <T>(probe: () => Promise<T>) => probe(),
element: overrides.element ?? { "page:/test": "test-page" },
classification: overrides.classification,
renderMode: overrides.renderMode,
skipDisposition: overrides.skipDisposition,
};

Expand Down Expand Up @@ -1588,6 +1594,49 @@ describe("layoutFlags injection into RSC payload", () => {
});
});

it("omits static layouts from dynamic-on-hover payloads after a shell prefetch", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/dynamic-on-hover/dynamic-on-hover.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/dynamic-on-hover/dynamic-on-hover.test.ts
let classificationCallCount = 0;
const { options, getCapturedElement } = createRscOptions({
element: {
"layout:/": "root-layout",
"layout:/dynamic": "static-layout",
"layout:/dynamic/private": "dynamic-layout",
"page:/dynamic/private": "dynamic-page",
},
layoutCount: 3,
probeLayoutAt: () => null,
classification: {
getLayoutId(index: number) {
return index === 0
? "layout:/"
: index === 1
? "layout:/dynamic"
: "layout:/dynamic/private";
},
buildTimeClassifications: null,
async runWithIsolatedDynamicScope(fn) {
classificationCallCount++;
const result = await fn();
return { result, dynamicDetected: classificationCallCount === 1 };
},
},
renderMode: APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_AFTER_SHELL,
});

await renderAppPageLifecycle(options);

expect(Object.hasOwn(getCapturedElement(), "layout:/")).toBe(false);
expect(Object.hasOwn(getCapturedElement(), "layout:/dynamic")).toBe(false);
expect(getCapturedElement()["layout:/dynamic/private"]).toBe("dynamic-layout");
expect(getCapturedElement()["page:/dynamic/private"]).toBe("dynamic-page");
expect(getCapturedElement()[APP_SKIPPED_LAYOUT_IDS_KEY]).toEqual(
expect.arrayContaining(["layout:/", "layout:/dynamic"]),
);
});

it("does not apply skip transport while producing an HTML response", async () => {
const common = createCommonOptions();
let capturedElement: Record<string, unknown> | null = null;
Expand Down
Loading
Loading