Skip to content

Commit d5ec5a4

Browse files
fix(arrows): prevent NaN propagation from zero-length labeled arrows (tldraw#8329)
When arrow endpoints overlap exactly (same position), the arrow has zero length. If that arrow has a label, NaN values propagate through geometry → spatial index → culling, causing all shapes on the canvas to disappear. Easiest to reproduce with "always snap" on, but can happen in any scenario where endpoints coincide. The root cause is `interpolateAlongEdge` computing `invLerp(0, 0, 0)` → `0/0` → NaN when two vertices are identical. This NaN flows into label positioning, arrow bounds, page bounds, and ultimately corrupts the R-tree spatial index, breaking viewport culling for every shape. This PR fixes the issue at the source and adds defense-in-depth: - Guard `interpolateAlongEdge` to return the vertex directly when segment length is 0 - Guard `uninterpolateAlongEdge` to return 0 when total length is 0 - Skip label geometry computation when the arrow is invalid (`isValid === false`), since the arrow component already returns null in that case - Reject NaN bounds from the spatial index R-tree to prevent tree corruption ### Before https://github.com/user-attachments/assets/fdac33de-2d06-44f6-9012-edd275d6387f ### After https://github.com/user-attachments/assets/5d387565-d829-4cd5-93e6-063ce0c9435b ### Change type - [x] `bugfix` ### Test plan 1. Enable "always snap" in user preferences 2. Create an arrow and add a label to it 3. Drag one endpoint to overlap the other (snap to same grid point) 4. Verify other shapes on canvas remain visible ### Release notes - Fix all shapes disappearing when a labeled arrow has zero length (e.g. when both endpoints overlap) ### Code changes | Section | LOC change | | ---------- | ---------- | | Core code | +7 / -4 |
1 parent f1cd46b commit d5ec5a4

3 files changed

Lines changed: 7 additions & 6 deletions

File tree

packages/editor/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export class SpatialIndexManager {
7474
// Collect all shape elements for bulk loading
7575
for (const shape of shapes) {
7676
const bounds = this.editor.getShapePageBounds(shape.id)
77-
if (bounds) {
77+
if (bounds && bounds.isValid()) {
7878
elements.push({
7979
minX: bounds.minX,
8080
minY: bounds.minY,
@@ -101,7 +101,7 @@ export class SpatialIndexManager {
101101
for (const shape of objectMapValues(changes.added) as TLShape[]) {
102102
if (isShape(shape) && this.editor.getAncestorPageId(shape) === this.lastPageId) {
103103
const bounds = this.editor.getShapePageBounds(shape.id)
104-
if (bounds) {
104+
if (bounds && bounds.isValid()) {
105105
this.rbush.upsert(shape.id, bounds)
106106
}
107107
processedShapeIds.add(shape.id)
@@ -125,7 +125,7 @@ export class SpatialIndexManager {
125125

126126
if (isOnPage) {
127127
const bounds = this.editor.getShapePageBounds(to.id)
128-
if (bounds) {
128+
if (bounds && bounds.isValid()) {
129129
this.rbush.upsert(to.id, bounds)
130130
}
131131
} else {
@@ -145,7 +145,7 @@ export class SpatialIndexManager {
145145
const indexedBounds = this.rbush.getBounds(shapeId)
146146

147147
if (!this.areBoundsEqual(currentBounds, indexedBounds)) {
148-
if (currentBounds) {
148+
if (currentBounds && currentBounds.isValid()) {
149149
this.rbush.upsert(shapeId, currentBounds)
150150
} else {
151151
this.rbush.remove(shapeId)

packages/editor/src/lib/primitives/geometry/Geometry2d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export abstract class Geometry2d {
191191
const dist = Vec.Dist(curr, next)
192192
const newDistanceTraveled = distanceTraveled + dist
193193
if (newDistanceTraveled >= distanceToTravel) {
194+
if (dist === 0) return curr
194195
const p = Vec.Lrp(
195196
curr,
196197
next,
@@ -241,7 +242,7 @@ export abstract class Geometry2d {
241242
const distanceAlongRoute =
242243
closestSegment.distanceToStart + Vec.Dist(closestSegment.start, closestSegment.nearestPoint)
243244

244-
return distanceAlongRoute / length
245+
return length === 0 ? 0 : distanceAlongRoute / length
245246
}
246247

247248
isPointInBounds(point: VecLike, margin = 0) {

packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
221221
: new Polyline2d({ points: info.route.points })
222222

223223
let labelGeom
224-
if (isEditing || !isEmptyRichText(shape.props.richText)) {
224+
if (info.isValid && (isEditing || !isEmptyRichText(shape.props.richText))) {
225225
const labelPosition = getArrowLabelPosition(this.editor, shape, isEditing)
226226
if (debugFlags.debugGeometry.get()) {
227227
debugGeom.push(...labelPosition.debugGeom)

0 commit comments

Comments
 (0)