evanw/esbuild

Safari: Inconsistent ESM module variable state - undefined then defined after timeout

Opened this issue · 2 comments

I've encountered an unusual behavior specifically in Safari (WebKit) where a variable imported from a dynamically loaded ESM module is initially undefined, but becomes defined after a small timeout. This doesn't occur in other browsers.

Reproduction

import * as mod from "./chunk-WEXA2WHQ.js";
await new Promise((res) => {
  console.log(1, mod);
  setTimeout(() => {
    res(console.log(2, mod));
  }, 10);
});
// Safari output:
// 1 undefined
// 2 {...}

// Chrome/Firefox output:
// 1 {...}
// 2 {...}

Build Configuration

Using esbuild with the following config:

{
    entryPoints,
    entryNames: "[name]-[hash]",
    outdir: "...",
    bundle: true,
    splitting: true,
    metafile: true,
    treeShaking: true,
    write: false,
    minify: true,
    format: "esm",
    jsx: "automatic"
}

The dependency graph:

strict digraph "dependency-cruiser output"{
    rankdir="LR" splines="true" overlap="false" nodesep="0.16" ranksep="0.18" fontname="Helvetica-bold" fontsize="9" style="rounded,bold,filled" fillcolor="#ffffff" compound="true"
    node [shape="box" style="rounded, filled" height="0.2" color="black" fillcolor="#ffffcc" fontcolor="black" fontname="Helvetica" fontsize="9"]
    edge [arrowhead="normal" arrowsize="0.6" penwidth="2.0" color="#00000033" fontname="Helvetica" fontsize="9"]

    "chunk-XJRNTRWC.js" -> "chunk-QPHLPAA5.js"
    "chunk-XJRNTRWC.js" -> "chunk-U67V476Y.js"
    "chunk-XJRNTRWC.js" -> "chunk-WCUTOVXG.js"
    "chunk-XJRNTRWC.js" -> "chunk-WEXA2WHQ.js"

    "chunk-WEXA2WHQ.js" -> "chunk-5VTA2WAL.js"
    "chunk-WEXA2WHQ.js" -> "chunk-AEZVEXA2.js"
    "chunk-WEXA2WHQ.js" -> "chunk-AXFVPSDH.js"
    "chunk-WEXA2WHQ.js" -> "chunk-EZEAVRPG.js"
    "chunk-WEXA2WHQ.js" -> "chunk-QPHLPAA5.js"
    "chunk-WEXA2WHQ.js" -> "chunk-TFVA3VVS.js"
    "chunk-WEXA2WHQ.js" -> "chunk-U67V476Y.js"

    # Files that depend on XJRNTRWC or WEXA2WHQ
    "cart-OH52QW3I.js" -> "chunk-XJRNTRWC.js"
    "cart-OH52QW3I.js" -> "chunk-WEXA2WHQ.js"
    "dialog-3WPGJR7Y.js" -> "chunk-XJRNTRWC.js"
    "dialog-3WPGJR7Y.js" -> "chunk-WEXA2WHQ.js"
    "sheet-PWMR3JFM.js" -> "chunk-WEXA2WHQ.js"
}

Questions

  1. Is this behavior compliant with the ESM spec?
  2. Could this be related to how code splitting works in Safari?
  3. Is there a recommended way to ensure consistent module loading behavior across browsers?

Environment

  • Browser: Safari (stable release)
  • esbuild version: 0.24.0
  • OS: macOS / iOS

Note: The issue appears to be Safari-specific as it works as expected in Chrome and Firefox.

Current hack

I'm adding this code:

await new Promise((res) => {
  let i = 0;
  (function check() {
    if (mod || ++i > 100) res(null);
    else setTimeout(check, 5);
  })();
});

Interesting. Is there any top-level await used anywhere? I could see browsers having timing bugs and/or differences with something like that. Also I assume the variable in question is initialized inline instead of asynchronously, right?

It's surprising but there is actually only one top-level await used which is never evaluated in the browser but needed because it's evaluated on the server, and the moment I remove it (using a Response Override in Safari) the issue doesn't seems to rise again. Yes the variable is defined inline, here a snippet of this file:

// chunk-WEXA2WHQ.js
import {
  Overlay,
} from "./chunk-XJRNTRWC.js";
import {
  __toESM,
  forwardRef,
  require_jsx_runtime
} from "./chunk-QPHLPAA5.js"; // <-- the top level await is in this file 

// app/components/ui/dialog.tsx
var import_jsx_runtime = __toESM(require_jsx_runtime());
var DialogOverlay = forwardRef(
  ({ className, ...props }, ref) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
    Overlay,
    {
      ref,
      ...props
    }
  )
);
DialogOverlay.displayName = Overlay.displayName;

export {
  DialogOverlay,
};

The top level await in chunk-QPHLPAA5.js is this:

var module = globalThis.document
  ? reactClient.default
  : await import("@bureaudouble/rsc-engine/react.react-server"); 
  // it's never evaluated on the browser, and doesn't lead to anything in the browser, only in the server

Also, currently, I don't succeed to produce a really minimal example to share, without all the libs I use, it seems to be dependent of things I'm not aware of at this point.