fix: rewritten pathnames and metadata title resolution#486
fix: rewritten pathnames and metadata title resolution#486JaredStowell wants to merge 8 commits intocloudflare:mainfrom
Conversation
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c58fc73188
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
/bigbonk review |
There was a problem hiding this comment.
Review
Good work fixing the pathname semantics for usePathname() after rewrites and improving the metadata title resolution. The approach of capturing navigationPathname before rewrites mutate cleanPathname is the right pattern, and the new resolveTitleState / mergeMetadataForParent split is cleaner than the old inline logic. Tests are thorough and well-structured.
However, there's one scoping bug that needs fixing before merge, plus a couple of minor observations.
Blocking issue
__cachePathname is out of scope for the RSC ISR cache write path. The first const __cachePathname = navigationPathname at line 2163 is inside an if block (lines 2158–2309). The RSC cache write at line 2675 references __cachePathname but lives outside that block scope. Since ES modules use strict mode, this will throw a ReferenceError at runtime for ISR-eligible RSC requests. The old code used cleanPathname (a let in function scope), so this is a regression introduced by this PR. See inline comment for fix.
Non-blocking observations
-
template: nulldoesn't resetstashedTemplate. If a layout setstitle: { template: null }, the currentresolveTitleStateignores it (null is falsy), so an ancestor's template bleeds through to deeper descendants. In Next.js,template: nullshould block ancestor template inheritance. This is pre-existing behavior and out of scope for this PR, but worth a follow-up. -
The
__cachePathnamepattern of re-declaringconst __cachePathname = navigationPathnamein each ISR block is fragile. Consider declaring it once alongsidenavigationPathnameat line 1524 to avoid this class of scoping issues.
| revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity | ||
| ) { | ||
| const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); | ||
| const __cachePathname = navigationPathname; |
There was a problem hiding this comment.
Bug: __cachePathname is block-scoped but referenced outside this block.
This const declaration lives inside the ISR cache-read if block (lines 2158–2309). However, __cachePathname is also referenced at lines 2675 and 2680 in the RSC cache-write path, which is outside this block. Since const is block-scoped and ES modules are strict mode, this will throw a ReferenceError at runtime for ISR-eligible RSC requests (e.g. client-side navigation to an ISR page).
The old code used cleanPathname (a let in function scope), so this is a regression.
Fix: Declare __cachePathname once in the function scope alongside navigationPathname, rather than inside each ISR block:
| const __cachePathname = navigationPathname; | |
| const __cachePathname = navigationPathname; |
Or better yet, hoist it to line 1524 right after const navigationPathname = cleanPathname;:
const __cachePathname = navigationPathname;and remove the duplicate declarations at lines 2163 and 2854.
| if (!title) continue; | ||
|
|
||
| sawTitle = true; | ||
| const titleTemplate = typeof title === "object" ? title.template : undefined; |
There was a problem hiding this comment.
Nit (non-blocking): When title.template is explicitly null (which the updated Metadata type now allows), this line sets titleTemplate = null. Since null is falsy, it won't update stashedTemplate at line 312, meaning an ancestor's template bleeds through to deeper descendants. In Next.js, template: null is the mechanism to explicitly opt out of template inheritance.
This is pre-existing behavior and out of scope for this PR, but worth noting for a follow-up. The fix would be:
if (titleTemplate !== undefined) {
stashedTemplate = titleTemplate ?? undefined;
}| if (title.default !== undefined) { | ||
| resolvedAbsolute = applyTitleTemplate(stashedTemplate, title.default); | ||
| } | ||
| if (title.absolute) { |
There was a problem hiding this comment.
Nit (non-blocking): title.absolute is typed as string | undefined. An empty string "" is falsy and would be skipped here, falling back to the title.default branch. In Next.js, title: { absolute: "" } produces an empty <title></title> (explicitly clearing the title). Consider using title.absolute !== undefined instead of a truthiness check.
|
|
||
| if (resolvedTitle) { | ||
| merged.title = { | ||
| absolute: resolvedTitle.absolute || "", |
There was a problem hiding this comment.
The fallback || "" means that if resolvedAbsolute is undefined (no ancestor set a title), the parent object will have absolute: "". This is a reasonable default for the parent accumulator, but it means generateMetadata(_, parent) will see (await parent).title.absolute === "" even when no title was set. Not a correctness issue, just noting for awareness.
|
Review submitted. Here's a summary of what I flagged: Blocking issue
Non-blocking observations
|
|
/bigbonk review |
There was a problem hiding this comment.
Review
Good work. The two core fixes are correct and well-tested:
-
Pathname preservation after rewrites: Capturing
navigationPathnamebefore rewrites mutatecleanPathnameis the right approach. Using it forsetNavigationContextensuresusePathname()returns the canonical URL. Using__cachePathname(same value) for ISR cache keys is also correct — the cache should be keyed by the source URL, not the rewrite target, since cache lookups happen before rewrites are applied. -
Metadata title resolution: The new
resolveTitleStatefunction cleanly separates the title resolution algorithm from the merge logic. Theterminalflag correctly distinguishes page-level merges (where the last entry's template is ignored) from parent accumulation (where all templates are preserved). ThemergeMetadataForParentfunction correctly exposes{ absolute, template }togenerateMetadata(_, parent)consumers.
The ISR rewrite test at app-router.test.ts:1689 is thorough — it validates the full MISS → HIT → STALE → background regen → HIT cycle and confirms source vs. target cache independence.
Note on the ask-bonk __cachePathname scoping concern: That was a false alarm. The reviewer analyzed the old code where __cachePathname was declared inside if blocks. In this PR, __cachePathname is declared at function scope (line 1525 of the template), alongside navigationPathname. All 18 references are within the same _handleRequest function body. No scoping issue exists.
Two minor suggestions inline, neither blocking.
|
|
||
| if (resolvedTitle) { | ||
| merged.title = { | ||
| absolute: resolvedTitle.absolute || "", |
There was a problem hiding this comment.
Nit: resolvedTitle.absolute || "" collapses undefined and "" to the same value, which is fine for the parent accumulator. But if you ever want to distinguish "no title was set" from "title was explicitly cleared to empty string via absolute: """, you'd want resolvedTitle.absolute ?? "" instead. Not blocking — the current behavior is a reasonable default for generateMetadata(_, parent) consumers.
| } | ||
|
|
||
| export function mergeMetadataForParent(metadataList: Metadata[]): Metadata { | ||
| const merged = mergeMetadata(metadataList); |
There was a problem hiding this comment.
Minor perf nit: mergeMetadata already calls resolveTitleState(metadataList) internally (line 347), and then this function calls resolveTitleState(metadataList, { terminal: false }) again. That's two passes over the metadata list. You could avoid this by extracting the non-title merge loop into a shared helper and calling resolveTitleState only once per function. Not blocking — the metadata lists are tiny (typically 2-4 entries), so this is negligible in practice.
|
Review posted successfully. Here's a summary of my findings: Overall verdict: The PR is correct and well-tested. No blocking issues. Key findings:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review
This PR correctly fixes two parity gaps with Next.js and the test coverage is thorough. The approach is clean and well-structured.
Pathname preservation after rewrites
Capturing navigationPathname (and its alias __cachePathname) at function scope before middleware/config rewrites can mutate cleanPathname is the right design. This ensures:
usePathname()reflects the canonical source URL, not the rewrite destination- ISR cache keys use the source URL (correct, since cache lookups happen before rewrites)
- Background regeneration also uses the correct navigation context
The pageSearchParams split is also correct: setNavigationContext uses url.searchParams (original request) for useSearchParams(), while pageSearchParams (potentially rewritten) is passed to buildPageElement for the page's searchParams prop.
Note on the earlier scoping concern: __cachePathname is declared at line 1525, in the _handleRequest function body, not inside any block. No scoping issue.
Metadata title resolution
The new resolveTitleState function is a well-factored algorithm that correctly handles:
- Template application via
stashedTemplate(accumulated from ancestors) title.absolutebypassing all templates- The
terminalflag distinguishing page-level merges (where the last entry's template is ignored) from parent accumulation template: nullblocking ancestor template inheritance viastashedTemplate = titleTemplate ?? undefined- Multi-placeholder templates (
%s | %s) viareplace(/%s/g, ...)
The separation into mergeMetadata (terminal, returns flat string title) and mergeMetadataForParent (non-terminal, returns { absolute, template } object) is the right design — it preserves the structured title state that generateMetadata(_, parent) consumers need.
Test coverage
Excellent coverage: 15 unit tests for title resolution edge cases, integration tests for pathname preservation across the full ISR lifecycle (MISS → HIT → STALE → regen → HIT), config-level and middleware-level rewrite pathname tests, and additional fixtures for grouped routes, cookies().has(), permanentRedirect(), social metadata, and the /not-found route conflict edge case.
Two minor non-blocking observations inline.
| let cleanPathname = pathname.replace(/\\.rsc$/, ""); | ||
| const navigationPathname = cleanPathname; | ||
| const __cachePathname = navigationPathname; | ||
| let pageSearchParams = url.searchParams; |
There was a problem hiding this comment.
Nit (non-blocking): __cachePathname is an alias for navigationPathname with no divergence point — they're always the same value. Consider whether the separate name adds clarity or whether using navigationPathname directly for cache keys would be simpler. The separate name does communicate intent ("this is for cache key computation"), so it's fine either way.
|
|
||
| if (resolvedTitle) { | ||
| merged.title = { | ||
| absolute: resolvedTitle.absolute ?? "", |
There was a problem hiding this comment.
Nit (non-blocking): resolvedTitle.absolute ?? "" collapses undefined to "", which is a reasonable default for the parent accumulator. But if a future consumer needs to distinguish "no title was set" from "title was explicitly cleared", consider ?? vs || semantics. Current behavior is correct for generateMetadata(_, parent) consumers.
Also, as noted by a previous reviewer, mergeMetadataForParent calls resolveTitleState(metadataList, { terminal: false }) and then mergeMetadata (called separately on the final list) calls resolveTitleState(metadataList) — two passes. Since metadata lists are tiny (2-4 entries), this is negligible, but if you ever refactor the merge pipeline, you could extract a single resolveTitleState call that returns both terminal and non-terminal results.
|
Review submitted with approval. Here's a summary: Verdict: Approved The PR correctly fixes two Next.js parity gaps:
I left two minor non-blocking observations: one about whether |
This fixes two App Router parity gaps with Next.js and hardens the related coverage.
usePathname()reflects the source URL, not the rewritten destination.generateMetadata(_, parent), includingtitle.absoluteandtitle.template, instead of collapsing parent titles to a plain string.Testing
pnpm test tests/features.test.ts -t "metadata title templates"pnpm test tests/nextjs-compat/hooks.test.ts tests/nextjs-compat/metadata.test.ts tests/entry-templates.test.tspnpm run fmtpnpm run typecheck