Skip to content

Commit b54d7b5

Browse files
steveruizokclaude
andauthored
fix(arrows): fix crash isolating degenerate curved arrows (tldraw#8176)
In order to prevent crashes when isolating curved arrows with degenerate geometry, this PR hardens the arrow binding update logic to handle cases where rebend geometry becomes invalid. When a curved arrow's geometry becomes degenerate (e.g., during shape deletion), the strict intersection assert could fire and the terminal update could write `NaN` values. This PR removes the strict assert, guards against zero-length direction vectors, and skips bend recomputation when inputs are non-finite. Arrow terminal updates now fall back to stored `arrow.props.start/end` when computed points are invalid. Fixes tldraw#8174. ### Change type - [x] `bugfix` ### Test plan 1. Create two adjacent shapes and draw a bent (curved) arrow between them 2. Delete the arrow — should not crash 3. Verify curved arrows still render and update correctly during normal editing - [x] Unit tests ### API changes - Added `Vec.IsFinite()` static method for checking if a vector has finite coordinates ### Release notes - Fix crash when isolating curved arrows with degenerate binding geometry <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches arrow binding/geometry update logic that runs during edits and deletions; added guards reduce crash risk but could subtly change bend recomputation behavior in edge cases. > > **Overview** > Fixes a crash path in curved arrow binding updates by making `updateArrowTerminal` resilient to degenerate geometry during isolation/deletion. > > Terminal point updates now *fallback to `arrow.props.start/end`* when computed points are non-finite, bend recomputation is skipped for zero-length directions or non-finite arc inputs, and the strict intersection assertion is replaced with best-effort intersection selection. Adds `Vec.IsFinite()` to the public editor API and a regression test covering deletion of a bent arrow bound between two adjacent shapes. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5fdfda1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fdd3bf3 commit b54d7b5

4 files changed

Lines changed: 109 additions & 12 deletions

File tree

packages/editor/api-report.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5005,6 +5005,8 @@ export class Vec {
50055005
// (undocumented)
50065006
static FromArray(v: number[]): Vec;
50075007
// (undocumented)
5008+
static IsFinite(A: VecLike): boolean;
5009+
// (undocumented)
50085010
static IsNaN(A: VecLike): boolean;
50095011
// (undocumented)
50105012
static Len(A: VecLike): number;

packages/editor/src/lib/primitives/Vec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@ export class Vec {
476476
return isNaN(A.x) || isNaN(A.y)
477477
}
478478

479+
static IsFinite(A: VecLike): boolean {
480+
return Number.isFinite(A.x) && Number.isFinite(A.y)
481+
}
482+
479483
/**
480484
* Get the angle from position A to position B.
481485
*/

packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
approximately,
1818
arrowBindingMigrations,
1919
arrowBindingProps,
20-
assert,
2120
getIndexAbove,
2221
getIndexBetween,
2322
intersectLineSegmentCircle,
@@ -235,8 +234,14 @@ export function updateArrowTerminal({
235234
throw new Error('expected arrow info')
236235
}
237236

238-
const startPoint = useHandle ? info.start.handle : info.start.point
239-
const endPoint = useHandle ? info.end.handle : info.end.point
237+
const startPoint = getValidTerminalPoint(
238+
useHandle ? info.start.handle : info.start.point,
239+
arrow.props.start
240+
)
241+
const endPoint = getValidTerminalPoint(
242+
useHandle ? info.end.handle : info.end.point,
243+
arrow.props.end
244+
)
240245
const point = terminal === 'start' ? startPoint : endPoint
241246

242247
const update = {
@@ -251,30 +256,58 @@ export function updateArrowTerminal({
251256
// fix up the bend:
252257
if (info.type === 'arc') {
253258
// find the new start/end points of the resulting arrow
254-
const newStart = terminal === 'start' ? startPoint : info.start.handle
255-
const newEnd = terminal === 'end' ? endPoint : info.end.handle
259+
const newStart =
260+
terminal === 'start'
261+
? startPoint
262+
: getValidTerminalPoint(info.start.handle, arrow.props.start)
263+
const newEnd =
264+
terminal === 'end' ? endPoint : getValidTerminalPoint(info.end.handle, arrow.props.end)
256265
const newMidPoint = Vec.Med(newStart, newEnd)
266+
const arrowDirection = Vec.Sub(newStart, newEnd)
267+
if (approximately(Vec.Len2(arrowDirection), 0)) {
268+
editor.updateShape(update)
269+
if (unbind) {
270+
removeArrowBinding(editor, arrow, terminal)
271+
}
272+
return
273+
}
257274

258275
// intersect a line segment perpendicular to the new arrow with the old arrow arc to
259276
// find the new mid-point
260-
const lineSegment = Vec.Sub(newStart, newEnd)
277+
const lineSegment = arrowDirection
261278
.per()
262279
.uni()
263280
.mul(info.handleArc.radius * 2 * Math.sign(arrow.props.bend))
281+
const targetPoint = Vec.Add(newMidPoint, lineSegment)
282+
if (
283+
!Vec.IsFinite(info.handleArc.center) ||
284+
!Number.isFinite(info.handleArc.radius) ||
285+
!Vec.IsFinite(targetPoint)
286+
) {
287+
editor.updateShape(update)
288+
if (unbind) {
289+
removeArrowBinding(editor, arrow, terminal)
290+
}
291+
return
292+
}
264293

265294
// find the intersections with the old arrow arc:
266295
const intersections = intersectLineSegmentCircle(
267296
info.handleArc.center,
268-
Vec.Add(newMidPoint, lineSegment),
297+
targetPoint,
269298
info.handleArc.center,
270299
info.handleArc.radius
271300
)
272301

273-
assert(intersections?.length === 1)
274-
const bend = Vec.Dist(newMidPoint, intersections[0]) * Math.sign(arrow.props.bend)
275-
// use `approximately` to avoid endless update loops
276-
if (!approximately(bend, update.props.bend)) {
277-
update.props.bend = bend
302+
if (intersections?.length) {
303+
const intersection = intersections.reduce((closest, candidate) =>
304+
Vec.Dist2(candidate, targetPoint) < Vec.Dist2(closest, targetPoint) ? candidate : closest
305+
)
306+
const bend = Vec.Dist(newMidPoint, intersection) * Math.sign(arrow.props.bend)
307+
// use `approximately` to avoid endless update loops
308+
if (!approximately(bend, update.props.bend)) {
309+
update.props.bend = bend
310+
}
278311
}
279312
}
280313

@@ -283,3 +316,10 @@ export function updateArrowTerminal({
283316
removeArrowBinding(editor, arrow, terminal)
284317
}
285318
}
319+
320+
function getValidTerminalPoint(
321+
point: { x: number; y: number },
322+
fallback: { x: number; y: number }
323+
) {
324+
return Vec.From(Vec.IsFinite(point) ? point : fallback)
325+
}

packages/tldraw/src/test/commands/deleteShapes.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,57 @@ describe('Editor.deleteShapes', () => {
103103
expect(editor.getShape(ids.box3)).toBeUndefined()
104104
expect(editor.getShape(ids.box4)).toBeUndefined()
105105
})
106+
107+
it('does not crash when deleting a bent arrow and two adjacent bound shapes', () => {
108+
const leftId = createShapeId('left')
109+
const rightId = createShapeId('right')
110+
const bentArrowId = createShapeId('bent-arrow')
111+
112+
editor.createShapes([
113+
{ id: leftId, type: 'geo', x: 500, y: 500, props: { w: 100, h: 100 } },
114+
{ id: rightId, type: 'geo', x: 600, y: 500, props: { w: 100, h: 100 } },
115+
{
116+
id: bentArrowId,
117+
type: 'arrow',
118+
x: 550,
119+
y: 550,
120+
props: {
121+
start: { x: 0, y: 0 },
122+
end: { x: 100, y: 0 },
123+
bend: -120,
124+
},
125+
},
126+
])
127+
128+
editor.createBindings([
129+
{
130+
id: createBindingId(),
131+
fromId: bentArrowId,
132+
toId: leftId,
133+
type: 'arrow',
134+
props: {
135+
terminal: 'start',
136+
isExact: false,
137+
normalizedAnchor: { x: 1, y: 0.5 },
138+
isPrecise: false,
139+
},
140+
},
141+
{
142+
id: createBindingId(),
143+
fromId: bentArrowId,
144+
toId: rightId,
145+
type: 'arrow',
146+
props: {
147+
terminal: 'end',
148+
isExact: false,
149+
normalizedAnchor: { x: 0, y: 0.5 },
150+
isPrecise: false,
151+
},
152+
},
153+
])
154+
155+
expect(() => editor.deleteShapes([leftId, bentArrowId, rightId])).not.toThrow()
156+
})
106157
})
107158

108159
describe('When deleting arrows', () => {

0 commit comments

Comments
 (0)