bake: keep the client's stylesheet when a CSS root fails after parsing - #37886
bake: keep the client's stylesheet when a CSS root fails after parsing#37886robobun wants to merge 1 commit into
Conversation
finish_from_bake_dev_server added every CSS entry point with a parsed stylesheet after the loop that removes failed ones, so a root whose resolution failure was reported asynchronously (through a plugin's onResolve) or whose imports were rejected by scan_css_imports still produced a CSS chunk. Seed the entry points before that loop instead. In the hot update payload, skip CSS chunks whose asset no longer exists: the root failed after its chunk was stored and insert_failure released the asset. Sending an empty stylesheet for it wiped the styles the client already had.
|
Warning Review limit reached
Next review available in: 5 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 3:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit e1b47f6 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 37886That installs a local version of the PR into your bun-37886 --bun |
|
Status: reproduced on the release build with the three scenarios in the PR body (plugin |
There was a problem hiding this comment.
I reviewed this PR and the automated bug hunt found no issues. The reordering in finish_from_bake_dev_server and the count-patching in the hot-update payload both look correct against the surrounding code, and the three new tests cover each failure mode. Because it touches ordering-sensitive dev-server/bundler state, carries a secondary semantic change to imported_on_server, and interacts with two in-flight PRs (#37039, #37844) editing the same paths, a human look would still be worthwhile.
What was reviewed:
bundle_v2.rs: confirmed the moved seed loop uses the samecss_astsbinding valid at the new location, and that the per-file loop'sswap_remove/get_or_putsites now execute after seeding — removals are final.DevServer.rs: confirmed the patched count usesto_le_bytes(), matchingw_int!'s encoding, and the placeholder is 4 bytes atcss_count_offset.- Checked that
css_entry_pointsmay already holdput_no_clobberentries fromenqueue_entry_points_dev_server; the seed loop's.put()withimported_on_server: falseis a no-op for those.
Extended reasoning...
Overview
The PR fixes a dev-server hot-reload bug where a CSS root that fails after parsing (async plugin onResolve failure, or @import of a non-CSS file) still produces a chunk, which then either wipes the client's stylesheet (asset released → b"" fallback) or applies the rejected content. Two source changes: in bundle_v2.rs, the entry-point seeding of css_entry_points moves from after the per-file loop to before it, so the loop's swap_remove calls stick; in DevServer.rs, the CSS section of the hot-update payload skips chunks whose asset is gone and patches the count in afterward. Three new tests plus one assertion added to an existing test.
Security risks
None. This is bundler/dev-server hot-reload logic; no auth, crypto, untrusted-input parsing, or filesystem-escape surface is touched.
Level of scrutiny
Medium-high. The dev server's incremental graph and asset registry are stateful and ordering-sensitive — the bug itself arose from a loop-ordering interaction. The bundle_v2.rs change carries a secondary semantic effect the author calls out: an entry point that is also imported server-side now keeps imported_on_server: true instead of having it reset by the late put(). That flag drives insert_css_file_on_server in DevServer.rs:4069, so this is a real behavior change (arguably a fix) that a maintainer should confirm is intended. The DevServer.rs change rewrites how the CSS-chunk count is encoded on the hot-update wire; I verified the placeholder write and later copy_from_slice(&css_count.to_le_bytes()) both match w_int!'s little-endian encoding and 4-byte width.
Other factors
- The PR description explicitly notes interaction with two open PRs: #37039 (whose
css_chunk_printedfilter becomes redundant once both land) and #37844 (edits the same loop). A human should be aware of merge ordering. - Test coverage is strong: each of the three failure modes (plugin falls through, plugin throws,
@importnon-CSS) has its own test asserting the overlay error, that old rules persist, and that recovery restyles the client. The author states each source change is independently load-bearing (removing either breaks a test). - I confirmed
enqueue_entry_points_dev_servermay pre-seedcss_entry_pointsviaput_no_clobberwithimported_on_server: false, so the moved seed loop's.put(... false)on those keys is a no-op — no regression there.
Given the subtlety of the state machine and the acknowledged secondary semantic change, deferring to a human.
|
On the No line comments to address. CI on e1b47f6 failed in the x64-asan and x64-musl build steps while downloading c-ares, mimalloc and WebKit ( |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Checked both candidates. #31945 (open since June, #37039 is about roots that fail to print and keeps failed roots in the route's CSS list. Its payload filter only skips print-failed chunks; a root whose failure is recorded after pass 1 still prints, so it still gets the empty stylesheet sent, and it does not change |
Problem
styles.css, aserve.staticplugin whoseonResolvereturnsundefined(or throws) for aurl()in it. Hot-editing the sheet reportserror: Could not resolve: "./missing.png"as it should, but the page also loses every rule ofstyles.csswhile the error is up. The same failure with no plugin, or a syntax error, keeps the old rules.@import "./not-css.js"reportsCannot import a ".jsx" file into a CSS fileand applies the rejected stylesheet anyway.@importcase the rejected chunk is sent as is.Fix
@importpaths now match them.Background
url()and@importreferences are resolved as part of that root's build, so a bad reference fails the whole root.serve.staticplugins are bunfig plugins loaded into the dev server's bundler. AnonResolvethat returnsundefinedfalls through to the builtin resolver; one that throws fails the importing file with the thrown message.Original description
Repro
Dev server, an HTML route linking
styles.css, and aserve.staticplugin whoseonResolvematchesmissing.pngand returnsundefined(so resolution falls through to the builtin resolver and fails). With a client connected, change the stylesheet toThe terminal and the overlay report
error: Could not resolve: "./missing.png", as they do without the plugin, but the page also loses every rule ofstyles.csswhile the error is up. Without the plugin, and for a syntax error, the client keeps the rules it had (css.test.ts"css file with syntax error does not kill old styles"). The same happens when the plugin'sonResolvethrows, and a related form shows up without any plugin: hot-editing a CSS root to@import "./not-css.js"reportsCannot import a ".jsx" file into a CSS fileand applies the rejected stylesheet anyway.Cause
finish_from_bake_dev_serverwalks every file and removes CSS roots that failed fromcss_entry_points(a root whose resolution failure was reported asynchronously has had its parts cleared byrun_resolver; a root with an invalid import is removed afterscan_css_imports). It then adds every entry point that has a parsed stylesheet, which puts those roots straight back, so a failed root still gets a CSS chunk. The synchronous resolver path never hit this because it drops the stylesheet before it reaches the graph.In
finalize_bundle, pass 1 stores that chunk's asset and registers the file,prepare_and_log_resolution_failuresthen marks the file failed, which releases the asset again, and the hot update's CSS list looks the asset up by hash, finds nothing, and sends an empty stylesheet (theb""fallback added in #36165 when this lookup was made hash based), which is what wipes the page. For the@importcase the chunk's content is what gets applied, and registering the chunk also cleared the failure that had just been inserted for it.Fix
bundle_v2.rs: seedcss_entry_pointsfrom the entry points before the per-file loop, so the removals in that loop are final. Same comment and semantics as before; the only other difference is that an entry point imported on the server in the same bundle now keepsimported_on_server: trueinstead of having it reset by the lateput.DevServer.rs: the hot update payload skips CSS chunks whose asset no longer exists and patches the count in afterwards. A missing asset means the root failed after pass 1 stored its chunk (the plugin-throws case still prints a chunk since nothing clears its parts); there is nothing to send for it, and the client keeps its current sheet, which is whattake_js_bundle_to_listalready does for JS files that fail after being received.This is the right behavior to have because a stylesheet that fails to rebuild has no new content for the client, and the dev server already defines the outcome for that situation on the syntax error and builtin resolver paths: overlay plus the previously applied rules, replaced when a rebuild succeeds. The plugin path only differed because the failed root still produced a chunk.
Related open PRs: #31945 (June) gates the same re-add on the file having parts. It was written for the debug crash that #31905 has since fixed at the source, and its gate would also cover the plugin fall-through case here, but not a root removed by
scan_css_imports(parts intact) and not the payload side (plugin throws), which are the other two tests in this PR; moving the seeding above the loop makes every removal in the loop final, so this PR supersedes that diff. #37039 handles roots that fail to print and keeps failed roots in the route's CSS list; the asset check here also covers its print-failed chunks (their failure releases the asset in pass 1), so itscss_chunk_printedfilter becomes redundant once both land. #37844 edits the same loop infinish_from_bake_dev_serverand is independent of this change.Verification
Three new tests in
test/bake/dev/css.test.ts(plugin falls through, plugin throws, root imports a non-CSS file), each asserting the overlay text, that the old rules are still applied, and that fixing the file restyles the connected client and serves 200 again. The existing "another root fails in the same rebuild" test now also asserts the failed root's rules on its client. All four fail on the release build (Selector '.a' was not found/Received: "#00f"/Selector '.first' was not found) and pass on the debug build. Each source change is needed: with only thebundle_v2.rschange, the plugin-throws test still fails; with only theDevServer.rschange, the non-CSS import test still fails.test/bake/devcss, html, plugins, bundle, hot and incremental-graph-edge-deletion suites pass on the debug build.The new tests do not request the route while it is failed with a client connected: with
BUN_ASSUME_PERFECT_INCREMENTAL=0(the harness default) that rebuilds the route and ships the HTML module to the client, which is #31908 and unrelated to this change.