Skip to content

Commit

Permalink
Preload chunks for next/dynamic in suspense mode (#37245)
Browse files Browse the repository at this point in the history
When using `next/dynamic` with `suspense: true`, the API will opt into `React.lazy` with react 18. But previously it doesn't preload the dynamic chunks. This pr will include the chunks into initial html for faster hydration instead of loading the chunk until the script is executed. This makes `next/dynamic` has a significant difference from `React.lazy` api

x-ref: #37197 (comment)
x-ref: #37244
  • Loading branch information
huozhi committed May 27, 2022
1 parent 3e178bb commit df77964
Show file tree
Hide file tree
Showing 5 changed files with 49 additions and 39 deletions.
28 changes: 8 additions & 20 deletions packages/next/shared/lib/dynamic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type LoadableGeneratedOptions = {
modules?(): LoaderMap
}

export type LoadableBaseOptions<P = {}> = LoadableGeneratedOptions & {
export type DynamicOptions<P = {}> = LoadableGeneratedOptions & {
loading?: ({
error,
isLoading,
Expand All @@ -31,27 +31,20 @@ export type LoadableBaseOptions<P = {}> = LoadableGeneratedOptions & {
loader?: Loader<P> | LoaderMap
loadableGenerated?: LoadableGeneratedOptions
ssr?: boolean
}

export type LoadableSuspenseOptions = {
suspense?: boolean
}

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

export type DynamicOptions<P = {}> =
| LoadableBaseOptions<P>
| LoadableSuspenseOptions
export type LoadableOptions<P = {}> = DynamicOptions<P>

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

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

export function noSSR<P = {}>(
LoadableInitializer: LoadableFn<P>,
loadableOptions: LoadableBaseOptions<P>
loadableOptions: DynamicOptions<P>
): React.ComponentType<P> {
// Removing webpack and modules means react-loadable won't try preloading
delete loadableOptions.webpack
Expand Down Expand Up @@ -114,18 +107,12 @@ export default function dynamic<P = {}>(
// Support for passing options, eg: dynamic(import('../hello-world'), {loading: () => <p>Loading something</p>})
loadableOptions = { ...loadableOptions, ...options }

const suspenseOptions = loadableOptions as LoadableSuspenseOptions & {
loader: Loader<P>
}
// Error if Fizz rendering is not enabled and `suspense` option is set to true
if (!process.env.__NEXT_REACT_ROOT && suspenseOptions.suspense) {
if (!process.env.__NEXT_REACT_ROOT && loadableOptions.suspense) {
throw new Error(
`Invalid suspense option usage in next/dynamic. Read more: https://nextjs.org/docs/messages/invalid-dynamic-suspense`
)
}
if (suspenseOptions.suspense) {
return loadableFn(suspenseOptions)
}

// coming from build/babel/plugins/react-loadable-plugin.js
if (loadableOptions.loadableGenerated) {
Expand All @@ -136,8 +123,9 @@ export default function dynamic<P = {}>(
delete loadableOptions.loadableGenerated
}

// support for disabling server side rendering, eg: dynamic(import('../hello-world'), {ssr: false})
if (typeof loadableOptions.ssr === 'boolean') {
// support for disabling server side rendering, eg: dynamic(import('../hello-world'), {ssr: false}).
// skip `ssr` for suspense mode and opt-in React.lazy directly
if (typeof loadableOptions.ssr === 'boolean' && !loadableOptions.suspense) {
if (!loadableOptions.ssr) {
delete loadableOptions.ssr
return noSSR(loadableFn, loadableOptions)
Expand Down
26 changes: 16 additions & 10 deletions packages/next/shared/lib/loadable.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,12 @@ function createLoadableComponent(loadFn, options) {
}

// Server only
if (typeof window === 'undefined' && !opts.suspense) {
if (typeof window === 'undefined') {
ALL_INITIALIZERS.push(init)
}

// Client only
if (!initialized && typeof window !== 'undefined' && !opts.suspense) {
if (!initialized && typeof window !== 'undefined') {
// require.resolveWeak check is needed for environments that don't have it available like Jest
const moduleIds =
opts.webpack && typeof require.resolveWeak === 'function'
Expand All @@ -116,10 +116,20 @@ function createLoadableComponent(loadFn, options) {
}
}

function LoadableImpl(props, ref) {
function useLoadableModule() {
init()

const context = React.useContext(LoadableContext)
if (context && Array.isArray(opts.modules)) {
opts.modules.forEach((moduleName) => {
context(moduleName)
})
}
}

function LoadableImpl(props, ref) {
useLoadableModule()

const state = useSyncExternalStore(
subscription.subscribe,
subscription.getCurrentValue,
Expand All @@ -134,12 +144,6 @@ function createLoadableComponent(loadFn, options) {
[]
)

if (context && Array.isArray(opts.modules)) {
opts.modules.forEach((moduleName) => {
context(moduleName)
})
}

return React.useMemo(() => {
if (state.loading || state.error) {
return React.createElement(opts.loading, {
Expand All @@ -158,11 +162,13 @@ function createLoadableComponent(loadFn, options) {
}

function LazyImpl(props, ref) {
useLoadableModule()

return React.createElement(opts.lazy, { ...props, ref })
}

const LoadableComponent = opts.suspense ? LazyImpl : LoadableImpl
LoadableComponent.preload = () => !opts.suspense && init()
LoadableComponent.preload = () => init()
LoadableComponent.displayName = 'LoadableComponent'

return React.forwardRef(LoadableComponent)
Expand Down
12 changes: 12 additions & 0 deletions test/integration/react-18/app/pages/dynamic-suspense.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import dynamic from 'next/dynamic'
import { Suspense } from 'react'

const Foo = dynamic(() => import('../components/foo'), { suspense: true })

export default () => (
<div>
<Suspense fallback="fallback">
<Foo />
</Suspense>
</div>
)
22 changes: 13 additions & 9 deletions test/integration/react-18/test/basics.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,19 @@ export default (context, env) => {
expect(ssrId).toEqual(csrId)
})

it('should contain dynamicIds in next data for basic dynamic imports', async () => {
const html = await renderViaHTTP(context.appPort, '/dynamic-imports')
const $ = cheerio.load(html)
const { dynamicIds } = JSON.parse($('#__NEXT_DATA__').html())

if (env === 'dev') {
expect(dynamicIds).toContain('dynamic-imports.js -> ../components/foo')
} else {
expect(dynamicIds.length).toBe(1)
it('should contain dynamicIds in next data for dynamic imports', async () => {
async function expectToContainPreload(page) {
const html = await renderViaHTTP(context.appPort, `/${page}`)
const $ = cheerio.load(html)
const { dynamicIds } = JSON.parse($('#__NEXT_DATA__').html())

if (env === 'dev') {
expect(dynamicIds).toContain(`${page}.js -> ../components/foo`)
} else {
expect(dynamicIds.length).toBe(1)
}
}
await expectToContainPreload('dynamic')
await expectToContainPreload('dynamic-suspense')
})
}

0 comments on commit df77964

Please sign in to comment.