diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index cd9d5b06a239..627e2bd11e30 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -3,10 +3,12 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we import { AppRouterContext, AppTreeContext, - CacheNode, FullAppTreeContext, } from '../../shared/lib/app-router-context' -import type { AppRouterInstance } from '../../shared/lib/app-router-context' +import type { + CacheNode, + AppRouterInstance, +} from '../../shared/lib/app-router-context' import type { FlightRouterState, FlightData } from '../../server/app-render' import { reducer } from './reducer' import { @@ -117,19 +119,19 @@ export default function AppRouter({ children: React.ReactNode hotReloader?: React.ReactNode }) { - const [{ tree, cache, pushRef, canonicalUrl }, dispatch] = React.useReducer< - typeof reducer - >(reducer, { - tree: initialTree, - cache: { - data: null, - subTreeData: children, - parallelRoutes: - typeof window === 'undefined' ? new Map() : initialParallelRoutes, - }, - pushRef: { pendingPush: false, mpaNavigation: false }, - canonicalUrl: initialCanonicalUrl, - }) + const [{ tree, cache, pushRef, focusRef, canonicalUrl }, dispatch] = + React.useReducer(reducer, { + tree: initialTree, + cache: { + data: null, + subTreeData: children, + parallelRoutes: + typeof window === 'undefined' ? new Map() : initialParallelRoutes, + }, + pushRef: { pendingPush: false, mpaNavigation: false }, + focusRef: { focus: false }, + canonicalUrl: initialCanonicalUrl, + }) useEffect(() => { initialParallelRoutes = null! @@ -302,6 +304,7 @@ export default function AppRouter({ value={{ changeByServerResponse, tree, + focusRef, }} > diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index fd3b8168ee23..ca1de4594d5a 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import React, { useContext, useEffect, useRef } from 'react' import type { ChildProp } from '../../server/app-render' import type { ChildSegmentMap } from '../../shared/lib/app-router-context' import type { @@ -61,8 +61,20 @@ export function InnerLayoutRouter({ isActive: boolean path: string }) { - const { changeByServerResponse, tree: fullTree } = - useContext(FullAppTreeContext) + const { + changeByServerResponse, + tree: fullTree, + focusRef, + } = useContext(FullAppTreeContext) + const focusAndScrollRef = useRef(null) + + useEffect(() => { + if (focusRef.focus && focusAndScrollRef.current) { + focusRef.focus = false + focusAndScrollRef.current.focus() + focusAndScrollRef.current.scrollIntoView() + } + }, [focusRef]) let childNode = childNodes.get(path) @@ -197,16 +209,18 @@ export function InnerLayoutRouter({ } return ( - - {childNode.subTreeData} - +
+ + {childNode.subTreeData} + +
) } diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 9a794fb0217d..abb2b82c3607 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -298,10 +298,20 @@ const walkTreeWithFlightDataPath = ( return tree } +type PushRef = { + pendingPush: boolean + mpaNavigation: boolean +} + +export type FocusRef = { + focus: boolean +} + type AppRouterState = { tree: FlightRouterState cache: CacheNode - pushRef: { pendingPush: boolean; mpaNavigation: boolean } + pushRef: PushRef + focusRef: FocusRef canonicalUrl: string } @@ -349,6 +359,7 @@ export function reducer( return { canonicalUrl: href, pushRef: state.pushRef, + focusRef: state.focusRef, cache: state.cache, tree: tree, } @@ -377,6 +388,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, cache: state.cache, tree: optimisticTree, } @@ -393,6 +405,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, cache: cache, tree: mutable.patchedTree, } @@ -430,6 +443,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, cache: cache, tree: optimisticTree, } @@ -446,6 +460,7 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, + focusRef: { focus: false }, cache: state.cache, tree: state.tree, } @@ -474,6 +489,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, cache: cache, tree: newTree, } @@ -490,6 +506,7 @@ export function reducer( return { canonicalUrl: state.canonicalUrl, pushRef: state.pushRef, + focusRef: state.focusRef, tree: state.tree, cache: state.cache, } @@ -500,6 +517,7 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, + focusRef: { focus: false }, cache: state.cache, tree: state.tree, } @@ -525,6 +543,7 @@ export function reducer( return { canonicalUrl: state.canonicalUrl, pushRef: state.pushRef, + focusRef: state.focusRef, tree: newTree, cache: cache, } @@ -546,6 +565,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, cache: cache, tree: mutable.patchedTree, } @@ -566,6 +586,7 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, + focusRef: { focus: false }, cache: state.cache, tree: state.tree, } @@ -597,6 +618,8 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, + // TODO-APP: Revisit if this needs to be true in certain cases + focusRef: { focus: false }, cache: cache, tree: newTree, } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index ea8e456a62e9..38dc12f388e3 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -1,4 +1,5 @@ import React from 'react' +import type { FocusRef } from '../../client/components/reducer' import type { FlightRouterState, FlightData } from '../../server/app-render' export type ChildSegmentMap = Map @@ -36,6 +37,7 @@ export const FullAppTreeContext = React.createContext<{ previousTree: FlightRouterState, flightData: FlightData ) => void + focusRef: FocusRef }>(null as any) if (process.env.NODE_ENV !== 'production') { diff --git a/test/e2e/app-dir/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic.test.ts index c00edef461c9..dc48fccfe3e8 100644 --- a/test/e2e/app-dir/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic.test.ts @@ -193,10 +193,7 @@ describe('app dir - react server components', () => { it('should support next/link in server components', async () => { const linkHTML = await renderViaHTTP(next.url, '/next-api/link') - const linkText = getNodeBySelector( - linkHTML, - 'body > div > a[href="/root"]' - ).text() + const linkText = getNodeBySelector(linkHTML, 'body a[href="/root"]').text() expect(linkText).toContain('home')