Skip to content

runtime: make CSS default export {} to match bun build - #35163

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/635213c0/css-import-runtime-parity
Jul 23, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/635213c0/css-import-runtime-parity

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

printf '.c{}' > s.css
printf 'import c from "./s.css";console.log(typeof c, JSON.stringify(c))' > e.ts
bun run e.ts
# before: string "/abs/path/s.css"
bun build e.ts --target=bun --outdir=o && (cd o && bun e.js)
# object {}

bun run and the artifact produced by bun build --target=bun disagreed on the default export of a plain .css import: the runtime returned the absolute file path as a string, the bundler emits var s_default = {}.

Cause

transpile_source_code_inner in src/runtime/jsc_hooks.rs has no Loader::Css match arm, so .css falls through to the _ catch-all (the file loader) which exports path.text as a string. The bundler's ParseTask builds an empty-object lazy export for the JS stub of a CSS entry, matching esbuild.

Fix

Return an empty object for the css loader in the fallback arm, after the existing auto-watch registration so --hot keeps picking up .css edits. require, dynamic import(), and Worker imports all go through the same path and now agree with the bundled output.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/css/css-loader.test.ts
(fail) css loader default export > bun run
  Expected: "object {}"
  Received: "string \"/tmp/.../s.css\""
(pass) css loader default export > bun build --target=bun then run matches bun run

$ bun bd test test/js/bun/css/css-loader.test.ts
(pass) css loader default export > bun run
(pass) css loader default export > bun build --target=bun then run matches bun run

The existing should hot reload when hot-file-loader.css is overwritten test still passes.


[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/css/css-loader.test.ts test/js/bun/resolve/import-empty.test.js
bun test v1.4.0 (786f6da16)

test/js/bun/resolve/import-empty.test.js:
(pass) importing empty text file returns empty string [27.98ms]
(pass) importing empty file with type file returns it path [6.51ms]
23 | 
24 | // MARK: - web imports
25 | 
26 | it("importing empty css file returns an empty object", async () => {
27 |   const empty_file_css = (await import("./empty-file", { with: { type: "css" } })).default;
28 |   expect(empty_file_css).toEqual({});
                              ^
error: expect(received).toEqual(expected)

Expected: {}
Received: "/workspace/bun/test/js/bun/resolve/empty-file"

      at <anonymous> (/workspace/bun/test/js/bun/resolve/import-empty.test.js:28:26)
(fail) importing empty css file returns an empty object [8.39ms]
(pass) importing empty html file returns HTMLBundle with its path [5.97ms]
(pass) importing empty js like file returns empty module [25.49ms]
(pass) importing empty json file throws JSON Parse error [9.99ms]
(pass) import
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (9ea153ae0)

test/js/bun/resolve/import-empty.test.js:
(pass) importing empty text file returns empty string [0.88ms]
(pass) importing empty file with type file returns it path [0.13ms]
(pass) importing empty css file returns an empty object [0.08ms]
(pass) importing empty html file returns HTMLBundle with its path [0.08ms]
(pass) importing empty js like file returns empty module [0.57ms]
(pass) importing empty json file throws JSON Parse error [0.25ms]
(pass) importing empty jsonc/toml file returns module with empty object as default export [0.25ms]
(pass) importing empty file returns module with path as default export [0.16ms]
(pass) importing empty sqlite files returns database object [0.66ms]

test/js/bun/css/css-loader.test.ts:
(pass) css loader default export > bun run [25.46ms]
(pass) css loader default export > bun build --target=bun then run matches bun run [32.04ms]

 11 pass
 0 fail
 31 expect() calls
Ran 11 tests across 2 files. [204.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/css/css-loader.test.ts test/js/bun/resolve/import-empty.test.js
bun test v1.4.0 (786f6da16)

test/js/bun/resolve/import-empty.test.js:
(pass) importing empty text file returns empty string [25.06ms]
(pass) importing empty file with type file returns it path [5.72ms]
(pass) importing empty css file returns an empty object [5.57ms]
(pass) importing empty html file returns HTMLBundle with its path [5.51ms]
(pass) importing empty js like file returns empty module [58.47ms]
(pass) importing empty json file throws JSON Parse error [10.71ms]
(pass) importing empty jsonc/toml file returns module with empty object as default export [13.15ms]
(pass) importing empty file returns module with path as default export [11.85ms]
(pass) importing empty sqlite files returns database object [25.95ms]

test/js/bun/css/css-loader.test.ts:
(pass) css loader default export > bun run [1258.94ms]
(pass) css loader default export > bun build --target=bun then run matches bun run [1482.41ms]

 11 pass
 0 fail
 31 expect() calls
Ran 11 tests across 2 f
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 628ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v
... (truncated)
diff hotspot
src/runtime/jsc_hooks.rs                 | 14 +++++++++
 test/js/bun/css/css-loader.test.ts       | 52 ++++++++++++++++++++++++++++++++
 test/js/bun/resolve/import-empty.test.js |  4 +--
 3 files changed, 68 insertions(+), 2 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                      reads  edits  tests
src/runtime/jsc_hooks.rs                      4      2      0
test/js/bun/css/css-loader.test.ts            1      3      0
test/js/bun/resolve/import-empty.test.js      1      1      0

Importing a .css file at runtime fell through to the file-loader
fallback in transpile_source_code_inner and exported the absolute
path as a string. The bundler emits an empty-object JS stub for the
same import (esbuild parity), so bun run and the output of
bun build --target=bun disagreed on typeof (string vs object).

Give the css loader its own early return in the fallback arm that
exports an empty object. The auto-watch registration stays in place
so --hot continues to pick up .css edits.
@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with import c from "./s.css" returning the absolute path string under bun run while bun build --target=bun emits var s_default = {}. Fix and parity test are up in this PR.

CI on 786f6da (build 78022) is green for this diff. Remaining red is unrelated:

  • test-net-connect-memleak.js / test-gc-http-client-connaborted.js: FinalizationRegistry timing on a couple of linux x64 lanes; this change does not touch net/http/GC. Reported for main triage.
  • darwin 26 aarch64 step failed outside the runner ("number of VMs exceeds the system limit"), CI infra.
  • The rest passed on retry.

Ready for review.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8c057871-77fb-41c9-90da-0a9fe802c588

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and 786f6da.

📒 Files selected for processing (3)
  • src/runtime/jsc_hooks.rs
  • test/js/bun/css/css-loader.test.ts
  • test/js/bun/resolve/import-empty.test.js

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:22 PM PT - Jul 22nd, 2026

❌ @robobun, your commit 786f6da has 3 failures in Build #78022 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35163

That installs a local version of the PR into your bun-35163 executable, so you can run:

bun-35163 --bun

Comment thread test/js/bun/css/css-loader.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the describe.concurrent nit was applied and no further issues found.

What was reviewed:

  • Placement of the L::Css check in the _ fallback arm: sits after the disable_transpilying early-return, after auto-watch registration (so --hot still tracks .css edits), and after the global_object null-check — mirrors the sibling file-loader path exactly.
  • create_empty_object(global, 0) matches the existing empty-JSON precedent at jsc_hooks.rs:2705 and cannot throw; the ResolvedSource { .. ExportDefaultObject } shape is identical to the Html and file-loader arms.
  • Tests: separate tempDir roots, all pipes drained concurrently, stderr/stdout asserted before exitCode, describe.concurrent applied.
Extended reasoning...

Overview

Adds a 12-line if matches!(loader, L::Css) branch inside the catch-all arm of transpile_source_code_inner (src/runtime/jsc_hooks.rs) that returns JSValue::create_empty_object as the module's jsvalue_for_export instead of falling through to the file-loader path-string export. Ships a new 52-line parity test at test/js/bun/css/css-loader.test.ts covering both bun run and bun build --target=bun output.

Security risks

None. No untrusted input parsing, no allocation sizing from external data, no new FFI surface. create_empty_object is a non-throwing JSC helper already used identically for empty JSON/TOML results (jsc_hooks.rs:2705).

Level of scrutiny

Low-to-medium. This is a small, mechanical parity fix that copies the exact ResolvedSource construction pattern from the adjacent L::Html arm (lines 3302-3308) and the file-loader tail (lines 3432-3438), differing only in the exported value. The branch is placed after disable_transpilying, after the 'auto_watch block, and after the global_object.is_null() guard, so hot-reload registration and the not-supported error path are preserved unchanged. It is a user-visible behavior change (CSS default export goes from path-string to {}), but it is fixing a divergence where bun build --target=bun already emits {} — so bundled code already sees this shape and this brings bun run into agreement (esbuild parity, per the PR description).

Other factors

The prior review round left one nit (use describe.concurrent), which was applied in 560d400 and is reflected in the current diff. The tests follow repo harness conventions: tempDir with independent roots, bunEnv/bunExe, concurrent pipe draining via Promise.all, and stderr/stdout assertions before exitCode. The PR description shows USE_SYSTEM_BUN=1 fails and bun bd passes. The bug-hunting system found no issues on this revision.

Comment thread src/runtime/jsc_hooks.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found — the new L::Css branch mirrors the sibling Html/file-loader ResolvedSource construction exactly, sits after the auto-watch registration and the global_object null-guard, and routes through the existing ExportDefaultObject synthetic-module path. Deferring only because this changes the user-visible value of import x from './foo.css' at runtime (path string → {}); a maintainer should confirm that break is intended even though the docs already say the import "returns no value".

What was reviewed:

  • Placement in transpile_source_code_inner's _ arm — auto-watch still fires, disable_transpilying early-return still applies, global deref is guarded.
  • ExportDefaultObject tag → generateJSValueExportDefaultObjectSourceCode gcProtects the empty object across the provider call, so no GC hazard.
  • import-empty.test.js update matches the new contract; hot.test.ts's hot-file-loader.css case only does a bare import (no default binding), so unaffected.
  • docs/runtime/file-types.mdx already documents CSS imports as returning no value — the old path-string behavior was undocumented fall-through.
Extended reasoning...

Overview

Adds a matches!(loader, L::Css) early-return inside the file-loader catch-all arm of transpile_source_code_inner (src/runtime/jsc_hooks.rs) so that importing a .css file at runtime yields export default {} instead of export default "<abs path>". This matches what bun build --target=bun already emits and what esbuild does. Also adds test/js/bun/css/css-loader.test.ts (two concurrent subprocess tests proving run/build parity) and updates the existing import-empty.test.js CSS assertion from path-string to {}.

Security risks

None. No untrusted input parsing, no new FFI surface. The new branch reuses JSValue::create_empty_object, input_specifier.dupe_ref(), create_if_different, and ResolvedSourceTag::ExportDefaultObject — all identical to the neighboring file-loader/Html arms. The ExportDefaultObject C++ path (generateJSValueExportDefaultObjectSourceCode) already gcProtects the value across the synthetic-provider closure, so the freshly allocated empty object cannot be collected before it's exported.

Level of scrutiny

Medium. The native change is 14 lines and mechanically mirrors sibling arms, so implementation risk is low. However, it lives in the module-loader hot path and changes what an existing import form returns to user code — that's a user-visible behavior change, even if the old behavior (leaking the absolute filesystem path as a string) was undocumented and inconsistent with bun build. docs/runtime/file-types.mdx already states the CSS import "returns no value; it's only used for its side effects", so the change aligns runtime with docs and bundler rather than introducing a new contract. The known .module.css divergence is now honestly documented in the code comment per prior review feedback.

Other factors

Both earlier review nits (make the tests describe.concurrent; scope the comment to plain .css and note the .module.css gap) were applied and the threads are resolved. Tests follow harness conventions (tempDir, bunEnv/bunExe, await using, concurrent pipe drain, stderr/stdout asserted before exitCode) and the PR shows fails-on-system-bun / passes-on-debug-build verification. I checked test/cli/hot/hot-runner.js — it does a bare import "./hot-file-loader.css" with no default binding, so the hot-reload test is unaffected. Deferring rather than approving purely because a change to runtime import semantics — however small and well-justified — is the kind of thing a maintainer should glance at before it ships.

@Jarred-Sumner
Jarred-Sumner merged commit 5ed042b into main Jul 23, 2026
52 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/635213c0/css-import-runtime-parity branch July 23, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants