From 712eda718b448ba1d51e2d67d2279f8e3135ff41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:37:33 +0000 Subject: [PATCH 1/2] fix: avoid quadratic parsing of unclosed parentheses in @media/@container preludes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_matching_paren rescanned to the end of the prelude for every unmatched "(", so e.g. `@media ((((…` took ~6s at 32KB. Remember the parens left open after a failed scan so later scans starting at one of them return immediately, making the parse linear. Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.test.ts | 19 +++++++++++++++++++ src/parse-condition.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 57f7b97..802be47 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -3077,3 +3077,22 @@ and (min-width: 768px) { }`) }) }) }) + +describe('unmatched parentheses performance', () => { + test.each(['media', 'container'])('@%s with many unclosed "(" parses in linear time', (name) => { + const css = `@${name} ${'('.repeat(32_000)}{}` + const start = performance.now() + parse(css) + // Quadratic behavior took ~6s at this size; linear takes a few ms + expect(performance.now() - start).toBeLessThan(1000) + }) + + test('a matched feature after unclosed "(" is still parsed', () => { + const ast = parse('@media ((min-width: 100px) {}') + const features: string[] = [] + walk(ast, (node) => { + if (node.type_name === 'Feature') features.push(node.text) + }) + expect(features).toContain('(min-width: 100px)') + }) +}) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index f0cb1dd..04718b0 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -104,17 +104,37 @@ export class ConditionParser { private paren_content_end = 0 private paren_close_end = 0 + // A failed scan means every '(' still open at the end of the range can never match (the + // range end is fixed), so remember them: callers that step past an unmatched '(' and retry + // at the next one would otherwise rescan the rest of the range each time, which is O(n^2) + // on input like `@media ((((((…`. Only valid for the `end` it was collected under. + private unmatched_opens: Set | null = null + private unmatched_opens_end = 0 + private scan_matching_paren(): boolean { let depth = 1 let content_end = this.lexer.pos let close_end = this.lexer.token_end + if (this.unmatched_opens !== null && this.unmatched_opens_end === this.end) { + if (this.unmatched_opens.has(this.lexer.token_start)) { + this.paren_content_end = content_end + this.paren_close_end = close_end + return false + } + } + + // Stack of open-paren positions; only needed to record the unmatched ones on failure + const open_stack: number[] = [this.lexer.token_start] + while (this.lexer.pos < this.end && depth > 0) { const token_type = this.next_token() if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { depth++ + open_stack.push(this.lexer.token_start) } else if (token_type === TOKEN_RIGHT_PAREN) { depth-- + open_stack.pop() if (depth === 0) { content_end = this.lexer.token_start close_end = this.lexer.token_end @@ -126,7 +146,16 @@ export class ConditionParser { this.paren_content_end = content_end this.paren_close_end = close_end - return depth === 0 + + if (depth !== 0) { + if (this.unmatched_opens === null || this.unmatched_opens_end !== this.end) { + this.unmatched_opens = new Set() + this.unmatched_opens_end = this.end + } + for (const pos of open_stack) this.unmatched_opens.add(pos) + return false + } + return true } // Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children. From 72144a7ceac36af6e2899d4e19c728fa0911a7fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:46:09 +0000 Subject: [PATCH 2/2] style: use braces for single-statement blocks Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.test.ts | 4 +++- src/parse-condition.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 802be47..cbe1945 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -3091,7 +3091,9 @@ describe('unmatched parentheses performance', () => { const ast = parse('@media ((min-width: 100px) {}') const features: string[] = [] walk(ast, (node) => { - if (node.type_name === 'Feature') features.push(node.text) + if (node.type_name === 'Feature') { + features.push(node.text) + } }) expect(features).toContain('(min-width: 100px)') }) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 04718b0..f21f115 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -152,7 +152,9 @@ export class ConditionParser { this.unmatched_opens = new Set() this.unmatched_opens_end = this.end } - for (const pos of open_stack) this.unmatched_opens.add(pos) + for (const pos of open_stack) { + this.unmatched_opens.add(pos) + } return false } return true