rsnext/packages/next/client/components/reducer.ts
Tim Neutkens a4668f29b6
Add handling for back/forward (popstate) between old and new router (#38453)
Handles the case where you navigate between routes in `pages` and `app`.



## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples)
2022-07-08 13:54:52 +00:00

462 lines
12 KiB
TypeScript

import type { CacheNode } from '../../shared/lib/app-router-context'
import type {
FlightRouterState,
FlightData,
FlightDataPath,
} from '../../server/app-render'
import { matchSegment } from './match-segments'
import { fetchServerResponse } from './app-router.client'
const fillCacheWithNewSubTreeData = (
newCache: CacheNode,
existingCache: CacheNode,
flightDataPath: FlightDataPath
) => {
// TODO: handle case of / (root of the tree) refetch
const isLastEntry = flightDataPath.length <= 4
const [parallelRouteKey, segment] = flightDataPath
const segmentForCache = Array.isArray(segment) ? segment[1] : segment
const existingChildSegmentMap =
existingCache.parallelRoutes.get(parallelRouteKey)
if (!existingChildSegmentMap) {
// Bailout because the existing cache does not have the path to the leaf node
// Will trigger lazy fetch in layout-router because of missing segment
return
}
let childSegmentMap = newCache.parallelRoutes.get(parallelRouteKey)
if (!childSegmentMap || childSegmentMap === existingChildSegmentMap) {
childSegmentMap = new Map(existingChildSegmentMap)
newCache.parallelRoutes.set(parallelRouteKey, childSegmentMap)
}
const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache)
let childCacheNode = childSegmentMap.get(segmentForCache)
// In case of last segment start off the fetch at this level and don't copy further down.
if (isLastEntry) {
if (
!childCacheNode ||
!childCacheNode.data ||
childCacheNode === existingChildCacheNode
) {
childSegmentMap.set(segmentForCache, {
data: null,
subTreeData: flightDataPath[3],
parallelRoutes: new Map(),
})
}
return
}
if (!childCacheNode || !existingChildCacheNode) {
// Bailout because the existing cache does not have the path to the leaf node
// Will trigger lazy fetch in layout-router because of missing segment
return
}
if (childCacheNode === existingChildCacheNode) {
childCacheNode = {
data: childCacheNode.data,
subTreeData: childCacheNode.subTreeData,
parallelRoutes: new Map(childCacheNode.parallelRoutes),
}
childSegmentMap.set(segmentForCache, childCacheNode)
}
fillCacheWithNewSubTreeData(
childCacheNode,
existingChildCacheNode,
flightDataPath.slice(2)
)
}
const fillCacheWithDataProperty = (
newCache: CacheNode,
existingCache: CacheNode,
segments: string[],
fetchResponse: any
): { bailOptimistic: boolean } | undefined => {
const isLastEntry = segments.length === 1
const parallelRouteKey = 'children'
const [segment] = segments
const existingChildSegmentMap =
existingCache.parallelRoutes.get(parallelRouteKey)
if (!existingChildSegmentMap) {
// Bailout because the existing cache does not have the path to the leaf node
// Will trigger lazy fetch in layout-router because of missing segment
return { bailOptimistic: true }
}
let childSegmentMap = newCache.parallelRoutes.get(parallelRouteKey)
if (!childSegmentMap || childSegmentMap === existingChildSegmentMap) {
childSegmentMap = new Map(existingChildSegmentMap)
newCache.parallelRoutes.set(parallelRouteKey, childSegmentMap)
}
const existingChildCacheNode = existingChildSegmentMap.get(segment)
let childCacheNode = childSegmentMap.get(segment)
// In case of last segment start off the fetch at this level and don't copy further down.
if (isLastEntry) {
if (
!childCacheNode ||
!childCacheNode.data ||
childCacheNode === existingChildCacheNode
) {
childSegmentMap.set(segment, {
data: fetchResponse(),
subTreeData: null,
parallelRoutes: new Map(),
})
}
return
}
if (!childCacheNode || !existingChildCacheNode) {
// Start fetch in the place where the existing cache doesn't have the data yet.
if (!childCacheNode) {
childSegmentMap.set(segment, {
data: fetchResponse(),
subTreeData: null,
parallelRoutes: new Map(),
})
}
return
}
if (childCacheNode === existingChildCacheNode) {
childCacheNode = {
data: childCacheNode.data,
subTreeData: childCacheNode.subTreeData,
parallelRoutes: new Map(childCacheNode.parallelRoutes),
}
childSegmentMap.set(segment, childCacheNode)
}
return fillCacheWithDataProperty(
childCacheNode,
existingChildCacheNode,
segments.slice(1),
fetchResponse
)
}
const createOptimisticTree = (
segments: string[],
flightRouterState: FlightRouterState | null,
isFirstSegment: boolean,
parentRefetch: boolean,
href?: string
): FlightRouterState => {
const [existingSegment, existingParallelRoutes] = flightRouterState || [
null,
{},
]
const segment = segments[0]
const isLastSegment = segments.length === 1
const shouldRefetchThisLevel =
!flightRouterState || segment !== flightRouterState[0]
let parallelRoutes: FlightRouterState[1] = {}
if (existingSegment !== null && matchSegment(existingSegment, segment)) {
parallelRoutes = existingParallelRoutes
}
let childTree
if (!isLastSegment) {
const childItem = createOptimisticTree(
segments.slice(1),
parallelRoutes ? parallelRoutes.children : null,
false,
parentRefetch || shouldRefetchThisLevel
)
childTree = childItem
}
const result: FlightRouterState = [
segment,
{
...parallelRoutes,
...(childTree ? { children: childTree } : {}),
},
]
if (!parentRefetch && shouldRefetchThisLevel) {
result[3] = 'refetch'
}
// Add url into the tree
if (isFirstSegment) {
result[2] = href
}
return result
}
const walkTreeWithFlightDataPath = (
flightSegmentPath: FlightData[0],
flightRouterState: FlightRouterState,
treePatch: FlightRouterState
): FlightRouterState => {
const [segment, parallelRoutes, url] = flightRouterState
const [currentSegment, parallelRouteKey] = flightSegmentPath
// Tree path returned from the server should always match up with the current tree in the browser
// TODO: verify
if (!matchSegment(currentSegment, segment)) {
throw new Error('SEGMENT MISMATCH')
}
const lastSegment = flightSegmentPath.length === 2
const tree: FlightRouterState = [
flightSegmentPath[0],
{
...parallelRoutes,
[parallelRouteKey]: lastSegment
? treePatch
: walkTreeWithFlightDataPath(
flightSegmentPath.slice(2),
parallelRoutes[parallelRouteKey],
treePatch
),
},
]
if (url) {
tree.push(url)
}
return tree
}
type AppRouterState = {
tree: FlightRouterState
cache: CacheNode
pushRef: { pendingPush: boolean; mpaNavigation: boolean }
canonicalUrl: string
}
export function reducer(
state: AppRouterState,
action:
| {
type: 'navigate'
payload: {
url: URL
cacheType: 'soft' | 'hard'
navigateType: 'push' | 'replace'
cache: CacheNode
mutable: {
previousTree?: FlightRouterState
patchedTree?: FlightRouterState
}
}
}
| { type: 'restore'; payload: { url: URL; tree: FlightRouterState } }
| {
type: 'server-patch'
payload: {
flightData: FlightData
previousTree: FlightRouterState
cache: CacheNode
}
}
): AppRouterState {
if (action.type === 'restore') {
const { url, tree } = action.payload
const href = url.pathname + url.search + url.hash
return {
canonicalUrl: href,
pushRef: state.pushRef,
cache: state.cache,
tree: tree,
}
}
if (action.type === 'navigate') {
const { url, cacheType, navigateType, cache, mutable } = action.payload
const pendingPush = navigateType === 'push' ? true : false
const { pathname } = url
const href = url.pathname + url.search + url.hash
const segments = pathname.split('/')
// TODO: figure out something better for index pages
segments.push('')
// In case of soft push data fetching happens in layout-router if a segment is missing
if (cacheType === 'soft') {
const optimisticTree = createOptimisticTree(
segments,
state.tree,
true,
false,
href
)
return {
canonicalUrl: href,
pushRef: { pendingPush, mpaNavigation: false },
cache: state.cache,
tree: optimisticTree,
}
}
// 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
if (cacheType === 'hard') {
if (
mutable.patchedTree &&
JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree)
) {
return {
canonicalUrl: href,
pushRef: { pendingPush, mpaNavigation: false },
cache: cache,
tree: mutable.patchedTree,
}
}
// TODO: flag on the tree of which part of the tree for if there is a loading boundary
const isOptimistic = false
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,
state.tree,
true,
false,
href
)
// Fill in the cache with blank that holds the `data` field.
// TODO: segments.slice(1) strips '', we can get rid of '' altogether.
const res = fillCacheWithDataProperty(
cache,
state.cache,
segments.slice(1),
() => {
return fetchServerResponse(url, optimisticTree)
}
)
if (!res?.bailOptimistic) {
mutable.previousTree = state.tree
mutable.patchedTree = optimisticTree
return {
canonicalUrl: href,
pushRef: { pendingPush, mpaNavigation: false },
cache: cache,
tree: optimisticTree,
}
}
}
if (!cache.data) {
cache.data = fetchServerResponse(url, state.tree)
}
const flightData = cache.data.readRoot()
// Handle case when navigating to page in `pages` from `app`
if (typeof flightData === 'string') {
return {
canonicalUrl: flightData,
pushRef: { pendingPush: true, mpaNavigation: true },
cache: state.cache,
tree: state.tree,
}
}
cache.data = null
// TODO: ensure flightDataPath does not have "" as first item
const flightDataPath = flightData[0]
const [treePatch] = flightDataPath.slice(-2)
const treePath = flightDataPath.slice(0, -3)
const newTree = walkTreeWithFlightDataPath(
// TODO: remove ''
['', ...treePath],
state.tree,
treePatch
)
mutable.previousTree = state.tree
mutable.patchedTree = newTree
fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath)
return {
canonicalUrl: href,
pushRef: { pendingPush, mpaNavigation: false },
cache: cache,
tree: newTree,
}
}
return state
}
if (action.type === 'server-patch') {
const { flightData, previousTree, cache } = action.payload
if (JSON.stringify(previousTree) !== JSON.stringify(state.tree)) {
// TODO: Handle tree mismatch
console.log('TREE MISMATCH')
return {
canonicalUrl: state.canonicalUrl,
pushRef: state.pushRef,
tree: state.tree,
cache: state.cache,
}
}
// Handle case when navigating to page in `pages` from `app`
if (typeof flightData === 'string') {
return {
canonicalUrl: flightData,
pushRef: { pendingPush: true, mpaNavigation: true },
cache: state.cache,
tree: state.tree,
}
}
// TODO: flightData could hold multiple paths
const flightDataPath = flightData[0]
// Slices off the last segment (which is at -3) as it doesn't exist in the tree yet
const treePath = flightDataPath.slice(0, -3)
const [treePatch] = flightDataPath.slice(-2)
const newTree = walkTreeWithFlightDataPath(
// TODO: remove ''
['', ...treePath],
state.tree,
treePatch
)
fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath)
return {
canonicalUrl: state.canonicalUrl,
pushRef: state.pushRef,
tree: newTree,
cache: cache,
}
}
return state
}