Skip to content

Commit

Permalink
Handle redirects in new router (#40396)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastian Markbåge <sebastian@calyptus.eu>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
3 people committed Sep 20, 2022
1 parent 0fb3284 commit c90e5f0
Show file tree
Hide file tree
Showing 23 changed files with 475 additions and 149 deletions.
28 changes: 22 additions & 6 deletions packages/next/client/components/app-router.client.tsx
Expand Up @@ -29,14 +29,22 @@ import {
} from './hooks-client-context'
import { useReducerWithReduxDevtools } from './use-reducer-with-devtools'

function urlToUrlWithoutFlightParameters(url: string): URL {
const urlWithoutFlightParameters = new URL(url, location.origin)
urlWithoutFlightParameters.searchParams.delete('__flight__')
urlWithoutFlightParameters.searchParams.delete('__flight_router_state_tree__')
urlWithoutFlightParameters.searchParams.delete('__flight_prefetch__')
return urlWithoutFlightParameters
}

/**
* Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side.
*/
export async function fetchServerResponse(
url: URL,
flightRouterState: FlightRouterState,
prefetch?: true
): Promise<[FlightData: FlightData]> {
): Promise<[FlightData: FlightData, canonicalUrlOverride: URL | undefined]> {
const flightUrl = new URL(url)
const searchParams = flightUrl.searchParams
// Enable flight response
Expand All @@ -51,9 +59,13 @@ export async function fetchServerResponse(
}

const res = await fetch(flightUrl.toString())
const canonicalUrl = res.redirected
? urlToUrlWithoutFlightParameters(res.url)
: undefined

// Handle the `fetch` readable stream that can be unwrapped by `React.use`.
const flightData: FlightData = await createFromFetch(Promise.resolve(res))
return [flightData]
return [flightData, canonicalUrl]
}

/**
Expand Down Expand Up @@ -140,11 +152,16 @@ export default function AppRouter({
* Server response that only patches the cache and tree.
*/
const changeByServerResponse = useCallback(
(previousTree: FlightRouterState, flightData: FlightData) => {
(
previousTree: FlightRouterState,
flightData: FlightData,
overrideCanonicalUrl: URL | undefined
) => {
dispatch({
type: ACTION_SERVER_PATCH,
flightData,
previousTree,
overrideCanonicalUrl,
cache: {
data: null,
subTreeData: null,
Expand Down Expand Up @@ -192,19 +209,18 @@ export default function AppRouter({

try {
// TODO-APP: handle case where history.state is not the new router history entry
const r = fetchServerResponse(
const serverResponse = await fetchServerResponse(
url,
// initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case.
window.history.state?.tree || initialTree,
true
)
const [flightData] = await r
// @ts-ignore startTransition exists
React.startTransition(() => {
dispatch({
type: ACTION_PREFETCH,
url,
flightData,
serverResponse,
})
})
} catch (err) {
Expand Down
22 changes: 22 additions & 0 deletions packages/next/client/components/infinite-promise.ts
@@ -0,0 +1,22 @@
/**
* Used to cache in createInfinitePromise
*/
let infinitePromise: Promise<void>

/**
* Create a Promise that does not resolve. This is used to suspend when data is not available yet.
*/
export function createInfinitePromise() {
if (!infinitePromise) {
// Only create the Promise once
infinitePromise = new Promise((/* resolve */) => {
// This is used to debug when the rendering is never updated.
// setTimeout(() => {
// infinitePromise = new Error('Infinite promise')
// resolve()
// }, 5000)
})
}

return infinitePromise
}
80 changes: 16 additions & 64 deletions packages/next/client/components/layout-router.client.tsx
Expand Up @@ -23,6 +23,8 @@ import {
TemplateContext,
} from '../../shared/lib/app-router-context'
import { fetchServerResponse } from './app-router.client'
import { createInfinitePromise } from './infinite-promise'

// import { matchSegment } from './match-segments'

/**
Expand Down Expand Up @@ -95,29 +97,6 @@ function walkAddRefetch(
return treeToRecreate
}

/**
* Used to cache in createInfinitePromise
*/
let infinitePromise: Promise<void> | 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 */) => {
// This is used to debug when the rendering is never updated.
// setTimeout(() => {
// infinitePromise = new Error('Infinite promise')
// resolve()
// }, 5000)
})
}

return infinitePromise
}

/**
* Check if the top of the HTMLElement is in the viewport.
*/
Expand Down Expand Up @@ -237,60 +216,33 @@ export function InnerLayoutRouter({
* Flight response data
*/
// When the data has not resolved yet `use` will suspend here.
const [flightData] = use(childNode.data)
const [flightData, overrideCanonicalUrl] = use(childNode.data)

// Handle case when navigating to page in `pages` from `app`
if (typeof flightData === 'string') {
window.location.href = url
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

// 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 (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) {
// 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(() => {
// TODO-APP: handle redirect
changeByServerResponse(fullTree, flightData)
})
// 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(() => {
// TODO-APP: handle redirect
changeByServerResponse(fullTree, flightData, overrideCanonicalUrl)
})
// Suspend infinitely as `changeByServerResponse` will cause a different part of the tree to be rendered.
throw createInfinitePromise()
}
})
// Suspend infinitely as `changeByServerResponse` will cause a different part of the tree to be rendered.
use(createInfinitePromise())
}

// 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()
use(createInfinitePromise())
}

const subtree = (
Expand Down
15 changes: 15 additions & 0 deletions packages/next/client/components/redirect-client.ts
@@ -0,0 +1,15 @@
import React, { experimental_use as use } from 'react'
import { AppRouterContext } from '../../shared/lib/app-router-context'
import { createInfinitePromise } from './infinite-promise'

export function redirect(url: string) {
const router = use(AppRouterContext)
setTimeout(() => {
// @ts-ignore startTransition exists
React.startTransition(() => {
router.replace(url, {})
})
})
// setTimeout is used to start a new transition during render, this is an intentional hack around React.
use(createInfinitePromise())
}
9 changes: 9 additions & 0 deletions packages/next/client/components/redirect.ts
@@ -0,0 +1,9 @@
export const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'

export function redirect(url: string) {
// eslint-disable-next-line no-throw-literal
throw {
url,
code: REDIRECT_ERROR_CODE,
}
}

0 comments on commit c90e5f0

Please sign in to comment.