Skip to content

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

Description

@areidyOTH

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

  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

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:

  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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions