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, Atom, Wtf8Atom},
9    common::{errors::HANDLER, FileName, Span, DUMMY_SP},
10    ecma::{
11        ast::{
12            op, ArrayLit, ArrowExpr, BinExpr, BlockStmt, BlockStmtOrExpr, Bool, CallExpr, Callee,
13            Expr, ExprOrSpread, ExprStmt, Id, Ident, IdentName, ImportDecl, ImportNamedSpecifier,
14            ImportSpecifier, KeyValueProp, Lit, ModuleDecl, ModuleItem, ObjectLit, Pass, Prop,
15            PropName, PropOrSpread, Stmt, Str, Tpl, UnaryExpr, UnaryOp,
16        },
17        utils::{private_ident, quote_ident, ExprFactory},
18        visit::{visit_mut_pass, VisitMut, VisitMutWith},
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            if let Expr::Ident(identifier) = &**i {
159                if self.dynamic_bindings.contains(&identifier.to_id()) {
160                    if expr.args.is_empty() {
161                        HANDLER.with(|handler| {
162                            handler
163                                .struct_span_err(
164                                    identifier.span,
165                                    "next/dynamic requires at least one argument",
166                                )
167                                .emit()
168                        });
169                        return;
170                    } else if expr.args.len() > 2 {
171                        HANDLER.with(|handler| {
172                            handler
173                                .struct_span_err(
174                                    identifier.span,
175                                    "next/dynamic only accepts 2 arguments",
176                                )
177                                .emit()
178                        });
179                        return;
180                    }
181                    if expr.args.len() == 2 {
182                        match &*expr.args[1].expr {
183                            Expr::Object(_) => {}
184                            _ => {
185                                HANDLER.with(|handler| {
186                          handler
187                              .struct_span_err(
188                                  identifier.span,
189                                  "next/dynamic options must be an object literal.\nRead more: https://nextjs.org/docs/messages/invalid-dynamic-options-type",
190                              )
191                              .emit();
192                      });
193                                return;
194                            }
195                        }
196                    }
197
198                    self.is_next_dynamic_first_arg = true;
199                    expr.args[0].expr.visit_mut_with(self);
200                    self.is_next_dynamic_first_arg = false;
201
202                    let Some((dynamically_imported_specifier, dynamically_imported_specifier_span)) =
203                        self.dynamically_imported_specifier.take()
204                    else {
205                        return;
206                    };
207
208                    let project_dir = match self.pages_or_app_dir.as_deref() {
209                        Some(pages_or_app) => pages_or_app.parent(),
210                        _ => None,
211                    };
212
213                    let generated = Box::new(Expr::Object(ObjectLit {
214                        span: DUMMY_SP,
215                        props: match &mut self.state {
216                            NextDynamicPatcherState::Webpack => {
217                                // dev client or server:
218                                // loadableGenerated: {
219                                //   modules:
220                                // ["/project/src/file-being-transformed.js -> " +
221                                // '../components/hello'] }
222                                //
223                                // prod client
224                                // loadableGenerated: {
225                                //   webpack: () => [require.resolveWeak('../components/hello')],
226                                if self.is_development || self.is_server_compiler {
227                                    module_id_options(quote!(
228                                        "$left + $right" as Expr,
229                                        left: Expr = format!(
230                                            "{} -> ",
231                                            rel_filename(project_dir, &self.filename)
232                                        )
233                                        .into(),
234                                        right: Expr = dynamically_imported_specifier.clone().into(),
235                                    ))
236                                } else {
237                                    webpack_options(quote!(
238                                        "require.resolveWeak($id)" as Expr,
239                                        id: Expr = dynamically_imported_specifier.clone().into()
240                                    ))
241                                }
242                            }
243
244                            NextDynamicPatcherState::Turbopack { imports, .. } => {
245                                // loadableGenerated: { modules: [
246                                // ".../client.js [app-client] (ecmascript, next/dynamic entry)"
247                                // ]}
248                                let id_ident =
249                                    private_ident!(dynamically_imported_specifier_span, "id");
250
251                                imports.push(TurbopackImport::Import {
252                                    id_ident: id_ident.clone(),
253                                    specifier: dynamically_imported_specifier.clone(),
254                                });
255
256                                module_id_options(Expr::Ident(id_ident))
257                            }
258                        },
259                    }));
260
261                    let mut props =
262                        vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
263                            key: PropName::Ident(IdentName::new(
264                                atom!("loadableGenerated"),
265                                DUMMY_SP,
266                            )),
267                            value: generated,
268                        })))];
269
270                    let mut has_ssr_false = false;
271
272                    if expr.args.len() == 2 {
273                        if let Expr::Object(ObjectLit {
274                            props: options_props,
275                            ..
276                        }) = &*expr.args[1].expr
277                        {
278                            for prop in options_props.iter() {
279                                if let Some(KeyValueProp { key, value }) = match prop {
280                                    PropOrSpread::Prop(prop) => match &**prop {
281                                        Prop::KeyValue(key_value_prop) => Some(key_value_prop),
282                                        _ => None,
283                                    },
284                                    _ => None,
285                                } {
286                                    if let Some(IdentName { sym, span: _ }) = match key {
287                                        PropName::Ident(ident) => Some(ident),
288                                        _ => None,
289                                    } {
290                                        if sym == "ssr" {
291                                            if let Some(Lit::Bool(Bool {
292                                                value: false,
293                                                span: _,
294                                            })) = value.as_lit()
295                                            {
296                                                has_ssr_false = true
297                                            }
298                                        }
299                                    }
300                                }
301                            }
302                            props.extend(options_props.iter().cloned());
303                        }
304                    }
305
306                    let should_skip_ssr_compile = has_ssr_false
307                        && self.is_server_compiler
308                        && !self.is_react_server_layer
309                        && self.prefer_esm;
310
311                    match &self.state {
312                        NextDynamicPatcherState::Webpack => {
313                            // Only use `require.resolveWebpack` to decouple modules for webpack,
314                            // turbopack doesn't need this
315
316                            // When it's not preferring to picking up ESM (in the pages router), we
317                            // don't need to do it as it doesn't need to enter the non-ssr module.
318                            //
319                            // Also transforming it to `require.resolveWeak` doesn't work with ESM
320                            // imports ( i.e. require.resolveWeak(esm asset)).
321                            if should_skip_ssr_compile {
322                                // if it's server components SSR layer
323                                // Transform 1st argument `expr.args[0]` aka the module loader from:
324                                // dynamic(() => import('./client-mod'), { ssr: false }))`
325                                // into:
326                                // dynamic(async () => {
327                                //   require.resolveWeak('./client-mod')
328                                // }, { ssr: false }))`
329
330                                let require_resolve_weak_expr = Expr::Call(CallExpr {
331                                    span: DUMMY_SP,
332                                    callee: quote_ident!("require.resolveWeak").as_callee(),
333                                    args: vec![ExprOrSpread {
334                                        spread: None,
335                                        expr: Box::new(Expr::Lit(Lit::Str(Str {
336                                            span: DUMMY_SP,
337                                            value: dynamically_imported_specifier.clone(),
338                                            raw: None,
339                                        }))),
340                                    }],
341                                    ..Default::default()
342                                });
343
344                                let side_effect_free_loader_arg = Expr::Arrow(ArrowExpr {
345                                    span: DUMMY_SP,
346                                    params: vec![],
347                                    body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {
348                                        span: DUMMY_SP,
349                                        stmts: vec![Stmt::Expr(ExprStmt {
350                                            span: DUMMY_SP,
351                                            expr: Box::new(exec_expr_when_resolve_weak_available(
352                                                &require_resolve_weak_expr,
353                                            )),
354                                        })],
355                                        ..Default::default()
356                                    })),
357                                    is_async: true,
358                                    is_generator: false,
359                                    ..Default::default()
360                                });
361
362                                expr.args[0] = side_effect_free_loader_arg.as_arg();
363                            }
364                        }
365                        NextDynamicPatcherState::Turbopack {
366                            dynamic_transition_name,
367                            ..
368                        } => {
369                            // When `ssr: false`
370                            // if it's server components SSR layer
371                            // Transform 1st argument `expr.args[0]` aka the module loader from:
372                            // dynamic(() => import('./client-mod'), { ssr: false }))`
373                            // into:
374                            // dynamic(async () => {}, { ssr: false }))`
375                            if should_skip_ssr_compile {
376                                let side_effect_free_loader_arg = Expr::Arrow(ArrowExpr {
377                                    span: DUMMY_SP,
378                                    params: vec![],
379                                    body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {
380                                        span: DUMMY_SP,
381                                        stmts: vec![],
382                                        ..Default::default()
383                                    })),
384                                    is_async: true,
385                                    is_generator: false,
386                                    ..Default::default()
387                                });
388
389                                expr.args[0] = side_effect_free_loader_arg.as_arg();
390                            } else {
391                                // Add `{with:{turbopack-transition: ...}}` to the dynamic import
392                                let mut visitor = DynamicImportTransitionAdder {
393                                    transition_name: dynamic_transition_name,
394                                };
395                                expr.args[0].visit_mut_with(&mut visitor);
396                            }
397                        }
398                    }
399
400                    let second_arg = ExprOrSpread {
401                        spread: None,
402                        expr: Box::new(Expr::Object(ObjectLit {
403                            span: DUMMY_SP,
404                            props,
405                        })),
406                    };
407
408                    if expr.args.len() == 2 {
409                        expr.args[1] = second_arg;
410                    } else {
411                        expr.args.push(second_arg)
412                    }
413                }
414            }
415        }
416    }
417}
418
419struct DynamicImportTransitionAdder<'a> {
420    transition_name: &'a str,
421}
422// Add `{with:{turbopack-transition: <self.transition_name>}}` to any dynamic imports
423impl VisitMut for DynamicImportTransitionAdder<'_> {
424    fn visit_mut_call_expr(&mut self, expr: &mut CallExpr) {
425        if let Callee::Import(..) = &expr.callee {
426            let options = ExprOrSpread {
427                expr: Box::new(
428                    ObjectLit {
429                        span: DUMMY_SP,
430                        props: vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
431                            key: PropName::Ident(IdentName::new(atom!("with"), DUMMY_SP)),
432                            value: with_transition(self.transition_name).into(),
433                        })))],
434                    }
435                    .into(),
436                ),
437                spread: None,
438            };
439
440            match expr.args.get_mut(1) {
441                Some(arg) => *arg = options,
442                None => expr.args.push(options),
443            }
444        } else {
445            expr.visit_mut_children_with(self);
446        }
447    }
448}
449
450fn module_id_options(module_id: Expr) -> Vec<PropOrSpread> {
451    vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
452        key: PropName::Ident(IdentName::new(atom!("modules"), DUMMY_SP)),
453        value: 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    })))]
461}
462
463fn webpack_options(module_id: Expr) -> Vec<PropOrSpread> {
464    vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
465        key: PropName::Ident(IdentName::new(atom!("webpack"), DUMMY_SP)),
466        value: Box::new(Expr::Arrow(ArrowExpr {
467            params: vec![],
468            body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Array(ArrayLit {
469                elems: vec![Some(ExprOrSpread {
470                    expr: Box::new(module_id),
471                    spread: None,
472                })],
473                span: DUMMY_SP,
474            })))),
475            is_async: false,
476            is_generator: false,
477            span: DUMMY_SP,
478            ..Default::default()
479        })),
480    })))]
481}
482
483impl NextDynamicPatcher {
484    fn maybe_add_dynamically_imported_specifier(&mut self, items: &mut Vec<ModuleItem>) {
485        let NextDynamicPatcherState::Turbopack {
486            dynamic_client_transition_name,
487            imports,
488            ..
489        } = &mut self.state
490        else {
491            return;
492        };
493
494        let mut new_items = Vec::with_capacity(imports.len());
495
496        for import in std::mem::take(imports) {
497            match import {
498                TurbopackImport::Import {
499                    id_ident,
500                    specifier,
501                } => {
502                    // Turbopack will automatically transform the imported `__turbopack_module_id__`
503                    // identifier into the imported module's id.
504                    new_items.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
505                        span: DUMMY_SP,
506                        specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier {
507                            span: DUMMY_SP,
508                            local: id_ident,
509                            imported: Some(
510                                Ident::new(
511                                    atom!("__turbopack_module_id__"),
512                                    DUMMY_SP,
513                                    Default::default(),
514                                )
515                                .into(),
516                            ),
517                            is_type_only: false,
518                        })],
519                        src: Box::new(specifier.into()),
520                        type_only: false,
521                        with: Some(with_transition_chunking_type(
522                            dynamic_client_transition_name,
523                            "none",
524                        )),
525                        phase: Default::default(),
526                    })));
527                }
528            }
529        }
530
531        new_items.append(items);
532
533        std::mem::swap(&mut new_items, items)
534    }
535}
536
537fn exec_expr_when_resolve_weak_available(expr: &Expr) -> Expr {
538    let undefined_str_literal = Expr::Lit(Lit::Str(Str {
539        span: DUMMY_SP,
540        value: atom!("undefined").into(),
541        raw: None,
542    }));
543
544    let typeof_expr = Expr::Unary(UnaryExpr {
545        span: DUMMY_SP,
546        op: UnaryOp::TypeOf, // 'typeof' operator
547        arg: Box::new(Expr::Ident(Ident {
548            sym: quote_ident!("require.resolveWeak").sym,
549            ..Default::default()
550        })),
551    });
552
553    // typeof require.resolveWeak !== 'undefined' && <expression>
554    Expr::Bin(BinExpr {
555        span: DUMMY_SP,
556        left: Box::new(Expr::Bin(BinExpr {
557            span: DUMMY_SP,
558            op: op!("!=="),
559            left: Box::new(typeof_expr),
560            right: Box::new(undefined_str_literal),
561        })),
562        op: op!("&&"),
563        right: Box::new(expr.clone()),
564    })
565}
566
567fn rel_filename(base: Option<&Path>, file: &FileName) -> String {
568    let base = match base {
569        Some(v) => v,
570        None => return file.to_string(),
571    };
572
573    let file = match file {
574        FileName::Real(v) => v,
575        _ => {
576            return file.to_string();
577        }
578    };
579
580    let rel_path = diff_paths(file, base);
581
582    let rel_path = match rel_path {
583        Some(v) => v,
584        None => return file.display().to_string(),
585    };
586
587    rel_path.display().to_string()
588}
589
590fn with_transition(transition_name: &str) -> ObjectLit {
591    with_clause(&[("turbopack-transition", transition_name)])
592}
593
594fn with_transition_chunking_type(transition_name: &str, chunking_type: &str) -> Box<ObjectLit> {
595    Box::new(with_clause(&[
596        ("turbopack-transition", transition_name),
597        ("turbopack-chunking-type", chunking_type),
598    ]))
599}
600
601fn with_clause<'a>(entries: impl IntoIterator<Item = &'a (&'a str, &'a str)>) -> ObjectLit {
602    ObjectLit {
603        span: DUMMY_SP,
604        props: entries.into_iter().map(|(k, v)| with_prop(k, v)).collect(),
605    }
606}
607
608fn with_prop(key: &str, value: &str) -> PropOrSpread {
609    PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
610        key: PropName::Str(key.into()),
611        value: Box::new(Expr::Lit(value.into())),
612    })))
613}