Skip to content

Dynamic import().then(m => m.foo) works in dev but fails in prod #19695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
7 tasks done
privatenumber opened this issue Mar 22, 2025 · 2 comments · May be fixed by #19722
Open
7 tasks done

Dynamic import().then(m => m.foo) works in dev but fails in prod #19695

privatenumber opened this issue Mar 22, 2025 · 2 comments · May be fixed by #19722
Labels
p3-minor-bug An edge case that only affects very specific usage (priority)

Comments

@privatenumber
Copy link
Contributor

Describe the bug

Dynamically importing a file and extracting the named export with a chained .then(m => m.foo) doesn't seem to work when the project is built (it works fine in dev mode).

index.js

(async () => {
    const { bar } = await import("./foo.js").then((a) => a.foo);
    console.log(bar);
})();

foo.js

export const foo = {
  bar: 123,
};

Error

index-Bew75usO.js:1 Uncaught (in promise) TypeError: Cannot destructure property 'bar' of '(intermediate value)' as it is undefined.
    at index-Bew75usO.js:1:1733
Built code
(function polyfill() {
  const relList = document.createElement("link").relList;
  if (relList && relList.supports && relList.supports("modulepreload")) {
    return;
  }
  for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
    processPreload(link);
  }
  new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      if (mutation.type !== "childList") {
        continue;
      }
      for (const node of mutation.addedNodes) {
        if (node.tagName === "LINK" && node.rel === "modulepreload")
          processPreload(node);
      }
    }
  }).observe(document, { childList: true, subtree: true });
  function getFetchOpts(link) {
    const fetchOpts = {};
    if (link.integrity) fetchOpts.integrity = link.integrity;
    if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
    if (link.crossOrigin === "use-credentials")
      fetchOpts.credentials = "include";
    else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
    else fetchOpts.credentials = "same-origin";
    return fetchOpts;
  }
  function processPreload(link) {
    if (link.ep)
      return;
    link.ep = true;
    const fetchOpts = getFetchOpts(link);
    fetch(link.href, fetchOpts);
  }
})();
const scriptRel = "modulepreload";
const assetsURL = function(dep) {
  return "/" + dep;
};
const seen = {};
const __vitePreload = function preload(baseModule, deps, importerUrl) {
  let promise = Promise.resolve();
  if (deps && deps.length > 0) {
    document.getElementsByTagName("link");
    const cspNonceMeta = document.querySelector(
      "meta[property=csp-nonce]"
    );
    const cspNonce = (cspNonceMeta == null ? void 0 : cspNonceMeta.nonce) || (cspNonceMeta == null ? void 0 : cspNonceMeta.getAttribute("nonce"));
    promise = Promise.allSettled(
      deps.map((dep) => {
        dep = assetsURL(dep);
        if (dep in seen) return;
        seen[dep] = true;
        const isCss = dep.endsWith(".css");
        const cssSelector = isCss ? '[rel="stylesheet"]' : "";
        if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
          return;
        }
        const link = document.createElement("link");
        link.rel = isCss ? "stylesheet" : scriptRel;
        if (!isCss) {
          link.as = "script";
        }
        link.crossOrigin = "";
        link.href = dep;
        if (cspNonce) {
          link.setAttribute("nonce", cspNonce);
        }
        document.head.appendChild(link);
        if (isCss) {
          return new Promise((res, rej) => {
            link.addEventListener("load", res);
            link.addEventListener(
              "error",
              () => rej(new Error(`Unable to preload CSS for ${dep}`))
            );
          });
        }
      })
    );
  }
  function handlePreloadError(err) {
    const e = new Event("vite:preloadError", {
      cancelable: true
    });
    e.payload = err;
    window.dispatchEvent(e);
    if (!e.defaultPrevented) {
      throw err;
    }
  }
  return promise.then((res) => {
    for (const item of res || []) {
      if (item.status !== "rejected") continue;
      handlePreloadError(item.reason);
    }
    return baseModule().catch(handlePreloadError);
  });
};
(async () => {
  const { bar } = await __vitePreload(async () => {
    const { bar: bar2 } = await Promise.resolve().then(() => foo);
    return { bar: bar2 };
  }, true ? void 0 : void 0).then((m) => m.foo);
  console.log(bar);
})();
const foo = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
  __proto__: null
}, Symbol.toStringTag, { value: "Module" }));

^ Seems foo.js is completely empty.

This reproduction is super boiled down, but for context, the bug originated from inside a library dependency that's also built with Vite. While avoiding .then(m => m.foo) might be a workaround, that's not easy to do as it's coming from minified dependency code.

Reproduction

https://stackblitz.com/edit/vitejs-vite-jdxhwfbi?file=src%2Findex.js

Steps to reproduce

npm start (which runs vite build && vite preview)

System Info

N/A

Used Package Manager

npm

Logs

No response

Validations

@privatenumber
Copy link
Contributor Author

To investigate, I experimented with this configuration:

export default {
    build: {
        minify: false,
        rollupOptions: {
            preserveEntrySignatures: true,
            treeshake: false,
            output: {
                preserveModules: true
            },
        }
    },
};

And this shows that this isn't a tree-shaking issue, but rather that the .then() chain is misplaced when __vitePreload() is injected:

Compiled foo.js:

const foo = {
  bar: 123
};
export {
  foo
};

Compiled index.js:

import { __vitePreload } from "../_virtual/preload-helper-DAnU8ViX.js";
(async () => {
  const { bar } = await __vitePreload(async () => {
    const { bar: bar2 } = await import("./foo-Wx1xxurL.js");
    return { bar: bar2 };
  }, true ? [] : void 0).then((a) => a.foo);
  console.log(bar);
})();

The .then((a) => a.foo) should remain right after import(), but instead, it's moved to the end of __vitePreload(). I think this is coming from

str().prependLeft(
expStart,
`${preloadMethod}(async () => { ${declaration} = await `,
)

@privatenumber
Copy link
Contributor Author

I dug deeper into this.

Looks like the __vitePreload() method is wrapping more than just the import() because it needs to annotate which named exports are being accessed for Rollup to tree-shake (added in #14221).

And the case I'm encountering is not handled here:

while ((match = dynamicImportTreeshakenRE.exec(source))) {
/* handle `const {foo} = await import('foo')`
*
* match[1]: `const {foo} = await import('foo')`
* match[2]: `{foo}`
* import end: `const {foo} = await import('foo')_`
* ^
*/
if (match[1]) {
dynamicImports[dynamicImportTreeshakenRE.lastIndex] = {
declaration: `const ${match[2]}`,
names: match[2]?.trim(),
}
continue
}
/* handle `(await import('foo')).foo`
*
* match[3]: `(await import('foo')).foo`
* match[4]: `.foo`
* import end: `(await import('foo'))`
* ^
*/
if (match[3]) {
let names = /\.([^.?]+)/.exec(match[4])?.[1] || ''
// avoid `default` keyword error
if (names === 'default') {
names = 'default: __vite_default__'
}
dynamicImports[
dynamicImportTreeshakenRE.lastIndex - match[4]?.length - 1
] = { declaration: `const {${names}}`, names: `{ ${names} }` }
continue
}
/* handle `import('foo').then(({foo})=>{})`
*
* match[5]: `.then(({foo})`
* match[6]: `foo`
* import end: `import('foo').`
* ^
*/
const names = match[6]?.trim()
dynamicImports[
dynamicImportTreeshakenRE.lastIndex - match[5]?.length
] = { declaration: `const {${names}}`, names: `{ ${names} }` }
}

I think this regular expression approach is frail and will encounter more edge-cases in the future (I'm thinking many people have already encountered this but just didn't debug/report).

Planning to look into alternative solutions soon.

@hi-ogawa hi-ogawa added p3-minor-bug An edge case that only affects very specific usage (priority) and removed pending triage labels Mar 24, 2025
privatenumber added a commit to privatenumber/vite that referenced this issue Apr 2, 2025
privatenumber added a commit to privatenumber/vite that referenced this issue Apr 2, 2025
privatenumber added a commit to privatenumber/vite that referenced this issue Apr 2, 2025
privatenumber added a commit to privatenumber/vite that referenced this issue Apr 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
p3-minor-bug An edge case that only affects very specific usage (priority)
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants