From cd0ebd8e8cf922360a6d3150b29c90013cd92d48 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 7 Dec 2022 19:42:10 +0100 Subject: [PATCH 1/2] 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 ``` 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 --- packages/next-swc/crates/core/src/lib.rs | 1 - .../next-swc/crates/core/src/next_dynamic.rs | 62 +-------- packages/next-swc/crates/core/tests/errors.rs | 1 - .../next-swc/crates/core/tests/fixture.rs | 3 - .../with-options/output-server.js | 8 +- .../wrapped-import/output-server.js | 5 +- packages/next/build/swc/options.js | 2 +- packages/next/build/webpack-config.ts | 18 --- packages/next/client/app-index.tsx | 29 ++++- packages/next/client/components/dynamic.tsx | 19 --- .../next/client/components/layout-router.tsx | 4 +- packages/next/client/index.tsx | 9 +- packages/next/export/worker.ts | 47 ++++--- packages/next/server/app-render.tsx | 9 +- packages/next/server/render.tsx | 8 ++ packages/next/shared/lib/dynamic-no-ssr.ts | 20 +++ packages/next/shared/lib/dynamic.tsx | 118 ++++++++---------- packages/next/shared/lib/loadable-context.ts | 2 + packages/next/shared/lib/loadable.js | 61 +++------ packages/next/shared/lib/no-ssr-error.ts | 3 + .../dynamic/dynamic-imports/dynamic-client.js | 17 +++ .../dynamic/dynamic-imports/dynamic-server.js | 14 +++ .../dynamic-imports/react-lazy-client.js | 4 +- .../{index => dynamic}/dynamic.module.css | 0 .../{index => dynamic}/lazy.module.css | 0 .../app-dir/app/app/dashboard/dynamic/page.js | 19 +++ .../app/app/dashboard/dynamic/text-client.js | 3 + .../{index => dynamic}/text-dynamic-client.js | 2 +- .../dynamic/text-dynamic-no-ssr-client.js | 13 ++ .../dynamic/text-dynamic-no-ssr-server.js | 12 ++ .../text-dynamic-server-import-client.js | 9 ++ .../{index => dynamic}/text-dynamic-server.js | 2 +- .../{index => dynamic}/text-lazy-client.js | 2 +- .../index/dynamic-imports/dynamic-client.js | 9 -- .../index/dynamic-imports/dynamic-server.js | 9 -- .../app-dir/app/app/dashboard/index/page.js | 13 +- test/e2e/app-dir/index.test.ts | 33 ++++- test/e2e/app-dir/vercel-analytics.test.ts | 4 +- .../test/index.test.js | 8 +- .../next-dynamic-with-suspense/pages/index.js | 19 --- .../next-dynamic-with-suspense/pages/thing.js | 3 - .../test/index.test.ts | 41 ------ .../next-dynamic/test/index.test.js | 4 +- 43 files changed, 315 insertions(+), 354 deletions(-) delete mode 100644 packages/next/client/components/dynamic.tsx create mode 100644 packages/next/shared/lib/dynamic-no-ssr.ts create mode 100644 packages/next/shared/lib/no-ssr-error.ts create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-client.js create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/dynamic-imports/dynamic-server.js rename test/e2e/app-dir/app/app/dashboard/{index => dynamic}/dynamic-imports/react-lazy-client.js (63%) rename test/e2e/app-dir/app/app/dashboard/{index => dynamic}/dynamic.module.css (100%) rename test/e2e/app-dir/app/app/dashboard/{index => dynamic}/lazy.module.css (100%) create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/page.js create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/text-client.js rename test/e2e/app-dir/app/app/dashboard/{index => dynamic}/text-dynamic-client.js (89%) create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-client.js create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-no-ssr-server.js create mode 100644 test/e2e/app-dir/app/app/dashboard/dynamic/text-dynamic-server-import-client.js rename test/e2e/app-dir/app/app/dashboard/{index => dynamic}/text-dynamic-server.js (82%) rename test/e2e/app-dir/app/app/dashboard/{index => dynamic}/text-lazy-client.js (88%) delete mode 100644 test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-client.js delete mode 100644 test/e2e/app-dir/app/app/dashboard/index/dynamic-imports/dynamic-server.js delete mode 100644 test/integration/next-dynamic-with-suspense/pages/index.js delete mode 100644 test/integration/next-dynamic-with-suspense/pages/thing.js delete mode 100644 test/integration/next-dynamic-with-suspense/test/index.test.ts 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 From 2332272ae1d14ba204b05157b8599b96c3d095c4 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 7 Dec 2022 21:28:10 +0100 Subject: [PATCH 2/2] Do not attach CSS checksum for production build (#43827) The flight CSS dev loader only does one thing: adding a checksum string to the module exports to make sure the content hash updates during development (so we can trigger HMR properly). This loader is not needed for production builds. This PR makes sure that the checksum isn't attached and shipped to the client: CleanShot 2022-12-07 at 20 49 30@2x Which is 0.25kB for that testing page. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Feature - [ ] 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` - [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- .../loaders/next-flight-css-dev-loader.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-flight-css-dev-loader.ts b/packages/next/build/webpack/loaders/next-flight-css-dev-loader.ts index 8bfd62b9f38f..43f994e35b42 100644 --- a/packages/next/build/webpack/loaders/next-flight-css-dev-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-css-dev-loader.ts @@ -5,25 +5,32 @@ */ export function pitch(this: any) { - const content = this.fs.readFileSync(this.resourcePath) - this.data.__checksum = ( - typeof content === 'string' ? Buffer.from(content) : content - ).toString('hex') + if (process.env.NODE_ENV !== 'production') { + const content = this.fs.readFileSync(this.resourcePath) + this.data.__checksum = ( + typeof content === 'string' ? Buffer.from(content) : content + ).toString('hex') + } } const NextServerCSSLoader = function (this: any, content: string) { this.cacheable && this.cacheable() - const isCSSModule = this.resourcePath.match(/\.module\.(css|sass|scss)$/) - if (isCSSModule) { - return ( - content + - '\nmodule.exports.__checksum = ' + - JSON.stringify(this.data.__checksum) - ) + // Only add the checksum during development. + if (process.env.NODE_ENV !== 'production') { + const isCSSModule = this.resourcePath.match(/\.module\.(css|sass|scss)$/) + if (isCSSModule) { + return ( + content + + '\nmodule.exports.__checksum = ' + + JSON.stringify(this.data.__checksum) + ) + } + + return `export default ${JSON.stringify(this.data.__checksum)}` } - return `export default ${JSON.stringify(this.data.__checksum)}` + return content } export default NextServerCSSLoader