vercel/next.js

next_ssg removes exported variables after the first in multi-declarator export statements

Summary

  • Context: The next_ssg transform in Next.js removes server-side data fetching functions (getStaticPropsgetStaticPathsgetServerSideProps) and their unused dependencies from the client bundle.

  • Bug: When multiple variable declarators are exported in a single export const statement, only the first declarator is protected from removal, while subsequent declarators can be incorrectly removed even though they are exported.

  • Actual vs. expected: Exported variables in positions 2+ of a multi-declarator export statement are incorrectly removed if they’re only referenced in data-fetching functions; they should be preserved because they’re exported and may be imported by other modules.

  • Impact: Exported constants become undefined at runtime when imported by other modules, causing application failures.

Code with bug

fn visit_mut_export_decl(&mut self, s: &mut ExportDecl) {
    if let Decl::Var(d) = &s.decl {
        if d.decls.is_empty() {
            return;
        }

        if let Pat::Ident(id) = &d.decls[0].name {
            // <-- BUG 🔴 Only checks first declarator
            if !SSG_EXPORTS.contains(&&*id.id.sym) {
                self.add_ref(id.to_id());
            }
        }
    }

    s.visit_mut_children_with(self)
}

Example

export const first = 1,
  second = 2,
  third = 3;

export const getStaticProps = async () => {
  console.log(first, second, third);
  return { props: {} };
};

export default function Home() {
  return <div>Hello</div>;
}

Expected: All three exports (firstsecondthird) are preserved because they are exported and may be imported elsewhere.

Actual: Only first is explicitly marked as referenced outside data functions. second and third are seen only within getStaticPropsand are removed by the tree-shaker:

  • visit_mut_export_decl adds only first to refs_from_other.

  • second and third are added only to refs_from_data_fn.

  • With:

fn should_remove(&self, id: Id) -> bool {
    self.state.refs_from_data_fn.contains(&id)
        && !self.state.refs_from_other.contains(&id)
}

second and third satisfy should_remove = true and are dropped, leading to undefined when imported by other modules.

Recommended fix

Copying the proposed change from the exploration: iterate all declarators, not just the first.

fn visit_mut_export_decl(&mut self, s: &mut ExportDecl) {
    if let Decl::Var(d) = &s.decl {
        if d.decls.is_empty() {
            return;
        }

        // FIX 🟢 Iterate over ALL declarators, not just the first
        for decl in &d.decls {
            if let Pat::Ident(id) = &decl.name {
                if !SSG_EXPORTS.contains(&&*id.id.sym) {
                    self.add_ref(id.to_id());
                }
            }
        }
    }

    s.visit_mut_children_with(self)
}