Skip to content

fix(desktop): long annotation strokes stay cheap to draw - #24573

Open
only21mil wants to merge 1 commit into
pingdotgg:mainfrom
only21mil:fix/i8059-annotation-draw-tool-performs-o-n-work-p
Open

only21mil wants to merge 1 commit into
pingdotgg:mainfrom
only21mil:fix/i8059-annotation-draw-tool-performs-o-n-work-p

Conversation

@only21mil

@only21mil only21mil commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

With the Draw tool in the desktop preview's annotation mode, every pointer move copied all points drawn so far, mapped them into two coordinate arrays, spread those into Math.min/Math.max, and rebuilt the whole SVG path string. One stroke of n points cost O(n²), and the spread threw RangeError once a stroke outgrew the engine's argument limit.

Fixes #8059

Change

Stroke geometry moves into apps/desktop/src/preview/AnnotationStroke.ts, a small module with no Electron imports so it can be tested directly. Each move now does constant work:

  • The point is pushed onto the array the stroke target already holds.
  • Bounds widen against the new point only. No spreads remain.
  • The stroke is drawn as a run of <path> elements of up to 100 curve segments each, grouped under one <g data-stroke-id>. Only the current chunk is extended. A boundary move finalizes that chunk and starts a new short path, keeping the path text parsed per move bounded. Each new path starts at the exact point where the previous one ended, preserving the curve and smoothing.

Stored points and bounds and the smoothing math match the old code. Erase still finds the stroke by data-stroke-id, now on the group.

Rejected alternatives:

  • Coalescing repaints with requestAnimationFrame. Chromium already delivers about one pointermove per frame (about 1,170 moves in each 20-second stroke below, at 60 frames per second), so it would save nothing.
  • Dropping samples by distance. That changes the recorded stroke.
  • One path with an appended string. The JS gets cheap, but setting d still makes Chromium parse the whole path on every move.

Scope and approval

Bug fix for #8059, which has no maintainer triage yet. It fits the focused obvious-bug route: each pointer move in a Draw stroke costs more as the stroke grows, and the bounds spread can throw on a long stroke. The fix changes no stored points, bounds, smoothing or drawing flow. The same approach in #8270 was closed only for missing desktop evidence (comment), and this PR adds that evidence. If you would rather reopen #8270, I'm happy to close this one.

Verification

vp test run apps/desktop/src/preview/AnnotationStroke.test.ts: 2 passed. One replays a 230-point stroke and checks at every length that the joined paths and bounds equal the old full-rescan output. The other draws 250,000 points. With the module swapped for base's path and bounds code, both fail: base makes one path, and its 250,000-point bounds throw RangeError: Maximum call stack size exceeded at Math.min(...xs).

The focused test against base's code:

Focused stroke tests fail against base geometry

The same test on the candidate:

Focused stroke tests pass on the candidate

Desktop on Linux x64 (Electron 44.9.3): production builds (vp run build:desktop) of base and candidate, run under Xvfb with a copy of real user data. In a thread's Browser panel I opened a local page, turned on Annotate, picked Draw and drew the same scripted stroke with real X pointer input. Metrics come from the preview page over CDP, three runs per build at similar host load, plus one stroke twice as long:

points <path> elements longest d script time main-thread task time frame gap p99
base 1,174 to 1,200 1 24.7 KB 0.27 to 0.36 s 1.1 to 1.7 s 16.8 ms
candidate 1,171 to 1,173 12 2.1 KB 0.09 s 0.84 to 0.88 s 16.8 ms
base, 2x stroke 2,322 1 47.7 KB 0.88 s 3.0 s 16.8 ms
candidate, 2x stroke 2,345 24 2.1 KB 0.18 s 1.7 s 16.8 ms

Base script time grew 2.8x when the stroke doubled. Candidate time doubled. At these lengths neither build drops frames on this machine apart from one host stall in a base run, so this fixes cost growth, not a visible stutter.

The finished stroke on base:

Finished annotation stroke on base

The same stroke on the candidate:

Finished annotation stroke on candidate

Zoomed 4x at three points where one candidate path hands off to the next (top row base, bottom row candidate). The joins render the same:

Base and candidate stroke joins enlarged four times

Both builds drawing the stroke, played at 2x (base left, candidate right):

Base and candidate drawing the same stroke at twice real time

Erase still removes the whole stroke: over CDP the page held 24 stroke paths in one group before one Erase click and none after.

Erase on the candidate, before and after one click on the stroke:

Candidate stroke before and after one erase click

After Attach on the candidate, the annotation appears in the composer:

Attached candidate annotation in the composer

vp run check:changed main: format, lint, knip, typecheck and tests pass.

Not checked:

  • macOS and Windows. The change is renderer-only TypeScript; fix(desktop): stop annotation draw strokes doing quadratic work #8270's author posted macOS captures of the same approach.
  • The RangeError inside the app. In Node the spread fails between 125,000 and 130,000 arguments, which at 60 points per second is over half an hour of continuous drawing. The unit test covers it.

Prior PRs

Generated with Claude Opus 5.5 in T3 Code Nightly

Each pointer move in the Draw tool copied every point, rescanned them
through Math.min/Math.max spreads and rebuilt the whole SVG path. Points
are now appended in place, bounds widen per point, and the stroke is drawn
as a run of 100-segment paths so only the last one is rewritten.

Fixes pingdotgg#8059

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Oct 24, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at c408a72

Macroscope's review found this PR approvable — This is a focused desktop annotation performance fix that bounds SVG path updates and replaces repeated bounds scans with constant-time updates while preserving the existing annotation data and curve. The accompanying tests cover equivalence with the prior geometry and very long strokes.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 3cc31ed and c408a72.

Files selected for processing: 3
  • apps/desktop/src/preview/AnnotationStroke.test.ts
  • apps/desktop/src/preview/AnnotationStroke.ts
  • apps/desktop/src/preview/PickPreload.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Walkthrough

The annotation draw path now tracks stroke points and bounds incrementally. It splits SVG paths after 100 segments. PickPreload uses the new geometry helpers, and tests compare incremental results with full-rescan references, including a 250,000-point stroke.

Changes: Annotation stroke drawing

Layer / File(s) Summary
Incremental stroke geometry and validation
apps/desktop/src/preview/AnnotationStroke.ts, apps/desktop/src/preview/AnnotationStroke.test.ts
New helpers initialize stroke geometry, append points, update bounds, split paths at 100 segments, and return padded bounds. Tests compare incremental geometry with full-rescan references and check a 250,000-point stroke.
Preview draw-path integration
apps/desktop/src/preview/PickPreload.ts
The preview draw path uses the geometry helpers, stores strokes in SVG groups, creates a new path when a segment closes, and removes the group for strokes with at most one point.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Severity of issue fixed: Medium

Suggested reviewers: macroscopeapp

Merge Risk: Minimal · up to c408a

No actionable merge-blocking issue was identified; the change is ready for normal merge checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: making long desktop annotation strokes cheaper to draw.
Linked Issues check Passed The changes address the active coding requirements in [#8059]. AnnotationStroke.ts appends points in place, updates min/max bounds from the new point, and removes unbounded Math.min(...array) and …
Out of Scope Changes check Passed The changed files contain only the stroke geometry implementation, its integration into the desktop annotation draw flow, and focused automated tests. The changes support [#8059] and do not add unrela…
Description check Passed The pull request description includes all required sections. It clearly states the problem, explains the implementation, documents scope and approval rationale, and provides focused tests, manual veri…
Full details: Linked Issues check

Explanation

The changes address the active coding requirements in [#8059]. AnnotationStroke.ts appends points in place, updates min/max bounds from the new point, and removes unbounded Math.min(...array) and Math.max(...array) calls. It splits the SVG stroke into bounded path chunks and updates only the current chunk. PickPreload.ts stores the geometry points on the stroke target, preserves the stroke group ID for erase behavior, and removes empty groups on pointer-up. AnnotationStroke.test.ts compares joined paths and bounds with the full-rescan behavior and exercises 250,000 points.

Full details: Out of Scope Changes check

Explanation

The changed files contain only the stroke geometry implementation, its integration into the desktop annotation draw flow, and focused automated tests. The changes support [#8059] and do not add unrelated product behavior.

Full details: Description check

Explanation

The pull request description includes all required sections. It clearly states the problem, explains the implementation, documents scope and approval rationale, and provides focused tests, manual verification, performance results, screenshots, limitations, and agent attribution.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@juliusmarminge juliusmarminge added bugfix Fix PR opened by an issue lead; enters the PR loop pr:gate-failed A land gate failed labels Oct 28, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Fix PR opened by an issue lead; enters the PR loop pr:gate-failed A land gate failed size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants