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 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
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
requestAnimationFrame callback.
- Optionally decimate pointer samples by a small distance threshold.
- 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
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
pointermoveduring one continuous freehand stroke copies and rescans all pointscollected 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_snapshotincident.Diagnosis
In
apps/desktop/src/preview/PickPreload.ts, the draw branch runs this on everypointermove:For a stroke containing
npoints, each event performs:strokeBounds.The accumulated cost of a stroke is therefore O(n²), with substantial short-lived
allocation on the preview renderer’s main thread.
strokeBoundsalso spreads the unbounded coordinate arrays intoMath.minandMath.max: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
mainstill contains the same implementation.Steps to reproduce
stroke.
Math.min(...xs)orMath.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.1172Also present on current
mainas 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
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:
requestAnimationFramecallback.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