Files
vercel__next.js/test/development/app-dir/css-bom-code-frame/css-bom-code-frame.test.ts
Luke Sandberg ff3a2cfaa9 [turbopack] Strip leading BOM before parsing CSS (#96678)
Fork PR #96379 by @lazerg, re-opened as a branch PR so the "when
deployed" CI jobs can run — those require Vercel deployment secrets that
GitHub does not expose to pull requests from forks, so they can never
pass on the original.

**The fix commits are unchanged and still authored by @lazerg.** This PR
only adds tests on top. Please credit them; #96379 should be closed in
favor of this one.

### What?

A CSS file beginning with a UTF-8 BOM (`EF BB BF`) is mishandled by
Turbopack. Lightning CSS does not skip the BOM, so it is tokenized as
content and the first token is misparsed:

```
./app/bom.css:1:2
Error: Parsing CSS source code failed
Unexpected token AtKeyword("layer")
```

The user-visible symptom is broader than a failed build. Turbopack
parses with `error_recovery: true`, and under that setting a leading BOM
makes Lightning CSS return `Ok` with **zero rules** — so a BOM-prefixed
stylesheet could silently drop all of its styles instead of erroring.
dart-sass (compressed style) and PostCSS >= 8.5.24 both emit or
round-trip such a BOM, so real projects hit this.

Fixes #96374

### How?

Strip a leading `U+FEFF` in `parse_css_stylesheet` before handing the
source to Lightning CSS, covering both `StyleSheet::parse` call sites
while leaving `ParseCssResult.code` as the original bytes that code
frames are rendered from.

That split makes parser positions relative to the stripped copy while
code frames still render the original line, so first-line positions need
compensating. `source_pos_for_loc` adds the stripped character back for
line 0 of BOM files. Only line 0 is affected, because the BOM contains
no newline.

### Tests

`test/e2e/app-dir/css-bom` — a BOM-prefixed stylesheet compiles and its
rules reach the page. Verified failing without the fix with the exact
error above, and passing with it, in dev-turbo, start-turbo and
start-webpack.

`test/development/app-dir/css-bom-code-frame` — covers the position
correction. Two fixtures hold the same invalid `@media (min-width: {})`
on line 1 and differ only by the leading BOM; the test asserts the BOM
file's reported column is exactly one greater:

| | `no-bom` | `bom` | |
|---|---|---|---|
| without `source_pos_for_loc` | 18 | 18 | fails |
| with it | 18 | 19 | passes |

Asserting the relationship rather than a literal column keeps this
robust if Lightning CSS changes its absolute column convention. It is
kept separate from the e2e suite because the fixtures are intentionally
invalid CSS, and scoped to Turbopack in dev, where the warning reaches
the CLI as the page is requested.

---------

Co-authored-by: lazerg <lazerg2@gmail.com>
Co-authored-by: vercel-gh-bot-3[bot] <282332853+vercel-gh-bot-3[bot]@users.noreply.github.com>
2026-08-04 21:25:04 +00:00

63 lines
2.5 KiB
TypeScript

import { readFileSync } from 'fs'
import { join } from 'path'
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
// A leading BOM is stripped before the CSS is handed to the parser, so parser
// positions are relative to the stripped copy while the code frame is rendered
// from the original (un-stripped) file. Only errors on the first line are
// affected, because the BOM contains no newline.
//
// Turbopack-only: this asserts on positions reported by Turbopack's CSS parser.
;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)(
'css BOM code frame',
() => {
const { next } = nextTestSetup({
files: __dirname,
})
it('keeps the fixtures byte-exact', () => {
// The whole point of the fixtures is the leading bytes, and an editor or
// formatter dropping them would silently turn the test below into a pass.
const bom = readFileSync(join(__dirname, 'app', 'bom', 'error.css'))
expect([bom[0], bom[1], bom[2]]).toEqual([0xef, 0xbb, 0xbf])
const noBom = readFileSync(join(__dirname, 'app', 'no-bom', 'error.css'))
expect(noBom[0]).not.toBe(0xef)
})
it('reports the right column for an error on the first line', async () => {
// A warning for one file can be re-emitted while the other is compiled,
// so each column is matched against its own path rather than whatever
// happens to appear first in the output.
const columnFor = async (dir: string) => {
// Anchored on `app/` so that the `bom` pattern does not also match
// inside `no-bom/error.css`.
const pattern = new RegExp(`app/${dir}/error\\.css:1:(\\d+)\\b`)
await next.fetch(`/${dir}`)
let column: number | undefined
await retry(async () => {
const match = stripAnsi(next.cliOutput).match(pattern)
expect(match).not.toBeNull()
column = Number(match[1])
})
return column
}
// Both files hold the same invalid `@media (min-width: {})` on line 1 and
// differ only by the leading BOM. The BOM occupies one character position
// in the line the code frame renders, so the reported column for the BOM
// file must be exactly one greater. Without that correction both report
// the same column and the BOM file's caret is rendered one column early.
const withoutBom = await columnFor('no-bom')
const withBom = await columnFor('bom')
expect(withBom).toBe(withoutBom + 1)
})
}
)