rsnext/packages/next/shared/lib/dynamic.tsx
Jiachi Liu cd0ebd8e8c
Implement loadable with lazy and suspense for next dynamic (#42589)
### Summary

Migrate `next/dynamic` to implementation based on `React.lazy` and
`Suspense`. Then it becomes easier to migrate the existing code in pages
to layouts. Then we can support both `ssr` and `loading` option for
`next/dynamic`.

For `loading` option, it will work like `Suspense`'s `fallback` property

```js
<Suspense fallback={loading}>
  <DynamicComponent />
 </Suspense>
```

For `ssr` option, by default `React.lazy` supports SSR, but we'll
disable the `ssr: false` case for dynamic import in server components
since there's no client side involved.

Then we don't need `suspense` option anymore as react >= 18 is always
required. Mark it as deprecated.

It also supports to load client component dynamically in server
components now.

#### Code code changes
* switch loadable component to `lazy` + `Suspense`
* will make sure it's retuning a module from `loader()` to
`loader().then(mod => ({ default: mod.default || mod }))` since `lazy()`
only accepts loader returning a module
* Inside suspense boundary, throwing an error for ssr: false, catch the
error on server and client side and ignore it.
* Ignore options like ssr: false for server components since they're on
server, doesn't make sense
* Remove legacy dynamic related transform
#### Feature changes
* `next/dynamic` will work in the same way across the board (appDir and
pages)
* For the throwing error, will make it become a API that throws error
later in the future, so users can customize more with `Suspense`
* You can load client components now in server components with dynamic.
Resolves #43147

#### Tests
* existing dynamic tests all work
* add case: import client component and load through next/dynamic in
server components

### Issues
2022-12-07 19:42:10 +01:00

144 lines
4.5 KiB
TypeScript

import React, { lazy, Suspense } from 'react'
import Loadable from './loadable'
import NoSSR from './dynamic-no-ssr'
type ComponentModule<P> = { default: React.ComponentType<P> }
export type LoaderComponent<P = {}> = Promise<ComponentModule<P>>
export 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<T>(mod: ComponentModule<T>) {
return { default: mod.default || mod }
}
export type DynamicOptions<P = {}> = LoadableGeneratedOptions & {
loading?: (loadingProps: DynamicOptionsLoadingProps) => JSX.Element | null
loader?: Loader<P> | LoaderMap
loadableGenerated?: LoadableGeneratedOptions
ssr?: boolean
/**
* @deprecated `suspense` prop is not required anymore
*/
suspense?: 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 function noSSR<P = {}>(
LoadableInitializer: Loader,
loadableOptions: DynamicOptions<P>
): React.ComponentType<P> {
// Removing webpack and modules means react-loadable won't try preloading
delete loadableOptions.webpack
delete loadableOptions.modules
const NoSSRComponent = lazy(LoadableInitializer)
const Loading = loadableOptions.loading!
const fallback = (
<Loading error={null} isLoading pastDelay={false} timedOut={false} />
)
return () => (
<Suspense fallback={fallback}>
<NoSSR>
<NoSSRComponent />
</NoSSR>
</Suspense>
)
}
export default function dynamic<P = {}>(
dynamicOptions: DynamicOptions<P> | Loader<P>,
options?: DynamicOptions<P>
): React.ComponentType<P> {
let loadableFn: LoadableFn<P> = Loadable
let 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
},
}
// Support for direct import(), eg: dynamic(import('../hello-world'))
// Note that this is only kept for the edge case where someone is passing in a promise as first argument
// The react-loadable babel plugin will turn dynamic(import('../hello-world')) into dynamic(() => import('../hello-world'))
// To make sure we don't execute the import without rendering first
if (dynamicOptions instanceof Promise) {
loadableOptions.loader = () => dynamicOptions
// Support for having import as a function, eg: dynamic(() => import('../hello-world'))
} else if (typeof dynamicOptions === 'function') {
loadableOptions.loader = dynamicOptions
// Support for having first argument being options, eg: dynamic({loader: import('../hello-world')})
} else if (typeof dynamicOptions === 'object') {
loadableOptions = { ...loadableOptions, ...dynamicOptions }
}
// Support for passing options, eg: dynamic(import('../hello-world'), {loading: () => <p>Loading something</p>})
loadableOptions = { ...loadableOptions, ...options }
const loaderFn = loadableOptions.loader as Loader<P>
const loader = () => loaderFn().then(convertModule)
// coming from build/babel/plugins/react-loadable-plugin.js
if (loadableOptions.loadableGenerated) {
loadableOptions = {
...loadableOptions,
...loadableOptions.loadableGenerated,
loader,
}
delete loadableOptions.loadableGenerated
}
// support for disabling server side rendering, eg: dynamic(() => import('../hello-world'), {ssr: false}).
if (typeof loadableOptions.ssr === 'boolean') {
if (!loadableOptions.ssr) {
delete loadableOptions.ssr
return noSSR(loader as Loader, loadableOptions)
}
delete loadableOptions.ssr
}
return loadableFn(loadableOptions)
}