UNPKG

next

Version:

The React Framework

529 lines (528 loc) • 25.9 kB
'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