Skip to content

Code splitting can omit alias modules and mishandle dynamic CSS imports #9

@flowerornament

Description

@flowerornament

Summary

After the fix for #6, there are two remaining code-splitting edge cases in the current production builder:

  1. Alias-resolved modules outside the entry root can be assigned collector labels such as _external/*, but chunk output selection currently looks up files by path suffix. That can omit an entry module from the chunk even though the chunk graph contains it.
  2. Dynamic CSS imports such as import("./theme.css") can survive into the browser chunk path as runtime imports of synthetic CSS/data modules. Since Volt already owns stylesheet output, these should be inert browser-loadable promises rather than real dynamic imports.

Both cases show up when an entry combines an alias import with at least one dynamic import, or when user code dynamically imports a CSS file/package stylesheet.

Environment

  • Volt 0.9.2 / current master after 3c6f117
  • OXC 0.11.0
  • Elixir 1.20.0-rc.4
  • macOS Darwin 25.4.0

Repro 1: alias module outside the entry root

Given this source layout:

fixtures/
  shared/rendered.ts
  src/external_alias_entry.ts
  src/lazy.ts

shared/rendered.ts:

export const rendered = "rendered-from-shared-root"

src/lazy.ts:

export const lazyValue = "lazy-loaded"

src/external_alias_entry.ts:

import { rendered } from "@shared/rendered"

document.body.dataset.rendered = rendered

import("./lazy").then((mod) => {
  document.body.dataset.lazy = mod.lazyValue
})

Build shape:

Volt.Builder.build(
  entry: Path.join(fixture_dir, "src/external_alias_entry.ts"),
  outdir: outdir,
  name: "external-alias-entry",
  format: :esm,
  hash: false,
  minify: false,
  sourcemap: false,
  aliases: %{"@shared" => Path.join(fixture_dir, "shared")}
)

Actual

The alias-resolved module can be missed when selecting files for the entry chunk, because the chunk contains the original absolute module path while the generated JS file is keyed by the collector label.

Expected

The entry chunk should include the alias module, and the async chunk should remain separately imported:

external-alias-entry.js       includes rendered-from-shared-root
external-alias-entry-lazy.js  includes lazy-loaded

Repro 2: dynamic CSS import

src/dynamic_css_entry.ts:

import("./theme.css").then(() => {
  document.body.dataset.css = "loaded"
})

src/theme.css:

body { color: red }

Build shape:

Volt.Builder.build(
  entry: Path.join(fixture_dir, "src/dynamic_css_entry.ts"),
  outdir: outdir,
  name: "dynamic-css-entry",
  format: :esm,
  hash: false,
  minify: false,
  sourcemap: false
)

Actual

The browser output can retain a runtime dynamic import path for CSS-like modules, even though Volt's CSS handling is not a browser JS module load.

Expected

The dynamic CSS import should become an inert fulfilled promise, similar in spirit to the existing static CSS import no-op handling. The emitted JS should not contain a runtime import("./theme.css"), CSS text, or a data:text/css module import.

Candidate fix

The minimal shape that fixed the two repros locally:

  • select chunk files using the collector's original module path to label map, rather than suffix-matching module paths against generated labels;
  • protect dynamic import(...) before per-chunk OXC bundling, then restore it before Volt rewrites chunk URLs;
  • rewrite dynamic CSS import expressions to an already-resolved no-op promise, while leaving static CSS imports on the existing no-op JS-module path.

I opened #10 with tests for both reduced cases and one candidate implementation.

AI disclosure

I used OpenAI Codex as a coding assistant to help reduce the repros and draft the candidate patch. I reviewed the generated issue text and patch before filing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions