diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index ea99cb612..0ad61a058 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -522,6 +522,7 @@ export default __createAppRscHandler({ scriptNonce, searchParams, renderMode, + segmentPrefetchPath, }) { const PageComponent = route.page?.default; const __segmentConfig = __resolveAppPageSegmentConfig({ diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 1aa875b55..9983c5693 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -101,6 +101,7 @@ type DispatchMatchedPageOptions = { scriptNonce?: string; searchParams: URLSearchParams; renderMode: AppRscRenderMode; + segmentPrefetchPath: string | null; }; type DispatchMatchedRouteHandlerOptions = { @@ -312,8 +313,14 @@ async function handleAppRscRequest( const normalized = normalizeRscRequest(request, options.basePath); if (normalized instanceof Response) return normalized; - const { url, isRscRequest, interceptionContextHeader, mountedSlotsHeader, renderMode } = - normalized; + const { + url, + isRscRequest, + interceptionContextHeader, + mountedSlotsHeader, + renderMode, + segmentPrefetchPath, + } = normalized; let { pathname, cleanPathname } = normalized; // Canonical (external) pathname the user requested. Middleware rewrites and // next.config.js rewrites mutate `cleanPathname` so internal route matching @@ -604,6 +611,7 @@ async function handleAppRscRequest( scriptNonce, searchParams: url.searchParams, renderMode, + segmentPrefetchPath, }); } diff --git a/packages/vinext/src/server/app-rsc-request-normalization.ts b/packages/vinext/src/server/app-rsc-request-normalization.ts index 5bbc3b274..0ecacbd65 100644 --- a/packages/vinext/src/server/app-rsc-request-normalization.ts +++ b/packages/vinext/src/server/app-rsc-request-normalization.ts @@ -19,6 +19,7 @@ import { parseAppRscRenderMode, type AppRscRenderMode, } from "./app-rsc-render-mode.js"; +import { extractSegmentPrefetchRsc } from "./app-segment-prefetch-normalizer.js"; import { badRequestResponse, notFoundResponse } from "./http-error-responses.js"; export { normalizeMountedSlotsHeader } from "./app-mounted-slots-header.js"; @@ -40,6 +41,17 @@ export type NormalizedRscRequest = { renderMode: AppRscRenderMode; /** Disabled ClientReuseManifest hint. Never authorizes skip transport in this stage. */ clientReuseManifest: ClientReuseManifestParseResult; + /** + * When present, this is a segment-prefetch RSC request for a specific + * route segment (e.g. "/_tree", "/dashboard/__PAGE__"). The cleanPathname + * is the original page path after the .segments/ prefix was stripped. + * + * Currently threaded through DispatchMatchedPageOptions for Phase 1 plumbing. + * Not yet consumed by render — the handler returns full-page RSC for all + * segment prefetch requests until Phase 2 adds segment-level response + * generation. + */ + segmentPrefetchPath: string | null; }; /** @@ -59,14 +71,14 @@ export type NormalizedRscRequest = { * 4. Collapse double-slashes, resolve `.` and `..` segments (normalizePath) * 5. basePath check + strip — 404 when pathname lacks the basePath prefix. * `/__vinext/` bypasses this for internal prerender endpoints. - * 6. RSC detection: `.rsc` suffix only. RSC headers do not select payload - * rendering at the canonical HTML URL, so caches that ignore Vary cannot - * store Flight responses under HTML URLs. - * 7. cleanPathname — pathname with `.rsc` suffix stripped - * 8. Sanitize X-Vinext-Interception-Context — strip null bytes (header injection) - * 9. Normalize x-vinext-mounted-slots — dedup and sort for canonical cache keys - * 10. Read semantic render mode for refresh/action payload rendering - * 11. Parse disabled ClientReuseManifest hints on canonical RSC payload requests + * 6. Segment-prefetch detection: `.segments/*.segment.rsc` URLs are normalized back + * to the original page path. The segment path is extracted for downstream handling. + * 7. RSC detection: `.rsc` suffix only. Segment-prefetch requests are always treated as RSC. + * 8. cleanPathname — pathname with `.rsc` suffix stripped + * 9. Sanitize X-Vinext-Interception-Context — strip null bytes (header injection) + * 10. Normalize x-vinext-mounted-slots — dedup and sort for canonical cache keys + * 11. Read semantic render mode for refresh/action payload rendering + * 12. Parse disabled ClientReuseManifest hints on canonical RSC payload requests * * @returns A 400 or 404 Response for invalid or out-of-scope inputs, * or a NormalizedRscRequest for valid requests. @@ -107,19 +119,34 @@ export function normalizeRscRequest( pathname = stripBasePath(pathname, basePath); } - // Steps 6-7: RSC detection and cleanPathname. - const isRscRequest = pathname.endsWith(".rsc"); + // Step 6: Segment-prefetch URL detection. + // extractSegmentPrefetchRsc returns null for non-matching paths (single + // regex run) so no separate match guard is needed. + let segmentPrefetchPath: string | null = null; + const extracted = extractSegmentPrefetchRsc(pathname); + if (extracted) { + segmentPrefetchPath = extracted.segmentPath; + pathname = extracted.originalPathname; + } + + // Steps 7-8: RSC detection and cleanPathname. + // Segment-prefetch requests are always treated as RSC requests regardless + // of whether the rewritten pathname ends with .rsc, because the original + // URL had a .segment.rsc suffix. + const isRscRequest = pathname.endsWith(".rsc") || segmentPrefetchPath !== null; const cleanPathname = stripRscSuffix(pathname); - // Step 8: Sanitize X-Vinext-Interception-Context. + // Step 9: Sanitize X-Vinext-Interception-Context. // Null bytes in header values can be used for injection in some HTTP stacks. const interceptionContextHeader = request.headers.get(VINEXT_INTERCEPTION_CONTEXT_HEADER)?.replaceAll("\0", "") || null; - // Step 9: Normalize mounted-slots header for canonical cache keying. + // Step 10: Normalize mounted-slots header for canonical cache keying. const mountedSlotsHeader = normalizeMountedSlotsHeader( request.headers.get(VINEXT_MOUNTED_SLOTS_HEADER), ); + + // Step 11: Read semantic render mode for refresh/action payload rendering. const renderMode = isRscRequest ? parseAppRscRenderMode(request.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)) : APP_RSC_RENDER_MODE_NAVIGATION; @@ -136,5 +163,6 @@ export function normalizeRscRequest( interceptionContextHeader, mountedSlotsHeader, renderMode, + segmentPrefetchPath, }; } diff --git a/packages/vinext/src/server/app-segment-prefetch-normalizer.ts b/packages/vinext/src/server/app-segment-prefetch-normalizer.ts new file mode 100644 index 000000000..e8f8edbae --- /dev/null +++ b/packages/vinext/src/server/app-segment-prefetch-normalizer.ts @@ -0,0 +1,57 @@ +/** + * Segment-prefetch RSC URL normalizer. + * + * Matches and extracts segment-prefetch URLs following the Next.js convention: + * /page-path.segments/_tree.segment.rsc + * /page-path.segments/_index.segment.rsc + * /page-path.segments/dashboard/__PAGE__.segment.rsc + * + * The normalizer extracts the original page pathname and the segment path, + * allowing the request to be routed through the normal RSC pipeline with + * the segment metadata set as request markers. + * + * Constants match Next.js: RSC_SEGMENTS_DIR_SUFFIX = '.segments', + * RSC_SEGMENT_SUFFIX = '.segment.rsc' + */ + +const RSC_SEGMENTS_DIR_SUFFIX = ".segments"; +const RSC_SEGMENT_SUFFIX = ".segment.rsc"; + +const SEGMENT_PREFETCH_PATTERN = new RegExp( + `^(/.*)${escapeRegex(RSC_SEGMENTS_DIR_SUFFIX)}(/.*)${escapeRegex(RSC_SEGMENT_SUFFIX)}$`, +); + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +type SegmentPrefetchResult = { + /** The original page pathname without segment-prefetch suffixes (e.g. "/dashboard"). */ + originalPathname: string; + /** The segment path (e.g. "/_tree", "/_index", "/dashboard/__PAGE__"). */ + segmentPath: string; +}; + +/** + * Check if a pathname matches the segment-prefetch URL pattern and extract the + * original page pathname and segment path. + * + * Returns null when the pathname does not match the expected pattern. + * This is the single entry point — prefer it over a separate match + extract + * to avoid running the regex twice on the hot path. + */ +export function extractSegmentPrefetchRsc(pathname: string): SegmentPrefetchResult | null { + const match = pathname.match(SEGMENT_PREFETCH_PATTERN); + if (!match) return null; + return { originalPathname: match[1], segmentPath: match[2] }; +} + +/** + * Check if a pathname matches the segment-prefetch URL pattern. + * + * Prefer extractSegmentPrefetchRsc() when you need the extracted path info, + * since it runs the regex once and returns null for non-matches. + */ +export function matchSegmentPrefetchRsc(pathname: string): boolean { + return SEGMENT_PREFETCH_PATTERN.test(pathname); +} diff --git a/tests/app-rsc-request-normalization.test.ts b/tests/app-rsc-request-normalization.test.ts index 71a095ddf..97b198518 100644 --- a/tests/app-rsc-request-normalization.test.ts +++ b/tests/app-rsc-request-normalization.test.ts @@ -463,3 +463,97 @@ describe("normalizeMountedSlotsHeader", () => { expect(normalizeMountedSlotsHeader("modal")).toBe("modal"); }); }); + +// ── Segment-prefetch URL normalization ────────────────────────────────────── + +describe("normalizeRscRequest — segment-prefetch URLs", () => { + it("detects a route tree prefetch and rewrites to the original page path", () => { + const result = normalized( + normalizeRscRequest(req("/dashboard.segments/_tree.segment.rsc"), ""), + ); + expect(result.pathname).toBe("/dashboard"); + expect(result.cleanPathname).toBe("/dashboard"); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBe("/_tree"); + }); + + it("detects a page segment prefetch and rewrites to the original page path", () => { + const result = normalized( + normalizeRscRequest(req("/dashboard.segments/__PAGE__.segment.rsc"), ""), + ); + expect(result.pathname).toBe("/dashboard"); + expect(result.cleanPathname).toBe("/dashboard"); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBe("/__PAGE__"); + }); + + it("handles root page segment-prefetch URL", () => { + const result = normalized(normalizeRscRequest(req("/.segments/_tree.segment.rsc"), "")); + expect(result.pathname).toBe("/"); + expect(result.cleanPathname).toBe("/"); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBe("/_tree"); + }); + + it("handles a deeply nested segment path", () => { + const result = normalized( + normalizeRscRequest(req("/dashboard/settings.segments/tab/profile/__PAGE__.segment.rsc"), ""), + ); + expect(result.pathname).toBe("/dashboard/settings"); + expect(result.cleanPathname).toBe("/dashboard/settings"); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBe("/tab/profile/__PAGE__"); + }); + + it("preserves isRscRequest=false for non-segment-prefetch URLs", () => { + const result = normalized(normalizeRscRequest(req("/dashboard"), "")); + expect(result.isRscRequest).toBe(false); + expect(result.segmentPrefetchPath).toBeNull(); + }); + + it("preserves isRscRequest=true for regular .rsc URLs", () => { + const result = normalized(normalizeRscRequest(req("/dashboard.rsc"), "")); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBeNull(); + }); + + it("works with basePath", () => { + const result = normalized( + normalizeRscRequest(req("/app/dashboard.segments/_tree.segment.rsc"), "/app"), + ); + expect(result.pathname).toBe("/dashboard"); + expect(result.cleanPathname).toBe("/dashboard"); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBe("/_tree"); + }); +}); + +describe("normalizeRscRequest — percent-encoded segment-prefetch URLs", () => { + it("detects %2Esegments as .segments after percent-decode step", () => { + // %2E is '.' — after step 3 (percent-decode), the path contains .segments + // which the match in step 6 should detect. + const result = normalized( + normalizeRscRequest(req("/dashboard%2Esegments/_tree%2Esegment%2Ersc"), ""), + ); + // normalizePathnameForRouteMatchStrict decodes %2E to '.' (it preserves + // only %2F for path separator boundaries), so the regex matches. + expect(result.pathname).toBe("/dashboard"); + expect(result.cleanPathname).toBe("/dashboard"); + expect(result.isRscRequest).toBe(true); + expect(result.segmentPrefetchPath).toBe("/_tree"); + }); + + it("does not match double-encoded %252Esegments", () => { + // The path /dashboard%252Esegments/_tree.segment.rsc: + // URL parser preserves %25, so pathname starts as /dashboard%252Esegments/... + // normalizePathnameForRouteMatchStrict decodes %25 -> %, leaving + // /dashboard%2Esegments/_tree.segment.rsc. Since %2E is not '.' at this + // point, the segment-prefetch regex does not match. + // isRscRequest is true because the path still ends with .rsc (.segment.rsc). + const result = normalized( + normalizeRscRequest(req("/dashboard%252Esegments/_tree.segment.rsc"), ""), + ); + expect(result.segmentPrefetchPath).toBeNull(); + expect(result.isRscRequest).toBe(true); + }); +}); diff --git a/tests/app-segment-prefetch-normalizer.test.ts b/tests/app-segment-prefetch-normalizer.test.ts new file mode 100644 index 000000000..68a3569ff --- /dev/null +++ b/tests/app-segment-prefetch-normalizer.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vite-plus/test"; +import { + matchSegmentPrefetchRsc, + extractSegmentPrefetchRsc, +} from "../packages/vinext/src/server/app-segment-prefetch-normalizer.js"; + +describe("matchSegmentPrefetchRsc", () => { + it("matches a root page route tree prefetch", () => { + expect(matchSegmentPrefetchRsc("/.segments/_tree.segment.rsc")).toBe(true); + }); + + it("matches a page route tree prefetch", () => { + expect(matchSegmentPrefetchRsc("/dashboard.segments/_tree.segment.rsc")).toBe(true); + }); + + it("matches a nested page route tree prefetch", () => { + expect(matchSegmentPrefetchRsc("/blog/posts.segments/_tree.segment.rsc")).toBe(true); + }); + + it("matches a root segment prefetch", () => { + expect(matchSegmentPrefetchRsc("/.segments/_index.segment.rsc")).toBe(true); + }); + + it("matches a page segment prefetch", () => { + expect(matchSegmentPrefetchRsc("/dashboard.segments/__PAGE__.segment.rsc")).toBe(true); + }); + + it("matches a nested page segment prefetch", () => { + expect(matchSegmentPrefetchRsc("/blog/posts.segments/__PAGE__.segment.rsc")).toBe(true); + }); + + it("matches a layout segment prefetch", () => { + expect(matchSegmentPrefetchRsc("/dashboard.segments/_index.segment.rsc")).toBe(true); + }); + + it("does not match a regular .rsc URL", () => { + expect(matchSegmentPrefetchRsc("/dashboard.rsc")).toBe(false); + }); + + it("does not match a regular HTML URL", () => { + expect(matchSegmentPrefetchRsc("/dashboard")).toBe(false); + }); + + it("does not match a path with partial .segments prefix", () => { + expect(matchSegmentPrefetchRsc("/my.segments-page")).toBe(false); + }); + + it("does not match a .segment path missing .rsc", () => { + expect(matchSegmentPrefetchRsc("/dashboard.segments/_tree")).toBe(false); + }); + + it("does not match an empty path", () => { + expect(matchSegmentPrefetchRsc("")).toBe(false); + }); + + it("matches a deeply nested segment path", () => { + expect( + matchSegmentPrefetchRsc("/dashboard/settings.segments/tab/profile/__PAGE__.segment.rsc"), + ).toBe(true); + }); +}); + +describe("extractSegmentPrefetchRsc", () => { + it("extracts route tree prefetch for root page", () => { + const result = extractSegmentPrefetchRsc("/.segments/_tree.segment.rsc"); + expect(result).toEqual({ originalPathname: "/", segmentPath: "/_tree" }); + }); + + it("extracts route tree prefetch for a page", () => { + const result = extractSegmentPrefetchRsc("/dashboard.segments/_tree.segment.rsc"); + expect(result).toEqual({ originalPathname: "/dashboard", segmentPath: "/_tree" }); + }); + + it("extracts a page segment prefetch", () => { + const result = extractSegmentPrefetchRsc("/dashboard.segments/__PAGE__.segment.rsc"); + expect(result).toEqual({ originalPathname: "/dashboard", segmentPath: "/__PAGE__" }); + }); + + it("extracts a deeply nested segment prefetch", () => { + const result = extractSegmentPrefetchRsc( + "/dashboard/settings.segments/tab/profile/__PAGE__.segment.rsc", + ); + expect(result).toEqual({ + originalPathname: "/dashboard/settings", + segmentPath: "/tab/profile/__PAGE__", + }); + }); + + it("extracts a root segment prefetch", () => { + const result = extractSegmentPrefetchRsc("/.segments/_index.segment.rsc"); + expect(result).toEqual({ originalPathname: "/", segmentPath: "/_index" }); + }); + + it("extracts a head request key segment prefetch", () => { + const result = extractSegmentPrefetchRsc("/.segments/_head.segment.rsc"); + expect(result).toEqual({ originalPathname: "/", segmentPath: "/_head" }); + }); + + it("returns null for non-matching pathname", () => { + expect(extractSegmentPrefetchRsc("/dashboard.rsc")).toBeNull(); + }); + + it("returns null for a regular HTML URL", () => { + expect(extractSegmentPrefetchRsc("/dashboard")).toBeNull(); + }); + + it("returns null for empty path", () => { + expect(extractSegmentPrefetchRsc("")).toBeNull(); + }); +});