Skip to content

Commit

Permalink
Separate next/dynamic implementation for app and pages (#45565)
Browse files Browse the repository at this point in the history
## Issue

To address the problem that we introduced in 13.0.7 (#42589) where we thought we could use same implementation `next/dynamic` for both `pages/` and `app/` directory. But it turns out it leads to many problems, such as:

* SSR preloading could miss the content, especially with nested dynamic calls
  * Closes #45213
* Introducing suspense boundary into `next/dynamic` with extra wrapped `<Suspense>` outside will lead to content is not resolevd during SSR
  * Related #45151
  * Closes #45099
* Unexpected hydration errors for suspense boundaries. Though react removed this error but the 18.3 is not out yet.
  * Closes #44083
  * Closes #45246
 
## Solution

Separate the dynamic implementation for `app/` dir and `pages/`. 

For `app/` dir we can encourage users to: 
  * Directly use `React.lazy` + `Suspense` for SSR'd content, and `next/dynamic` 
  * For non SSR components since it requires some internal integeration with next.js.

For `pages/` dir we still keep the original implementation

If you want to use `<Suspense>` with dynamic `fallback` value, use `React.lazy` + `Suspense` directly instead of picking up `next/dynamic` 
  * Closes #45116

This will solve various issue before react 18.3 is out and let users still progressively upgrade to new versions of next.js.

## Bug Fix

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)
  • Loading branch information
huozhi committed Feb 4, 2023
1 parent e3ed2f2 commit a2dc530
Show file tree
Hide file tree
Showing 34 changed files with 257 additions and 87 deletions.
9 changes: 6 additions & 3 deletions packages/next/src/build/webpack-config.ts
Expand Up @@ -1166,7 +1166,7 @@ export default async function getBaseWebpackConfig(
if (layer === WEBPACK_LAYERS.server) return

const isNextExternal =
/next[/\\]dist[/\\](esm[\\/])?(shared|server)[/\\](?!lib[/\\](router[/\\]router|dynamic|head[^-]))/.test(
/next[/\\]dist[/\\](esm[\\/])?(shared|server)[/\\](?!lib[/\\](router[/\\]router|dynamic|app-dynamic|head[^-]))/.test(
localRes
)

Expand Down Expand Up @@ -1718,16 +1718,19 @@ export default async function getBaseWebpackConfig(
: []),
...(hasServerComponents
? [
// Alias next/head component to noop for RSC
{
test: codeCondition.test,
issuerLayer: appDirIssuerLayer,
resolve: {
alias: {
// Alias `next/dynamic` to React.lazy implementation for RSC
// Alias next/head component to noop for RSC
[require.resolve('next/head')]: require.resolve(
'next/dist/client/components/noop-head'
),
// Alias next/dynamic
[require.resolve('next/dynamic')]: require.resolve(
'next/dist/shared/lib/app-dynamic'
),
},
},
},
Expand Down
@@ -1,4 +1,4 @@
import { suspense } from '../../shared/lib/dynamic-no-ssr'
import { suspense } from '../../shared/lib/app-dynamic/dynamic-no-ssr'
import { staticGenerationAsyncStorage } from './static-generation-async-storage'

export function bailoutToClientRendering(): boolean | never {
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/client/on-recoverable-error.ts
@@ -1,4 +1,4 @@
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/no-ssr-error'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/app-dynamic/no-ssr-error'

export default function onRecoverableError(err: any, errorInfo: any) {
const digest = err.digest || errorInfo.digest
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/export/worker.ts
Expand Up @@ -32,7 +32,7 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import { REDIRECT_ERROR_CODE } from '../client/components/redirect'
import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context'
import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/no-ssr-error'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/app-dynamic/no-ssr-error'
import { IncrementalCache } from '../server/lib/incremental-cache'

loadRequireHook()
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/app-render.tsx
Expand Up @@ -36,7 +36,7 @@ import { REDIRECT_ERROR_CODE } from '../client/components/redirect'
import { RequestCookies } from './web/spec-extension/cookies'
import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context'
import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/no-ssr-error'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/app-dynamic/no-ssr-error'
import { HeadManagerContext } from '../shared/lib/head-manager-context'
import { Writable } from 'stream'
import stringHash from 'next/dist/compiled/string-hash'
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/base-server.ts
Expand Up @@ -1954,7 +1954,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
<script>
async function check() {
const res = await fetch(location.href).catch(() => ({}))
if (res.status === 200) {
location.reload()
} else {
Expand Down
8 changes: 0 additions & 8 deletions packages/next/src/server/render.tsx
Expand Up @@ -89,7 +89,6 @@ import {
} from '../shared/lib/router/adapters'
import { AppRouterContext } from '../shared/lib/app-router-context'
import { SearchParamsContext } from '../shared/lib/hooks-client-context'
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/no-ssr-error'

let tryGetPreviewData: typeof import('./api-utils/node').tryGetPreviewData
let warn: typeof import('../build/output/log').warn
Expand Down Expand Up @@ -1245,13 +1244,6 @@ export async function renderToHTML(
return await renderToInitialStream({
ReactDOMServer,
element: content,
streamOptions: {
onError(streamingErr: any) {
if (streamingErr?.digest === NEXT_DYNAMIC_NO_SSR_CODE) {
return streamingErr.digest
}
},
},
})
}

Expand Down
90 changes: 90 additions & 0 deletions packages/next/src/shared/lib/app-dynamic/index.tsx
@@ -0,0 +1,90 @@
import React from 'react'
import Loadable from './loadable'

type ComponentModule<P = {}> = { default: React.ComponentType<P> }

export declare type LoaderComponent<P = {}> = Promise<
React.ComponentType<P> | ComponentModule<P>
>

export declare type Loader<P = {}> = () => LoaderComponent<P>

export type LoaderMap = { [module: string]: () => Loader<any> }

export type LoadableGeneratedOptions = {
webpack?(): any
modules?(): LoaderMap
}

export type DynamicOptionsLoadingProps = {
error?: Error | null
isLoading?: boolean
pastDelay?: boolean
retry?: () => void
timedOut?: boolean
}

// Normalize loader to return the module as form { default: Component } for `React.lazy`.
// Also for backward compatible since next/dynamic allows to resolve a component directly with loader
// Client component reference proxy need to be converted to a module.
function convertModule<P>(mod: React.ComponentType<P> | ComponentModule<P>) {
return { default: (mod as ComponentModule<P>)?.default || mod }
}

export type DynamicOptions<P = {}> = LoadableGeneratedOptions & {
loading?: (loadingProps: DynamicOptionsLoadingProps) => JSX.Element | null
loader?: Loader<P>
loadableGenerated?: LoadableGeneratedOptions
ssr?: boolean
}

export type LoadableOptions<P = {}> = DynamicOptions<P>

export type LoadableFn<P = {}> = (
opts: LoadableOptions<P>
) => React.ComponentType<P>

export type LoadableComponent<P = {}> = React.ComponentType<P>

export default function dynamic<P = {}>(
dynamicOptions: DynamicOptions<P> | Loader<P>,
options?: DynamicOptions<P>
): React.ComponentType<P> {
const loadableFn: LoadableFn<P> = Loadable

const loadableOptions: LoadableOptions<P> = {
// A loading component is not required, so we default it
loading: ({ error, isLoading, pastDelay }) => {
if (!pastDelay) return null
if (process.env.NODE_ENV !== 'production') {
if (isLoading) {
return null
}
if (error) {
return (
<p>
{error.message}
<br />
{error.stack}
</p>
)
}
}
return null
},
}

if (typeof dynamicOptions === 'function') {
loadableOptions.loader = dynamicOptions
}

Object.assign(loadableOptions, options)

const loaderFn = loadableOptions.loader as () => LoaderComponent<P>
const loader = () =>
loaderFn != null
? loaderFn().then(convertModule)
: Promise.resolve(convertModule(() => null))

return loadableFn({ ...loadableOptions, loader: loader as Loader<P> })
}
39 changes: 39 additions & 0 deletions packages/next/src/shared/lib/app-dynamic/loadable.tsx
@@ -0,0 +1,39 @@
import React from 'react'
import { NoSSR } from './dynamic-no-ssr'

function Loadable(options: any) {
const opts = Object.assign(
{
loader: null,
loading: null,
ssr: true,
},
options
)

opts.lazy = React.lazy(opts.loader)

function LoadableComponent(props: any) {
const Loading = opts.loading
const fallbackElement = (
<Loading isLoading={true} pastDelay={true} error={null} />
)

const Wrap = opts.ssr ? React.Fragment : NoSSR
const Lazy = opts.lazy

return (
<React.Suspense fallback={fallbackElement}>
<Wrap>
<Lazy {...props} />
</Wrap>
</React.Suspense>
)
}

LoadableComponent.displayName = 'LoadableComponent'

return LoadableComponent
}

export default Loadable
24 changes: 24 additions & 0 deletions packages/next/src/shared/lib/dynamic.tsx
@@ -1,6 +1,8 @@
import React from 'react'
import Loadable from './loadable'

const isServerSide = typeof window === 'undefined'

type ComponentModule<P = {}> = { default: React.ComponentType<P> }

export declare type LoaderComponent<P = {}> = Promise<
Expand Down Expand Up @@ -52,6 +54,26 @@ export type LoadableFn<P = {}> = (

export type LoadableComponent<P = {}> = React.ComponentType<P>

export function noSSR<P = {}>(
LoadableInitializer: LoadableFn<P>,
loadableOptions: DynamicOptions<P>
): React.ComponentType<P> {
// Removing webpack and modules means react-loadable won't try preloading
delete loadableOptions.webpack
delete loadableOptions.modules

// This check is necessary to prevent react-loadable from initializing on the server
if (!isServerSide) {
return LoadableInitializer(loadableOptions)
}

const Loading = loadableOptions.loading!
// This will only be rendered on the server side
return () => (
<Loading error={null} isLoading pastDelay={false} timedOut={false} />
)
}

export default function dynamic<P = {}>(
dynamicOptions: DynamicOptions<P> | Loader<P>,
options?: DynamicOptions<P>
Expand Down Expand Up @@ -116,6 +138,8 @@ export default function dynamic<P = {}>(
if (typeof loadableOptions.ssr === 'boolean' && !loadableOptions.ssr) {
delete loadableOptions.webpack
delete loadableOptions.modules

return noSSR(loadableFn, loadableOptions)
}

return loadableFn({ ...loadableOptions, loader: loader as Loader<P> })
Expand Down
60 changes: 32 additions & 28 deletions packages/next/src/shared/lib/loadable.tsx
Expand Up @@ -23,9 +23,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE
// Modified to be compatible with webpack 4 / Next.js

import React from 'react'
import { NoSSR } from './dynamic-no-ssr'
import { LoadableContext } from './loadable-context'

function resolve(obj: any) {
return obj && obj.default ? obj.default : obj
}

const ALL_INITIALIZERS: any[] = []
const READY_INITIALIZERS: any[] = []
let initialized = false
Expand Down Expand Up @@ -63,7 +66,6 @@ function createLoadableComponent(loadFn: any, options: any) {
timeout: null,
webpack: null,
modules: null,
ssr: true,
},
options
)
Expand All @@ -84,18 +86,6 @@ function createLoadableComponent(loadFn: any, options: any) {
return subscription.promise()
}

opts.lazy = React.lazy(async () => {
// If dynamic options.ssr == true during SSR,
// passing the preloaded promise of component to `React.lazy`.
// This guarantees the loader is always resolved after preloading.
if (opts.ssr && subscription) {
const value = subscription.getCurrentValue()
const resolved = await value.loaded
if (resolved) return resolved
}
return await opts.loader()
})

// Server only
if (typeof window === 'undefined') {
ALL_INITIALIZERS.push(init)
Expand Down Expand Up @@ -130,30 +120,44 @@ function createLoadableComponent(loadFn: any, options: any) {
}
}

function LoadableComponent(props: any) {
function LoadableComponent(props: any, ref: any) {
useLoadableModule()

const Loading = opts.loading
const fallbackElement = (
<Loading isLoading={true} pastDelay={true} error={null} />
const state = (React as any).useSyncExternalStore(
subscription.subscribe,
subscription.getCurrentValue,
subscription.getCurrentValue
)

const Wrap = opts.ssr ? React.Fragment : NoSSR
const Lazy = opts.lazy

return (
<React.Suspense fallback={fallbackElement}>
<Wrap>
<Lazy {...props} />
</Wrap>
</React.Suspense>
React.useImperativeHandle(
ref,
() => ({
retry: subscription.retry,
}),
[]
)

return React.useMemo(() => {
if (state.loading || state.error) {
return React.createElement(opts.loading, {
isLoading: state.loading,
pastDelay: state.pastDelay,
timedOut: state.timedOut,
error: state.error,
retry: subscription.retry,
})
} else if (state.loaded) {
return React.createElement(resolve(state.loaded), props)
} else {
return null
}
}, [props, state])
}

LoadableComponent.preload = () => init()
LoadableComponent.displayName = 'LoadableComponent'

return LoadableComponent
return React.forwardRef(LoadableComponent)
}

class LoadableSubscription {
Expand Down
8 changes: 8 additions & 0 deletions test/development/basic/next-dynamic.test.ts
Expand Up @@ -102,6 +102,14 @@ describe.each([
}
})

it('should SSR nested dynamic components and skip nonSSR ones', async () => {
const $ = await get$(basePath + '/dynamic/nested')
const text = $('#__next').text()
expect(text).toContain('Nested 1')
expect(text).toContain('Nested 2')
expect(text).not.toContain('Browser hydrated')
})

it('should hydrate nested chunks', async () => {
let browser
try {
Expand Down

0 comments on commit a2dc530

Please sign in to comment.