Skip to content

Commit

Permalink
Implement loadable with lazy and suspense for next dynamic (#42589)
Browse files Browse the repository at this point in the history
### 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
<Suspense fallback={loading}>
  <DynamicComponent />
 </Suspense>
```

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
  • Loading branch information
huozhi committed Dec 7, 2022
1 parent a704b71 commit cd0ebd8
Show file tree
Hide file tree
Showing 43 changed files with 315 additions and 354 deletions.
1 change: 0 additions & 1 deletion packages/next-swc/crates/core/src/lib.rs
Expand Up @@ -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()
),
Expand Down
62 changes: 2 additions & 60 deletions packages/next-swc/crates/core/src/next_dynamic.rs
Expand Up @@ -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<PathBuf>,
) -> impl Fold {
NextDynamicPatcher {
is_development,
is_server,
is_server_components,
pages_dir,
filename,
dynamic_bindings: vec![],
Expand All @@ -37,7 +34,6 @@ pub fn next_dynamic(
struct NextDynamicPatcher {
is_development: bool,
is_server: bool,
is_server_components: bool,
pages_dir: Option<PathBuf>,
filename: FileName,
dynamic_bindings: Vec<Id>,
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion packages/next-swc/crates/core/tests/errors.rs
Expand Up @@ -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()),
)
Expand Down
3 changes: 0 additions & 3 deletions packages/next-swc/crates/core/tests/fixture.rs
Expand Up @@ -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()),
)
Expand All @@ -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")),
Expand All @@ -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()),
)
Expand Down
@@ -1,23 +1,21 @@
import dynamic from 'next/dynamic';
const DynamicComponentWithCustomLoading = dynamic(()=>import('../components/hello')
, {
const DynamicComponentWithCustomLoading = dynamic(()=>import('../components/hello'), {
loadableGenerated: {
modules: [
"some-file.js -> " + "../components/hello"
]
},
loading: ()=><p >...</p>
});
const DynamicClientOnlyComponent = dynamic(null, {
const DynamicClientOnlyComponent = dynamic(()=>import('../components/hello'), {
loadableGenerated: {
modules: [
"some-file.js -> " + "../components/hello"
]
},
ssr: false
});
const DynamicClientOnlyComponentWithSuspense = dynamic(()=>import('../components/hello')
, {
const DynamicClientOnlyComponentWithSuspense = dynamic(()=>import('../components/hello'), {
loadableGenerated: {
modules: [
"some-file.js -> " + "../components/hello"
Expand Down
@@ -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
});
2 changes: 1 addition & 1 deletion packages/next/build/swc/options.js
Expand Up @@ -128,7 +128,7 @@ function getBaseSWCOptions({
? {
isServer: !!isServerLayer,
}
: false,
: undefined,
}
}

Expand Down
18 changes: 0 additions & 18 deletions packages/next/build/webpack-config.ts
Expand Up @@ -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.
Expand Down
29 changes: 26 additions & 3 deletions packages/next/client/app-index.tsx
Expand Up @@ -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'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
<GlobalLayoutRouterContext.Provider
Expand Down Expand Up @@ -234,11 +254,14 @@ export function hydrate() {
</StrictModeIfEnabled>
)

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)
Expand Down
19 changes: 0 additions & 19 deletions packages/next/client/components/dynamic.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions packages/next/client/components/layout-router.tsx
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/next/client/index.tsx
Expand Up @@ -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'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit cd0ebd8

Please sign in to comment.