Skip to content

Fix App Router scroll padding visibility - #96308

Merged
DavidIlie merged 1 commit into
canaryfrom
codex/fix-layout-router-scroll-padding-top-pr
Aug 3, 2026
Merged

Fix App Router scroll padding visibility#96308
DavidIlie merged 1 commit into
canaryfrom
codex/fix-layout-router-scroll-padding-top-pr

Conversation

@DavidIlie

@DavidIlie DavidIlie commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Treat the root scroll-padding-top as the lower boundary of the usable viewport during App Router navigations, so content obscured by a sticky header is not incorrectly considered visible.
  • Preserve the merged Fragment scroll-ownership state machine: empty Fragments remain unavailable targets, while real targets are classified against the padding-aware viewport.
  • Resolve pixel and percentage values and use the same visibility rule in both the legacy element handler and the Fragment-ref handler.
  • Add regression coverage showing that Next scrolls when the destination is hidden inside the padding boundary, while preserving the current scroll position when the destination is genuinely visible below it.

Performance

The root computed style is resolved lazily, only after the candidate produces client rects. Empty Fragments and hash navigations do not perform this lookup. For a real route-scroll target, the resolved value is cached locally and reused by the second geometry check after scrollTop = 0.

This does not add work to scroll events or every render, and keeping the value local allows responsive CSS, root classes, and custom properties to change between navigations. In a 50-iteration dirty-style Chromium probe, both the existing and updated paths reported 50 style recalculations and 50 layouts, indicating that the lookup reused the style/layout update already required by the handler in that test.

Related work

#96342 is now merged into canary and handles empty Fragment scroll ownership. This PR composes with its NoClientRects / InViewport / OutOfViewport state machine by changing only the visible-region boundary for real targets.

Verification

  • HEADLESS=true pnpm test-dev-turbo test/e2e/app-dir/router-autoscroll/router-autoscroll.test.ts -t "scroll padding" (3/3)
  • HEADLESS=true __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=false pnpm test-dev-turbo test/e2e/app-dir/router-autoscroll/router-autoscroll.test.ts -t "scroll padding" (3/3)
  • HEADLESS=true pnpm test-dev-turbo test/e2e/app-dir/parallel-routes-scroll-owner/parallel-routes-scroll-owner.test.ts (8/8)
  • HEADLESS=true pnpm test-dev-webpack test/e2e/app-dir/parallel-routes-scroll-owner/parallel-routes-scroll-owner.test.ts (8/8)
  • HEADLESS=true pnpm test-dev-turbo test/e2e/app-dir/navigation-focus/navigation-focus.test.ts (5/5)
  • Manually compared the same sticky-header reproduction before and after the change.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 4748515

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 3 improvements

Metric Canary PR Change Trend
Turbo Build Time 5.458s 5.009s 🟢 449ms (-8%) ▂▄▂▂▁
Turbo Build Time (cached) 5.668s 5.069s 🟢 599ms (-11%) ▃▇▇▃▁
Webpack Build Time 24.856s 23.795s 🟢 1.061s (-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) 813ms 812ms ████▁
Cold (Ready in log) 784ms 774ms ▃▅▅▃▂
Cold (First Request) 1.286s 1.278s ▁▄▃▂▁
Warm (Listen) 813ms 813ms ████▁
Warm (Ready in log) 792ms 793ms ▃▅▄▃▁
Warm (First Request) 1.298s 1.308s ▁▅▃▂▃
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 811ms 813ms ▁███▁
Cold (Ready in log) 779ms 777ms ▂▇▄▃▂
Cold (First Request) 3.354s 3.319s ▃▇▃▂▁
Warm (Listen) 813ms 811ms ███▁█
Warm (Ready in log) 781ms 774ms ▄▆▂▂▂
Warm (First Request) 3.375s 3.345s ▃▆▃▂▂

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.458s 5.009s 🟢 449ms (-8%) ▂▄▂▂▁
Cached Build 5.668s 5.069s 🟢 599ms (-11%) ▃▇▇▃▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 24.856s 23.795s 🟢 1.061s (-4%) ▁▆▃▂▄
Cached Build 23.896s 24.289s ▁▆▃▂▃
node_modules Size 551 MB 551 MB ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
02rmq--hyx94r.js gzip 3.54 kB N/A -
06ckrdq6blni8.js gzip 46.3 kB N/A -
0a-p0meqxynjz.js gzip 10.3 kB N/A -
0avtke4yefj4-.js gzip 13.1 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0lh1g5ukxu-s3.js gzip 155 B N/A -
0pt5of3ch6d2u.js gzip 13.6 kB N/A -
0xnsdo-6y9r3k.js gzip 5.72 kB N/A -
0yi7is8u4covm.js gzip 8.77 kB N/A -
117ymd2qpcalo.js gzip 7.4 kB N/A -
118h5506vofb5.js gzip 157 B N/A -
17ui4p746tamq.js gzip 158 B N/A -
19tbbi7ehh4jm.js gzip 71.4 kB N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1ouojim4wr6ut.js gzip 155 B N/A -
1prv-wg6jgtt2.js gzip 8.77 kB N/A -
1t47vod02e_4a.js gzip 8.75 kB N/A -
1tf1phijqlx9j.js gzip 220 B 220 B
1tp0t8qu2ff52.js gzip 157 B N/A -
1tw002sq5bo4l.js gzip 10 kB N/A -
1wlerik1n7zbr.js gzip 8.7 kB N/A -
2hxzcggj41-is.js gzip 156 B N/A -
2ic8d1w7mxx4u.js gzip 162 B N/A -
2lzanqfacxids.js gzip 65.6 kB N/A -
2mjtybu7y-c1y.js gzip 169 B N/A -
2pevdqjpbf3xk.js gzip 9.45 kB N/A -
2qd2anu9eizlz.js gzip 8.75 kB N/A -
2sv39ae5_8vfj.js gzip 8.81 kB N/A -
2sxuipaom0y32.js gzip 156 B N/A -
2utx6n7w65xqa.js gzip 8.7 kB N/A -
3_af5qsch_muy.js gzip 156 B N/A -
3-ukivglqkfeq.js gzip 8.78 kB N/A -
325xisqrylugu.js gzip 450 B N/A -
3jc9ghvds1spf.js gzip 2.29 kB N/A -
3juwkvkgvywfe.js gzip 10.6 kB N/A -
3kbove7q58uqh.js gzip 13.2 kB N/A -
3qn7vntp30gaj.js gzip 1.47 kB N/A -
3t3w-e87ve2mk.js gzip 160 B N/A -
3v4u3_bjms2s0.js gzip 154 B N/A -
409zdtoqjfc-g.js gzip 155 B N/A -
turbopack-03..oqxr.js gzip 3.86 kB N/A -
turbopack-03..h75a.js gzip 3.84 kB N/A -
turbopack-0q..7ee5.js gzip 3.84 kB N/A -
turbopack-1k..xkqj.js gzip 3.84 kB N/A -
turbopack-2h..fsr-.js gzip 3.85 kB N/A -
turbopack-2l..gy20.js gzip 3.84 kB N/A -
turbopack-2n..2m2s.js gzip 3.84 kB N/A -
turbopack-2n..v69b.js gzip 3.84 kB N/A -
turbopack-2r..z2mq.js gzip 3.84 kB N/A -
turbopack-37..uw0q.js gzip 3.85 kB N/A -
turbopack-3d..3f0z.js gzip 3.84 kB N/A -
turbopack-3j..yq-w.js gzip 3.82 kB N/A -
turbopack-3p..06hl.js gzip 3.84 kB N/A -
turbopack-43..8zks.js gzip 3.84 kB N/A -
0_sfs0nf236wo.js gzip N/A 3.65 kB -
030a1njeh5b77.js gzip N/A 10 kB -
06gzmxtam9g1e.js gzip N/A 160 B -
0jakjfnvoc5gb.js gzip N/A 156 B -
0kxo-4ao1_97a.js gzip N/A 154 B -
0ot0qt5np20h7.js gzip N/A 8.78 kB -
12m9rr7_7cexd.js gzip N/A 8.7 kB -
144japgxpbyvc.js gzip N/A 152 B -
186r3y_rfvr0b.js gzip N/A 8.78 kB -
19v97848yrjp7.js gzip N/A 8.81 kB -
1eqxhnympk7lp.js gzip N/A 156 B -
1es85ig00uv6s.js gzip N/A 158 B -
1nd_0rwgz2ozk.js gzip N/A 9.45 kB -
1oi9pltr_e10m.js gzip N/A 10.6 kB -
1rd9tzqgzsz2w.js gzip N/A 8.77 kB -
1rq1g_cwv642d.js gzip N/A 10.3 kB -
1s01spml602u8.js gzip N/A 157 B -
1twj9ss5cioci.js gzip N/A 7.41 kB -
1uojw_uabeg93.js gzip N/A 450 B -
1w-7lusgyl81y.js gzip N/A 8.7 kB -
21h56wdn9zddo.js gzip N/A 8.75 kB -
2a2vdecrdn6a7.js gzip N/A 155 B -
2ovff1533zvno.js gzip N/A 13.2 kB -
2s4z8jy0z7v93.js gzip N/A 1.46 kB -
2y6gb5bs4mnam.js gzip N/A 8.75 kB -
3_dt4gnd6ua1c.js gzip N/A 156 B -
341h71ln1jpl3.js gzip N/A 2.29 kB -
34ttpt_n7605h.js gzip N/A 5.72 kB -
36ckfq9o-ys-a.js gzip N/A 13.1 kB -
36jzsthx7a78y.js gzip N/A 46.3 kB -
36pen3zcswl1v.js gzip N/A 161 B -
3hqp1h_uc53vt.js gzip N/A 168 B -
3obmosaxs4tza.js gzip N/A 156 B -
3odif4gt_3n3b.js gzip N/A 65.6 kB -
3ov7esl2zvrl4.js gzip N/A 155 B -
3t8332o6t9z0n.js gzip N/A 71.4 kB -
3vdh6_u50pu5t.js gzip N/A 13.6 kB -
turbopack-00..fkmk.js gzip N/A 3.84 kB -
turbopack-03..yuuw.js gzip N/A 3.82 kB -
turbopack-1-..l51-.js gzip N/A 3.84 kB -
turbopack-16..f041.js gzip N/A 3.84 kB -
turbopack-1b..0byo.js gzip N/A 3.84 kB -
turbopack-1n..8e15.js gzip N/A 3.85 kB -
turbopack-1n..db3p.js gzip N/A 3.84 kB -
turbopack-2v..5kku.js gzip N/A 3.84 kB -
turbopack-3_..nwcn.js gzip N/A 3.84 kB -
turbopack-33..1x64.js gzip N/A 3.84 kB -
turbopack-3d..3o7c.js gzip N/A 3.86 kB -
turbopack-3n..6gja.js gzip N/A 3.84 kB -
turbopack-3u..90k5.js gzip N/A 3.84 kB -
turbopack-3y..yfxg.js gzip N/A 3.84 kB -
Total 450 kB 450 kB ⚠️ +156 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 777 B 770 B
Total 777 B 770 B ✅ -7 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 433 B 433 B
Total 433 B 433 B

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 64.9 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.68 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 254 B 254 B
main-HASH.js gzip 40 kB 40 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 65.7 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.69 kB -
a8984546-HASH.js gzip N/A 63.2 kB -
Total 240 kB 241 kB ⚠️ +788 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.95 kB ⚠️ +1 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 128 kB
page.js gzip 293 kB 293 kB
Total 421 kB 422 kB ⚠️ +415 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 619 B 615 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 45.7 kB 45.4 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 47.3 kB 47 kB ✅ -314 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 718 B 718 B
Total 718 B 718 B
Build Cache
Canary PR Change
0.pack gzip 4.83 MB 4.85 MB 🔴 +11 kB (+0%)
index.pack gzip 123 kB 121 kB 🟢 1.28 kB (-1%)
index.pack.old gzip 122 kB 123 kB
Total 5.08 MB 5.09 MB ⚠️ +9.9 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 372 kB 372 kB
app-page-exp..prod.js gzip 205 kB 205 kB
app-page-tur...dev.js gzip 372 kB 372 kB
app-page-tur..prod.js gzip 205 kB 205 kB
app-page-tur...dev.js gzip 368 kB 368 kB
app-page-tur..prod.js gzip 203 kB 203 kB
app-page.run...dev.js gzip 368 kB 368 kB
app-page.run..prod.js gzip 203 kB 203 kB
app-route-ex...dev.js gzip 82.5 kB 82.5 kB
app-route-ex..prod.js gzip 56 kB 56 kB
app-route-tu...dev.js gzip 82.6 kB 82.6 kB
app-route-tu..prod.js gzip 56 kB 56 kB
app-route-tu...dev.js gzip 82.2 kB 82.2 kB
app-route-tu..prod.js gzip 55.8 kB 55.8 kB
app-route.ru...dev.js gzip 82.1 kB 82.1 kB
app-route.ru..prod.js gzip 55.7 kB 55.7 kB
dev-validati...dev.js gzip 132 kB 132 kB
dev-validati...dev.js gzip 132 kB 132 kB
dev-validati...dev.js gzip 130 kB 130 kB
dev-validati...dev.js gzip 130 kB 130 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 45.5 kB 45.5 kB
pages-api-tu..prod.js gzip 34.1 kB 34.1 kB
pages-api.ru...dev.js gzip 45.5 kB 45.5 kB
pages-api.ru..prod.js gzip 34.1 kB 34.1 kB
pages-turbo....dev.js gzip 54.9 kB 54.9 kB
pages-turbo...prod.js gzip 39.8 kB 39.8 kB
pages.runtim...dev.js gzip 54.9 kB 54.9 kB
pages.runtim..prod.js gzip 39.8 kB 39.8 kB
server.runti..prod.js gzip 68.3 kB 68.3 kB
use-cache-pr...dev.js gzip 72.1 kB 72.1 kB
use-cache-pr...dev.js gzip 72.1 kB 72.1 kB
use-cache-pr...dev.js gzip 70.4 kB 70.4 kB
use-cache-pr...dev.js gzip 70.4 kB 70.4 kB
Total 4.08 MB 4.08 MB ⚠️ +6 B
📝 Changed Files (2 files)

Files with changes:

  • pages-api-tu..time.prod.js
  • pages-turbo...time.prod.js
View diffs
pages-api-tu..time.prod.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

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

Commit: 4748515

@DavidIlie
DavidIlie force-pushed the codex/fix-layout-router-scroll-padding-top-pr branch from e763145 to 4748515 Compare July 30, 2026 18:01
@DavidIlie
DavidIlie marked this pull request as ready for review July 30, 2026 18:44
@DavidIlie
DavidIlie merged commit da90782 into canary Aug 3, 2026
131 checks passed
@DavidIlie
DavidIlie deleted the codex/fix-layout-router-scroll-padding-top-pr branch August 3, 2026 15:00
m-kawafuji pushed a commit to m-kawafuji/next.js that referenced this pull request Aug 8, 2026
## Summary

- Treat the root `scroll-padding-top` as the lower boundary of the
usable viewport during App Router navigations, so content obscured by a
sticky header is not incorrectly considered visible.
- Preserve the merged Fragment scroll-ownership state machine: empty
Fragments remain unavailable targets, while real targets are classified
against the padding-aware viewport.
- Resolve pixel and percentage values and use the same visibility rule
in both the legacy element handler and the Fragment-ref handler.
- Add regression coverage showing that Next scrolls when the destination
is hidden inside the padding boundary, while preserving the current
scroll position when the destination is genuinely visible below it.

## Performance

The root computed style is resolved lazily, only after the candidate
produces client rects. Empty Fragments and hash navigations do not
perform this lookup. For a real route-scroll target, the resolved value
is cached locally and reused by the second geometry check after
`scrollTop = 0`.

This does not add work to scroll events or every render, and keeping the
value local allows responsive CSS, root classes, and custom properties
to change between navigations. In a 50-iteration dirty-style Chromium
probe, both the existing and updated paths reported 50 style
recalculations and 50 layouts, indicating that the lookup reused the
style/layout update already required by the handler in that test.

## Related work

vercel#96342 is now merged into `canary` and handles empty Fragment scroll
ownership. This PR composes with its `NoClientRects` / `InViewport` /
`OutOfViewport` state machine by changing only the visible-region
boundary for real targets.

## Verification

- `HEADLESS=true pnpm test-dev-turbo
test/e2e/app-dir/router-autoscroll/router-autoscroll.test.ts -t "scroll
padding"` (3/3)
- `HEADLESS=true __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=false pnpm
test-dev-turbo
test/e2e/app-dir/router-autoscroll/router-autoscroll.test.ts -t "scroll
padding"` (3/3)
- `HEADLESS=true pnpm test-dev-turbo
test/e2e/app-dir/parallel-routes-scroll-owner/parallel-routes-scroll-owner.test.ts`
(8/8)
- `HEADLESS=true pnpm test-dev-webpack
test/e2e/app-dir/parallel-routes-scroll-owner/parallel-routes-scroll-owner.test.ts`
(8/8)
- `HEADLESS=true pnpm test-dev-turbo
test/e2e/app-dir/navigation-focus/navigation-focus.test.ts` (5/5)
- Manually compared the same sticky-header reproduction before and after
the change.

<!-- NEXT_JS_LLM -->
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.

2 participants