Skip to content

dev server: rebuild the CSS root when a failed @import is fixed - #42740

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/6f5c42cc/dev-css-import-recover
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/6f5c42cc/dev-css-import-recover

Conversation

@robobun

@robobun robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Dev server: a stylesheet that a CSS root @imports is saved broken (syntax error or unresolved @import), then saved fixed. The page answers 200, but the served stylesheet has none of that file's rules ([MAIN_v1], a fresh dev server serves [DEP_v2, MAIN_v1]) until one more save. Reproduces on 1.4.2, canary and main.
  • Cause: a failure frees the file's content, so Content::CssChild becomes Content::Unknown (insert_failure, src/runtime/bake/dev_server/incremental_graph.rs). On the fixing save invalidate takes the Js | Unknown arm and queues only the file. The root that inlines it is not built again.

Fix

  • In that arm, a failed file also queues each importer that is a CssRoot, as the CssRoot | CssChild arm does for a healthy child.
  • The edge root -> child survives the failed rebuild. A root holds an edge to every file of its chunk, so depth does not matter.
  • The file is still queued as its own entry. Only receive_chunk on a chunk entry retracts a failure, and a CSS child never gets one.
  • Verified: new test in test/bake/dev/css.test.ts (fails on canary). Also all of test/bake/ and test/js/bun/http/bun-serve-html*.test.ts.

Background

  • IncrementalGraph is the dev server's module graph. An edge tells the importer when the imported file changes.
  • A CSS root is the stylesheet that HTML links or a script imports. The bundler inlines every @imported file into the root's chunk. Those files are CssChild and have no output of their own.
  • invalidate turns the changed paths from the watcher into the entry points of the next rebuild.
Notes

The file is bundled alone on the fixing save and becomes a CSS root of its own. The CssRoot arm walks importers, which is why a second save heals the page.

Found by a convergence differential on the dev server (fuzz ledger #51215): after a script of ordinary saves has settled, the page and every stylesheet and script it references are compared with a fresh dev server on the same files.

Graph state from a debug build, index.html links main.css, main.css has @import "./dep.css":

after the failed rebuild
  [2] main.css stale=false failed=false CssRoot(..) imports=[3] importers=[0]
  [3] dep.css  stale=true  failed=true  Unknown     imports=[]  importers=[2]
invalidate on the fixing save, before: entry_points=[("dep.css", CLIENT)]
invalidate on the fixing save, after:  entry_points=[("dep.css", CLIENT), ("main.css", CLIENT|CSS)]

Both ways to break the file end in the same state. A syntax error goes through handle_parse_task_failure -> insert_failure. An unresolved @import goes through get_log_for_resolution_failures -> insert_stale (frees the content) and then insert_failure in prepare_and_log_resolution_failures.

Standalone repro from the report, on a debug build with the fix (HOW=syntax gives the same lines):

initial                           GET / 200  stylesheet rules: [DEP_v1, MAIN_v1]
dep.css broken (unresolved)       GET / 500  stylesheet rules: []
dep.css repaired as DEP_v2        GET / 200  stylesheet rules: [DEP_v2, MAIN_v1]
FRESH dev server, same files      GET / 200  stylesheet rules: [DEP_v2, MAIN_v1]

Not changed here:

Suites run on a debug+ASAN build with the fix: all of test/bake/dev/ (144 pass; the 5 production.test.ts cases that time out at the 5 s default pass with --timeout 120000 and pass on a release build), test/bake/*.test.ts (62 pass), test/js/bun/http/bun-serve-html*.test.ts (49 pass).

A failure frees the content of the failed file, so a CssChild becomes
Unknown. On the next save, invalidate() took the Js/Unknown arm and
queued only the file itself. The root that inlines the file was not
built again, so the served stylesheet had none of the file's rules
until one more save.

A failed file now also queues each importer that is a CssRoot, as the
CssRoot/CssChild arm does for a healthy child.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e8936885-5490-4129-aa46-1df4a29508d2

📥 Commits

Reviewing files that changed from the base of the PR and between 123bfb4 and 1e7d3df.

📒 Files selected for processing (2)
  • src/runtime/bake/dev_server/incremental_graph.rs
  • test/bake/dev/css.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

The dev-server incremental graph now requeues dependent CSS roots when a client file fails. A regression test covers recovery from syntax errors and missing imports in nested CSS, including hard reload validation.

Changes

Nested CSS recovery

Layer / File(s) Summary
Requeue failed CSS roots
src/runtime/bake/dev_server/incremental_graph.rs, test/bake/dev/css.test.ts
Failed client files mark dependent CSS roots stale and re-enqueue them. The regression test verifies recovery from syntax errors and missing imports, restored styles, and hard reload behavior.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 1e7d3

The CSS recovery change has targeted regression coverage, and no actionable merge risk is identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: rebuilding the CSS root after a failed @import is fixed.
Description check ✅ Passed The description explains the problem, cause, fix, scope, regression test, and verification results. It does not use the exact template headings, but it provides the required information and is suffici…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the 1.4.3 canary (09bb546) and on a debug build of main, two ways:

  • The new test in test/bake/dev/css.test.ts: USE_SYSTEM_BUN=1 bun test test/bake/dev/css.test.ts -t "fixing a broken" fails with Selector 'h2' was not found. bun bd test test/bake/dev/css.test.ts passes on this branch.
  • The standalone script from the report: index.html links main.css, main.css has @import "./dep.css". Save dep.css with a syntax error or an @import that does not resolve, then save it fixed. The served stylesheet holds [MAIN_v1]. A fresh dev server on the same files serves [DEP_v2, MAIN_v1]. On this branch both serve [DEP_v2, MAIN_v1].

The sibling for the HTML side of the same report is #42741.

@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 — targeted fix that mirrors the adjacent CssRoot | CssChild arm, with a test that exercises both failure modes at two import depths.

What was reviewed:

  • The new dependent-walk loop in invalidate is byte-for-byte the same shape as the existing one at lines 1635-1650 (same RawSlice-over-key pattern, same append_css); entry_points is a separate map so appending can't invalidate bundled_files keys.
  • Checked sibling arms: Asset already walks dependents, Server side has no CSS handling, and the healthy CssChild path already does this — the failed → Unknown case was the only gap.
  • Test uses the devTest harness with awaited style assertions and dev.write error expectations (no sleeps), covers syntax error + unresolved @ import, transitive and direct child, and a hard reload.
Extended reasoning...

Overview

The PR fixes a bake dev-server HMR bug: when a CSS file that a root stylesheet @ imports fails to build and is then fixed, the root was not re-bundled because the failure had reset the child's Content to Unknown, sending the fixing save down the Js | Unknown arm of invalidate, which only queued the file itself. The fix adds a ~20-line block to that arm that, when the file is currently failed, walks its incoming-edge list and queues each CssRoot dependent as a CSS entry point — the exact same loop the CssRoot | CssChild arm already runs for a healthy child. A new devTest in test/bake/dev/css.test.ts sets up a two-level @ import chain, breaks the leaf with a syntax error and the middle file with an unresolved @ import, fixes each, and asserts every rule is present both live and after a hard reload.

Security risks

None. This is internal dev-server incremental-graph bookkeeping — deciding which files to rebundle on a watched-file change. No untrusted input parsing, no auth, no network, no crypto. The RawSlice usage is the same borrow-lifetime pattern already used three times in the same function; the slice points into bundled_files.keys() and is consumed by entry_points.append_css (a separate StringArrayHashMap), so no map growth can invalidate it mid-use.

Level of scrutiny

Low-to-moderate. The Rust change is small, mechanical, and copies an adjacent established pattern verbatim (same edge-list traversal, same RawSlice::new(&*keys()[dep]) idiom, same stale_files.set + append_css pair). The PR description gives a clear root-cause trace with graph-state dumps and explains why depth doesn't matter (a root holds an edge to every file in its chunk) and why both failure modes converge on the same Unknown state. The bug hunt ran to a dry streak with no findings.

Other factors

Neither changed file is covered by CODEOWNERS. The test is added to the existing css.test.ts alongside the related "css import another css file" case, uses the harness's awaited dev.write({ errors }) and c.style(...).expect.toBe(...) primitives (no sleeps, no timeouts), and covers the variant matrix REVIEW.md asks for: both failure kinds, both a direct and a transitive child, and a hard reload to verify the served stylesheet, not just the HMR patch. The PR timeline shows a single commit with no prior reviews or outstanding objections.

@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:28 PM PT - Sep 14th, 2026

✅ @robobun, your commit 1e7d3df070fb27c18f2d15e7633adc699c124828 passed in Build #115576! 🎉


🧪   To try this PR locally:

bunx bun-pr 42740

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

bun-42740 --bun

@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #42738. Both PRs change the same arm of invalidate for the same bug, so only one can land. I ran each change on main (d513feb) against the tests of both PRs.

The test from this PR (a file two imports deep, both kinds of failure) is now in #42738 as f43e185. It also saves the repaired file three more times after each repair.

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