Skip to content

Commit

Permalink
app router: support side effects on server requests (#48939)
Browse files Browse the repository at this point in the history
<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation or adding/fixing Examples

- The "examples guidelines" are followed from our contributing doc
https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md
- Make sure the linting passes by running `pnpm build && pnpm lint`. See
https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature
request has been accepted for implementation before opening a PR. (A
discussion must be opened, see
https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added
(https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md



## For Maintainers

- Minimal description (aim for explaining to someone not on the team to
understand the PR)
- When linking to a Slack thread, you might want to share details of the
conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->

link NEXT-920

---------

Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
  • Loading branch information
feedthejim and timneutkens authored May 3, 2023
1 parent 248f2de commit 2dc0ba4
Show file tree
Hide file tree
Showing 25 changed files with 871 additions and 127 deletions.
20 changes: 7 additions & 13 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ const babelIncludeRegexes: RegExp[] = [

const reactPackagesRegex = /^(react|react-dom|react-server-dom-webpack)($|\/)/

const staticGenerationAsyncStorageRegex =
/next[\\/]dist[\\/]client[\\/]components[\\/]static-generation-async-storage/
const asyncStoragesRegex =
/next[\\/]dist[\\/]client[\\/]components[\\/](static-generation-async-storage|action-async-storage)/

const mainFieldsPerCompiler: Record<CompilerNameValues, string[]> = {
[COMPILER_NAMES.server]: ['main', 'module'],
Expand Down Expand Up @@ -1841,10 +1841,7 @@ export default async function getBaseWebpackConfig(
and: [
codeCondition.test,
{
not: [
optOutBundlingPackageRegex,
staticGenerationAsyncStorageRegex,
],
not: [optOutBundlingPackageRegex, asyncStoragesRegex],
},
],
},
Expand All @@ -1866,7 +1863,7 @@ export default async function getBaseWebpackConfig(
// Make sure that AsyncLocalStorage module instance is shared between server and client
// layers.
layer: WEBPACK_LAYERS.shared,
test: staticGenerationAsyncStorageRegex,
test: asyncStoragesRegex,
},
]
: []),
Expand Down Expand Up @@ -1900,7 +1897,7 @@ export default async function getBaseWebpackConfig(
// Alias react for switching between default set and share subset.
oneOf: [
{
exclude: [staticGenerationAsyncStorageRegex],
exclude: [asyncStoragesRegex],
issuerLayer: {
or: [WEBPACK_LAYERS.server, WEBPACK_LAYERS.action],
},
Expand Down Expand Up @@ -1974,7 +1971,7 @@ export default async function getBaseWebpackConfig(
issuerLayer: {
or: [WEBPACK_LAYERS.server, WEBPACK_LAYERS.action],
},
exclude: [staticGenerationAsyncStorageRegex],
exclude: [asyncStoragesRegex],
use: swcLoaderForServerLayer,
},
{
Expand All @@ -1987,10 +1984,7 @@ export default async function getBaseWebpackConfig(
issuerLayer: {
or: [WEBPACK_LAYERS.client, WEBPACK_LAYERS.appClient],
},
exclude: [
staticGenerationAsyncStorageRegex,
codeCondition.exclude,
],
exclude: [asyncStoragesRegex, codeCondition.exclude],
use: [
...(dev && isClient
? [
Expand Down
29 changes: 13 additions & 16 deletions packages/next/src/client/app-call-server.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
// @ts-ignore
// eslint-disable-next-line import/no-extraneous-dependencies
import { encodeReply } from 'react-server-dom-webpack/client'
import { getServerActionDispatcher } from './components/app-router'

export async function callServer(id: string, args: any[]) {
const actionId = id
export async function callServer(actionId: string, actionArgs: any[]) {
const actionDispatcher = getServerActionDispatcher()

// Fetching the current url with the action header.
// TODO: Refactor this to look up from a manifest.
const res = await fetch('', {
method: 'POST',
headers: { Accept: 'text/x-component', 'Next-Action': actionId },
body: await encodeReply(args),
})

if (!res.ok) {
throw new Error(await res.text())
if (!actionDispatcher) {
throw new Error('Invariant: missing action dispatcher.')
}

return (await res.json())[0]
return new Promise((resolve, reject) => {
actionDispatcher({
actionId,
actionArgs,
resolve,
reject,
})
})
}
53 changes: 44 additions & 9 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ import {
ACTION_PREFETCH,
ACTION_REFRESH,
ACTION_RESTORE,
ACTION_SERVER_ACTION,
ACTION_SERVER_PATCH,
PrefetchKind,
RouterChangeByServerResponse,
RouterNavigate,
ServerActionDispatcher,
} from './router-reducer/router-reducer-types'
import { createHrefFromUrl } from './router-reducer/create-href-from-url'
import {
Expand Down Expand Up @@ -53,6 +57,12 @@ let initialParallelRoutes: CacheNode['parallelRoutes'] = isServer
? null!
: new Map()

let globalServerActionDispatcher = null as ServerActionDispatcher | null

export function getServerActionDispatcher() {
return globalServerActionDispatcher
}

export function urlToUrlWithoutFlightMarker(url: string): URL {
const urlWithoutFlightParameters = new URL(url, location.origin)
// TODO-APP: handle .rsc for static export case
Expand Down Expand Up @@ -170,7 +180,7 @@ function Router({
/**
* Server response that only patches the cache and tree.
*/
const changeByServerResponse = useCallback(
const changeByServerResponse: RouterChangeByServerResponse = useCallback(
(
previousTree: FlightRouterState,
flightData: FlightData,
Expand All @@ -193,11 +203,8 @@ function Router({
[dispatch]
)

/**
* The app router that is exposed through `useRouter`. It's only concerned with dispatching actions to the reducer, does not hold state.
*/
const appRouter = useMemo<AppRouterInstance>(() => {
const navigate = (
const navigate: RouterNavigate = useCallback(
(
href: string,
navigateType: 'push' | 'replace',
forceOptimisticNavigation: boolean
Expand All @@ -219,8 +226,31 @@ function Router({
},
mutable: {},
})
}
},
[dispatch]
)

const serverActionDispatcher: ServerActionDispatcher = useCallback(
(actionPayload) => {
React.startTransition(() => {
dispatch({
...actionPayload,
type: ACTION_SERVER_ACTION,
mutable: {},
navigate,
changeByServerResponse,
})
})
},
[changeByServerResponse, dispatch, navigate]
)

globalServerActionDispatcher = serverActionDispatcher

/**
* The app router that is exposed through `useRouter`. It's only concerned with dispatching actions to the reducer, does not hold state.
*/
const appRouter = useMemo<AppRouterInstance>(() => {
const routerInstance: AppRouterInstance = {
back: () => window.history.back(),
forward: () => window.history.forward(),
Expand Down Expand Up @@ -297,13 +327,18 @@ function Router({
}

return routerInstance
}, [dispatch])
}, [dispatch, navigate])

// Add `window.nd` for debugging purposes.
// This is not meant for use in applications as concurrent rendering will affect the cache/tree/router.
if (typeof window !== 'undefined') {
// @ts-ignore this is for debugging
window.nd = { router: appRouter, cache, prefetchCache, tree }
window.nd = {
router: appRouter,
cache,
prefetchCache,
tree,
}
}

// When mpaNavigation flag is set do a hard navigation to the new url.
Expand Down
29 changes: 21 additions & 8 deletions packages/next/src/client/components/redirect-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import React, { useEffect } from 'react'
import { AppRouterInstance } from '../../shared/lib/app-router-context'
import { useRouter } from './navigation'
import { getURLFromRedirectError, isRedirectError } from './redirect'
import {
RedirectType,
getRedirectTypeFromError,
getURLFromRedirectError,
isRedirectError,
} from './redirect'

interface RedirectBoundaryProps {
router: AppRouterInstance
Expand All @@ -12,47 +17,55 @@ interface RedirectBoundaryProps {
function HandleRedirect({
redirect,
reset,
redirectType,
}: {
redirect: string
redirectType: RedirectType
reset: () => void
}) {
const router = useRouter()

useEffect(() => {
// @ts-ignore startTransition exists
React.startTransition(() => {
router.replace(redirect, {})
if (redirectType === RedirectType.push) {
router.push(redirect, {})
} else {
router.replace(redirect, {})
}
reset()
})
}, [redirect, reset, router])
}, [redirect, redirectType, reset, router])

return null
}

export class RedirectErrorBoundary extends React.Component<
RedirectBoundaryProps,
{ redirect: string | null }
{ redirect: string | null; redirectType: RedirectType | null }
> {
constructor(props: RedirectBoundaryProps) {
super(props)
this.state = { redirect: null }
this.state = { redirect: null, redirectType: null }
}

static getDerivedStateFromError(error: any) {
if (isRedirectError(error)) {
const url = getURLFromRedirectError(error)
return { redirect: url }
const redirectType = getRedirectTypeFromError(error)
return { redirect: url, redirectType }
}
// Re-throw if error is not for redirect
throw error
}

render() {
const redirect = this.state.redirect
if (redirect !== null) {
const { redirect, redirectType } = this.state
if (redirect !== null && redirectType !== null) {
return (
<HandleRedirect
redirect={redirect}
redirectType={redirectType}
reset={() => this.setState({ redirect: null })}
/>
)
Expand Down
52 changes: 41 additions & 11 deletions packages/next/src/client/components/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'

export enum RedirectType {
push = 'push',
replace = 'replace',
}

type RedirectError<U extends string> = Error & {
digest: `${typeof REDIRECT_ERROR_CODE};${U}`
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U}`
}

export function getRedirectError(
url: string,
type: RedirectType
): RedirectError<typeof url> {
// eslint-disable-next-line no-throw-literal
const error = new Error(REDIRECT_ERROR_CODE)
;(
error as RedirectError<typeof url>
).digest = `${REDIRECT_ERROR_CODE};${type};${url}`
return error as RedirectError<typeof url>
}

/**
Expand All @@ -11,11 +28,11 @@ type RedirectError<U extends string> = Error & {
*
* @param url the url to redirect to
*/
export function redirect(url: string): never {
// eslint-disable-next-line no-throw-literal
const error = new Error(REDIRECT_ERROR_CODE)
;(error as RedirectError<typeof url>).digest = `${REDIRECT_ERROR_CODE};${url}`
throw error
export function redirect(
url: string,
type: RedirectType = RedirectType.replace
): never {
throw getRedirectError(url, type)
}

/**
Expand All @@ -28,10 +45,14 @@ export function redirect(url: string): never {
export function isRedirectError<U extends string>(
error: any
): error is RedirectError<U> {
if (typeof error?.digest !== 'string') return false

const [errorCode, type, destination] = (error.digest as string).split(';', 3)

return (
typeof error?.digest === 'string' &&
error.digest.startsWith(REDIRECT_ERROR_CODE + ';') &&
error.digest.length > REDIRECT_ERROR_CODE.length + 1
errorCode === REDIRECT_ERROR_CODE &&
(type === 'replace' || type === 'push') &&
typeof destination === 'string'
)
}

Expand All @@ -45,11 +66,20 @@ export function isRedirectError<U extends string>(
export function getURLFromRedirectError<U extends string>(
error: RedirectError<U>
): U
export function getURLFromRedirectError(error: any): string | null
export function getURLFromRedirectError(error: any): string | null {
if (!isRedirectError(error)) return null

// Slices off the beginning of the digest that contains the code and the
// separating ';'.
return error.digest.slice(REDIRECT_ERROR_CODE.length + 1)
return error.digest.split(';', 3)[2]
}

export function getRedirectTypeFromError<U extends string>(
error: RedirectError<U>
): RedirectType {
if (!isRedirectError(error)) {
throw new Error('Not a redirect error')
}

return error.digest.split(';', 3)[1] as RedirectType
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function navigateReducer(
) {
const segments = pathname.split('/')
// TODO-APP: figure out something better for index pages
segments.push('')
segments.push('__PAGE__')

// Optimistic tree case.
// If the optimistic tree is deeper than the current state leave that deeper part out of the fetch
Expand All @@ -165,7 +165,7 @@ export function navigateReducer(
// TODO-APP: re-evaluate if we need to strip the last segment
const optimisticFlightSegmentPath = segments
.slice(1)
.map((segment) => ['children', segment === '' ? '__PAGE__' : segment])
.map((segment) => ['children', segment])
.flat()

// Copy existing cache nodes as far as possible and fill in `data` property with the started data fetch.
Expand Down
Loading

0 comments on commit 2dc0ba4

Please sign in to comment.