Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separate next/dynamic implementation for app and pages #45565

Merged
merged 4 commits into from
Feb 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading