Skip to content

fix(app-router): normalize trailing slash in implicit cache tags - #2390

Merged
james-elicx merged 3 commits into
cloudflare:mainfrom
MaxtuneLee:worktree-port-trailingslash-revalidate-test
Jun 28, 2026
Merged

james-elicx merged 3 commits into
cloudflare:mainfrom
MaxtuneLee:worktree-port-trailingslash-revalidate-test

Conversation

@MaxtuneLee

Copy link
Copy Markdown
Contributor

Summary

revalidatePath() strips the trailing slash before constructing its _N_T_ implicit cache tag. Pages served at trailing-slash URLs (e.g. /en/) were building their own tags with the slash, so the tags never matched and revalidation silently did nothing.

Fix: normalize the pathname (strip trailing /) in both tag-building sites before generating the _N_T_ tag, matching what revalidatePath() produces.

Files touched

File Change
packages/vinext/src/server/app-page-cache.ts Strip trailing slash from stem before building _N_T_${stem} tag in buildAppPageCacheTags()
packages/vinext/src/server/implicit-tags.ts Same normalization in buildPageCacheTags() — normalizedPathname used for the implicit tag
tests/fixtures/app-trailing-slash-isr/app/[lang]/legacy/page.tsx Extend fixture page to render ISO timestamp + <span id="lang"> + RevalidateButton
tests/fixtures/app-trailing-slash-isr/app/[lang]/revalidate-button.tsx New client component — triggers /api/revalidate and surfaces result
tests/fixtures/app-trailing-slash-isr/app/api/revalidate/route.ts New API route — calls revalidatePath() with the path supplied by the button
tests/e2e/app-router/trailingslash-revalidate.browser.spec.ts New Playwright spec — ports the revalidation test from Next.js (see Reference)
tests/app-trailing-slash-isr.test.ts Add timestamp content check

Testing plan

  • Pass the new Playwright spec: PLAYWRIGHT_PROJECT=app-router-chrome-browser-specific pnpm playwright test trailingslash-revalidate
    • Both withSlash=true and withSlash=false variants pass
    • Verified the timestamp changes after clicking the revalidate button
  • Pass the existing vitest integration test: pnpm vitest run tests/app-trailing-slash-isr.test.ts
    • Checks /en/legacy/ returns 200 with timestamp content
    • Checks /en redirects to /en/ and renders correct lang param

Reference

https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/trailingslash/trailingslash.test.ts
Fix #1832

@pkg-pr-new

pkg-pr-new Bot commented Jun 27, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2390
npm i https://pkg.pr.new/vinext@2390

commit: 399f246

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 399f246 against base da086c0 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 122.0 KB 122.0 KB ⚫ -0.0%
Client entry size (gzip) vinext 117.0 KB 117.0 KB ⚫ -0.0%
Dev server cold start vinext 2.53 s 2.55 s ⚫ +0.7%
Production build time vinext 3.02 s 2.99 s ⚫ -0.9%
RSC entry closure size (gzip) vinext 94.3 KB 94.3 KB ⚫ 0.0%
Server bundle size (gzip) vinext 160.0 KB 160.1 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: normalize trailing slash in implicit cache tags

The fix is correct and well-scoped. I traced the producer/consumer sides:

  • Producer (shims/cache.ts:135-136): revalidatePath() strips the trailing slash (stem = path.endsWith("/") ? path.slice(0,-1) : path) and builds _N_T_${stem || "/"}.
  • Consumers (the two builders this PR touches): now strip the trailing slash with pathname.length > 1 && pathname.endsWith("/") before building _N_T_${stem}.

These line up for all cases I checked:

  • /en/legacy/ -> _N_T_/en/legacy (matches revalidatePath("/en/legacy") and revalidatePath("/en/legacy/")).
  • Root / -> the guard length > 1 keeps it /, producing _N_T_/, matching revalidatePath where stem || "/" yields _N_T_/. Good — the guard is the key detail that prevents the root case from regressing to _N_T_.

The type: "page"/type: "layout" tags were already correct since they derive from segment splitting (empty trailing segment is skipped), so limiting the change to the no-type tag is right.

Dev/prod parity check (per AGENTS.md): I verified the Pages Router path (server/pages-page-response.ts:643) already normalizes via stem || "/", so no parallel gap is left behind. Both App Router builders (app-page-cache.ts and implicit-tags.ts) are used in distinct code paths, so updating both is correct.

Test coverage: The e2e spec correctly exercises the regression — before this fix the render-time tag was _N_T_/en/legacy/ which matched neither withSlash variant. The self-build/serve pattern matches the existing hash-rsc-requests.browser.spec.ts.

No blocking issues. A couple of minor nits below on the test fixtures.

@ask-bonk

ask-bonk Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my findings on PR #2390:

Verdict: Correct, well-scoped fix — no blocking issues

The fix is sound. I traced both sides of the cache-tag matching:

  • Producer (shims/cache.ts:135-136): revalidatePath() strips trailing slash → _N_T_${stem || "/"}
  • Consumers (the two App Router builders this PR touches): now strip trailing slash before building _N_T_${stem}

These line up for all cases including the tricky root / case — the pathname.length > 1 guard correctly prevents / from collapsing to _N_T_ (it stays _N_T_/, matching the producer's stem || "/").

Key verifications:

  • The type: "page"/type: "layout" tags were already correct (they derive from segment splitting which skips empty trailing segments), so limiting the change to the no-type tag is right.
  • Dev/prod parity (per AGENTS.md): The Pages Router path (pages-page-response.ts:643) already normalizes via stem || "/", so no parallel gap is left behind. Both modified App Router builders are used in distinct, active code paths — updating both is necessary.
  • The e2e test genuinely exercises the regression: before the fix, the render-time tag was _N_T_/en/legacy/ which matched neither withSlash variant.
  • The self-build/serve test pattern matches the existing hash-rsc-requests.browser.spec.ts.

Two minor non-blocking nits posted inline: redundant nested startTransition in the fixture button, and a note that the e2e test compares against initialGeneratedAt rather than refreshedGeneratedAt (equivalent given the prior toPass block).

github run

@james-elicx
james-elicx merged commit c602026 into cloudflare:main Jun 28, 2026
52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App Router: residual trailingSlash canonical-URL / revalidation cases (follow-up to #1332)

2 participants