SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

css: fix @page selector parsing and printing so minified rules roundtrip (#31259)

### What does this PR do?

Fixes a CSS fuzzer invariant violation (`invariant:css:minified CSS does
not reparse`): `@page` rules with pseudo-class selectors minify to
output Bun's own CSS parser cannot read back.

**Repro:**

```bash
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING=1 bun -e 'const c = require("bun:internal-for-testing").cssInternals;
const min = c.minifyTest("@page :left { margin: 1em }", ""); // "@page:left{margin:1em}"
c.minifyTest(min, "");'
# error: parsing failed: Unexpected token: :
```

The same failure hits `@page:first,:blank{…}`, `@page foo:left{…}`, and
even the pretty-printed form `@page toc, index { … }` (the first
selector is immediately followed by the `,` delimiter). Any such
stylesheet fails `bun build` / `import "./x.css"` outright, and minified
`@page` output can't be re-consumed by Bun.

A second bug in the same rule surfaced while testing: `@page foo {
margin: 1em }` minifies to `@pagefoo{margin:1em}`, which tokenizes as a
single unknown at-keyword — the page rule is silently lost on a second
pass.

### Root cause

Both are divergences from lightningcss in `src/css/rules/page.rs`:

1. **`PageSelector::parse`** peeks for the next pseudo-class with
`input.next_including_whitespace()?`. When the selector is immediately
followed by the prelude delimiter (`{` or `,`) — exactly the shape the
minifier emits — that call returns an end-of-input error and the `?`
aborts the whole selector parse. The `@page` prelude handler swallows
the error via `try_parse` and resets, leaving the unconsumed `:left` in
the prelude, so `expect_exhausted` then fails with `Unexpected token:
:`. lightningcss treats the error as "no more pseudo classes" (reset +
break); only a space before `{` avoided the bug, which is why the pretty
form parsed but its minified output didn't.

2. **`PageRule::to_css`** wrote the space after `@page` only when
`!dest.minify`. The space is syntactically required whenever the first
selector has a name, since `@page` + `foo` with no whitespace tokenizes
as the at-keyword `@pagefoo`. lightningcss writes it unconditionally.

### The fix

`src/css/rules/page.rs`, matching lightningcss behavior:

- Treat an error from the pseudo-class peek as the end of the selector
(reset + break) instead of propagating it.
- Write the space after `@page` whenever the first selector has a name,
regardless of minification.
- Print the page name with `serialize_identifier` (review follow-up):
the parsed name is stored decoded, so `write_str` emitted `@page \31 st`
as `@page 1st{…}`, which reparses as a dimension token and fails.

### Verification

- New `describe("page")` block in `test/js/bun/css/css.test.ts` covering
`@page` minification and reparsing of the minified forms
(`@page:left{…}`, `@page:first,:blank{…}`, `@page toc,index{…}`, `@page
LandscapeTable{…}`, `@page CompanyLetterHead:first{…}`, `@page \31
st{…}`, …). On the current release build 14/17 of these fail; with this
change all pass.
- Fuzzer input roundtrips: `@page:left {` → `@page:left{}` → reparses to
the same output.
- `test/js/bun/css/css.test.ts` + `test/js/bun/css/doesnt_crash.test.ts`
on the debug build: 1109 pass, 67 skip, 0 fail.
R
robobun committed
3aa8a0e6abd2ac33aad0d007252e7d1e4c5665d8
Parent: b733df4
Committed by GitHub <noreply@github.com> on 5/23/2026, 6:31:13 AM