pingdotgg / pingdotgg/t3code

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

Open
#8,059 0 comments 0 reactions 0 assignees View on GitHub

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

  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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.