Skip to content

Commit d14da39

Browse files
authored
Support suspense in next dynamic (#27611)
### Changes #### Feature * Adopt `React.lazy` into `next/dynamic`, enable it when `options.suspense` is `true` * Support `next/dynamic` with `suspense=true` in SSR and SSG #### Tests | Scenario | Case | Behavior | |:----:|:----:|:----:| | basics | react 17 or 18 by default | dev/build error or pass | | blocking rendering | `reactRoot: true` + `concurrentFeatures: false` | dev/build pass | | concurrent rendering | `reactRoot: true` + `concurrentFeatures: true` | dev/build pass | ## Feature - [x] 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` - [x] 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`
1 parent c969b81 commit d14da39

File tree

29 files changed

+445
-83
lines changed

29 files changed

+445
-83
lines changed

packages/next/build/webpack-config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -769,9 +769,9 @@ export default async function getBaseWebpackConfig(
769769

770770
if (isLocal) {
771771
// Makes sure dist/shared and dist/server are not bundled
772-
// we need to process shared/lib/router/router so that
773-
// the DefinePlugin can inject process.env values
774-
const isNextExternal = /next[/\\]dist[/\\](shared|server)[/\\](?!lib[/\\]router[/\\]router)/.test(
772+
// we need to process shared `router/router` and `dynamic`,
773+
// so that the DefinePlugin can inject process.env values
774+
const isNextExternal = /next[/\\]dist[/\\](shared|server)[/\\](?!lib[/\\](router[/\\]router|dynamic))/.test(
775775
res
776776
)
777777

@@ -1185,6 +1185,9 @@ export default async function getBaseWebpackConfig(
11851185
config.reactStrictMode
11861186
),
11871187
'process.env.__NEXT_REACT_ROOT': JSON.stringify(hasReactRoot),
1188+
'process.env.__NEXT_CONCURRENT_FEATURES': JSON.stringify(
1189+
config.experimental.concurrentFeatures && hasReactRoot
1190+
),
11881191
'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify(
11891192
config.optimizeFonts && !dev
11901193
),

packages/next/export/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ export default async function exportApp(
380380
disableOptimizedLoading: nextConfig.experimental.disableOptimizedLoading,
381381
// TODO: We should support dynamic HTML too
382382
requireStaticHTML: true,
383+
concurrentFeatures: nextConfig.experimental.concurrentFeatures,
383384
}
384385

385386
const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig

packages/next/shared/lib/dynamic.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,24 @@ export type LoadableBaseOptions<P = {}> = LoadableGeneratedOptions & {
3333
ssr?: boolean
3434
}
3535

36+
export type LoadableSuspenseOptions<P = {}> = {
37+
loader: Loader<P>
38+
suspense?: boolean
39+
}
40+
3641
export type LoadableOptions<P = {}> = LoadableBaseOptions<P>
3742

3843
export type DynamicOptions<P = {}> = LoadableBaseOptions<P>
3944

4045
export type LoadableFn<P = {}> = (
41-
opts: LoadableOptions<P>
46+
opts: LoadableOptions<P> | LoadableSuspenseOptions<P>
4247
) => React.ComponentType<P>
4348

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

4651
export function noSSR<P = {}>(
4752
LoadableInitializer: LoadableFn<P>,
48-
loadableOptions: LoadableOptions<P>
53+
loadableOptions: LoadableBaseOptions<P>
4954
): React.ComponentType<P> {
5055
// Removing webpack and modules means react-loadable won't try preloading
5156
delete loadableOptions.webpack
@@ -63,8 +68,6 @@ export function noSSR<P = {}>(
6368
)
6469
}
6570

66-
// function dynamic<P = {}, O extends DynamicOptions>(options: O):
67-
6871
export default function dynamic<P = {}>(
6972
dynamicOptions: DynamicOptions<P> | Loader<P>,
7073
options?: DynamicOptions<P>
@@ -110,6 +113,21 @@ export default function dynamic<P = {}>(
110113
// Support for passing options, eg: dynamic(import('../hello-world'), {loading: () => <p>Loading something</p>})
111114
loadableOptions = { ...loadableOptions, ...options }
112115

116+
const suspenseOptions = loadableOptions as LoadableSuspenseOptions<P>
117+
if (!process.env.__NEXT_CONCURRENT_FEATURES) {
118+
// Error if react root is not enabled and `suspense` option is set to true
119+
if (!process.env.__NEXT_REACT_ROOT && suspenseOptions.suspense) {
120+
// TODO: add error doc when this feature is stable
121+
throw new Error(
122+
`Disallowed suspense option usage with next/dynamic in blocking mode`
123+
)
124+
}
125+
suspenseOptions.suspense = false
126+
}
127+
if (suspenseOptions.suspense) {
128+
return loadableFn(suspenseOptions)
129+
}
130+
113131
// coming from build/babel/plugins/react-loadable-plugin.js
114132
if (loadableOptions.loadableGenerated) {
115133
loadableOptions = {

packages/next/shared/lib/loadable.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,16 @@ function createLoadableComponent(loadFn, options) {
6666
timeout: null,
6767
webpack: null,
6868
modules: null,
69+
suspense: false,
6970
},
7071
options
7172
)
7273

73-
let subscription = null
74+
if (opts.suspense) {
75+
opts.lazy = React.lazy(opts.loader)
76+
}
7477

78+
let subscription = null
7579
function init() {
7680
if (!subscription) {
7781
const sub = new LoadableSubscription(loadFn, opts)
@@ -86,7 +90,7 @@ function createLoadableComponent(loadFn, options) {
8690
}
8791

8892
// Server only
89-
if (typeof window === 'undefined') {
93+
if (typeof window === 'undefined' && !opts.suspense) {
9094
ALL_INITIALIZERS.push(init)
9195
}
9296

@@ -95,7 +99,8 @@ function createLoadableComponent(loadFn, options) {
9599
!initialized &&
96100
typeof window !== 'undefined' &&
97101
typeof opts.webpack === 'function' &&
98-
typeof require.resolveWeak === 'function'
102+
typeof require.resolveWeak === 'function' &&
103+
!opts.suspense
99104
) {
100105
const moduleIds = opts.webpack()
101106
READY_INITIALIZERS.push((ids) => {
@@ -107,12 +112,11 @@ function createLoadableComponent(loadFn, options) {
107112
})
108113
}
109114

110-
const LoadableComponent = (props, ref) => {
115+
function LoadableImpl(props, ref) {
111116
init()
112117

113118
const context = React.useContext(LoadableContext)
114119
const state = useSubscription(subscription)
115-
116120
React.useImperativeHandle(
117121
ref,
118122
() => ({
@@ -144,7 +148,12 @@ function createLoadableComponent(loadFn, options) {
144148
}, [props, state])
145149
}
146150

147-
LoadableComponent.preload = () => init()
151+
function LazyImpl(props, ref) {
152+
return React.createElement(opts.lazy, { ...props, ref })
153+
}
154+
155+
const LoadableComponent = opts.suspense ? LazyImpl : LoadableImpl
156+
LoadableComponent.preload = () => !opts.suspense && init()
148157
LoadableComponent.displayName = 'LoadableComponent'
149158

150159
return React.forwardRef(LoadableComponent)

test/integration/chunking/test/index.test.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ describe('Chunking', () => {
8484

8585
it('should execute the build manifest', async () => {
8686
const html = await renderViaHTTP(appPort, '/')
87-
console.log(html)
8887
const $ = cheerio.load(html)
8988
expect(
9089
Array.from($('script'))
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Suspense } from 'react'
2+
import dynamic from 'next/dynamic'
3+
import { useCachedPromise } from './promise-cache'
4+
5+
const Foo = dynamic(() => import('./foo'), {
6+
suspense: true,
7+
})
8+
9+
export default function Bar() {
10+
useCachedPromise(
11+
'bar',
12+
() => new Promise((resolve) => setTimeout(resolve, 300)),
13+
true
14+
)
15+
16+
return (
17+
<div>
18+
bar
19+
<Suspense fallback={'oof'}>
20+
<Foo />
21+
</Suspense>
22+
</div>
23+
)
24+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Suspense } from 'react'
2+
import dynamic from 'next/dynamic'
3+
4+
let ssr
5+
const suspense = false
6+
7+
const Hello = dynamic(() => import('./hello'), {
8+
ssr,
9+
suspense,
10+
})
11+
12+
export default function DynamicHello(props) {
13+
return (
14+
<Suspense fallback={'loading'}>
15+
<Hello {...props} />
16+
</Suspense>
17+
)
18+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Foo() {
2+
return 'foo'
3+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom'
3+
import { useCachedPromise } from './promise-cache'
4+
5+
export default function Hello({ name, thrown = false }) {
6+
useCachedPromise(
7+
name,
8+
() => new Promise((resolve) => setTimeout(resolve, 200)),
9+
thrown
10+
)
11+
12+
return <p>hello {ReactDOM.version}</p>
13+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react'
2+
3+
const PromiseCacheContext = React.createContext(null)
4+
5+
export const cache = new Map()
6+
export const PromiseCacheProvider = PromiseCacheContext.Provider
7+
8+
export function useCachedPromise(key, fn, thrown = false) {
9+
const cache = React.useContext(PromiseCacheContext)
10+
11+
if (!thrown) return undefined
12+
let entry = cache.get(key)
13+
if (!entry) {
14+
entry = {
15+
status: 'PENDING',
16+
value: fn().then(
17+
(value) => {
18+
cache.set(key, {
19+
status: 'RESOLVED',
20+
value,
21+
})
22+
},
23+
(err) => {
24+
cache.set(key, {
25+
status: 'REJECTED',
26+
value: err,
27+
})
28+
}
29+
),
30+
}
31+
cache.set(key, entry)
32+
}
33+
if (['PENDING', 'REJECTED'].includes(entry.status)) {
34+
throw entry.value
35+
}
36+
return entry.value
37+
}

0 commit comments

Comments
 (0)