fix(tui): stop nested blockquote rendering from growing exponentially (#11321)

**Problem.** A line of many `>` characters makes `marked` produce one
nested blockquote token per `>`. The blockquote branch of
`Markdown.renderToken` renders its children at `Math.max(1, width - 2)`
columns, then wraps each child line again at that width and adds a `│ `
prefix.

Once the nesting uses up the width (about 39 levels at 80 columns), the
width stays clamped at 1. From then on, each child line (`│ x`, 3
columns wide) is wrapped into about 3 lines at every further level, so
the output roughly triples per level. At 80 columns:

- 50 levels: 4096 lines, about 30 s to render;
- 55 levels: `push(...lines)` exceeds the JavaScript argument limit and
throws `RangeError: Maximum call stack size exceeded`, which crashes pi.
Reopening the session crashes it again.

**Fix.**
- When there is no room left for another border (`width < 3`), the
blockquote branch now renders its child tokens at the same width without
adding a border and without wrapping them again.
- Child lines are appended with a loop instead of a spread `push`.

Quotes that fit in the width render exactly as before.

**Testing.**
- Added a regression test in `packages/tui/test/markdown.test.ts`: 60
nested `>` rendered at width 80 give one line that keeps the content.
Without the fix the test fails with the RangeError; with it, all 92
tests in that file pass (`node --test test/markdown.test.ts`).
- `npm run check` passes.
- Reproduction script rendering `">".repeat(n) + "x"` at width 80:
- before: 50 levels gave 4096 lines in 29.5 s, and 55 or 60 levels threw
a RangeError;
  - after: 40, 50, 60 and 200 levels each give 1 line in about 10 ms.

**Not covered.** A run of about 2000 `>` still overflows the stack
inside `marked`'s own lexer, before pi's renderer runs. That is a
separate limit and is not changed here.

Fixes #11272

Requested by @davidbrai

Co-authored-by: triagent <triagent@localhost>
This commit is contained in:
robopipi[bot] 2026-10-23 12:46:03 +02:00 • committed by GitHub
parent 6dfb546785
commit 8a2ebd3854
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 34 additions and 4 deletions

View file

@ -596,8 +596,21 @@ export class Markdown implements Component {
return quoteStyle(lineWithReappliedStyle);
};
// With no room for another border ("│ " plus at least one content column), render the
// nested content without one. Otherwise each extra level re-wraps every child line at
// width 1 and the output grows exponentially with nesting depth.
if (width < 3) {
const quoteTokens = token.tokens || [];
for (let i = 0; i < quoteTokens.length; i++) {
for (const line of this.renderToken(quoteTokens[i], width, quoteTokens[i + 1]?.type, styleContext)) {
lines.push(line);
}
}
break;
}
// Calculate available width for quote content (subtract border "│ " = 2 chars)
const quoteContentWidth = Math.max(1, width - 2);
const quoteContentWidth = width - 2;
// Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
// children with renderToken() instead of renderInlineTokens().
@ -611,9 +624,14 @@ export class Markdown implements Component {
for (let i = 0; i < quoteTokens.length; i++) {
const quoteToken = quoteTokens[i];
const nextQuoteToken = quoteTokens[i + 1];
renderedQuoteLines.push(
...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
);
for (const line of this.renderToken(
quoteToken,
quoteContentWidth,
nextQuoteToken?.type,
quoteInlineStyleContext,
)) {
renderedQuoteLines.push(line);
}
}
// Avoid rendering an extra empty quote line before the outer blockquote spacing.

View file

@ -1367,6 +1367,18 @@ again, hello world`,
});
});
describe("Deeply nested blockquotes", () => {
// Regression test for https://github.com/earendil-works/pi/issues/11272
it("should render quotes nested deeper than the width allows without exploding", () => {
const markdown = new Markdown(`${">".repeat(60)}x`, 1, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
assert.strictEqual(plainLines.length, 1, `Expected a single line: ${JSON.stringify(plainLines)}`);
assert.ok(plainLines[0].endsWith("x"), `Expected content to be kept: ${JSON.stringify(plainLines)}`);
});
});
describe("Blockquotes with multiline content", () => {
it("should apply consistent styling to all lines in lazy continuation blockquote", () => {
// Markdown "lazy continuation" - second line without > is still part of the quote