Skip to main content

next_custom_transforms/transforms/
track_dynamic_imports.rs

1use rustc_hash::FxHashMap;
2use swc_core::{
3    common::{
4        BytePos, DUMMY_SP, Mark, Span, Spanned, SyntaxContext,
5        comments::{Comment, CommentKind, Comments},
6        source_map::PURE_SP,
7        util::take::Take,
8    },
9    ecma::{
10        ast::*,
11        utils::{prepend_stmt, private_ident, quote_ident, quote_str},
12        visit::{VisitMut, VisitMutWith, noop_visit_mut_type, visit_mut_pass},
13    },
14    quote,
15};
16
17pub fn track_dynamic_imports<C: Comments>(
18    unresolved_mark: Mark,
19    comments: C,
20) -> impl VisitMut + Pass {
21    visit_mut_pass(ImportReplacer::new(unresolved_mark, comments))
22}
23
24struct ImportReplacer<C: Comments> {
25    comments: C,
26    unresolved_ctxt: SyntaxContext,
27    has_dynamic_import: bool,
28    wrapper_function_local_ident: Ident,
29    /// Maps import call span lo → export names extracted from destructuring
30    import_export_names: FxHashMap<BytePos, Vec<String>>,
31}
32
33impl<C: Comments> ImportReplacer<C> {
34    pub fn new(unresolved_mark: Mark, comments: C) -> Self {
35        ImportReplacer {
36            comments,
37            unresolved_ctxt: SyntaxContext::empty().apply_mark(unresolved_mark),
38            has_dynamic_import: false,
39            wrapper_function_local_ident: private_ident!("$$trackDynamicImport__"),
40            import_export_names: Default::default(),
41        }
42    }
43}
44
45/// Try to find an `await import(...)` CallExpr inside an expression,
46/// unwrapping parentheses. Returns the span of the import call only if
47/// `await` is present — without await, the destructuring targets the
48/// Promise, not the module namespace.
49fn find_awaited_import_call_span(expr: &Expr) -> Option<Span> {
50    let mut current: &Expr = expr;
51    let mut seen_await = false;
52    loop {
53        match current {
54            Expr::Call(CallExpr {
55                callee: Callee::Import(_),
56                span,
57                ..
58            }) if seen_await => {
59                break Some(*span);
60            }
61            Expr::Await(AwaitExpr { arg, .. }) => {
62                seen_await = true;
63                current = arg;
64            }
65            Expr::Paren(ParenExpr { expr, .. }) => {
66                current = expr;
67            }
68            _ => break None,
69        }
70    }
71}
72
73/// Extract export names from an ObjectPattern (destructuring pattern).
74/// Returns `Some(names)` for recognized patterns, `None` for patterns
75/// that can't be statically analyzed (rest elements, computed keys).
76fn extract_export_names_from_pat(pat: &ObjectPat) -> Option<Vec<String>> {
77    let mut names = Vec::new();
78    for prop in &pat.props {
79        match prop {
80            ObjectPatProp::KeyValue(KeyValuePatProp { key, .. }) => match key {
81                PropName::Ident(ident) => names.push(ident.sym.to_string()),
82                PropName::Str(s) => names.push(s.value.to_string_lossy().into_owned()),
83                // Computed keys can't be statically analyzed
84                _ => return None,
85            },
86            ObjectPatProp::Assign(AssignPatProp { key, .. }) => {
87                names.push(key.sym.to_string());
88            }
89            // Rest elements mean all exports are potentially used
90            ObjectPatProp::Rest(_) => return None,
91        }
92    }
93    Some(names)
94}
95
96impl<C: Comments> VisitMut for ImportReplacer<C> {
97    noop_visit_mut_type!();
98
99    fn visit_mut_program(&mut self, program: &mut Program) {
100        program.visit_mut_children_with(self);
101        // if we wrapped a dynamic import while visiting the children, we need to import the wrapper
102
103        if self.has_dynamic_import {
104            let import_args = MakeNamedImportArgs {
105                original_ident: quote_ident!("trackDynamicImport").into(),
106                local_ident: self.wrapper_function_local_ident.clone(),
107                source: "private-next-rsc-track-dynamic-import",
108                unresolved_ctxt: self.unresolved_ctxt,
109            };
110            match program {
111                Program::Module(module) => {
112                    prepend_stmt(&mut module.body, make_named_import_esm(import_args));
113                }
114                Program::Script(script) => {
115                    // CJS modules can still use `import()`. for CJS, we have to inject the helper
116                    // using `require` instead of `import` to avoid accidentally turning them
117                    // into ESM modules.
118                    prepend_stmt(&mut script.body, make_named_import_cjs(import_args));
119                }
120            }
121        }
122    }
123
124    fn visit_mut_var_declarator(&mut self, decl: &mut VarDeclarator) {
125        // Detect: const { x, y } = await import('...')
126        // Collect export names BEFORE visiting children, because visit_mut_expr
127        // (triggered by visit_mut_children_with) will wrap the import and look up
128        // the names from the map.
129        //
130        // Only extract names when `await` is present — without await, the
131        // destructuring targets the Promise, not the module namespace.
132        if let Some(init) = &decl.init
133            && let Some(import_span) = find_awaited_import_call_span(init)
134            && let Pat::Object(obj_pat) = &decl.name
135            && let Some(names) = extract_export_names_from_pat(obj_pat)
136        {
137            self.import_export_names.insert(import_span.lo, names);
138        }
139
140        decl.visit_mut_children_with(self);
141    }
142
143    fn visit_mut_expr(&mut self, expr: &mut Expr) {
144        expr.visit_mut_children_with(self);
145
146        // before: `import(...)`
147        // after:  `$$trackDynamicImport__(import(...))`
148
149        if let Expr::Call(
150            call_expr @ CallExpr {
151                callee: Callee::Import(_),
152                ..
153            },
154        ) = expr
155        {
156            self.has_dynamic_import = true;
157
158            // Add /* webpackExports: [...] */ comment if we detected destructuring
159            if let Some(names) = self.import_export_names.remove(&call_expr.span.lo)
160                && let Some(first_arg) = call_expr.args.first()
161            {
162                let comment_text = if names.is_empty() {
163                    " webpackExports: [] ".to_string()
164                } else {
165                    let names_json: Vec<String> =
166                        names.iter().map(|n| format!("\"{}\"", n)).collect();
167                    format!(" webpackExports: [{}] ", names_json.join(", "))
168                };
169                self.comments.add_leading(
170                    first_arg.span_lo(),
171                    Comment {
172                        span: DUMMY_SP,
173                        kind: CommentKind::Block,
174                        text: comment_text.into(),
175                    },
176                );
177            }
178
179            let replacement_expr = quote!(
180                "$wrapper_fn($expr)" as Expr,
181                wrapper_fn = self.wrapper_function_local_ident.clone(),
182                expr: Expr = expr.take()
183            )
184            .with_span(PURE_SP);
185            *expr = replacement_expr
186        }
187    }
188}
189
190struct MakeNamedImportArgs<'a> {
191    original_ident: Ident,
192    local_ident: Ident,
193    source: &'a str,
194    unresolved_ctxt: SyntaxContext,
195}
196
197fn make_named_import_esm(args: MakeNamedImportArgs) -> ModuleItem {
198    let MakeNamedImportArgs {
199        original_ident,
200        local_ident,
201        source,
202        ..
203    } = args;
204    let mut item = quote!(
205        "import { $original_ident as $local_ident } from 'dummy'" as ModuleItem,
206        original_ident = original_ident,
207        local_ident = local_ident,
208    );
209    // the import source cannot be parametrized in `quote!()`, so patch it manually
210    let decl = item.as_mut_module_decl().unwrap().as_mut_import().unwrap();
211    *decl.src = source.into();
212    item
213}
214
215fn make_named_import_cjs(args: MakeNamedImportArgs) -> Stmt {
216    let MakeNamedImportArgs {
217        original_ident,
218        local_ident,
219        source,
220        unresolved_ctxt,
221    } = args;
222    quote!(
223        "const { [$original_name]: $local_ident } = $require($source)" as Stmt,
224        original_name: Expr = quote_str!(original_ident.sym).into(),
225        local_ident = local_ident,
226        source: Expr = quote_str!(source).into(),
227        // the builtin `require` is considered an unresolved identifier.
228        // we have to match that, or it won't be recognized as
229        // a proper `require()` call.
230        require = quote_ident!(unresolved_ctxt, "require")
231    )
232}