Skip to content

Fix: Optimistic routing bugs leading to repeated prefetch loops - #97128

Merged
acdlite merged 3 commits into
canaryfrom
worktree-intl-rewrite-prefetch-loop
Aug 11, 2026
Merged

Fix: Optimistic routing bugs leading to repeated prefetch loops#97128
acdlite merged 3 commits into
canaryfrom
worktree-intl-rewrite-prefetch-loop

Conversation

@acdlite

@acdlite acdlite commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes a few bugs related to optimistic routing.

Originally reported as a next-intl request waterfall in MarkBekooy/prefetching-request-waterfall-bug#1, then extracted into an isolated regression test: A proxy that rewrites every URL to inject a leading path segment (/one/two/en/one/two, i.e. i18n with the default locale hidden from the URL) plus a fully dynamic target route like /[locale]/[...pages] leads to an infinite prefetch loop that never resolves.

This regression uncovered several oversights in the optimistic routing implementation: when receiving a prefetch response, we did not check whether the response matched the expected result. If the tree mismatched, in some cases the prefetch task would fall into a loop, repeatedly attempting to fulfill the missing data.

Now when this happens, we record on the local route definition that a dynamic rewrite occurred, disabling further attempts to optimistically resolve the route. This is the same strategy we were already using for normal navigation responses, now applied to the prefetch path.

We also received another bug report with a similar root cause: parallel routes with conflicting dynamic params at the same level (@modal/[...catchAll] next to [username]) cannot be distinguished using the current traversal algorithm, because it assumes that each segment is resolvable independently without inspecting the children or sibling branches. To work around this issue for now, when this scenario is detected, we disable optimistic routing for the conflicting route. We can model this properly in a future PR by having the server send down the "sibling" dynamic route segments, similar to what we do for static siblings already.

Fixes #97135

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🔴 1 regression, 1 improvement

Metric Canary PR Change Trend
node_modules Size 552 MB 553 MB 🔴 +1.22 MB (+0%) ▁▁▁▁▁
Webpack Warm (First Request) 3.458s 3.313s 🟢 145ms (-4%) ▃▃▁▅▂
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 761ms 760ms ▂▂▃▂▂
Cold (Ready in log) 761ms 760ms ▁▁▂▁▁
Cold (First Request) 1.263s 1.271s ▁▁▃▁▂
Warm (Listen) 762ms 760ms ▂▂▃▂▂
Warm (Ready in log) 762ms 766ms ▁▁▂▁▁
Warm (First Request) 1.265s 1.232s ▁▁▃▁▂
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 812ms 811ms █████
Cold (Ready in log) 784ms 775ms ▂▅▂█▃
Cold (First Request) 3.360s 3.270s ▃▅▂█▂
Warm (Listen) 813ms 813ms ██▁██
Warm (Ready in log) 791ms 783ms ▃▄▂█▄
Warm (First Request) 3.458s 3.313s 🟢 145ms (-4%) ▃▃▁▅▂

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.509s 5.506s ▁▁▂▁▂
Cached Build 2.806s 2.867s ▁▁▃▁▂
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 24.494s 24.075s ▂▃▁█▂
Cached Build 24.328s 24.279s ▂▃▂█▃
node_modules Size 552 MB 553 MB 🔴 +1.22 MB (+0%) ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
050icza-xjz0i.js gzip 5.73 kB N/A -
07jdby0ue616s.js gzip 450 B N/A -
0bjdc8muo74n5.js gzip 8.71 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0m9c9q0rz25qu.js gzip 158 B N/A -
0oqs7cc9o84pv.js gzip 157 B N/A -
0ovjlvfuyiq3j.js gzip 157 B N/A -
0q36apx7b1q_e.js gzip 7.54 kB N/A -
0rci1f3or1a19.js gzip 13.3 kB N/A -
0zah5dpgqmyho.js gzip 162 B N/A -
1_2x714--ii1i.js gzip 8.76 kB N/A -
1-3y752pkth5-.js gzip 10 kB N/A -
1eldsmtdrlz8v.js gzip 157 B N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1k6dd7su4cfei.js gzip 3.57 kB N/A -
1tf1phijqlx9j.js gzip 220 B 220 B
1uzabyd1120a1.js gzip 8.71 kB N/A -
1wrk7bbj0uz86.js gzip 159 B N/A -
2-ufv8lc-g7gg.js gzip 10.6 kB N/A -
21kmjy_10x14f.js gzip 8.81 kB N/A -
26yo6ek9kvl_4.js gzip 65.6 kB N/A -
27--hr8ide_gd.js gzip 159 B N/A -
28dhc6t85q1_p.js gzip 8.78 kB N/A -
2f-ilvczue-tp.js gzip 9.46 kB N/A -
2f1u17u5c8iny.js gzip 8.79 kB N/A -
2ffdylyzi23cg.js gzip 170 B N/A -
2ikltg_8iegxw.js gzip 10.3 kB N/A -
2km2ee25g49dj.js gzip 158 B N/A -
2vxi673cz1-t4.js gzip 8.79 kB N/A -
30jyp3d2k1qut.js gzip 154 B N/A -
31slcwo3-b6lh.js gzip 157 B N/A -
37567obx0b6wp.js gzip 163 B N/A -
38-q43pzktqhs.js gzip 1.46 kB N/A -
3jnfba4i561bm.js gzip 46.7 kB N/A -
3l5lmj-xm_2kk.js gzip 71.5 kB N/A -
3mrwxzed0ylgv.js gzip 13.1 kB N/A -
3nbojhxiy1qv_.js gzip 13.7 kB N/A -
3sdnix0ekjoj-.js gzip 157 B N/A -
41u5s3oe2-erp.js gzip 2.29 kB N/A -
445s_9hf8o7ao.js gzip 8.76 kB N/A -
turbopack-1l..-5yv.js gzip 3.73 kB 3.73 kB
0-6bcj16ji2wf.js gzip N/A 10.6 kB -
0-sc9c-86q4-i.js gzip N/A 71.6 kB -
05bc8af8ar0hm.js gzip N/A 157 B -
07ryk0jced-sc.js gzip N/A 8.78 kB -
0ehfrsq4t9oxv.js gzip N/A 157 B -
0fcqiazz8rrj6.js gzip N/A 169 B -
0jwho9fkrb_t3.js gzip N/A 2.29 kB -
0roh390ijzxa5.js gzip N/A 8.75 kB -
0ryyb26nk8l4-.js gzip N/A 65.6 kB -
0vex9w55ursqj.js gzip N/A 8.79 kB -
0wths-c5rbh4w.js gzip N/A 47 kB -
17oe55cu76cd7.js gzip N/A 450 B -
1drww5xikb-c-.js gzip N/A 9.46 kB -
1f0sbbzbtb8-d.js gzip N/A 156 B -
1g60xde_dv17t.js gzip N/A 8.79 kB -
1hfawbaxawa1b.js gzip N/A 156 B -
1uzv47btzam64.js gzip N/A 5.73 kB -
1vy7n7wxv_rh5.js gzip N/A 8.81 kB -
2-0i7pl900-ou.js gzip N/A 8.71 kB -
2-kcbngm7ik7y.js gzip N/A 8.75 kB -
24xppfz7hzt_u.js gzip N/A 161 B -
29p5-xa4jmdhg.js gzip N/A 13.7 kB -
2kgg_yiaz8rd6.js gzip N/A 155 B -
2lpk5_hknut8q.js gzip N/A 13.1 kB -
2o4cibp3awtbw.js gzip N/A 3.56 kB -
2ugc69z0t0ypz.js gzip N/A 1.46 kB -
2ysmo6k0h552j.js gzip N/A 156 B -
31v10264sxw__.js gzip N/A 150 B -
36dn8i-_3dnq5.js gzip N/A 10 kB -
37nj9nluvb6lr.js gzip N/A 157 B -
3a9sn6l54cidw.js gzip N/A 156 B -
3be3-77wi4dm2.js gzip N/A 156 B -
3fljpmwcjxqhx.js gzip N/A 10.3 kB -
3g7d_qk0nyha6.js gzip N/A 158 B -
3gz44skqdlsrb.js gzip N/A 8.71 kB -
3oslhuq9luvp2.js gzip N/A 7.55 kB -
3y9tus7kb5su0.js gzip N/A 13.3 kB -
Total 401 kB 401 kB ⚠️ +269 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 1.06 kB 1.06 kB
Total 1.06 kB 1.06 kB ⚠️ +1 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 880 B 876 B
Total 880 B 876 B ✅ -4 B
Build Cache
Canary PR Change
00000001.sst gzip 14.6 MB 14.1 MB 🟢 505 kB (-3%)
00000002.sst gzip 14.5 MB 14.3 MB 🟢 153 kB (-1%)
00000003.sst gzip 11.8 MB 12.7 MB 🔴 +989 kB (+8%)
00000004.sst gzip 14.1 MB 14 MB 🟢 134 kB (-1%)
00000005.sst gzip 10.2 MB 10.2 MB 🔴 +64.3 kB (+1%)
00000006.sst gzip 2.8 MB 2.81 MB 🔴 +9.03 kB (+0%)
00000007.sst gzip 59 B 59 B
00000008.meta gzip 89 B 89 B
00000009.meta gzip 298 kB 298 kB
00000010.meta gzip 298 kB 298 kB
00000011.meta gzip 298 kB 298 kB
00000012.sst gzip 51.9 kB 51.6 kB
00000013.sst gzip 1.83 MB 1.81 MB 🟢 22.3 kB (-1%)
00000014.sst gzip 59 B 59 B
00000015.meta gzip 116 B 116 B
00000016.meta gzip 324 kB 325 kB
00000017.meta gzip 404 kB 405 kB
00000018.sst gzip 52.7 kB 52.3 kB
00000019.sst gzip 1.38 MB 1.39 MB 🔴 +9.57 kB (+1%)
00000020.sst gzip 59 B 59 B
00000021.meta gzip 116 B 116 B
00000022.meta gzip 324 kB 325 kB
00000023.meta gzip 370 kB 371 kB
00000024.sst gzip 52.7 kB 52.4 kB
00000025.sst gzip 1.38 MB 1.39 MB 🔴 +10 kB (+1%)
00000026.sst gzip 59 B 59 B
00000027.meta gzip 116 B 116 B
00000028.meta gzip 324 kB 325 kB
00000029.meta gzip 370 kB 371 kB
00000030.sst gzip 52.1 kB 51.9 kB
00000031.sst gzip 1.38 MB 1.39 MB 🔴 +10.3 kB (+1%)
00000032.sst gzip 59 B 59 B
00000033.meta gzip 116 B 116 B
00000034.meta gzip 324 kB 325 kB
00000035.meta gzip 370 kB 371 kB
00000036.sst gzip 52.5 kB 52.3 kB
00000037.sst gzip 1.38 MB 1.39 MB 🔴 +10 kB (+1%)
00000038.sst gzip 59 B 59 B
00000039.meta gzip 116 B 116 B
00000040.meta gzip 324 kB 325 kB
00000041.meta gzip 370 kB 371 kB
CURRENT gzip 24 B 24 B
LOG gzip 667 B 683 B 🔴 +16 B (+2%)
Total 80 MB 80.3 MB ⚠️ +293 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 65.7 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.67 kB N/A -
9784-HASH.js gzip 5.63 kB N/A -
b1ad9f4c-HASH.js gzip 63.2 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 251 B 254 B 🔴 +3 B (+1%)
main-HASH.js gzip 40.1 kB 40.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 66.7 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.68 kB -
a8984546-HASH.js gzip N/A 63.2 kB -
Total 241 kB 242 kB ⚠️ +943 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B
_error-HASH.js gzip 181 B 182 B
css-HASH.js gzip 334 B 331 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 255 B 253 B
head-HASH.js gzip 349 B 351 B
hooks-HASH.js gzip 382 B 384 B
image-HASH.js gzip 581 B 582 B
index-HASH.js gzip 260 B 259 B
link-HASH.js gzip 2.48 kB 2.48 kB
routerDirect..HASH.js gzip 317 B 318 B
script-HASH.js gzip 384 B 386 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.95 kB 7.96 kB ⚠️ +4 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 129 kB
page.js gzip 293 kB 296 kB 🔴 +2.92 kB (+1%)
Total 422 kB 424 kB ⚠️ +2.58 kB
Middleware
Canary PR Change
middleware-b..fest.js gzip 620 B 617 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 45.4 kB 45.4 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 47 kB 47 kB ✅ -23 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 717 B 718 B
Total 717 B 718 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 4.76 MB 4.84 MB 🔴 +86.2 kB (+2%)
index.pack gzip 122 kB 124 kB 🔴 +2.12 kB (+2%)
index.pack.old gzip 122 kB 124 kB 🔴 +2.81 kB (+2%)
Total 5 MB 5.09 MB ⚠️ +91.2 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 374 kB 375 kB
app-page-exp..prod.js gzip 206 kB 207 kB
app-page-tur...dev.js gzip 374 kB 375 kB
app-page-tur..prod.js gzip 206 kB 207 kB
app-page-tur...dev.js gzip 370 kB 371 kB
app-page-tur..prod.js gzip 204 kB 205 kB
app-page.run...dev.js gzip 370 kB 372 kB
app-page.run..prod.js gzip 204 kB 205 kB
app-route-ex...dev.js gzip 82.6 kB 83.4 kB
app-route-ex..prod.js gzip 55.8 kB 56.4 kB 🔴 +600 B (+1%)
app-route-tu...dev.js gzip 82.7 kB 83.4 kB
app-route-tu..prod.js gzip 55.8 kB 56.4 kB 🔴 +598 B (+1%)
app-route-tu...dev.js gzip 82.3 kB 83 kB
app-route-tu..prod.js gzip 55.6 kB 56.2 kB 🔴 +602 B (+1%)
app-route.ru...dev.js gzip 82.2 kB 82.9 kB
app-route.ru..prod.js gzip 55.6 kB 56.2 kB 🔴 +603 B (+1%)
dev-validati...dev.js gzip 134 kB 133 kB
dev-validati...dev.js gzip 134 kB 133 kB
dev-validati...dev.js gzip 131 kB 131 kB
dev-validati...dev.js gzip 131 kB 131 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 46.1 kB 46.1 kB
pages-api-tu..prod.js gzip 34.3 kB 34.3 kB
pages-api.ru...dev.js gzip 46 kB 46.1 kB
pages-api.ru..prod.js gzip 34.3 kB 34.3 kB
pages-turbo....dev.js gzip 54.8 kB 54.9 kB
pages-turbo...prod.js gzip 39.8 kB 39.9 kB
pages.runtim...dev.js gzip 54.8 kB 54.9 kB
pages.runtim..prod.js gzip 39.8 kB 39.9 kB
server.runti..prod.js gzip 66.7 kB 67 kB
use-cache-pr...dev.js gzip 72.3 kB 72.4 kB
use-cache-pr...dev.js gzip 72.3 kB 72.5 kB
use-cache-pr...dev.js gzip 70.6 kB 70.7 kB
use-cache-pr...dev.js gzip 70.5 kB 70.7 kB
Total 4.09 MB 4.11 MB ⚠️ +15.2 kB
📝 Changed Files (33 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route.runtime.dev.js
  • app-route.ru..time.prod.js
  • dev-validati..ntime.dev.js
  • dev-validati..ntime.dev.js
  • dev-validati..ntime.dev.js
  • dev-validati..ntime.dev.js
  • ... and 13 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route.runtime.dev.js

Diff too large to display

app-route.ru..time.prod.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

pages-api-tu..ntime.dev.js

Diff too large to display

pages-api-tu..time.prod.js

Diff too large to display

pages-api.runtime.dev.js

Diff too large to display

pages-api.ru..time.prod.js

Diff too large to display

pages-turbo...ntime.dev.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

pages.runtime.dev.js

Diff too large to display

pages.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/b109b08880d5cdb81c00f018cc5b40fcfbb97045/next

Commit: b109b08

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: b109b08

@cgatian

cgatian commented Aug 10, 2026

Copy link
Copy Markdown

We just hit this in production 😨

@acdlite
acdlite force-pushed the worktree-intl-rewrite-prefetch-loop branch 2 times, most recently from 313b896 to 2206254 Compare August 11, 2026 04:16
When a proxy rewrites every request to inject a leading path segment
(what i18n libraries do when the default locale is hidden from the URL,
e.g. next-intl's `localePrefix: 'as-needed'`), the client learns the
route pattern from the rendered pathname reported by
`x-nextjs-rewritten-path` — so it learns `/[locale]/[...pages]` for a
URL the browser only ever saw as `/one/two`.

Predicting the route for a sibling URL then matches the URL parts
against that pattern without accounting for the injected segment,
binding `locale` to `one` and `pages` to `['two']`. The server renders
`locale=en` and `pages=['one','two']`, so it can never return the
segments the request asked for, the prefetch task never settles, and it
retries in a tight loop — hundreds of requests per second, starving the
other links in the queue.

Reproduced from MarkBekooy/prefetching-request-waterfall-bug#1 and
reduced to plain Next (no next-intl). Fails in start mode, passes
in dev.
When a proxy injects a leading path segment (`/one/two` →
`/en/one/two`, what i18n libraries do when the default locale is hidden
from the URL) and the target route is entirely dynamic
(`/[locale]/[...pages]`), optimistic routing learned a pattern indexed
by the requested URL's parts but shaped by the rendered pathname. Every
prediction made from that pattern bound the wrong params, the server
could never satisfy the resulting prefetch, and nothing on the prefetch
path ever marked the pattern as bad — so the task re-derived the same
prediction and spun at ~140 requests per second, starving every other
prefetch in the queue.

On the learning side, discoverKnownRoutePart now checks the response
for evidence of a rewrite before storing a pattern. A route tree's
dynamic segments carry param cache keys parsed from the rendered
pathname, so each dynamic segment's cache key is compared against the
(canonicalized) URL part(s) it would consume. A mismatch means the URL
and the tree describe different paths, so no pattern is stored — the
same bail static segments already perform. This covers every learn site
(initial load, route tree prefetch, unprefetched navigations) and is a
no-op for non-rewritten routes, where the cache key equals the URL part
by construction.

Learning can't detect a rewrite that preserves the route's shape but
only applies to some param values (e.g. `/products/promo/[id]` →
`/products/sale/[id]` where `/products/[category]/[id]` exists). So the
transport decoder now reports divergence as part of the same traversal
that decodes the response: while overlaying the server patch onto the
base tree, decodeTransportNode compares each rendered segment's
identity against the base tree's at the same position (inactive
parallel route branches are expected to differ and are excluded) and
records the result on the NavigationSeed as treeDivergedFromBase.

During a prefetch the base is the request tree derived from the cached
route entry, so divergence means the entry doesn't describe what the
server actually renders. When that happens,
fetchSegmentPrefetchesUsingDynamicRequest marks the entry as a dynamic
rewrite and invalidates the entries derived from it — the same response
dispatchRetryDueToTreeMismatch already performs on the navigation path.
An entry constructed by route prediction doubles as the stored pattern,
so marking it disables the pattern that produced the misprediction: one
bad request instead of an unbounded loop.

Bounding prefetch retries in general (a counter on the task object) is
left as a TODO next to the new check.

The repro added in the previous commit now passes in start mode. A new
optimistic-routing test covers the value-dependent rewrite case; it
fails with the divergence check reverted.
@acdlite
acdlite force-pushed the worktree-intl-rewrite-prefetch-loop branch from 2206254 to b41b315 Compare August 11, 2026 04:26
The same class of loop occurs without any rewrite. When two parallel
routes define dynamic children with different param names or types at
the same level (`@modal/[...catchAll]` alongside `[username]`), route
discovery reused the same trie node for both branches, so one branch's
route entry was stored under the other's param pattern. Any URL that
matched the poisoned pattern predicted the wrong route, diverged from
the server response on every prefetch, and looped. The misprediction
was head-only — a metadata-only request tree — which is exactly the
case the treeDivergedFromBase backstop skips.

Now, when discovery encounters a dynamic child whose param name or type
disagrees with the one already stored, it marks the trie node with a
sticky hasConflictingDynamicChildren flag and bails the same way it
does for a rewrite mismatch. Matching refuses the dynamic subtree at a
flagged level, falling back to a regular dynamic navigation. The better
long-term fix — sending conflicting sibling dynamic params in the route
tree like static siblings and matching both — is left as a TODO.

The new e2e suite renders a modal catch-all alongside a dynamic
sibling; on canary it fails with a sustained stream of metadata-only
prefetches for a single URL.

Fixes #97135
@acdlite
acdlite force-pushed the worktree-intl-rewrite-prefetch-loop branch from b41b315 to b109b08 Compare August 11, 2026 04:35
@acdlite acdlite changed the title Fix infinite prefetch loop when a proxy rewrite changes the URL's shape Fix: Optimistic routing bugs leading to repeated prefetch loops Aug 11, 2026
@acdlite
acdlite marked this pull request as ready for review August 11, 2026 04:55
@acdlite
acdlite requested a review from unstubbable August 11, 2026 04:56
amannn pushed a commit to amannn/nextjs-bug-repro-prefetch that referenced this pull request Aug 11, 2026
Next.js asks that reproductions be verified against canary before
being reported, since the issue may already be fixed there. It isn't:
the loop reproduces unchanged on 16.3.1-canary.11.

Re-verified every row of the surface area table against canary rather
than carrying over the 16.3.0 results. All rows hold, including that
`export const instant = false` still does not stop the loop.

Also links vercel/next.js#97128, which adds the missing divergence
check for rewrite-affected prefetches and names this scenario
explicitly. It is unmerged, which matches canary still failing here.

`pnpm-workspace.yaml` is required for `pnpm install` to accept the
canary packages under pnpm 11's minimum release age policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKPaC9zccuP3EQsZidfXt9
// immediately expired. The next prefetch will re-fetch the tree with
// correct hints from the /_tree response.
const acc = { metadataVaryPath: null }
const acc = { metadataVaryPath: null, treeDivergedFromBase: false }

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.

nit:

Suggested change
const acc = { metadataVaryPath: null, treeDivergedFromBase: false }
const acc: RouteTreeAccumulator = {
metadataVaryPath: null,
treeDivergedFromBase: false,
}

}

const acc = { metadataVaryPath: null }
const acc = { metadataVaryPath: null, treeDivergedFromBase: false }

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.

nit:

Suggested change
const acc = { metadataVaryPath: null, treeDivergedFromBase: false }
const acc: RouteTreeAccumulator = {
metadataVaryPath: null,
treeDivergedFromBase: false,
}

@acdlite
acdlite merged commit 5942b37 into canary Aug 11, 2026
132 checks passed
@acdlite
acdlite deleted the worktree-intl-rewrite-prefetch-loop branch August 11, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants