Skip to content

[pull] main from tldraw:main#562

Merged
pull[bot] merged 3 commits into
code:mainfrom
tldraw:main
May 27, 2026
Merged

[pull] main from tldraw:main#562
pull[bot] merged 3 commits into
code:mainfrom
tldraw:main

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented May 27, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

max-drake and others added 3 commits May 27, 2026 09:49
In order to preserve the user's original intent when creating GitHub
issues via the issue skill, this PR adds a convention to include the
user's description verbatim at the top of the issue body, annotated with
a human emoji and separated from researched content by a horizontal
rule.

### Change type

- [x] `other`

### Test plan

- [ ] Run `/issue` with a description and verify the user's text appears
verbatim at the top of the created issue

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +8 / -0    |
In order to avoid redundant work in the spatial index when shape props
change without affecting bounds, this PR makes `SpatialIndexManager`
skip rbush mutation on prop-only diffs and exposes a private
`_boundsEpoch` that only ticks when the index actually changes.

While drawing on a populated page, every pointer move emits one updated
shape (the active draw shape with new `segments`) through the store
diff. Previously the index would unconditionally `upsert` the shape — an
O(log N) tree rebalance — and bump its return value, invalidating
downstream consumers like `getShapeIdsInsideBounds`. It also walked
every indexed shape allocating a `Box` per shape during the
transitive-bounds sweep.

Three changes:

1. `processIncrementalUpdate` compares incoming bounds to the indexed
bounds and skips `upsert` when they're equal, removing the rebalance for
prop-only updates.
2. The computed returns a private `_boundsEpoch` that only ticks when an
add/remove/upsert actually happened, so consumers like
`notVisibleShapes` no longer invalidate when nothing in the rbush moved.
Bookkeeping is centralised in a small `rebuildAndBumpEpoch` helper.
3. The transitive-bounds sweep iterates the rbush's element map directly
and compares against the raw `SpatialElement` instead of allocating a
`Box` per shape and a fresh shape-id array. Existence checks use
`RBushIndex.has(id)` instead of `getBounds(id) !== undefined`.

The transitive sweep itself still runs unconditionally — it has to,
because some diffs (e.g. a geo's `rectangle`→`ellipse` flip at the same
width/height) leave the source shape's axis-aligned bounds unchanged but
shift a bound arrow's intersection points. There's a regression test
covering exactly that case.

### Change type

- [x] \`improvement\`

### Test plan

1. Open a page with a few hundred shapes.
2. Start drawing a freehand stroke and move the pointer continuously.
3. Confirm no perf regression in interaction; existing culling behavior
(shapes outside the viewport hidden) is unchanged when panning/zooming
or when shapes are added, moved, or deleted.

- [x] Unit tests
- [ ] End to end tests

### Release notes

- Improve drawing performance on pages with many shapes by skipping
spatial index updates when only shape props change.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +100 / -28 |
| Tests     | +240 / -0  |
In order to stop the canvas culling derivation from invalidating on
every shape prop change, this PR makes `notVisibleShapes` skip per-shape
subscriptions for shapes whose `ShapeUtil` uses the default `canCull`
(which is always-true).

Previously the derivation read `editor.getCurrentPageShapes()`, which
calls `store.get(id)` for every shape on the page. Each shape record
became a parent of the derivation, so any prop change anywhere — even an
onscreen color flip during a draw stroke — invalidated
`notVisibleShapes` and cascaded to `CullingController`, `Shape`
consumers, and `PerformanceManager`.

The new flow:

1. Subscribe to `getCurrentPageShapeIds()` (a Set; only changes on
add/remove/reparent) instead of `getCurrentPageShapes()`.
2. For each offscreen id, peek the record via
`store.unsafeGetWithoutCapture` to get `type` without subscribing.
3. If `util.canCull === ShapeUtil.prototype.canCull` (the default), push
the id with no per-shape subscription.
4. Only when a shape's util overrides `canCull` do we call
`editor.getShape(id)` (which subscribes) and invoke `canCull(shape)`.

Across the repo only two real overrides of `canCull` exist
(`ConditionalCullingExample` and `SizeFromDomExample`), plus test-only
shapes — so >99% of shapes in the wild now contribute zero per-shape
subscriptions to this derivation.

### Findings: interaction with #8799

`notVisibleShapes` had two propagation paths that fired on any shape
change:

1. `getCurrentPageShapes()` → `notVisibleShapes` (5000 per-shape
`store.get` subs)
2. `spatialIndex` → `notVisibleShapes` (via `getShapeIdsInsideBounds` —
bumped its computed value on every shape diff)

Either path alone is sufficient to invalidate the derivation. Benchmark
on a 5000-shape page (500 onscreen, 4500 offscreen, 60 onscreen prop
flips), counting `notVisibleShapes` body executions:

| Configuration | body runs |
| --- | --- |
| main | 60 |
| this PR alone | 60 (spatial-index path still propagates) |
| #8799 alone | 60 (`getCurrentPageShapes` path still propagates) |
| this PR + #8799 | **0** |

This PR and #8799 are complementary. Together they drop body runs to
zero on prop-only changes; either alone leaves the other path firing.
Read-time average for `getNotVisibleShapes()` over 60 iterations on the
combined config is ~620ms vs ~687ms on main (~10% faster); standalone
this PR is a smaller ~3-7% win driven by removing per-shape capture
overhead.

### Change type

- [x] `improvement`

### Test plan

1. Open the conditional culling example, scroll a `preventCulling` shape
offscreen, toggle the prop, confirm it remains visible/culled correctly.

- [x] Unit tests

### Release notes

- Reduce `notVisibleShapes` recomputation cost by skipping per-shape
subscriptions for shapes with default `canCull`.

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Core code       | +30 / -21  |
| Tests           | +39 / -0   |
@pull pull Bot locked and limited conversation to collaborators May 27, 2026
@pull pull Bot added the ⤵️ pull label May 27, 2026
@pull pull Bot merged commit a8199ec into code:main May 27, 2026
@pull pull Bot had a problem deploying to bemo-canary May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to deploy-production May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to npm deploy May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to npm deploy May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to deploy-staging May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to bemo-canary May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to deploy-staging May 27, 2026 15:13 Error
@pull pull Bot had a problem deploying to vsce publish May 27, 2026 15:13 Failure
@pull pull Bot had a problem deploying to deploy-staging May 28, 2026 00:48 Failure
@pull pull Bot temporarily deployed to e2e-dotcom May 28, 2026 02:37 Inactive
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants