Skip to content

Strip whitespace in linear time - #36

Merged
NullVoxPopuli merged 1 commit into
masterfrom
perf-improvements
Oct 7, 2026
Merged

NullVoxPopuli merged 1 commit into
masterfrom
perf-improvements

Conversation

@jaylinski

Copy link
Copy Markdown
Member

The regexes that find trailing whitespace in whitespace control were retried at every position of a whitespace run, which takes quadratic time on long runs. They are now anchored so that matching takes linear time.

Ported from handlebars.js 812c226.

The regexes that find trailing whitespace in whitespace control were
retried at every position of a whitespace run, which takes quadratic time
on long runs. They are now anchored so that matching takes linear time.

Ported from handlebars.js 812c226.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The optimized regexes preserve existing behavior while addressing the performance issue with focused regression coverage.

Review effort: Balanced
Findings: None

What changed in this PR

Anchors whitespace-control regexes to ensure linear-time parsing of long whitespace runs.

Changes:

  • Optimizes trailing-whitespace detection and removal.
  • Adds regression tests for whitespace stripping, standalone detection, and partial indentation.
File Description
lib/​whitespace-control.js Anchors whitespace regexes to avoid quadratic retries.
spec/​ast.js Adds large-input performance regression tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

any benches you have with either approach?

@jaylinski

jaylinski commented Oct 7, 2026 •

Copy link
Copy Markdown
Member Author

any benches you have with either approach?

@NullVoxPopuli Note that this is mainly to prevent users from doing DoS attacks on a Node.js backend with a template that takes forever to parse. I tasked Opus 5.5 to bench it:

Each template was parsed with the full parse() on Node 26. "Old" is the whitespace control before the change and "new" is after it. <sp> is a run of N spaces and <nl> is a run of N newlines.

case N old new speedup
strip ~ (<sp>x<sp>{{~foo}}) 1k 777 µs 26 µs 30×
10k 68 ms 142 µs 480×
30k 625 ms 424 µs 1,470×
100k 6.69 s 1.31 ms 5,090×
standalone (<nl>x{{foo}}) 1k 515 µs 31 µs 17×
10k 46 ms 211 µs 218×
30k 380 ms 579 µs 657×
100k 4.57 s 1.98 ms 2,310×
partial indent (<sp>x\n {{> dude}}) 1k 1.34 ms 22 µs 60×
10k 124 ms 114 µs 1,090×
30k 1.09 s 309 µs 3,520×
100k 11.94 s 1.02 ms 11,660×
realistic template ×1 63 µs 66 µs ~1×
×500 37.3 ms 35.2 ms ~1×
  • Old: time grows with the square of the whitespace run. Each 10× longer run is about 100× slower, so 100k characters of whitespace ties up the thread for 5–12 seconds.
  • New: time grows in step with the input. At 100k characters all three cases parse in 1–2 ms.
  • Normal templates: no measurable difference. The realistic template mixes #each/else, ~, a partial and a comment, and parsing it is dominated by the parser itself.

One bad template is enough to stall the event loop: a 100 KB input of mostly spaces took about 12 seconds to parse with the old code.

Bench node bench.mjs <handlebars-folder>

const [, , which] = process.argv;
const { parse } = await import(`./${which}/index.js`);

function time(fn, minMs = 200) {
  fn(); // warm up
  let runs = 0, start = performance.now(), elapsed;
  do { fn(); runs++; } while ((elapsed = performance.now() - start) < minMs);
  return elapsed / runs;
}

const cases = {};
for (const n of [1000, 10000, 30000, 100000]) {
  const sp = ' '.repeat(n), nl = '\n'.repeat(n);
  cases[`strip ~    ${n}`] = sp + 'x' + sp + '{{~foo}}';
  cases[`standalone ${n}`] = nl + 'x{{foo}}';
  cases[`partial    ${n}`] = sp + 'x\n  {{> dude}}\n';
}
const realistic = `<ul>
  {{#each items}}
    <li class="{{cls}}">
      {{~name~}}
    </li>
    {{> row this}}
  {{else}}
    <li>none</li>
  {{/each}}
</ul>
{{! comment }}
`;
cases['realistic x1'] = realistic;
cases['realistic x500'] = realistic.repeat(500);

const out = {};
for (const [name, tpl] of Object.entries(cases)) {
  out[name] = time(() => parse(tpl), name.includes('100000') && which === 'old' ? 1 : 200);
}
console.log(JSON.stringify(out));

@NullVoxPopuli NullVoxPopuli added the bug Something isn't working label Oct 7, 2026
@NullVoxPopuli
NullVoxPopuli merged commit 90745ea into master Oct 7, 2026
6 checks passed
@NullVoxPopuli
NullVoxPopuli deleted the perf-improvements branch October 7, 2026 16:08
@github-actions github-actions Bot mentioned this pull request Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants