diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index c2dd1fa8d3f..a5f2f9e5862 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react' import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' import { AppRouterContext, - AppTreeContext, + LayoutRouterContext, GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import type { @@ -99,7 +99,7 @@ export default function AppRouter({ children: React.ReactNode hotReloader?: React.ReactNode }) { - const [{ tree, cache, pushRef, focusRef, canonicalUrl }, dispatch] = + const [{ tree, cache, pushRef, focusAndScrollRef, canonicalUrl }, dispatch] = React.useReducer(reducer, { tree: initialTree, cache: { @@ -109,7 +109,7 @@ export default function AppRouter({ typeof window === 'undefined' ? new Map() : initialParallelRoutes, }, pushRef: { pendingPush: false, mpaNavigation: false }, - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, canonicalUrl: initialCanonicalUrl + // Hash is read as the initial value for canonicalUrl in the browser @@ -300,11 +300,11 @@ export default function AppRouter({ value={{ changeByServerResponse, tree, - focusRef, + focusAndScrollRef, }} > - + diff --git a/packages/next/client/components/hooks-client.ts b/packages/next/client/components/hooks-client.ts index 8606c757dc1..dec38ef62bb 100644 --- a/packages/next/client/components/hooks-client.ts +++ b/packages/next/client/components/hooks-client.ts @@ -9,7 +9,7 @@ import { } from './hooks-client-context' import { AppRouterContext, - AppTreeContext, + LayoutRouterContext, } from '../../shared/lib/app-router-context' /** @@ -59,7 +59,7 @@ export function usePathname(): string { export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string { - const { tree } = useContext(AppTreeContext) + const { tree } = useContext(LayoutRouterContext) const segment = tree[1][parallelRouteKey][0] diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 6406d6679ce..1b0b93ad123 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -1,5 +1,5 @@ import React, { useContext, useEffect, useRef } from 'react' -import type { ChildProp } from '../../server/app-render' +import type { ChildProp, Segment } from '../../server/app-render' import type { ChildSegmentMap } from '../../shared/lib/app-router-context' import type { FlightRouterState, @@ -7,31 +7,45 @@ import type { FlightDataPath, } from '../../server/app-render' import { - AppTreeContext, + LayoutRouterContext, GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' import { matchSegment } from './match-segments' -let infinitePromise: Promise | Error - -function equalArray(a: any[], b: any[]) { +/** + * Check if every segment in array a and b matches + */ +function equalSegmentPaths(a: Segment[], b: Segment[]) { + // Comparing length is a fast path. return a.length === b.length && a.every((val, i) => matchSegment(val, b[i])) } -function pathMatches( +/** + * Check if flightDataPath matches layoutSegmentPath + */ +function segmentPathMatches( flightDataPath: FlightDataPath, layoutSegmentPath: FlightSegmentPath ): boolean { - // The last two items are the tree and subTreeData + // The last three items are the current segment, tree, and subTreeData const pathToLayout = flightDataPath.slice(0, -3) - return equalArray(layoutSegmentPath, pathToLayout) + return equalSegmentPaths(layoutSegmentPath, pathToLayout) } +/** + * Used to cache in createInfinitePromise + */ +let infinitePromise: Promise | Error + +/** + * Create a Promise that does not resolve. This is used to suspend when data is not available yet. + */ function createInfinitePromise() { if (!infinitePromise) { + // Only create the Promise once infinitePromise = new Promise((/* resolve */) => { - // Note: this is used to debug when the rendering is never updated. + // This is used to debug when the rendering is never updated. // setTimeout(() => { // infinitePromise = new Error('Infinite promise') // resolve() @@ -42,11 +56,17 @@ function createInfinitePromise() { return infinitePromise } +/** + * Check if the top of the HTMLElement is in the viewport. + */ function topOfElementInViewport(element: HTMLElement) { const rect = element.getBoundingClientRect() return rect.top >= 0 } +/** + * InnerLayoutRouter handles rendering the provided segment based on the cache. + */ export function InnerLayoutRouter({ parallelRouterKey, url, @@ -54,6 +74,7 @@ export function InnerLayoutRouter({ childProp, segmentPath, tree, + // TODO-APP: implement `` when available. // isActive, path, rootLayoutIncluded, @@ -71,35 +92,48 @@ export function InnerLayoutRouter({ const { changeByServerResponse, tree: fullTree, - focusRef, + focusAndScrollRef, } = useContext(GlobalLayoutRouterContext) - const focusAndScrollRef = useRef(null) + const focusAndScrollElementRef = useRef(null) useEffect(() => { - if (focusRef.focus && focusAndScrollRef.current) { - focusRef.focus = false - focusAndScrollRef.current.focus() + // Handle scroll and focus, it's only applied once in the first useEffect that triggers that changed. + if (focusAndScrollRef.apply && focusAndScrollElementRef.current) { + // State is mutated to ensure that the focus and scroll is applied only once. + focusAndScrollRef.apply = false + // Set focus on the element + focusAndScrollElementRef.current.focus() // Only scroll into viewport when the layout is not visible currently. - if (!topOfElementInViewport(focusAndScrollRef.current)) { - focusAndScrollRef.current.scrollIntoView() + if (!topOfElementInViewport(focusAndScrollElementRef.current)) { + focusAndScrollElementRef.current.scrollIntoView() } } - }, [focusRef]) + }, [focusAndScrollRef]) + // Read segment path from the parallel router cache node. let childNode = childNodes.get(path) + // If childProp is available this means it's the Flight / SSR case. if (childProp && !childNode) { + // Add the segment's subTreeData to the cache. + // This writes to the cache when there is no item in the cache yet. It never *overwrites* existing cache items which is why it's safe in concurrent mode. childNodes.set(path, { data: null, subTreeData: childProp.current, parallelRoutes: new Map(), }) + // Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache. childProp.current = null // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. childNode = childNodes.get(path) } + // When childNode is not available during rendering client-side we need to fetch it from the server. if (!childNode) { + /** + * Add refetch marker to router state at the point of the current layout segment. + * This ensures the response returned is not further down than the current layout segment. + */ const walkAddRefetch = ( segmentPathToWalk: FlightSegmentPath | undefined, treeToRecreate: FlightRouterState @@ -145,9 +179,15 @@ export function InnerLayoutRouter({ return treeToRecreate } + /** + * Router state with refetch marker added + */ // TODO-APP: remove '' const refetchTree = walkAddRefetch(['', ...segmentPath], fullTree) + /** + * Flight data fetch kicked off during render and put into the cache. + */ const data = fetchServerResponse(new URL(url, location.origin), refetchTree) childNodes.set(path, { data, @@ -158,19 +198,23 @@ export function InnerLayoutRouter({ childNode = childNodes.get(path) } - // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. - childNode = childNodes.get(path) - + // This case should never happen so it throws an error. It indicates there's a bug in the Next.js. if (!childNode) { throw new Error('Child node should always exist') } + // This case should never happen so it throws an error. It indicates there's a bug in the Next.js. if (childNode.subTreeData && childNode.data) { throw new Error('Child node should not have both subTreeData and data') } + // If cache node has a data request we have to readRoot and update the cache. if (childNode.data) { // TODO-APP: error case + /** + * Flight response data + */ + // When the data has not resolved yet readRoot will suspend here. const flightData = childNode.data.readRoot() // Handle case when navigating to page in `pages` from `app` @@ -179,28 +223,36 @@ export function InnerLayoutRouter({ return null } + /** + * If the fast path was triggered. + * The fast path is when the returned Flight data path matches the layout segment path, then we can write the data to the cache in render instead of dispatching an action. + */ let fastPath: boolean = false - // segmentPath matches what came back from the server. This is the happy path. + + // If there are multiple patches returned in the Flight data we need to dispatch to ensure a single render. if (flightData.length === 1) { const flightDataPath = flightData[0] - if (pathMatches(flightDataPath, segmentPath)) { + if (segmentPathMatches(flightDataPath, segmentPath)) { + // Ensure data is set to null as subTreeData will be set in the cache now. childNode.data = null // Last item is the subtreeData // TODO-APP: routerTreePatch needs to be applied to the tree, handle it in render? const [, /* routerTreePatch */ subTreeData] = flightDataPath.slice(-2) + // Add subTreeData into the cache childNode.subTreeData = subTreeData + // This field is required for new items childNode.parallelRoutes = new Map() fastPath = true } } + // When the fast path is not used a new action is dispatched to update the tree and cache. if (!fastPath) { - // For push we can set data in the cache - // segmentPath from the server does not match the layout's segmentPath childNode.data = null + // setTimeout is used to start a new transition during render, this is an intentional hack around React. setTimeout(() => { // @ts-ignore startTransition exists React.startTransition(() => { @@ -213,13 +265,15 @@ export function InnerLayoutRouter({ } } - // TODO-APP: double check users can't return null in a component that will kick in here + // If cache node has no subTreeData and no data request we have to infinitely suspend as the data will likely flow in from another place. + // TODO-APP: double check users can't return null in a component that will kick in here. if (!childNode.subTreeData) { throw createInfinitePromise() } const subtree = ( - {childNode.subTreeData} - + ) - // Ensure root layout is not wrapped in a div + // Ensure root layout is not wrapped in a div as the root layout renders `` return rootLayoutIncluded ? ( -
{subtree}
+
{subtree}
) : ( subtree ) } +/** + * Renders suspense boundary with the provided "loading" property as the fallback. + * If no loading property is provided it renders the children without a suspense boundary. + */ function LoadingBoundary({ children, loading, @@ -253,6 +311,10 @@ function LoadingBoundary({ return <>{children} } +/** + * OuterLayoutRouter handles the current segment as well as rendering of other segments. + * It can be rendered next to each other with a different `parallelRouterKey`, allowing for Parallel routes. + */ export default function OuterLayoutRouter({ parallelRouterKey, segmentPath, @@ -266,23 +328,34 @@ export default function OuterLayoutRouter({ loading: React.ReactNode | undefined rootLayoutIncluded: boolean }) { - const { childNodes, tree, url } = useContext(AppTreeContext) + const { childNodes, tree, url } = useContext(LayoutRouterContext) + // Get the current parallelRouter cache node let childNodesForParallelRouter = childNodes.get(parallelRouterKey) + // If the parallel router cache node does not exist yet, create it. + // This writes to the cache when there is no item in the cache yet. It never *overwrites* existing cache items which is why it's safe in concurrent mode. if (!childNodesForParallelRouter) { childNodes.set(parallelRouterKey, new Map()) childNodesForParallelRouter = childNodes.get(parallelRouterKey)! } - // This relates to the segments in the current router - // tree[1].children[0] refers to tree.children.segment in the data format + // Get the active segment in the tree + // The reason arrays are used in the data format is that these are transferred from the server to the browser so it's optimized to save bytes. const treeSegment = tree[1][parallelRouterKey][0] + const childPropSegment = Array.isArray(childProp.segment) ? childProp.segment[1] : childProp.segment - const currentChildSegment = - (Array.isArray(treeSegment) ? treeSegment[1] : treeSegment) ?? - childPropSegment + + // If segment is an array it's a dynamic route and we want to read the dynamic route value as the segment to get from the cache. + const currentChildSegment = Array.isArray(treeSegment) + ? treeSegment[1] + : treeSegment + + /** + * Decides which segments to keep rendering, all segments that are not active will be wrapped in ``. + */ + // TODO-APP: Add handling of `` when it's available. const preservedSegments: string[] = [currentChildSegment] return ( @@ -294,6 +367,8 @@ export default function OuterLayoutRouter({ : null} */} {preservedSegments.map((preservedSegment) => { return ( + // Loading boundary is render for each segment to ensure they have their own loading state. + // The loading boundary is passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. , + action: Readonly< + ReloadAction | NavigateAction | RestoreAction | ServerPatchAction + > ): AppRouterState { switch (action.type) { case ACTION_RESTORE: { @@ -372,18 +422,20 @@ export function reducer( const href = url.pathname + url.search + url.hash return { + // Set canonical url canonicalUrl: href, pushRef: state.pushRef, - focusRef: state.focusRef, + focusAndScrollRef: state.focusAndScrollRef, cache: state.cache, + // Restore provided tree tree: tree, } } case ACTION_NAVIGATE: { const { url, cacheType, navigateType, cache, mutable } = action - const pendingPush = navigateType === 'push' ? true : false - const { pathname } = url - const href = url.pathname + url.search + url.hash + const { pathname, search, hash } = url + const href = pathname + search + hash + const pendingPush = navigateType === 'push' const segments = pathname.split('/') // TODO-APP: figure out something better for index pages @@ -391,6 +443,7 @@ export function reducer( // In case of soft push data fetching happens in layout-router if a segment is missing if (cacheType === 'soft') { + // Create optimistic tree that causes missing data to be fetched in layout-router during render. const optimisticTree = createOptimisticTree( segments, state.tree, @@ -400,10 +453,15 @@ export function reducer( ) return { + // Set href. canonicalUrl: href, + // Set pendingPush. mpaNavigation is handled during rendering in layout-router for this case. pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: true }, + // Existing cache is used for soft navigation. cache: state.cache, + // Optimistic tree is applied. tree: optimisticTree, } } @@ -412,24 +470,33 @@ export function reducer( // The with optimistic tree case only happens when the layouts have a loading state (loading.js) // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer if (cacheType === 'hard') { + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. if ( mutable.patchedTree && JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) ) { return { + // Set href. canonicalUrl: href, + // TODO-APP: verify mpaNavigation not being set is correct here. pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: true }, + // Apply cache. cache: cache, + // Apply patched router state. tree: mutable.patchedTree, } } // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary + /** + * If the tree can be optimistically rendered and suspend in layout-router instead of in the reducer. + */ const isOptimistic = canOptimisticallyRender(segments, state.tree) + // Optimistic tree case. if (isOptimistic) { - // Build optimistic tree // If the optimistic tree is deeper than the current state leave that deeper part out of the fetch const optimisticTree = createOptimisticTree( segments, @@ -441,54 +508,76 @@ export function reducer( // Fill in the cache with blank that holds the `data` field. // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. + // Copy subTreeData for the root node of the cache. cache.subTreeData = state.cache.subTreeData + // Copy existing cache nodes as far as possible and fill in `data` property with the started data fetch. + // The `data` property is used to suspend in layout-router during render if it hasn't resolved yet by the time it renders. const res = fillCacheWithDataProperty( cache, state.cache, segments.slice(1), - () => { - return fetchServerResponse(url, optimisticTree) - } + (): { readRoot: () => FlightData } => + fetchServerResponse(url, optimisticTree) ) + // If optimistic fetch couldn't happen it falls back to the non-optimistic case. if (!res?.bailOptimistic) { mutable.previousTree = state.tree mutable.patchedTree = optimisticTree return { + // Set href. canonicalUrl: href, + // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: true }, + // Apply patched cache. cache: cache, + // Apply optimistic tree. tree: optimisticTree, } } } + // Below is the not-optimistic case. + + // If no in-flight fetch at the top, start it. if (!cache.data) { cache.data = fetchServerResponse(url, state.tree) } + + // readRoot to suspend here (in the reducer) until the fetch resolves. const flightData = cache.data.readRoot() // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { return { canonicalUrl: flightData, + // Enable mpaNavigation pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, + // Don't apply scroll and focus management. + focusAndScrollRef: { apply: false }, cache: state.cache, tree: state.tree, } } + // Remove cache.data as it has been resolved at this point. cache.data = null + // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. const flightDataPath = flightData[0] + // The one before last item is the router state tree patch const [treePatch] = flightDataPath.slice(-2) - const treePath = flightDataPath.slice(0, -3) + + // Path without the last segment, router state, and the subTreeData + const flightSegmentPath = flightDataPath.slice(0, -3) + + // Create new tree based on the flightSegmentPath and router state patch const newTree = walkTreeWithFlightDataPath( // TODO-APP: remove '' - ['', ...treePath], + ['', ...flightSegmentPath], state.tree, treePatch ) @@ -496,46 +585,56 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree + // Copy subTreeData for the root node of the cache. cache.subTreeData = state.cache.subTreeData + // Create a copy of the existing cache with the subTreeData applied. fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) return { + // Set href. canonicalUrl: href, + // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: true }, + // Apply patched cache. cache: cache, + // Apply patched tree. tree: newTree, } } - return state + // This case should never be hit as `cacheType` is required and both cases are implemented. + // Short error to save bundle space. + throw new Error('Invalid navigate') } case ACTION_SERVER_PATCH: { const { flightData, previousTree, cache } = action + // When a fetch is slow to resolve it could be that you navigated away while the request was happening or before the reducer runs. + // In that case opt-out of applying the patch given that the data could be stale. if (JSON.stringify(previousTree) !== JSON.stringify(state.tree)) { // TODO-APP: Handle tree mismatch console.log('TREE MISMATCH') - return { - canonicalUrl: state.canonicalUrl, - pushRef: state.pushRef, - focusRef: state.focusRef, - tree: state.tree, - cache: state.cache, - } + // Keep everything as-is. + return state } // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { return { + // Set href. canonicalUrl: flightData, + // Enable mpaNavigation as this is a navigation that the app-router shouldn't handle. pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, + // Don't apply scroll and focus management. + focusAndScrollRef: { apply: false }, + // Other state is kept as-is. cache: state.cache, tree: state.tree, } } - // TODO-APP: flightData could hold multiple paths + // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. const flightDataPath = flightData[0] // Slices off the last segment (which is at -3) as it doesn't exist in the tree yet @@ -549,40 +648,49 @@ export function reducer( treePatch ) + // Copy subTreeData for the root node of the cache. cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) return { + // Keep href as it was set during navigate / restore canonicalUrl: state.canonicalUrl, + // Keep pushRef as server-patch only causes cache/tree update. pushRef: state.pushRef, - focusRef: state.focusRef, + // Keep focusAndScrollRef as server-patch only causes cache/tree update. + focusAndScrollRef: state.focusAndScrollRef, + // Apply patched router state tree: newTree, + // Apply patched cache cache: cache, } } case ACTION_RELOAD: { const { url, cache, mutable } = action const href = url.pathname + url.search + url.hash + // Reload is always a replace. const pendingPush = false - // When doing a hard push there can be two cases: with optimistic tree and without - // The with optimistic tree case only happens when the layouts have a loading state (loading.js) - // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer - + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. if ( mutable.patchedTree && JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) ) { return { + // Set href. canonicalUrl: href, + // set pendingPush (always false in this case). pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + // Apply focus and scroll. + // TODO-APP: might need to disable this for Fast Refresh. + focusAndScrollRef: { apply: true }, cache: cache, tree: mutable.patchedTree, } } if (!cache.data) { + // Fetch data from the root of the tree. cache.data = fetchServerResponse(url, [ state.tree[0], state.tree[1], @@ -597,23 +705,27 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, cache: state.cache, tree: state.tree, } } + // Remove cache.data as it has been resolved at this point. cache.data = null + // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. const flightDataPath = flightData[0] + // FlightDataPath with more than two items means unexpected Flight data was returned if (flightDataPath.length !== 2) { // TODO-APP: handle this case better console.log('RELOAD FAILED') return state } - const [treePatch, subTreeData] = flightDataPath.slice(-2) + // Given the path can only have two items the items are only the router state and subTreeData for the root. + const [treePatch, subTreeData] = flightDataPath const newTree = walkTreeWithFlightDataPath( // TODO-APP: remove '' [''], @@ -624,17 +736,23 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree + // Set subTreeData for the root node of the cache. cache.subTreeData = subTreeData return { + // Set href, this doesn't reuse the state.canonicalUrl as because of concurrent rendering the href might change between dispatching and applying. canonicalUrl: href, + // set pendingPush (always false in this case). pushRef: { pendingPush, mpaNavigation: false }, - // TODO-APP: Revisit if this needs to be true in certain cases - focusRef: { focus: false }, + // TODO-APP: might need to disable this for Fast Refresh. + focusAndScrollRef: { apply: false }, + // Apply patched cache. cache: cache, + // Apply patched router state. tree: newTree, } } + // This case should never be hit as dispatch is strongly typed. default: throw new Error('Unknown action') } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 34edab97d88..7abceb4c978 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -1,5 +1,5 @@ import React from 'react' -import type { FocusRef } from '../../client/components/reducer' +import type { FocusAndScrollRef } from '../../client/components/reducer' import type { FlightRouterState, FlightData } from '../../server/app-render' export type ChildSegmentMap = Map @@ -46,7 +46,7 @@ export type AppRouterInstance = { export const AppRouterContext = React.createContext( null as any ) -export const AppTreeContext = React.createContext<{ +export const LayoutRouterContext = React.createContext<{ childNodes: CacheNode['parallelRoutes'] tree: FlightRouterState url: string @@ -58,11 +58,11 @@ export const GlobalLayoutRouterContext = React.createContext<{ previousTree: FlightRouterState, flightData: FlightData ) => void - focusRef: FocusRef + focusAndScrollRef: FocusAndScrollRef }>(null as any) if (process.env.NODE_ENV !== 'production') { AppRouterContext.displayName = 'AppRouterContext' - AppTreeContext.displayName = 'AppTreeContext' + LayoutRouterContext.displayName = 'LayoutRouterContext' GlobalLayoutRouterContext.displayName = 'GlobalLayoutRouterContext' }