Annotation draw tool performs O(n²) work per stroke and can exceed V8 argument limits
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
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:
activeStroke.target.points = [
...activeStroke.target.points,
{ x: event.clientX, y: event.clientY },
];
activeStroke.target.bounds = strokeBounds(
activeStroke.target.points,
activeStroke.target.width,
);
activeStroke.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:
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const left = Math.min(...xs) - padding;
const top = Math.min(...ys) - padding;
const right = Math.max(...xs) + padding;
const 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
- Open a desktop preview and activate its annotation interface.
- Select the freehand Draw tool.
- Hold the primary pointer down and produce one long, high-sample-rate continuous
stroke. - Observe increasing pointer latency, CPU use, and allocation as the stroke grows.
- If the point count grows past V8's argument limit, the
Math.min(...xs)or
Math.max(...xs)call can throwRangeError.
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
apps/desktop/src/preview/PickPreload.ts
strokeBounds:
points.map(...) twice
Math.min(...xs)
Math.min(...ys)
Math.max(...xs)
Math.max(...ys)
onPointerMove, draw branch:
activeStroke.target.points = [...activeStroke.target.points, point]
strokeBounds(activeStroke.target.points, ...)
pathFromPoints(activeStroke.target.points)
Related issues
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:
- Append points in place rather than copying the complete array.
- Maintain stroke min/max bounds incrementally.
- Coalesce SVG path repainting to one
requestAnimationFramecallback. - Optionally decimate pointer samples by a small distance threshold.
- Remove all
Math.min(...unboundedArray)andMath.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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/desktop/src/preview/PickPreload.ts, reading the draw branch in onPointerMove along with strokeBounds and pathFromPoints. Reproduce with a long, high-sample-rate freehand stroke, then verify that point handling and preview repainting no longer rescan or rebuild all accumulated data on every event. Done means long strokes avoid increasing pointer latency and allocation, and large strokes do not hit V8 argument limits.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- desktop, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100