Skip to main content

next_custom_transforms/transforms/
dynamic.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::Arc,
4};
5
6use pathdiff::diff_paths;
7use swc_core::{
8    atoms::{Atom, Wtf8Atom, atom},
9    common::{DUMMY_SP, FileName, Span, errors::HANDLER},
10    ecma::{
11        ast::{
12            ArrayLit, ArrowExpr, BinExpr, BlockStmt, BlockStmtOrExpr, Bool, CallExpr, Callee, Expr,
13            ExprOrSpread, ExprStmt, Id, Ident, IdentName, ImportDecl, ImportNamedSpecifier,
14            ImportSpecifier, KeyValueProp, Lit, ModuleDecl, ModuleItem, ObjectLit, Pass, Prop,
15            PropName, PropOrSpread, Stmt, Str, Tpl, UnaryExpr, UnaryOp, op,
16        },
17        utils::{ExprFactory, private_ident, quote_ident},
18        visit::{VisitMut, VisitMutWith, visit_mut_pass},
19    },
20    quote,
21};
22
23/// Creates a SWC visitor to transform `next/dynamic` calls to have the
24/// corresponding `loadableGenerated` property.
25///
26/// **NOTE** We do not use `NextDynamicMode::Turbopack` yet. It isn't compatible
27/// with current loadable manifest, which causes hydration errors.
28pub fn next_dynamic(
29    is_development: bool,
30    is_server_compiler: bool,
31    is_react_server_layer: bool,
32    prefer_esm: bool,
33    mode: NextDynamicMode,
34    filename: Arc<FileName>,
35    pages_or_app_dir: Option<PathBuf>,
36) -> impl Pass {
37    visit_mut_pass(NextDynamicPatcher {
38        is_development,
39        is_server_compiler,
40        is_react_server_layer,
41        prefer_esm,
42        pages_or_app_dir,
43        filename,
44        dynamic_bindings: vec![],
45        is_next_dynamic_first_arg: false,
46        dynamically_imported_specifier: None,
47        state: match mode {
48            NextDynamicMode::Webpack => NextDynamicPatcherState::Webpack,
49            NextDynamicMode::Turbopack {
50                dynamic_client_transition_name,
51                dynamic_transition_name,
52            } => NextDynamicPatcherState::Turbopack {
53                dynamic_client_transition_name,
54                dynamic_transition_name,
55                imports: vec![],
56            },
57        },
58    })
59}
60
61#[derive(Debug, Clone, Eq, PartialEq)]
62pub enum NextDynamicMode {
63    /// In Webpack mode, each `dynamic()` call will generate a key composed
64    /// from:
65    /// 1. The current module's path relative to the pages directory;
66    /// 2. The relative imported module id.
67    ///
68    /// This key is of the form:
69    /// {currentModulePath} -> {relativeImportedModulePath}
70    ///
71    /// It corresponds to an entry in the React Loadable Manifest generated by
72    /// the React Loadable Webpack plugin.
73    Webpack,
74    /// In Turbopack mode:
75    /// * each dynamic import is amended with a transition to `dynamic_transition_name`
76    /// * the ident of the client module (via `dynamic_client_transition_name`) is added to the
77    ///   metadata
78    Turbopack {
79        dynamic_client_transition_name: Atom,
80        dynamic_transition_name: Atom,
81    },
82}
83
84#[derive(Debug)]
85struct NextDynamicPatcher {
86    is_development: bool,
87    is_server_compiler: bool,
88    is_react_server_layer: bool,
89    prefer_esm: bool,
90    pages_or_app_dir: Option<PathBuf>,
91    filename: Arc<FileName>,
92    dynamic_bindings: Vec<Id>,
93    is_next_dynamic_first_arg: bool,
94    dynamically_imported_specifier: Option<(Wtf8Atom, Span)>,
95    state: NextDynamicPatcherState,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq)]
99enum NextDynamicPatcherState {
100    Webpack,
101    /// In Turbo mode, contains a list of modules that need to be imported with
102    /// the given transition under a particular ident.
103    #[allow(unused)]
104    Turbopack {
105        dynamic_client_transition_name: Atom,
106        dynamic_transition_name: Atom,
107        imports: Vec<TurbopackImport>,
108    },
109}
110
111#[derive(Debug, Clone, Eq, PartialEq)]
112enum TurbopackImport {
113    // TODO do we need more variants? server vs client vs dev vs prod?
114    Import {
115        id_ident: Ident,
116        specifier: Wtf8Atom,
117    },
118}
119
120impl VisitMut for NextDynamicPatcher {
121    fn visit_mut_module_items(&mut self, items: &mut Vec<ModuleItem>) {
122        items.visit_mut_children_with(self);
123
124        self.maybe_add_dynamically_imported_specifier(items);
125    }
126
127    fn visit_mut_import_decl(&mut self, decl: &mut ImportDecl) {
128        if &decl.src.value == "next/dynamic" {
129            for specifier in &decl.specifiers {
130                if let ImportSpecifier::Default(default_specifier) = specifier {
131                    self.dynamic_bindings.push(default_specifier.local.to_id());
132                }
133            }
134        }
135    }
136
137    fn visit_mut_call_expr(&mut self, expr: &mut CallExpr) {
138        if self.is_next_dynamic_first_arg {
139            if let Callee::Import(..) = &expr.callee {
140                match &*expr.args[0].expr {
141                    Expr::Lit(Lit::Str(Str { value, span, .. })) => {
142                        self.dynamically_imported_specifier = Some((value.clone(), *span));
143                    }
144                    Expr::Tpl(Tpl { exprs, quasis, .. }) if exprs.is_empty() => {
145                        self.dynamically_imported_specifier =
146                            Some((quasis[0].raw.clone().into(), quasis[0].span));
147                    }
148                    _ => {}
149                }
150            }
151            expr.visit_mut_children_with(self);
152            return;
153        }
154
155        expr.visit_mut_children_with(self);
156
157        if let Callee::Expr(i) = &expr.callee
158            && let Expr::Ident(identifier) = &**i
159            && self.dynamic_bindings.contains(&identifier.to_id())
160        {
161            if expr.args.is_empty() {
162                HANDLER.with(|handler| {
163                    handler
164                        .struct_span_err(
165                            identifier.span,
166                            "next/dynamic requires at least one argument",
167                        )
168                        .emit()
169                });
170                return;
171            } else if expr.args.len() > 2 {
172                HANDLER.with(|handler| {
173                    handler
174                        .struct_span_err(identifier.span, "next/dynamic only accepts 2 arguments")
175                        .emit()
176                });
177                return;
178            }
179            if expr.args.len() == 2 {
180                match &*expr.args[1].expr {
181                    Expr::Object(_) => {}
182                    _ => {
183                        HANDLER.with(|handler| {
184                          handler
185                              .struct_span_err(
186                                  identifier.span,
187                                  "next/dynamic options must be an object literal.\nRead more: https://nextjs.org/docs/messages/invalid-dynamic-options-type",
188                              )
189                              .emit();
190                      });
191                        return;
192                    }
193                }
194            }
195
196            self.is_next_dynamic_first_arg = true;
197            expr.args[0].expr.visit_mut_with(self);
198            self.is_next_dynamic_first_arg = false;
199
200            let Some((dynamically_imported_specifier, dynamically_imported_specifier_span)) =
201                self.dynamically_imported_specifier.take()
202            else {
203                return;
204            };
205
206            let project_dir = match self.pages_or_app_dir.as_deref() {
207                Some(pages_or_app) => pages_or_app.parent(),
208                _ => None,
209            };
210
211            let generated = Box::new(Expr::Object(ObjectLit {
212                span: DUMMY_SP,
213                props: match &mut self.state {
214                    NextDynamicPatcherState::Webpack => {
215                        // dev client or server:
216                        // loadableGenerated: {
217                        //   modules:
218                        // ["/project/src/file-being-transformed.js -> " +
219                        // '../components/hello'] }
220                        //
221                        // prod client
222                        // loadableGenerated: {
223                        //   webpack: () => [require.resolveWeak('../components/hello')],
224                        if self.is_development || self.is_server_compiler {
225                            module_id_options(quote!(
226                                "$left + $right" as Expr,
227                                left: Expr = format!(
228                                    "{} -> ",
229                                    rel_filename(project_dir, &self.filename)
230                                )
231                                .into(),
232                                right: Expr = dynamically_imported_specifier.clone().into(),
233                            ))
234                        } else {
235                            webpack_options(quote!(
236                                "require.resolveWeak($id)" as Expr,
237                                id: Expr = dynamically_imported_specifier.clone().into()
238                            ))
239                        }
240                    }
241
242                    NextDynamicPatcherState::Turbopack { imports, .. } => {
243                        // loadableGenerated: { modules: [
244                        // ".../client.js [app-client] (ecmascript, next/dynamic entry)"
245                        // ]}
246                        let id_ident = private_ident!(dynamically_imported_specifier_span, "id");
247
248                        imports.push(TurbopackImport::Import {
249                            id_ident: id_ident.clone(),
250                            specifier: dynamically_imported_specifier.clone(),
251                        });
252
253                        module_id_options(Expr::Ident(id_ident))
254                    }
255                },
256            }));
257
258            let mut props = vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
259                key: PropName::Ident(IdentName::new(atom!("loadableGenerated"), DUMMY_SP)),
260                value: generated,
261            })))];
262
263            let mut has_ssr_false = false;
264
265            if expr.args.len() == 2
266                && let Expr::Object(ObjectLit {
267                    props: options_props,
268                    ..
269                }) = &*expr.args[1].expr
270            {
271                for prop in options_props.iter() {
272                    if let Some(KeyValueProp { key, value }) = match prop {
273                        PropOrSpread::Prop(prop) => match &**prop {
274                            Prop::KeyValue(key_value_prop) => Some(key_value_prop),
275                            _ => None,
276                        },
277                        _ => None,
278                    } && let Some(IdentName { sym, span: _ }) = match key {
279                        PropName::Ident(ident) => Some(ident),
280                        _ => None,
281                    } && sym == "ssr"
282                        && let Some(Lit::Bool(Bool {
283                            value: false,
284                            span: _,
285                        })) = value.as_lit()
286                    {
287                        has_ssr_false = true
288                    }
289                }
290                props.extend(options_props.iter().cloned());
291            }
292
293            let should_skip_ssr_compile = has_ssr_false
294                && self.is_server_compiler
295                && !self.is_react_server_layer
296                && self.prefer_esm;
297
298            match &self.state {
299                NextDynamicPatcherState::Webpack => {
300                    // Only use `require.resolveWebpack` to decouple modules for webpack,
301                    // turbopack doesn't need this
302
303                    // When it's not preferring to picking up ESM (in the pages router), we
304                    // don't need to do it as it doesn't need to enter the non-ssr module.
305                    //
306                    // Also transforming it to `require.resolveWeak` doesn't work with ESM
307                    // imports ( i.e. require.resolveWeak(esm asset)).
308                    if should_skip_ssr_compile {
309                        // if it's server components SSR layer
310                        // Transform 1st argument `expr.args[0]` aka the module loader from:
311                        // dynamic(() => import('./client-mod'), { ssr: false }))`
312                        // into:
313                        // dynamic(async () => {
314                        //   require.resolveWeak('./client-mod')
315                        // }, { ssr: false }))`
316
317                        let require_resolve_weak_expr = Expr::Call(CallExpr {
318                            span: DUMMY_SP,
319                            callee: quote_ident!("require.resolveWeak").as_callee(),
320                            args: vec![ExprOrSpread {
321                                spread: None,
322                                expr: Box::new(Expr::Lit(Lit::Str(Str {
323                                    span: DUMMY_SP,
324                                    value: dynamically_imported_specifier.clone(),
325                                    raw: None,
326                                }))),
327                            }],
328                            ..Default::default()
329                        });
330
331                        let side_effect_free_loader_arg = Expr::Arrow(ArrowExpr {
332                            span: DUMMY_SP,
333                            params: vec![],
334                            body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {
335                                span: DUMMY_SP,
336                                stmts: vec![Stmt::Expr(ExprStmt {
337                                    span: DUMMY_SP,
338                                    expr: Box::new(exec_expr_when_resolve_weak_available(
339                                        &require_resolve_weak_expr,
340                                    )),
341                                })],
342                                ..Default::default()
343                            })),
344                            is_async: true,
345                            is_generator: false,
346                            ..Default::default()
347                        });
348
349                        expr.args[0] = side_effect_free_loader_arg.as_arg();
350                    }
351                }
352                NextDynamicPatcherState::Turbopack {
353                    dynamic_transition_name,
354                    ..
355                } => {
356                    // When `ssr: false`
357                    // if it's server components SSR layer
358                    // Transform 1st argument `expr.args[0]` aka the module loader from:
359                    // dynamic(() => import('./client-mod'), { ssr: false }))`
360                    // into:
361                    // dynamic(async () => {}, { ssr: false }))`
362                    if should_skip_ssr_compile {
363                        let side_effect_free_loader_arg = Expr::Arrow(ArrowExpr {
364                            span: DUMMY_SP,
365                            params: vec![],
366                            body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {
367                                span: DUMMY_SP,
368                                stmts: vec![],
369                                ..Default::default()
370                            })),
371                            is_async: true,
372                            is_generator: false,
373                            ..Default::default()
374                        });
375
376                        expr.args[0] = side_effect_free_loader_arg.as_arg();
377                    } else {
378                        // Add `{with:{turbopack-transition: ...}}` to the dynamic import
379                        let mut visitor = DynamicImportTransitionAdder {
380                            transition_name: dynamic_transition_name,
381                        };
382                        expr.args[0].visit_mut_with(&mut visitor);
383                    }
384                }
385            }
386
387            let second_arg = ExprOrSpread {
388                spread: None,
389                expr: Box::new(Expr::Object(ObjectLit {
390                    span: DUMMY_SP,
391                    props,
392                })),
393            };
394
395            if expr.args.len() == 2 {
396                expr.args[1] = second_arg;
397            } else {
398                expr.args.push(second_arg)
399            }
400        }
401    }
402}
403
404struct DynamicImportTransitionAdder<'a> {
405    transition_name: &'a str,
406}
407// Add `{with:{turbopack-transition: <self.transition_name>}}` to any dynamic imports
408impl VisitMut for DynamicImportTransitionAdder<'_> {
409    fn visit_mut_call_expr(&mut self, expr: &mut CallExpr) {
410        if let Callee::Import(..) = &expr.callee {
411            let options = ExprOrSpread {
412                expr: Box::new(
413                    ObjectLit {
414                        span: DUMMY_SP,
415                        props: vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
416                            key: PropName::Ident(IdentName::new(atom!("with"), DUMMY_SP)),
417                            value: with_transition(self.transition_name).into(),
418                        })))],
419                    }
420                    .into(),
421                ),
422                spread: None,
423            };
424
425            match expr.args.get_mut(1) {
426                Some(arg) => *arg = options,
427                None => expr.args.push(options),
428            }
429        } else {
430            expr.visit_mut_children_with(self);
431        }
432    }
433}
434
435fn module_id_options(module_id: Expr) -> Vec<PropOrSpread> {
436    vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
437        key: PropName::Ident(IdentName::new(atom!("modules"), DUMMY_SP)),
438        value: Box::new(Expr::Array(ArrayLit {
439            elems: vec![Some(ExprOrSpread {
440                expr: Box::new(module_id),
441                spread: None,
442            })],
443            span: DUMMY_SP,
444        })),
445    })))]
446}
447
448fn webpack_options(module_id: Expr) -> Vec<PropOrSpread> {
449    vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
450        key: PropName::Ident(IdentName::new(atom!("webpack"), DUMMY_SP)),
451        value: Box::new(Expr::Arrow(ArrowExpr {
452            params: vec![],
453            body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Array(ArrayLit {
454                elems: vec![Some(ExprOrSpread {
455                    expr: Box::new(module_id),
456                    spread: None,
457                })],
458                span: DUMMY_SP,
459            })))),
460            is_async: false,
461            is_generator: false,
462            span: DUMMY_SP,
463            ..Default::default()
464        })),
465    })))]
466}
467
468impl NextDynamicPatcher {
469    fn maybe_add_dynamically_imported_specifier(&mut self, items: &mut Vec<ModuleItem>) {
470        let NextDynamicPatcherState::Turbopack {
471            dynamic_client_transition_name,
472            imports,
473            ..
474        } = &mut self.state
475        else {
476            return;
477        };
478
479        let mut new_items = Vec::with_capacity(imports.len());
480
481        for import in std::mem::take(imports) {
482            match import {
483                TurbopackImport::Import {
484                    id_ident,
485                    specifier,
486                } => {
487                    // Turbopack will automatically transform the imported `__turbopack_module_id__`
488                    // identifier into the imported module's id.
489                    new_items.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
490                        span: DUMMY_SP,
491                        specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier {
492                            span: DUMMY_SP,
493                            local: id_ident,
494                            imported: Some(
495                                Ident::new(
496                                    atom!("__turbopack_module_id__"),
497                                    DUMMY_SP,
498                                    Default::default(),
499                                )
500                                .into(),
501                            ),
502                            is_type_only: false,
503                        })],
504                        src: Box::new(specifier.into()),
505                        type_only: false,
506                        with: Some(with_transition_chunking_type(
507                            dynamic_client_transition_name,
508                            "none",
509                        )),
510                        phase: Default::default(),
511                    })));
512                }
513            }
514        }
515
516        new_items.append(items);
517
518        std::mem::swap(&mut new_items, items)
519    }
520}
521
522fn exec_expr_when_resolve_weak_available(expr: &Expr) -> Expr {
523    let undefined_str_literal = Expr::Lit(Lit::Str(Str {
524        span: DUMMY_SP,
525        value: atom!("undefined").into(),
526        raw: None,
527    }));
528
529    let typeof_expr = Expr::Unary(UnaryExpr {
530        span: DUMMY_SP,
531        op: UnaryOp::TypeOf, // 'typeof' operator
532        arg: Box::new(Expr::Ident(Ident {
533            sym: quote_ident!("require.resolveWeak").sym,
534            ..Default::default()
535        })),
536    });
537
538    // typeof require.resolveWeak !== 'undefined' && <expression>
539    Expr::Bin(BinExpr {
540        span: DUMMY_SP,
541        left: Box::new(Expr::Bin(BinExpr {
542            span: DUMMY_SP,
543            op: op!("!=="),
544            left: Box::new(typeof_expr),
545            right: Box::new(undefined_str_literal),
546        })),
547        op: op!("&&"),
548        right: Box::new(expr.clone()),
549    })
550}
551
552fn rel_filename(base: Option<&Path>, file: &FileName) -> String {
553    let base = match base {
554        Some(v) => v,
555        None => return file.to_string(),
556    };
557
558    let file = match file {
559        FileName::Real(v) => v,
560        _ => {
561            return file.to_string();
562        }
563    };
564
565    let rel_path = diff_paths(file, base);
566
567    let rel_path = match rel_path {
568        Some(v) => v,
569        None => return file.display().to_string(),
570    };
571
572    rel_path.display().to_string()
573}
574
575fn with_transition(transition_name: &str) -> ObjectLit {
576    with_clause(&[("turbopack-transition", transition_name)])
577}
578
579fn with_transition_chunking_type(transition_name: &str, chunking_type: &str) -> Box<ObjectLit> {
580    Box::new(with_clause(&[
581        ("turbopack-transition", transition_name),
582        ("turbopack-chunking-type", chunking_type),
583    ]))
584}
585
586fn with_clause<'a>(entries: impl IntoIterator<Item = &'a (&'a str, &'a str)>) -> ObjectLit {
587    ObjectLit {
588        span: DUMMY_SP,
589        props: entries.into_iter().map(|(k, v)| with_prop(k, v)).collect(),
590    }
591}
592
593fn with_prop(key: &str, value: &str) -> PropOrSpread {
594    PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
595        key: PropName::Str(key.into()),
596        value: Box::new(Expr::Lit(value.into())),
597    })))
598}