next
Version:
The React Framework
529 lines (528 loc) • 25.9 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import React, { Activity, Fragment, useContext, use, Suspense, useDeferredValue, useLayoutEffect } from 'react';
import { LayoutRouterContext, GlobalLayoutRouterContext, TemplateContext } from '../../shared/lib/app-router-context.shared-runtime';
import { unresolvedThenable } from './unresolved-thenable';
import { ErrorBoundary } from './error-boundary';
import { disableSmoothScrollDuringRouteTransition } from '../../shared/lib/router/utils/disable-smooth-scroll';
import { RedirectBoundary } from './redirect-boundary';
import { HTTPAccessFallbackBoundary } from './http-access-fallback/error-boundary';
import { InstantValidationBoundaryContext, RenderValidationBoundaryAtThisLevel } from './instant-validation/boundary';
import { createSegmentKey } from './router-reducer/create-segment-key';
import { useRouterBFCache } from './bfcache-state-manager';
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths';
import { NavigationPromisesContext } from '../../shared/lib/hooks-client-context.shared-runtime';
import { getParamValueFromCacheKey } from '../route-params';
import { isDeferredRsc } from './render-tree';
/**
* Resolve the root scroll padding used by the viewport check.
*
* Computed lengths serialize as pixels, but percentages remain relative to
* the scrollport. Preserve the existing behavior for values that still
* contain unresolved CSS math.
*/ function getScrollPaddingTopInPixels(htmlElement, viewportHeight) {
const scrollPaddingTop = getComputedStyle(htmlElement).scrollPaddingTop;
const value = Number.parseFloat(scrollPaddingTop);
if (!Number.isFinite(value) || value < 0) {
return 0;
}
if (scrollPaddingTop.endsWith('px')) {
return value;
}
if (scrollPaddingTop.endsWith('%')) {
return value / 100 * viewportHeight;
}
return 0;
}
/**
* Check where the top corner of the HTMLElement is relative to the usable
* viewport.
*
* Scroll padding is resolved lazily so an empty Fragment does not trigger a
* computed style read. The caller caches the value for the second check.
*/ function getScrollTargetState(instance, viewportHeight, getScrollPaddingTop) {
const rects = instance.getClientRects();
if (rects.length === 0) {
return 0;
}
let elementTop = Number.POSITIVE_INFINITY;
for(let i = 0; i < rects.length; i++){
const rect = rects[i];
if (rect.top < elementTop) {
elementTop = rect.top;
}
}
return elementTop >= getScrollPaddingTop() && elementTop <= viewportHeight ? 1 : 2;
}
/**
* Find the DOM node for a hash fragment.
* If `top` the page has to scroll to the top of the page. This mirrors the browser's behavior.
* If the hash fragment is an id, the page has to scroll to the element with that id.
* If the hash fragment is a name, the page has to scroll to the first element with that name.
*/ function getHashFragmentDomNode(hashFragment) {
// If the hash fragment is `top` the page has to scroll to the top of the page.
if (hashFragment === 'top') {
return document.body;
}
// If the hash fragment is an id, the page has to scroll to the element with that id.
return document.getElementById(hashFragment) ?? // If the hash fragment is a name, the page has to scroll to the first element with that name.
document.getElementsByName(hashFragment)[0] ?? null;
}
/**
* Uses Fragment refs for scrolling.
* Does not focus the first host descendant.
*/ function InnerScrollHandler(props) {
const childrenRef = React.useRef(null);
useLayoutEffect(()=>{
const { scrollRef: scrollHandlerRef, renderTree } = props;
const scrollRef = scrollHandlerRef.forceScroll ? scrollHandlerRef.scrollRef : renderTree.data.scrollRef;
if (scrollRef === null || !scrollRef.current) return;
let instance = null;
const hashFragment = scrollHandlerRef.hashFragment;
if (hashFragment) {
instance = getHashFragmentDomNode(hashFragment);
if (instance === null) {
// A missing hash target is still a handled scroll intent. Do not
// fall back to the route Fragment or leave the intent pending.
scrollRef.current = false;
scrollHandlerRef.onlyHashChange = false;
scrollHandlerRef.hashFragment = null;
return;
}
} else {
instance = childrenRef.current;
}
// If there is no DOM node this layout-router level is skipped. It'll be handled higher-up in the tree.
if (instance === null) {
return;
}
let didHandleScroll = false;
disableSmoothScrollDuringRouteTransition(()=>{
const htmlElement = document.documentElement;
let viewportHeight = null;
let initialTargetState = null;
let scrollPaddingTop = null;
const getScrollPaddingTop = ()=>{
if (scrollPaddingTop === null) {
// Reuse the style and layout update from the geometry read.
scrollPaddingTop = getScrollPaddingTopInPixels(htmlElement, viewportHeight);
}
return scrollPaddingTop;
};
if (!hashFragment) {
// Store the current viewport height because reading `clientHeight` causes a reflow,
// and it won't change during this function.
viewportHeight = htmlElement.clientHeight;
initialTargetState = getScrollTargetState(instance, viewportHeight, getScrollPaddingTop);
// An empty Fragment is not a scroll target. In particular, avoid
// React's sibling fallback and leave the scroll signal available
// for another changed segment.
if (initialTargetState === 0) {
return;
}
}
didHandleScroll = true;
// Mark as scrolled so no other segment scrolls for this navigation.
scrollRef.current = false;
// This handler intentionally leaves focus untouched; resetting focus on
// navigation is deferred.
// In case of hash scroll, we only need to scroll the element into view
if (hashFragment) {
instance.scrollIntoView();
return;
}
// If the element's top edge is already in the viewport, exit early.
if (initialTargetState === 1) {
return;
}
// Otherwise, try scrolling go the top of the document to be backward compatible with pages
// scrollIntoView() called on `<html/>` element scrolls horizontally on chrome and firefox (that shouldn't happen)
// We could use it to scroll horizontally following RTL but that also seems to be broken - it will always scroll left
// scrollLeft = 0 also seems to ignore RTL and manually checking for RTL is too much hassle so we will scroll just vertically
htmlElement.scrollTop = 0;
// Scroll to domNode if domNode is not in viewport when scrolled to top of document
if (getScrollTargetState(instance, viewportHeight, getScrollPaddingTop) === 2) {
// Scroll into view doesn't scroll horizontally by default when not needed
instance.scrollIntoView();
}
}, {
// We will force layout by querying domNode position
dontForceLayout: true,
onlyHashChange: scrollHandlerRef.onlyHashChange
});
if (!didHandleScroll) {
return;
}
// Mutate after scrolling so that it can be read by `disableSmoothScrollDuringRouteTransition`
scrollHandlerRef.onlyHashChange = false;
scrollHandlerRef.hashFragment = null;
}, // Used to run on every commit. We may be able to be smarter about this
// but be prepared for lots of manual testing.
undefined);
return /*#__PURE__*/ _jsx(Fragment, {
ref: childrenRef,
children: props.children
});
}
function ScrollHandler({ children, renderTree }) {
const context = useContext(GlobalLayoutRouterContext);
if (!context) {
throw new Error('invariant global layout router not mounted');
}
return /*#__PURE__*/ _jsx(InnerScrollHandler, {
scrollRef: context.scrollRef,
renderTree: renderTree,
children: children
});
}
/**
* InnerLayoutRouter handles rendering the provided segment based on the cache.
*/ function InnerLayoutRouter({ tree, segmentPath, debugNameContext, renderTree, params, url, isActive }) {
const context = useContext(GlobalLayoutRouterContext);
const parentNavPromises = useContext(NavigationPromisesContext);
if (!context) {
throw new Error('invariant global layout router not mounted');
}
// `rsc` represents the renderable node for this segment.
// If this segment has a `prefetchRsc`, it's the statically prefetched data.
// We should use that on initial render instead of `rsc`. Then we'll switch
// to `rsc` when the dynamic response streams in.
//
// If no prefetch data is available, then we go straight to rendering `rsc`.
const resolvedPrefetchRsc = renderTree.data.prefetchRsc !== null ? renderTree.data.prefetchRsc : renderTree.data.rsc;
// We use `useDeferredValue` to handle switching between the prefetched and
// final values. The second argument is returned on initial render, then it
// re-renders with the first argument.
const rsc = useDeferredValue(renderTree.data.rsc, resolvedPrefetchRsc);
// `rsc` is either a React node or a promise for a React node, except we
// special case `null` to represent that this segment's data is missing. If
// it's a promise, we need to unwrap it so we can determine whether or not the
// data is missing.
let resolvedRsc;
if (isDeferredRsc(rsc)) {
const unwrappedRsc = use(rsc);
if (unwrappedRsc === null) {
// If the promise was resolved to `null`, it means the data for this
// segment was not returned by the server. Suspend indefinitely. When this
// happens, the router is responsible for triggering a new state update to
// un-suspend this segment.
use(unresolvedThenable);
}
resolvedRsc = unwrappedRsc;
} else {
// This is not a deferred RSC promise. Don't need to unwrap it.
if (rsc === null) {
use(unresolvedThenable);
}
resolvedRsc = rsc;
}
// In dev, we create a NavigationPromisesContext containing the instrumented promises that provide
// `useSelectedLayoutSegment` and `useSelectedLayoutSegments`.
// Promises are cached outside of render to survive suspense retries.
let navigationPromises = null;
if (process.env.NODE_ENV !== 'production') {
const { createNestedLayoutNavigationPromises } = require('./navigation-devtools');
navigationPromises = createNestedLayoutNavigationPromises(tree, parentNavPromises);
}
let children = resolvedRsc;
if (navigationPromises) {
children = /*#__PURE__*/ _jsx(NavigationPromisesContext.Provider, {
value: navigationPromises,
children: resolvedRsc
});
}
children = // The layout router context narrows down tree and childNodes at each level.
/*#__PURE__*/ _jsx(LayoutRouterContext.Provider, {
value: {
parentTree: tree,
parentRenderTree: renderTree,
parentSegmentPath: segmentPath,
parentParams: params,
// This is always set to null as we enter a child segment. It's
// populated by LoadingBoundaryProvider the next time we reach a
// loading boundary.
parentLoadingData: null,
debugNameContext: debugNameContext,
// TODO-APP: overriding of url for parallel routes
url: url,
isActive: isActive
},
children: children
});
return children;
}
export function LoadingBoundaryProvider({ loading, children }) {
// Provides the data needed to render a loading.tsx boundary, via context.
//
// loading.tsx creates a Suspense boundary around each of a layout's child
// slots. (Might be bit confusing to think about the data flow, but: if
// loading.tsx and layout.tsx are in the same directory, they are assigned
// to the same render tree.)
//
// This provider component does not render the Suspense boundary directly;
// that's handled by LoadingBoundary.
//
// TODO: For simplicity, we should combine this provider with LoadingBoundary
// and render the Suspense boundary directly. The only real benefit of doing
// it separately is so that when there are multiple parallel routes, we only
// send the boundary data once, rather than once per child. But that's a
// negligible benefit and can be achieved via caching instead.
const parentContext = use(LayoutRouterContext);
if (parentContext === null) {
return children;
}
// All values except for parentLoadingData are the same as the parent context.
return /*#__PURE__*/ _jsx(LayoutRouterContext.Provider, {
value: {
parentTree: parentContext.parentTree,
parentRenderTree: parentContext.parentRenderTree,
parentSegmentPath: parentContext.parentSegmentPath,
parentParams: parentContext.parentParams,
parentLoadingData: loading,
debugNameContext: parentContext.debugNameContext,
url: parentContext.url,
isActive: parentContext.isActive
},
children: children
});
}
/**
* 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({ name, loading, children }) {
// TODO: For LoadingBoundary, and the other built-in boundary types, don't
// wrap in an extra function component if no user-defined boundary is
// provided. In other words, inline this conditional wrapping logic into
// the parent component. More efficient and keeps unnecessary junk out of
// the component stack.
if (loading !== null) {
const loadingRsc = loading[0];
const loadingStyles = loading[1];
const loadingScripts = loading[2];
return /*#__PURE__*/ _jsx(Suspense, {
name: name,
fallback: /*#__PURE__*/ _jsxs(_Fragment, {
children: [
loadingStyles,
loadingScripts,
loadingRsc
]
}),
children: children
});
}
return /*#__PURE__*/ _jsx(_Fragment, {
children: children
});
}
/**
* OuterLayoutRouter handles the current segment as well as <Offscreen> 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, error, errorStyles, errorScripts, templateStyles, templateScripts, template, notFound, forbidden, unauthorized, segmentViewBoundaries }) {
const context = useContext(LayoutRouterContext);
if (!context) {
throw new Error('invariant expected layout router to be mounted');
}
const { parentTree, parentRenderTree, parentSegmentPath, parentParams, parentLoadingData, url, isActive, debugNameContext } = context;
// Get the render tree for this segment by reading it from the parent segment's
// child map.
const parentTreeSegment = parentTree[0];
const segmentPath = parentSegmentPath === null ? // path. This has led to a bunch of special cases scattered throughout
// the code. We should clean this up.
[
parallelRouterKey
] : parentSegmentPath.concat([
parentTreeSegment,
parallelRouterKey
]);
// The "state" key of a segment is the one passed to React. In the browser it
// represents the identity of the UI tree. Whenever the state key changes,
// the tree is recreated and the state is reset. In the App Router model,
// search params do not cause state to be lost, so two segments with the same
// segment path but different search params should have the same state key.
// On the server, the key describes the segment's structure instead, so it
// stays the same when unknown params become known during HTML resume.
//
// Whether the data can be reused is tracked separately, by the segment
// cache's vary paths.
const activeTree = parentTree[1][parallelRouterKey];
const activeRenderTree = parentRenderTree.slots?.get(parallelRouterKey);
if (activeTree === undefined || activeRenderTree === undefined) {
// Could not find a matching segment. The client tree is inconsistent with
// the server tree. Suspend indefinitely; the router will have already
// detected the inconsistency when handling the server response, and
// triggered a refresh of the page to recover.
use(unresolvedThenable);
}
let maybeValidationBoundaryId = null;
if (typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS) {
maybeValidationBoundaryId = use(InstantValidationBoundaryContext);
}
const activeSegment = activeTree[0];
const activeStateKey = createSegmentKey(activeSegment);
// At each level of the route tree, not only do we render the currently
// active segment — we also render the last N segments that were active at
// this level inside a hidden <Activity> boundary, to preserve their state
// if or when the user navigates to them again.
//
// bfcacheEntry is a linked list of FlightRouterStates.
let bfcacheEntry = useRouterBFCache(activeTree, activeRenderTree, activeStateKey);
let children = [];
do {
const tree = bfcacheEntry.tree;
const renderTree = bfcacheEntry.renderTree;
const stateKey = bfcacheEntry.stateKey;
const segment = tree[0];
/*
- Error boundary
- Only renders error boundary if error component is provided.
- Rendered for each segment to ensure they have their own error state.
- When gracefully degrade for bots, skip rendering error boundary.
- Loading boundary
- Only renders suspense boundary if loading components is provided.
- Rendered for each segment to ensure they have their own loading state.
- Passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch.
*/ let segmentBoundaryTriggerNode = null;
let segmentViewStateNode = null;
if (process.env.NODE_ENV !== 'production') {
const { SegmentBoundaryTriggerNode, SegmentViewStateNode } = require('../../next-devtools/userspace/app/segment-explorer-node');
const pagePrefix = normalizeAppPath(url);
segmentViewStateNode = /*#__PURE__*/ _jsx(SegmentViewStateNode, {
page: pagePrefix
}, pagePrefix);
segmentBoundaryTriggerNode = /*#__PURE__*/ _jsx(_Fragment, {
children: /*#__PURE__*/ _jsx(SegmentBoundaryTriggerNode, {})
});
}
let params = parentParams;
if (Array.isArray(segment)) {
// This segment contains a route param. Accumulate these as we traverse
// down the router tree. The result represents the set of params that
// the layout/page components are permitted to access below this point.
const paramName = segment[0];
const paramCacheKey = segment[1];
const paramType = segment[2];
const paramValue = getParamValueFromCacheKey(paramCacheKey, paramType);
if (paramValue !== null) {
params = {
...parentParams,
[paramName]: paramValue
};
}
}
const debugName = getBoundaryDebugNameFromSegment(segment);
// `debugNameContext` represents the nearest non-"virtual" parent segment.
// `getBoundaryDebugNameFromSegment` returns undefined for virtual segments.
// So if `debugName` is undefined, the context is passed through unchanged.
const childDebugNameContext = debugName ?? debugNameContext;
// In practical terms, clicking this name in the Suspense DevTools
// should select the child slots of that layout.
//
// So the name we apply to the Activity boundary is actually based on
// the nearest parent segments.
//
// We skip over "virtual" parents, i.e. ones inserted by Next.js that
// don't correspond to application-defined code.
const isVirtual = debugName === undefined;
const debugNameToDisplay = isVirtual ? undefined : debugNameContext;
let templateValue = /*#__PURE__*/ _jsxs(ScrollHandler, {
renderTree: renderTree,
children: [
/*#__PURE__*/ _jsx(ErrorBoundary, {
errorComponent: error,
errorStyles: errorStyles,
errorScripts: errorScripts,
children: /*#__PURE__*/ _jsx(LoadingBoundary, {
name: debugNameToDisplay,
// TODO: The loading module data for a segment is stored on the
// parent, then applied to each of that parent segment's
// parallel route slots. In the simple case where there's only
// one parallel route (the `children` slot), this is no
// different from if the loading module data were stored on the
// child directly. But I'm not sure this actually makes sense
// when there are multiple parallel routes. It's not a huge
// issue because you always have the option to define a narrower
// loading boundary for a particular slot. But this sort of
// smells like an implementation accident to me.
loading: parentLoadingData,
children: /*#__PURE__*/ _jsx(HTTPAccessFallbackBoundary, {
notFound: notFound,
forbidden: forbidden,
unauthorized: unauthorized,
children: /*#__PURE__*/ _jsxs(RedirectBoundary, {
children: [
/*#__PURE__*/ _jsx(InnerLayoutRouter, {
url: url,
tree: tree,
params: params,
renderTree: renderTree,
segmentPath: segmentPath,
debugNameContext: childDebugNameContext,
isActive: isActive && stateKey === activeStateKey
}),
segmentBoundaryTriggerNode
]
})
})
})
}),
segmentViewStateNode
]
});
if (typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS && typeof maybeValidationBoundaryId === 'string') {
templateValue = /*#__PURE__*/ _jsx(RenderValidationBoundaryAtThisLevel, {
id: maybeValidationBoundaryId,
children: templateValue
});
}
let child = /*#__PURE__*/ _jsxs(TemplateContext.Provider, {
value: templateValue,
children: [
templateStyles,
templateScripts,
template
]
}, stateKey);
if (process.env.NODE_ENV !== 'production') {
const { SegmentStateProvider } = require('../../next-devtools/userspace/app/segment-explorer-node');
child = /*#__PURE__*/ _jsxs(SegmentStateProvider, {
children: [
child,
segmentViewBoundaries
]
}, stateKey);
}
if (process.env.__NEXT_CACHE_COMPONENTS) {
child = /*#__PURE__*/ _jsx(Activity, {
name: debugNameToDisplay,
mode: stateKey === activeStateKey ? 'visible' : 'hidden',
children: child
}, stateKey);
}
children.push(child);
bfcacheEntry = bfcacheEntry.next;
}while (bfcacheEntry !== null);
return children;
}
function getBoundaryDebugNameFromSegment(segment) {
if (segment === '/') {
// Reached the root
return '/';
}
if (typeof segment === 'string') {
if (isVirtualLayout(segment)) {
return undefined;
} else {
return segment + '/';
}
}
const paramCacheKey = segment[1];
return paramCacheKey + '/';
}
function isVirtualLayout(segment) {
return(// This is inserted by the loader. Uses double-underscore convention
// (like __PAGE__ and __DEFAULT__) to avoid collisions with
// user-defined route groups.
segment === '(__SLOT__)');
}
//# sourceMappingURL=layout-router.js.map