Annotation draw tool performs O(n²) work per stroke and can exceed V8 argument limits#8059

openedAug 24, 2026byAareidyOTH

What happened

While investigating an unrelated preview-renderer freeze, source review found an independent performance and crash defect in the preview annotation draw tool.

Every pointermove during one continuous freehand stroke copies and rescans all points collected so far, then rebuilds the entire SVG path. As the stroke grows, each new point becomes progressively more expensive.

This was identified by source inspection; it was not established as the cause of the separate preview_snapshot incident.

Diagnosis

In apps/desktop/src/preview/PickPreload.ts, the draw branch runs this on every pointermove:

ts
1activeStroke.target.points = [
2 ...activeStroke.target.points,
3 { x: event.clientX, y: event.clientY },
4];
5activeStroke.target.bounds = strokeBounds(
6 activeStroke.target.points,
7 activeStroke.target.width,
8);
9activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points));

For a stroke containing n points, each event performs:

  • An O(n) points-array copy.
  • Two O(n) coordinate-array allocations in strokeBounds.
  • O(n) min/max scans.
  • An O(n) SVG path reconstruction.

The accumulated cost of a stroke is therefore O(n²), with substantial short-lived allocation on the preview renderer’s main thread.

strokeBounds also spreads the unbounded coordinate arrays into Math.min and Math.max:

ts
1const xs = points.map((point) => point.x);
2const ys = points.map((point) => point.y);
3const left = Math.min(...xs) - padding;
4const top = Math.min(...ys) - padding;
5const right = Math.max(...xs) + padding;
6const bottom = Math.max(...ys) + padding;

Once a stroke exceeds the engine-dependent maximum function-argument count, these spread calls can throw RangeError. The exact threshold should not be relied upon.

Current main still contains the same implementation.

Steps to reproduce

  1. Open a desktop preview and activate its annotation interface.
  2. Select the freehand Draw tool.
  3. Hold the primary pointer down and produce one long, high-sample-rate continuous stroke.
  4. Observe increasing pointer latency, CPU use, and allocation as the stroke grows.
  5. If the point count grows past V8's argument limit, the Math.min(...xs) or Math.max(...xs) call can throw RangeError.

A performance test can also invoke the pointer-move path with progressively larger point arrays and verify that total time and allocation grow quadratically.

Version

0.0.34-nightly.20260824.1172

Also present on current main as of 2026-08-24.

Environment

T3 Code Desktop on Linux x64. The defect is in renderer-side TypeScript and is not expected to be Linux-specific.

Evidence

shell
1apps/desktop/src/preview/PickPreload.ts
2
3strokeBounds:
4 points.map(...) twice
5 Math.min(...xs)
6 Math.min(...ys)
7 Math.max(...xs)
8 Math.max(...ys)
9
10onPointerMove, draw branch:
11 activeStroke.target.points = [...activeStroke.target.points, point]
12 strokeBounds(activeStroke.target.points, ...)
13 pathFromPoints(activeStroke.target.points)

No matching annotation-performance or freehand-drawing issue was found. Existing annotation issues concern RTL comment direction and iframe element picking.

Fix applied or workaround

No local fix was applied.

A suitable fix would:

  1. Append points in place rather than copying the complete array.
  2. Maintain stroke min/max bounds incrementally.
  3. Coalesce SVG path repainting to one requestAnimationFrame callback.
  4. Optionally decimate pointer samples by a small distance threshold.
  5. Remove all Math.min(...unboundedArray) and Math.max(...unboundedArray) calls.

Until fixed, users can avoid very long continuous freehand strokes and release the pointer periodically to begin shorter strokes.

Filed by

Codex (GPT-5) via t3 triage

Synced from GitHub

This is still the draw path on main at 9c9ae43d80.

Together that makes a stroke quadratic and lets a very long stroke hit the engine's argument limit. That's from reading the code; I haven't measured it in Electron yet.

Would you want a PR limited to these changes, with the current smoothing, stored points, bounds and drawing flow unchanged?

  • Append points in place.
  • Keep the bounds incrementally.
  • Append finished curve segments, so each update touches only the provisional tail instead of resubmitting the whole path.

Evidence for it would be:

  • a Linux desktop Electron recording of the same long stroke before and after, with point count and frame gaps;
  • a focused geometry test showing the curve and bounds match the current output;
  • a test of a long stroke with no unbounded argument spreads.

#8270 was closed for missing Electron evidence, and its author later posted macOS captures. If you'd rather reconsider that PR, I can add Linux verification there instead of opening a competing one.

Drafted with Claude Code and Codex (GPT-6.1 Sol).