Skip to content

Commit 9662feb

Browse files
authored
[fragment-scroll] Ensure hoisted elements don't break scroll-to-top on page navigations (#83108)
1 parent f63be5d commit 9662feb

File tree

6 files changed

+178
-8
lines changed

6 files changed

+178
-8
lines changed

packages/next/src/client/components/layout-router.tsx

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import type { FocusAndScrollRef } from './router-reducer/router-reducer-types'
1212

1313
import React, {
1414
Activity,
15+
Fragment,
1516
useContext,
1617
use,
1718
Suspense,
1819
useDeferredValue,
20+
useLayoutEffect,
21+
type FragmentInstance,
1922
type JSX,
2023
type ActivityProps,
2124
} from 'react'
@@ -45,6 +48,8 @@ import { getParamValueFromCacheKey } from '../route-params'
4548
import type { Params } from '../../server/request/params'
4649
import { isDeferredRsc } from './router-reducer/ppr-navigations'
4750

51+
const enableNewScrollHandler = process.env.__NEXT_APP_NEW_SCROLL_HANDLER
52+
4853
const __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = (
4954
ReactDOM as any
5055
).__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE
@@ -96,9 +101,23 @@ function shouldSkipElement(element: HTMLElement) {
96101
/**
97102
* Check if the top corner of the HTMLElement is in the viewport.
98103
*/
99-
function topOfElementInViewport(element: HTMLElement, viewportHeight: number) {
100-
const rect = element.getBoundingClientRect()
101-
return rect.top >= 0 && rect.top <= viewportHeight
104+
function topOfElementInViewport(
105+
instance: HTMLElement | FragmentInstance,
106+
viewportHeight: number
107+
): boolean {
108+
const rects = instance.getClientRects()
109+
if (rects.length === 0) {
110+
// Just to be explicit.
111+
return false
112+
}
113+
let elementTop = Number.POSITIVE_INFINITY
114+
for (let i = 0; i < rects.length; i++) {
115+
const rect = rects[i]
116+
if (rect.top < elementTop) {
117+
elementTop = rect.top
118+
}
119+
}
120+
return elementTop >= 0 && elementTop <= viewportHeight
102121
}
103122

104123
/**
@@ -125,7 +144,7 @@ interface ScrollAndFocusHandlerProps {
125144
children: React.ReactNode
126145
segmentPath: FlightSegmentPath
127146
}
128-
class InnerScrollAndFocusHandler extends React.Component<ScrollAndFocusHandlerProps> {
147+
class InnerScrollAndFocusHandlerOld extends React.Component<ScrollAndFocusHandlerProps> {
129148
handlePotentialScroll = () => {
130149
// Handle scroll and focus, it's only applied once.
131150
const { focusAndScrollRef, segmentPath } = this.props
@@ -170,9 +189,9 @@ class InnerScrollAndFocusHandler extends React.Component<ScrollAndFocusHandlerPr
170189
while (!(domNode instanceof HTMLElement) || shouldSkipElement(domNode)) {
171190
if (process.env.NODE_ENV !== 'production') {
172191
if (domNode.parentElement?.localName === 'head') {
173-
// TODO: We enter this state when metadata was rendered as part of the page or via Next.js.
192+
// We enter this state when metadata was rendered as part of the page or via Next.js.
174193
// This is always a bug in Next.js and caused by React hoisting metadata.
175-
// We need to replace `findDOMNode` in favor of Fragment Refs (when available) so that we can skip over metadata.
194+
// Fixed with `experimental.appNewScrollHandler`
176195
}
177196
}
178197

@@ -246,6 +265,108 @@ class InnerScrollAndFocusHandler extends React.Component<ScrollAndFocusHandlerPr
246265
}
247266
}
248267

268+
function InnerScrollAndFocusHandlerNew(props: ScrollAndFocusHandlerProps) {
269+
const childrenRef = React.useRef<FragmentInstance>(null)
270+
271+
useLayoutEffect(
272+
() => {
273+
const { focusAndScrollRef, segmentPath } = props
274+
// Handle scroll and focus, it's only applied once in the first useEffect that triggers that changed.
275+
276+
if (focusAndScrollRef.apply) {
277+
// segmentPaths is an array of segment paths that should be scrolled to
278+
// if the current segment path is not in the array, the scroll is not applied
279+
// unless the array is empty, in which case the scroll is always applied
280+
if (
281+
focusAndScrollRef.segmentPaths.length !== 0 &&
282+
!focusAndScrollRef.segmentPaths.some((scrollRefSegmentPath) =>
283+
segmentPath.every((segment, index) =>
284+
matchSegment(segment, scrollRefSegmentPath[index])
285+
)
286+
)
287+
) {
288+
return
289+
}
290+
291+
let instance: FragmentInstance | HTMLElement | null = null
292+
const hashFragment = focusAndScrollRef.hashFragment
293+
294+
if (hashFragment) {
295+
instance = getHashFragmentDomNode(hashFragment)
296+
}
297+
298+
if (!instance) {
299+
instance = childrenRef.current
300+
}
301+
302+
// If there is no DOM node this layout-router level is skipped. It'll be handled higher-up in the tree.
303+
if (instance === null) {
304+
return
305+
}
306+
307+
// State is mutated to ensure that the focus and scroll is applied only once.
308+
focusAndScrollRef.apply = false
309+
focusAndScrollRef.hashFragment = null
310+
focusAndScrollRef.segmentPaths = []
311+
312+
disableSmoothScrollDuringRouteTransition(
313+
() => {
314+
// In case of hash scroll, we only need to scroll the element into view
315+
if (hashFragment) {
316+
instance.scrollIntoView()
317+
318+
return
319+
}
320+
// Store the current viewport height because reading `clientHeight` causes a reflow,
321+
// and it won't change during this function.
322+
const htmlElement = document.documentElement
323+
const viewportHeight = htmlElement.clientHeight
324+
325+
// If the element's top edge is already in the viewport, exit early.
326+
if (topOfElementInViewport(instance, viewportHeight)) {
327+
return
328+
}
329+
330+
// Otherwise, try scrolling go the top of the document to be backward compatible with pages
331+
// scrollIntoView() called on `<html/>` element scrolls horizontally on chrome and firefox (that shouldn't happen)
332+
// We could use it to scroll horizontally following RTL but that also seems to be broken - it will always scroll left
333+
// scrollLeft = 0 also seems to ignore RTL and manually checking for RTL is too much hassle so we will scroll just vertically
334+
htmlElement.scrollTop = 0
335+
336+
// Scroll to domNode if domNode is not in viewport when scrolled to top of document
337+
if (!topOfElementInViewport(instance, viewportHeight)) {
338+
// Scroll into view doesn't scroll horizontally by default when not needed
339+
instance.scrollIntoView()
340+
}
341+
},
342+
{
343+
// We will force layout by querying domNode position
344+
dontForceLayout: true,
345+
onlyHashChange: focusAndScrollRef.onlyHashChange,
346+
}
347+
)
348+
349+
// Mutate after scrolling so that it can be read by `disableSmoothScrollDuringRouteTransition`
350+
focusAndScrollRef.onlyHashChange = false
351+
352+
// Set focus on the element but don't scroll since we already did that.
353+
// The focus might have targetted a deep element outside of the instances
354+
// top edge.
355+
instance.focus({ preventScroll: true })
356+
}
357+
},
358+
// Used to run on every commit. We may be able to be smarter about this
359+
// but be prepared for lots of manual testing.
360+
undefined
361+
)
362+
363+
return <Fragment ref={childrenRef}>{props.children}</Fragment>
364+
}
365+
366+
const InnerScrollAndFocusHandler = enableNewScrollHandler
367+
? InnerScrollAndFocusHandlerNew
368+
: InnerScrollAndFocusHandlerOld
369+
249370
function ScrollAndFocusHandler({
250371
segmentPath,
251372
children,

test/development/browser-logs/browser-logs.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import stripAnsi from 'strip-ansi'
66
import { retry } from 'next-test-utils'
77

88
const bundlerName = process.env.IS_TURBOPACK_TEST ? 'Turbopack' : 'Webpack'
9+
const enableNewScrollHandler =
10+
process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER === 'true'
11+
const innerScrollAndFocusHandlerName = enableNewScrollHandler
12+
? 'InnerScrollAndFocusHandlerNew'
13+
: 'InnerScrollAndFocusHandlerOld'
914

1015
function setupLogCapture() {
1116
const logs: string[] = []
@@ -332,7 +337,7 @@ describe(`Terminal Logging (${bundlerName})`, () => {
332337
...
333338
<RenderFromTemplateContext>
334339
<ScrollAndFocusHandler segmentPath={[...]}>
335-
<InnerScrollAndFocusHandler segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
340+
<${innerScrollAndFocusHandlerName} segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
336341
<ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
337342
<LoadingBoundary name="hydration-..." loading={null}>
338343
<HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default function Page() {
2+
return (
3+
<>
4+
<style precedence="alpha" href="custom-stylesheet"></style>
5+
{
6+
// Repeat 500 elements
7+
Array.from({ length: 500 }, (_, i) => (
8+
<div key={i}>{i}</div>
9+
))
10+
}
11+
</>
12+
)
13+
}

test/e2e/app-dir/router-autoscroll/app/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export default function Page() {
3535
<div>
3636
<Link href="/new-metadata">To new metadata</Link>
3737
</div>
38+
39+
<div>
40+
<Link href="/hoisted">To hoisted</Link>
41+
</div>
3842
</>
3943
)
4044
}

test/e2e/app-dir/router-autoscroll/router-autoscroll.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { Playwright } from 'next-webdriver'
22
import { nextTestSetup } from 'e2e-utils'
33
import { check, assertNoConsoleErrors, retry } from 'next-test-utils'
44

5+
const enableNewScrollHandler =
6+
process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER === 'true'
7+
58
describe('router autoscrolling on navigation', () => {
69
const { next, isNextDev } = nextTestSetup({
710
files: __dirname,
@@ -237,4 +240,27 @@ describe('router autoscrolling on navigation', () => {
237240
})
238241
})
239242
})
243+
244+
it('should scroll to top even if React hoists children', async () => {
245+
const browser = await next.browser('/')
246+
247+
// scroll to bottom
248+
await browser.eval(
249+
`window.scrollTo(0, ${await browser.eval('document.documentElement.scrollHeight')})`
250+
)
251+
// Just need to scroll by something
252+
expect(await getTopScroll(browser)).toBeGreaterThan(0)
253+
254+
await browser.elementByCss('[href="/hoisted"]').click()
255+
expect(
256+
await browser.eval('document.documentElement.scrollHeight')
257+
).toBeGreaterThan(0)
258+
if (enableNewScrollHandler) {
259+
await waitForScrollToComplete(browser, { x: 0, y: 0 })
260+
} else {
261+
await expect(
262+
waitForScrollToComplete(browser, { x: 0, y: 0 })
263+
).rejects.toThrow()
264+
}
265+
})
240266
})

test/lib/next-test-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1486,7 +1486,8 @@ const nextjsClientComponentNames = [
14861486
'HTTPAccessFallbackBoundary',
14871487
'HTTPAccessFallbackErrorBoundary',
14881488
'InnerLayoutRouter',
1489-
'InnerScrollAndFocusHandler',
1489+
'InnerScrollAndFocusHandlerOld',
1490+
'InnerScrollAndFocusHandlerNew',
14901491
'RedirectBoundary',
14911492
'RedirectErrorBoundary',
14921493
'RenderFromTemplateContext',

0 commit comments

Comments
 (0)