diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index 32c6c0221895..ddc76edec38a 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -185,7 +185,6 @@ where next_dynamic::next_dynamic( opts.is_development, opts.is_server, - opts.server_components.is_some(), file.name.clone(), opts.pages_dir.clone() ), diff --git a/packages/next-swc/crates/core/src/next_dynamic.rs b/packages/next-swc/crates/core/src/next_dynamic.rs index 7e0588c02b03..c8c24f6d67af 100644 --- a/packages/next-swc/crates/core/src/next_dynamic.rs +++ b/packages/next-swc/crates/core/src/next_dynamic.rs @@ -5,26 +5,23 @@ use pathdiff::diff_paths; use swc_core::{ common::{errors::HANDLER, FileName, DUMMY_SP}, ecma::ast::{ - ArrayLit, ArrowExpr, BinExpr, BinaryOp, BlockStmtOrExpr, Bool, CallExpr, Callee, Expr, + ArrayLit, ArrowExpr, BinExpr, BinaryOp, BlockStmtOrExpr, CallExpr, Callee, Expr, ExprOrSpread, Id, Ident, ImportDecl, ImportSpecifier, KeyValueProp, Lit, MemberExpr, - MemberProp, Null, ObjectLit, Prop, PropName, PropOrSpread, Str, Tpl, + MemberProp, ObjectLit, Prop, PropName, PropOrSpread, Str, Tpl, }, ecma::atoms::js_word, - ecma::utils::ExprFactory, ecma::visit::{Fold, FoldWith}, }; pub fn next_dynamic( is_development: bool, is_server: bool, - is_server_components: bool, filename: FileName, pages_dir: Option, ) -> impl Fold { NextDynamicPatcher { is_development, is_server, - is_server_components, pages_dir, filename, dynamic_bindings: vec![], @@ -37,7 +34,6 @@ pub fn next_dynamic( struct NextDynamicPatcher { is_development: bool, is_server: bool, - is_server_components: bool, pages_dir: Option, filename: FileName, dynamic_bindings: Vec, @@ -233,70 +229,16 @@ impl Fold for NextDynamicPatcher { value: generated, })))]; - let mut has_ssr_false = false; - let mut has_suspense = false; - if expr.args.len() == 2 { if let Expr::Object(ObjectLit { props: options_props, .. }) = &*expr.args[1].expr { - for prop in options_props.iter() { - if let Some(KeyValueProp { key, value }) = match prop { - PropOrSpread::Prop(prop) => match &**prop { - Prop::KeyValue(key_value_prop) => Some(key_value_prop), - _ => None, - }, - _ => None, - } { - if let Some(Ident { - sym, - span: _, - optional: _, - }) = match key { - PropName::Ident(ident) => Some(ident), - _ => None, - } { - if sym == "ssr" { - if let Some(Lit::Bool(Bool { - value: false, - span: _, - })) = value.as_lit() - { - has_ssr_false = true - } - } - if sym == "suspense" { - if let Some(Lit::Bool(Bool { - value: true, - span: _, - })) = value.as_lit() - { - has_suspense = true - } - } - } - } - } props.extend(options_props.iter().cloned()); } } - // Don't strip the `loader` argument if suspense is true - // See https://github.com/vercel/next.js/issues/36636 for background. - - // Also don't strip the `loader` argument for server components (both - // server/client layers), since they're aliased to a - // React.lazy implementation. - if has_ssr_false - && !has_suspense - && self.is_server - && !self.is_server_components - { - expr.args[0] = Lit::Null(Null { span: DUMMY_SP }).as_arg(); - } - let second_arg = ExprOrSpread { spread: None, expr: Box::new(Expr::Object(ObjectLit { diff --git a/packages/next-swc/crates/core/tests/errors.rs b/packages/next-swc/crates/core/tests/errors.rs index 6d627c12e665..5d2691d3f36f 100644 --- a/packages/next-swc/crates/core/tests/errors.rs +++ b/packages/next-swc/crates/core/tests/errors.rs @@ -46,7 +46,6 @@ fn next_dynamic_errors(input: PathBuf) { next_dynamic( true, false, - false, FileName::Real(PathBuf::from("/some-project/src/some-file.js")), Some("/some-project/src".into()), ) diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index 478f667c1ccd..cc7f9cd85b33 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -49,7 +49,6 @@ fn next_dynamic_fixture(input: PathBuf) { next_dynamic( true, false, - false, FileName::Real(PathBuf::from("/some-project/src/some-file.js")), Some("/some-project/src".into()), ) @@ -62,7 +61,6 @@ fn next_dynamic_fixture(input: PathBuf) { syntax(), &|_tr| { next_dynamic( - false, false, false, FileName::Real(PathBuf::from("/some-project/src/some-file.js")), @@ -79,7 +77,6 @@ fn next_dynamic_fixture(input: PathBuf) { next_dynamic( false, true, - false, FileName::Real(PathBuf::from("/some-project/src/some-file.js")), Some("/some-project/src".into()), ) diff --git a/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js b/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js index fbaab8c10c03..e152280bdef1 100644 --- a/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js +++ b/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js @@ -1,6 +1,5 @@ import dynamic from 'next/dynamic'; -const DynamicComponentWithCustomLoading = dynamic(()=>import('../components/hello') -, { +const DynamicComponentWithCustomLoading = dynamic(()=>import('../components/hello'), { loadableGenerated: { modules: [ "some-file.js -> " + "../components/hello" @@ -8,7 +7,7 @@ const DynamicComponentWithCustomLoading = dynamic(()=>import('../components/hell }, loading: ()=>

...

}); -const DynamicClientOnlyComponent = dynamic(null, { +const DynamicClientOnlyComponent = dynamic(()=>import('../components/hello'), { loadableGenerated: { modules: [ "some-file.js -> " + "../components/hello" @@ -16,8 +15,7 @@ const DynamicClientOnlyComponent = dynamic(null, { }, ssr: false }); -const DynamicClientOnlyComponentWithSuspense = dynamic(()=>import('../components/hello') -, { +const DynamicClientOnlyComponentWithSuspense = dynamic(()=>import('../components/hello'), { loadableGenerated: { modules: [ "some-file.js -> " + "../components/hello" diff --git a/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js b/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js index cd66983f6bb1..95b51047d831 100644 --- a/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js +++ b/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js @@ -1,11 +1,10 @@ import dynamic from 'next/dynamic'; -const DynamicComponent = dynamic(null, { +const DynamicComponent = dynamic(()=>handleImport(import('./components/hello')), { loadableGenerated: { modules: [ "some-file.js -> " + "./components/hello" ] }, - loading: ()=>null - , + loading: ()=>null, ssr: false }); diff --git a/packages/next/build/swc/options.js b/packages/next/build/swc/options.js index 8608a39d8bee..525cd7863d6a 100644 --- a/packages/next/build/swc/options.js +++ b/packages/next/build/swc/options.js @@ -128,7 +128,7 @@ function getBaseSWCOptions({ ? { isServer: !!isServerLayer, } - : false, + : undefined, } } diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 650a6b31d446..fac7fe4b5a78 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1707,26 +1707,8 @@ export default async function getBaseWebpackConfig( }, ] : []), - // Alias `next/dynamic` to React.lazy implementation for RSC ...(hasServerComponents ? [ - { - test: codeCondition.test, - issuerLayer(layer: string) { - return ( - layer === WEBPACK_LAYERS.client || - layer === WEBPACK_LAYERS.server - ) - }, - resolve: { - alias: { - // Alias `next/dynamic` to React.lazy implementation for RSC - [require.resolve('next/dynamic')]: require.resolve( - 'next/dist/client/components/dynamic' - ), - }, - }, - }, { // Alias react-dom for ReactDOM.preload usage. // Alias react for switching between default set and share subset. diff --git a/packages/next/client/app-index.tsx b/packages/next/client/app-index.tsx index 7cc36bcfd0b4..597258c195bd 100644 --- a/packages/next/client/app-index.tsx +++ b/packages/next/client/app-index.tsx @@ -7,6 +7,7 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we import { HeadManagerContext } from '../shared/lib/head-manager-context' import { GlobalLayoutRouterContext } from '../shared/lib/app-router-context' +import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/no-ssr-error' /// @@ -116,6 +117,23 @@ if (document.readyState === 'loading') { DOMContentLoaded() } +function onRecoverableError(err: any) { + // Using default react onRecoverableError + // x-ref: https://github.com/facebook/react/blob/d4bc16a7d69eb2ea38a88c8ac0b461d5f72cdcab/packages/react-dom/src/client/ReactDOMRoot.js#L83 + const defaultOnRecoverableError = + typeof reportError === 'function' + ? // In modern browsers, reportError will dispatch an error event, + // emulating an uncaught JavaScript error. + reportError + : (error: any) => { + window.console.error(error) + } + + // Skip certain custom errors which are not expected to be reported on client + if (err.digest === NEXT_DYNAMIC_NO_SSR_CODE) return + defaultOnRecoverableError(err) +} + const nextServerDataLoadingGlobal = ((self as any).__next_f = (self as any).__next_f || []) nextServerDataLoadingGlobal.forEach(nextServerDataCallback) @@ -193,7 +211,9 @@ export function hydrate() { if (rootLayoutMissingTagsError) { const reactRootElement = document.createElement('div') document.body.appendChild(reactRootElement) - const reactRoot = (ReactDOMClient as any).createRoot(reactRootElement) + const reactRoot = (ReactDOMClient as any).createRoot(reactRootElement, { + onRecoverableError, + }) reactRoot.render( ) + const options = { + onRecoverableError, + } const isError = document.documentElement.id === '__next_error__' const reactRoot = isError - ? (ReactDOMClient as any).createRoot(appElement) + ? (ReactDOMClient as any).createRoot(appElement, options) : (React as any).startTransition(() => - (ReactDOMClient as any).hydrateRoot(appElement, reactEl) + (ReactDOMClient as any).hydrateRoot(appElement, reactEl, options) ) if (isError) { reactRoot.render(reactEl) diff --git a/packages/next/client/components/dynamic.tsx b/packages/next/client/components/dynamic.tsx deleted file mode 100644 index 4c3f9c827819..000000000000 --- a/packages/next/client/components/dynamic.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react' - -export type LoaderComponent

= Promise<{ - default: React.ComponentType

-}> - -export type Loader

= () => LoaderComponent

- -export type DynamicOptions

= { - loader?: Loader

-} - -export type LoadableComponent

= React.ComponentType

- -export default function dynamic

( - loader: Loader

-): React.ComponentType

{ - return React.lazy(loader) -} diff --git a/packages/next/client/components/layout-router.tsx b/packages/next/client/components/layout-router.tsx index 2a254414582d..670de2e7b840 100644 --- a/packages/next/client/components/layout-router.tsx +++ b/packages/next/client/components/layout-router.tsx @@ -359,7 +359,7 @@ class RedirectErrorBoundary extends React.Component< } static getDerivedStateFromError(error: any) { - if (error.digest?.startsWith('NEXT_REDIRECT')) { + if (error?.digest?.startsWith('NEXT_REDIRECT')) { const url = error.digest.split(';')[1] return { redirect: url } } @@ -400,7 +400,7 @@ class NotFoundErrorBoundary extends React.Component< } static getDerivedStateFromError(error: any) { - if (error.digest === 'NEXT_NOT_FOUND') { + if (error?.digest === 'NEXT_NOT_FOUND') { return { notFoundTriggered: true } } // Re-throw if error is not for 404 diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 7c5c09078d7d..1a84362885aa 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -43,6 +43,7 @@ import { PathnameContextProviderAdapter, } from '../shared/lib/router/adapters' import { SearchParamsContext } from '../shared/lib/hooks-client-context' +import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/no-ssr-error' /// @@ -510,7 +511,13 @@ function renderReactElement( const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete) if (!reactRoot) { // Unlike with createRoot, you don't need a separate root.render() call here - reactRoot = ReactDOM.hydrateRoot(domEl, reactEl) + reactRoot = ReactDOM.hydrateRoot(domEl, reactEl, { + onRecoverableError(err: any) { + // Skip certain custom errors which are not expected to throw on client + if (err.message === NEXT_DYNAMIC_NO_SSR_CODE) return + throw err + }, + }) // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing shouldHydrate = false } else { diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index c2e0b095fffb..e99098f07342 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -32,6 +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 { IncrementalCache } from '../server/lib/incremental-cache' loadRequireHook() @@ -370,6 +371,7 @@ export default async function exportPage({ if ( err.digest !== DYNAMIC_ERROR_CODE && err.digest !== NOT_FOUND_ERROR_CODE && + err.digest !== NEXT_DYNAMIC_NO_SSR_CODE && !err.digest?.startsWith(REDIRECT_ERROR_CODE) ) { throw err @@ -420,14 +422,20 @@ export default async function exportPage({ if (optimizeCss) { process.env.__NEXT_OPTIMIZE_CSS = JSON.stringify(true) } - renderResult = await renderMethod( - req, - res, - page, - query, - // @ts-ignore - curRenderOpts - ) + try { + renderResult = await renderMethod( + req, + res, + page, + query, + // @ts-ignore + curRenderOpts + ) + } catch (err: any) { + if (err.digest !== NEXT_DYNAMIC_NO_SSR_CODE) { + throw err + } + } } results.ssgNotFound = (curRenderOpts as any).isNotFound @@ -470,15 +478,22 @@ export default async function exportPage({ try { await promises.access(ampHtmlFilepath) } catch (_) { + let ampRenderResult // make sure it doesn't exist from manual mapping - let ampRenderResult = await renderMethod( - req, - res, - page, - // @ts-ignore - { ...query, amp: '1' }, - curRenderOpts as any - ) + try { + ampRenderResult = await renderMethod( + req, + res, + page, + // @ts-ignore + { ...query, amp: '1' }, + curRenderOpts as any + ) + } catch (err: any) { + if (err.digest !== NEXT_DYNAMIC_NO_SSR_CODE) { + throw err + } + } const ampHtml = ampRenderResult ? ampRenderResult.toUnchunkedString() diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index ab1a260bda4c..c126356e5021 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -35,6 +35,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 { HeadManagerContext } from '../shared/lib/head-manager-context' import { Writable } from 'stream' import stringHash from 'next/dist/compiled/string-hash' @@ -216,9 +217,11 @@ function createErrorHandler( if (allCapturedErrors) allCapturedErrors.push(err) if ( - err.digest === DYNAMIC_ERROR_CODE || - err.digest === NOT_FOUND_ERROR_CODE || - err.digest?.startsWith(REDIRECT_ERROR_CODE) + err && + (err.digest === DYNAMIC_ERROR_CODE || + err.digest === NOT_FOUND_ERROR_CODE || + err.digest === NEXT_DYNAMIC_NO_SSR_CODE || + err.digest?.startsWith(REDIRECT_ERROR_CODE)) ) { return err.digest } diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index c5b2686a6e2b..7ab0e3205aec 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -89,6 +89,7 @@ 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 @@ -1244,6 +1245,13 @@ 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 + } + }, + }, }) } diff --git a/packages/next/shared/lib/dynamic-no-ssr.ts b/packages/next/shared/lib/dynamic-no-ssr.ts new file mode 100644 index 000000000000..d32e49b51639 --- /dev/null +++ b/packages/next/shared/lib/dynamic-no-ssr.ts @@ -0,0 +1,20 @@ +'use client' + +import React from 'react' +import { NEXT_DYNAMIC_NO_SSR_CODE } from './no-ssr-error' + +export function suspense() { + const error = new Error(NEXT_DYNAMIC_NO_SSR_CODE) + ;(error as any).digest = NEXT_DYNAMIC_NO_SSR_CODE + throw error +} + +type Child = React.ReactElement + +export default function NoSSR({ children }: { children: Child }): Child { + if (typeof window === 'undefined') { + suspense() + } + + return children +} diff --git a/packages/next/shared/lib/dynamic.tsx b/packages/next/shared/lib/dynamic.tsx index 1cf5d213b73d..14728c6ea12f 100644 --- a/packages/next/shared/lib/dynamic.tsx +++ b/packages/next/shared/lib/dynamic.tsx @@ -1,15 +1,14 @@ -import React from 'react' +import React, { lazy, Suspense } from 'react' import Loadable from './loadable' +import NoSSR from './dynamic-no-ssr' -const isServerSide = typeof window === 'undefined' +type ComponentModule

= { default: React.ComponentType

} -export type LoaderComponent

= Promise< - React.ComponentType

| { default: React.ComponentType

} -> +export type LoaderComponent

= Promise> -export type Loader

= (() => LoaderComponent

) | LoaderComponent

+export type Loader

= () => LoaderComponent

-export type LoaderMap = { [mdule: string]: () => Loader } +export type LoaderMap = { [module: string]: () => Loader } export type LoadableGeneratedOptions = { webpack?(): any @@ -24,11 +23,21 @@ export type DynamicOptionsLoadingProps = { 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(mod: ComponentModule) { + return { default: mod.default || mod } +} + export type DynamicOptions

= LoadableGeneratedOptions & { loading?: (loadingProps: DynamicOptionsLoadingProps) => JSX.Element | null loader?: Loader

| LoaderMap loadableGenerated?: LoadableGeneratedOptions ssr?: boolean + /** + * @deprecated `suspense` prop is not required anymore + */ suspense?: boolean } @@ -41,23 +50,27 @@ export type LoadableFn

= ( export type LoadableComponent

= React.ComponentType

export function noSSR

( - LoadableInitializer: LoadableFn

, + LoadableInitializer: Loader, loadableOptions: DynamicOptions

): React.ComponentType

{ // 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 NoSSRComponent = lazy(LoadableInitializer) const Loading = loadableOptions.loading! - // This will only be rendered on the server side - return () => ( + const fallback = ( ) + + return () => ( + + + + + + ) } export default function dynamic

( @@ -66,31 +79,27 @@ export default function dynamic

( ): React.ComponentType

{ let loadableFn: LoadableFn

= Loadable - let loadableOptions: LoadableOptions

= options?.suspense - ? {} - : // only provide a default loading component when suspense is disabled - { - // A loading component is not required, so we default it - loading: ({ error, isLoading, pastDelay }) => { - if (!pastDelay) return null - if (process.env.NODE_ENV === 'development') { - if (isLoading) { - return null - } - if (error) { - return ( -

- {error.message} -
- {error.stack} -

- ) - } - } - + let loadableOptions: LoadableOptions

= { + // 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 ( +

+ {error.message} +
+ {error.stack} +

+ ) + } } + 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 @@ -109,47 +118,24 @@ export default function dynamic

( // Support for passing options, eg: dynamic(import('../hello-world'), {loading: () =>

Loading something

}) loadableOptions = { ...loadableOptions, ...options } - if (loadableOptions.suspense) { - if (process.env.NODE_ENV !== 'production') { - /** - * TODO: Currently, next/dynamic will opt-in to React.lazy if { suspense: true } is used - * React 18 will always resolve the Suspense boundary on the server-side, effectively ignoring the ssr option - * - * In the future, when React Suspense with third-party libraries is stable, we can implement a custom version of - * React.lazy that can suspense on the server-side while only loading the component on the client-side - */ - if (loadableOptions.ssr === false) { - console.warn( - `"ssr: false" is ignored by next/dynamic because you can not enable "suspense" while disabling "ssr" at the same time. Read more: https://nextjs.org/docs/messages/invalid-dynamic-suspense` - ) - } - - if (loadableOptions.loading != null) { - console.warn( - `"loading" is ignored by next/dynamic because you have enabled "suspense". Place your loading element in your suspense boundary's "fallback" prop instead. Read more: https://nextjs.org/docs/messages/invalid-dynamic-suspense` - ) - } - } - - delete loadableOptions.ssr - delete loadableOptions.loading - } + const loaderFn = loadableOptions.loader as Loader

+ 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}). - // skip `ssr` for suspense mode and opt-in React.lazy directly - if (typeof loadableOptions.ssr === 'boolean' && !loadableOptions.suspense) { + // 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(loadableFn, loadableOptions) + return noSSR(loader as Loader, loadableOptions) } delete loadableOptions.ssr } diff --git a/packages/next/shared/lib/loadable-context.ts b/packages/next/shared/lib/loadable-context.ts index 914b64939d38..c449b61d3ee9 100644 --- a/packages/next/shared/lib/loadable-context.ts +++ b/packages/next/shared/lib/loadable-context.ts @@ -1,3 +1,5 @@ +'use client' + import React from 'react' type CaptureFn = (moduleName: string) => void diff --git a/packages/next/shared/lib/loadable.js b/packages/next/shared/lib/loadable.js index 9b8d7a73ddb3..4df5d11263e7 100644 --- a/packages/next/shared/lib/loadable.js +++ b/packages/next/shared/lib/loadable.js @@ -21,7 +21,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE // https://github.com/jamiebuilds/react-loadable/blob/v5.5.0/src/index.js // Modified to be compatible with webpack 4 / Next.js -import React, { useSyncExternalStore } from 'react' +import React from 'react' import { LoadableContext } from './loadable-context' const ALL_INITIALIZERS = [] @@ -52,10 +52,6 @@ function load(loader) { return state } -function resolve(obj) { - return obj && obj.__esModule ? obj.default : obj -} - function createLoadableComponent(loadFn, options) { let opts = Object.assign( { @@ -65,14 +61,11 @@ function createLoadableComponent(loadFn, options) { timeout: null, webpack: null, modules: null, - suspense: false, }, options ) - if (opts.suspense) { - opts.lazy = React.lazy(opts.loader) - } + opts.lazy = React.lazy(opts.loader) /** @type LoadableSubscription */ let subscription = null @@ -122,52 +115,28 @@ function createLoadableComponent(loadFn, options) { }) } } - - function LoadableImpl(props, ref) { + function LoadableComponent(props) { useLoadableModule() - const state = useSyncExternalStore( - subscription.subscribe, - subscription.getCurrentValue, - subscription.getCurrentValue - ) + const fallbackElement = React.createElement(opts.loading, { + isLoading: true, + pastDelay: true, + error: null, + }) - React.useImperativeHandle( - ref, - () => ({ - retry: subscription.retry, - }), - [] + return React.createElement( + React.Suspense, + { + fallback: fallbackElement, + }, + React.createElement(opts.lazy, props) ) - - 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]) - } - - function LazyImpl(props, ref) { - useLoadableModule() - - return React.createElement(opts.lazy, { ...props, ref }) } - const LoadableComponent = opts.suspense ? LazyImpl : LoadableImpl LoadableComponent.preload = () => init() LoadableComponent.displayName = 'LoadableComponent' - return React.forwardRef(LoadableComponent) + return LoadableComponent } class LoadableSubscription { diff --git a/packages/next/shared/lib/no-ssr-error.ts b/packages/next/shared/lib/no-ssr-error.ts new file mode 100644 index 000000000000..1bf650c989da --- /dev/null +++ b/packages/next/shared/lib/no-ssr-error.ts @@ -0,0 +1,3 @@ +// This has to be a shared module which is shared between client component error boundary and dynamic component + +export const NEXT_DYNAMIC_NO_SSR_CODE = 'DYNAMIC_SERVER_USAGE' diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-client.js new file mode 100644 index 000000000000..db6ee9565520 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-client.js @@ -0,0 +1,17 @@ +'use client' + +import dynamic from 'next/dynamic' + +const Dynamic = dynamic(() => import('../text-dynamic-client')) +const DynamicNoSSR = dynamic(() => import('../text-dynamic-no-ssr-client'), { + ssr: false, +}) + +export function NextDynamicClientComponent() { + return ( + <> + + + + ) +} diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-server.js b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-server.js new file mode 100644 index 000000000000..a4222e9211d4 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-server.js @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic' + +export const NextDynamicServerComponent = dynamic(() => + import('../text-dynamic-server') +) +export const NextDynamicNoSSRServerComponent = dynamic( + () => import('../text-dynamic-no-ssr-server'), + { + ssr: false, + } +) +export const NextDynamicServerImportClientComponent = dynamic(() => + import('../text-dynamic-server-import-client') +) diff --git a/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/react-lazy-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/react-lazy-client.js similarity index 63% rename from test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/react-lazy-client.js rename to test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/react-lazy-client.js index e8010fd28c73..d96d92ffa025 100644 --- a/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/react-lazy-client.js +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/react-lazy-client.js @@ -2,14 +2,14 @@ import { useState, lazy } from 'react' -const Lazy = lazy(() => import('../text-lazy-client.js')) +const Lazy = lazy(() => import('../text-lazy-client')) export function LazyClientComponent() { let [state] = useState('use client') return ( <> -

hello from {state}

+

next-dynamic {state}

) } diff --git a/test/e2e/app-dir/app/app/dashboard/index/dynamic.module.css b/test/e2e/app-dir/app/app/dashboard/dynamic/dynamic.module.css similarity index 100% rename from test/e2e/app-dir/app/app/dashboard/index/dynamic.module.css rename to test/e2e/app-dir/app/app/dashboard/dynamic/dynamic.module.css diff --git a/test/e2e/app-dir/app/app/dashboard/index/lazy.module.css b/test/e2e/app-dir/app/app/dashboard/dynamic/lazy.module.css similarity index 100% rename from test/e2e/app-dir/app/app/dashboard/index/lazy.module.css rename to test/e2e/app-dir/app/app/dashboard/dynamic/lazy.module.css diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/page.js b/test/e2e/app-dir/app/app/dashboard/dynamic/page.js new file mode 100644 index 000000000000..bafe56314760 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/page.js @@ -0,0 +1,19 @@ +import { LazyClientComponent } from './dynamic-imports/react-lazy-client' +import { NextDynamicClientComponent } from './dynamic-imports/dynamic-client' +import { + NextDynamicServerComponent, + NextDynamicServerImportClientComponent, + NextDynamicNoSSRServerComponent, +} from './dynamic-imports/dynamic-server' + +export default function page() { + return ( +
+ + + + + +
+ ) +} diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/text-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-client.js new file mode 100644 index 000000000000..f3e4c209aba4 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-client.js @@ -0,0 +1,3 @@ +export default function TextClient() { + return

text client under sever

+} diff --git a/test/e2e/app-dir/app/app/dashboard/index/text-dynamic-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-client.js similarity index 89% rename from test/e2e/app-dir/app/app/dashboard/index/text-dynamic-client.js rename to test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-client.js index 55479cd44bb7..1bed845738f6 100644 --- a/test/e2e/app-dir/app/app/dashboard/index/text-dynamic-client.js +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-client.js @@ -7,7 +7,7 @@ export default function Dynamic() { let [state] = useState('dynamic on client') return (

- {`hello from ${state}`} + {`next-dynamic ${state}`}

) } diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-client.js new file mode 100644 index 000000000000..de707c60d868 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-client.js @@ -0,0 +1,13 @@ +'use client' + +import { useState } from 'react' +import styles from './dynamic.module.css' + +export default function Dynamic() { + let [state] = useState('dynamic no ssr on client') + return ( +

+ {`next-dynamic ${state}`} +

+ ) +} diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-server.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-server.js new file mode 100644 index 000000000000..c54da3a133f7 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-server.js @@ -0,0 +1,12 @@ +import TextClient from './text-client' + +export default function Dynamic() { + return ( + <> +

+ next-dynamic dynamic no ssr on server +

+ + + ) +} diff --git a/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server-import-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server-import-client.js new file mode 100644 index 000000000000..e43b9342f5e0 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server-import-client.js @@ -0,0 +1,9 @@ +'use client' + +export default function ClientImportedByServer() { + return ( +

+ next-dynamic server import client +

+ ) +} diff --git a/test/e2e/app-dir/app/app/dashboard/index/text-dynamic-server.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server.js similarity index 82% rename from test/e2e/app-dir/app/app/dashboard/index/text-dynamic-server.js rename to test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server.js index 9f87f73b47df..e7451568be74 100644 --- a/test/e2e/app-dir/app/app/dashboard/index/text-dynamic-server.js +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server.js @@ -3,7 +3,7 @@ import styles from './dynamic.module.css' export default function Dynamic() { return (

- hello from dynamic on server + next-dynamic dynamic on server

) } diff --git a/test/e2e/app-dir/app/app/dashboard/index/text-lazy-client.js b/test/e2e/app-dir/app/app/dashboard/dynamic/text-lazy-client.js similarity index 88% rename from test/e2e/app-dir/app/app/dashboard/index/text-lazy-client.js rename to test/e2e/app-dir/app/app/dashboard/dynamic/text-lazy-client.js index 53a718688115..56842021538a 100644 --- a/test/e2e/app-dir/app/app/dashboard/index/text-lazy-client.js +++ b/test/e2e/app-dir/app/app/dashboard/dynamic/text-lazy-client.js @@ -6,7 +6,7 @@ export default function LazyComponent() { return ( <>

- hello from lazy + next-dynamic lazy

) diff --git a/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-client.js b/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-client.js deleted file mode 100644 index bfa17f6a14b6..000000000000 --- a/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-client.js +++ /dev/null @@ -1,9 +0,0 @@ -'use client' - -import dynamic from 'next/dynamic' - -const Dynamic = dynamic(() => import('../text-dynamic-client')) - -export function NextDynamicClientComponent() { - return -} diff --git a/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-server.js b/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-server.js deleted file mode 100644 index 267a1febc5da..000000000000 --- a/test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-server.js +++ /dev/null @@ -1,9 +0,0 @@ -import dynamic from 'next/dynamic' - -const Dynamic = dynamic(() => import('../text-dynamic-server'), { - ssr: false, -}) - -export function NextDynamicServerComponent() { - return -} diff --git a/test/e2e/app-dir/app/app/dashboard/index/page.js b/test/e2e/app-dir/app/app/dashboard/index/page.js index c946b76e18cc..399383de2929 100644 --- a/test/e2e/app-dir/app/app/dashboard/index/page.js +++ b/test/e2e/app-dir/app/app/dashboard/index/page.js @@ -1,14 +1,3 @@ -import { LazyClientComponent } from './dynamic-imports/react-lazy-client' -import { NextDynamicServerComponent } from './dynamic-imports/dynamic-server' -import { NextDynamicClientComponent } from './dynamic-imports/dynamic-client' - export default function DashboardIndexPage() { - return ( - <> -

hello from app/dashboard/index

- - - - - ) + return

hello from app/dashboard/index

} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 7eac3c045436..e80861d4acd9 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -138,11 +138,38 @@ describe('app dir', () => { it('should serve /index as separate page', async () => { const html = await renderViaHTTP(next.url, '/dashboard/index') expect(html).toContain('hello from app/dashboard/index') + }) + + it('should handle next/dynamic correctly', async () => { + const html = await renderViaHTTP(next.url, '/dashboard/dynamic') + const $ = cheerio.load(html) + // filter out the script + const selector = 'body div' + const serverContent = $(selector).text() // should load chunks generated via async import correctly with React.lazy - expect(html).toContain('hello from lazy') + expect(serverContent).toContain('next-dynamic lazy') // should support `dynamic` in both server and client components - expect(html).toContain('hello from dynamic on server') - expect(html).toContain('hello from dynamic on client') + expect(serverContent).toContain('next-dynamic dynamic on server') + expect(serverContent).toContain('next-dynamic dynamic on client') + expect(serverContent).toContain('next-dynamic server import client') + expect(serverContent).not.toContain( + 'next-dynamic dynamic no ssr on client' + ) + + expect(serverContent).not.toContain( + 'next-dynamic dynamic no ssr on server' + ) + expect(serverContent).not.toContain('text client under sever') + + const browser = await webdriver(next.url, '/dashboard/dynamic') + const clientContent = await browser.elementByCss(selector).text() + expect(clientContent).toContain('next-dynamic dynamic no ssr on server') + expect(clientContent).toContain('text client under sever') + await browser.waitForElementByCss('#css-text-dynamic-no-ssr-client') + + expect( + await browser.elementByCss('#css-text-dynamic-no-ssr-client').text() + ).toBe('next-dynamic dynamic no ssr on client') }) it('should serve polyfills for browsers that do not support modules', async () => { diff --git a/test/e2e/app-dir/vercel-analytics.test.ts b/test/e2e/app-dir/vercel-analytics.test.ts index 1640675a078e..a1776f4d7108 100644 --- a/test/e2e/app-dir/vercel-analytics.test.ts +++ b/test/e2e/app-dir/vercel-analytics.test.ts @@ -20,8 +20,8 @@ describe('vercel analytics', () => { files: new FileRef(path.join(__dirname, 'app')), dependencies: { swr: '2.0.0-rc.0', - react: 'experimental', - 'react-dom': 'experimental', + react: 'latest', + 'react-dom': 'latest', sass: 'latest', }, skipStart: true, diff --git a/test/integration/next-dynamic-lazy-compilation/test/index.test.js b/test/integration/next-dynamic-lazy-compilation/test/index.test.js index e76b1176b91e..1711b20a795b 100644 --- a/test/integration/next-dynamic-lazy-compilation/test/index.test.js +++ b/test/integration/next-dynamic-lazy-compilation/test/index.test.js @@ -29,7 +29,9 @@ function runTests() { const browser = await webdriver(appPort, '/') const text = await browser.elementByCss('#before-hydration').text() - expect(text).toBe('Index12344') + expect(text).toMatch( + /^Index1()+2()+3()+4()+4$/ + ) expect(await browser.eval('window.caughtErrors')).toBe('') }) @@ -37,7 +39,9 @@ function runTests() { const browser = await webdriver(appPort, '/') const text = await browser.elementByCss('#first-render').text() - expect(text).toBe('Index12344') + expect(text).toMatch( + /^Index1()+2()+3()+4()+4$/ + ) expect(await browser.eval('window.caughtErrors')).toBe('') }) } diff --git a/test/integration/next-dynamic-with-suspense/pages/index.js b/test/integration/next-dynamic-with-suspense/pages/index.js deleted file mode 100644 index 284fcc7f7f8f..000000000000 --- a/test/integration/next-dynamic-with-suspense/pages/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Suspense } from 'react' -import dynamic from 'next/dynamic' - -const Thing = dynamic(() => import('./thing'), { - ssr: false, - suspense: true, - loading: () => 'Loading...', -}) - -export default function IndexPage() { - return ( -
-

Next.js Example

- - - -
- ) -} diff --git a/test/integration/next-dynamic-with-suspense/pages/thing.js b/test/integration/next-dynamic-with-suspense/pages/thing.js deleted file mode 100644 index 561df9831e5b..000000000000 --- a/test/integration/next-dynamic-with-suspense/pages/thing.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Thing() { - return 'Thing' -} diff --git a/test/integration/next-dynamic-with-suspense/test/index.test.ts b/test/integration/next-dynamic-with-suspense/test/index.test.ts deleted file mode 100644 index 625fdde999d5..000000000000 --- a/test/integration/next-dynamic-with-suspense/test/index.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-env jest */ - -import webdriver from 'next-webdriver' -import { join } from 'path' -import { - renderViaHTTP, - findPort, - launchApp, - killApp, - hasRedbox, -} from 'next-test-utils' - -let app -let appPort: number -const appDir = join(__dirname, '../') - -describe('next/dynamic with suspense', () => { - beforeAll(async () => { - appPort = await findPort() - app = await launchApp(appDir, appPort) - }) - afterAll(() => killApp(app)) - - it('should render server-side', async () => { - const html = await renderViaHTTP(appPort, '/') - expect(html).toContain('Next.js Example') - expect(html).toContain('Thing') - }) - - it('should render client-side', async () => { - const browser = await webdriver(appPort, '/') - const warnings = (await browser.log()).map((log) => log.message).join('\n') - - expect(await hasRedbox(browser)).toBe(false) - expect(warnings).toMatch( - /"ssr: false" is ignored by next\/dynamic because you can not enable "suspense" while disabling "ssr" at the same time/gim - ) - - await browser.close() - }) -}) diff --git a/test/integration/next-dynamic/test/index.test.js b/test/integration/next-dynamic/test/index.test.js index b926fdfca98a..5207e55b64c0 100644 --- a/test/integration/next-dynamic/test/index.test.js +++ b/test/integration/next-dynamic/test/index.test.js @@ -29,7 +29,9 @@ function runTests() { const text = await browser.elementByCss('#first-render').text() // Failure case is 'Index3' - expect(text).toBe('Index12344') + expect(text).toMatch( + /^Index1()+2()+3()+4()+4$/ + ) expect(await browser.eval('window.caughtErrors')).toBe('') // should not print "invalid-dynamic-suspense" warning in browser's console