Skip to content

perf(store): reduce allocations in hot paths across store package#8227

Open
steveruizok wants to merge 11 commits intomainfrom
claude/optimize-store-package
Open

perf(store): reduce allocations in hot paths across store package#8227
steveruizok wants to merge 11 commits intomainfrom
claude/optimize-store-package

Conversation

@steveruizok
Copy link
Collaborator

@steveruizok steveruizok commented Mar 14, 2026

This one has changes separated into clean commits commits. I ran the bots through the package looking to minimize allocations. This isn't something we've really paid attention to before, however it's easy work for the bots and will make a difference on our hot paths. See #8225 for a similar PR. –steve


In order to reduce GC pressure and improve throughput in the store's hot paths, this PR eliminates unnecessary intermediate allocations across the @tldraw/store package. The changes target iteration patterns, empty-object checks, set operations, and query matching—all areas that run frequently during incremental reactive updates.

Summary of changes

  • Replace Object.entries/Object.values/objectMapValues with for...in loops — avoids allocating intermediate arrays on every iteration
  • Replace Object.keys().length === 0 with for...in early return — O(1) empty check without array allocation
  • Avoid object spread in RecordType.create — mutate the defaults object directly instead of creating a temporary spread
  • Inline filterChangesByScope — replaces three filterEntries() calls + three Object.keys().length checks with a single for...in pass
  • Optimize intersectSets — iterate the smallest set first, use labeled loops instead of rest.every() closures
  • Add fast paths to diffSets — short-circuit for same-reference and empty sets
  • Optimize executeQuery — use an array of sets instead of Object.fromEntries, skip intersection for single-predicate queries
  • Avoid double Map.get lookup — cache the result of Map.get() instead of calling has() then get()
  • Add compiled query matcher — compile query expressions into flat descriptor arrays for fast incremental matching, replacing objectMatchesQuery() in the ids() hot path

Change type

  • improvement

Test plan

  • Existing unit tests cover all modified functions
  • Unit tests

API changes

  • Store.filterChangesByScope return type changed from mapped type { [K in IdOf<R>]: R } to equivalent Record<IdOf<R>, R> (cosmetic, not a breaking change)

Release notes

  • Reduce allocations in store hot paths for better performance during reactive updates

Code changes

Section LOC change
Core code +184 / -63
Automated files +3 / -3

Note

Medium Risk
Touches core store diff application, history filtering, and reactive query/index incremental paths; while changes are intended to be behavior-preserving, subtle iteration/empty-check/matcher differences could affect which records are included or which updates are emitted.

Overview
Reduces GC pressure in @tldraw/store by rewriting several hot paths to avoid intermediate allocations (replacing Object.*/objectMap* helpers and Object.keys().length checks with for...in loops and early-exit emptiness checks).

Optimizes diff and history handling by inlining Store.filterChangesByScope, updating applyDiff to build toPut/toRemove arrays without helper allocations, and updating RecordsDiff utilities (reverseRecordsDiff, isRecordsDiffEmpty, squashRecordDiffsMutable) to iterate without Object.values/objectMapEntries.

Speeds up querying by optimizing executeQuery (array-of-sets + single-predicate fast path), improving intersectSets/diffSets fast paths, and adding a compiled query matcher used in StoreQueries.ids() incremental updates (replacing per-update objectMatchesQuery calls). API report updates filterChangesByScope to return Record<IdOf<R>, …> types.

Written by Cursor Bugbot for commit 4c85390. This will update automatically on new commits. Configure here.

steveruizok and others added 9 commits March 14, 2026 18:37
… loops

Avoid intermediate array allocations from Object.entries(), Object.values(),
and objectMapEntries()/objectMapValues() by using for...in to iterate object
keys directly. This eliminates per-call array creation in hot paths across
RecordType.create, reverseRecordsDiff, squashRecordDiffsMutable,
filterHistory, index incremental updates, ids incremental updates,
extractMatcherPaths, and objectMatchesQuery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…eturn

Avoid allocating a keys array just to check if an object is empty. Use
for...in with an immediate return/break instead, which is O(1) for empty
objects and avoids the array allocation entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace {...defaults, id} spread with direct property assignment on the
defaults object. This avoids creating a temporary object just to merge
in the id property.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace three filterEntries() calls (each allocating an intermediate
object and iterating with Object.entries) plus three Object.keys().length
checks with a single pass using for...in. Track hasChanges with a boolean
flag instead of checking emptiness after the fact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Find the smallest set up front and iterate only its elements, checking
membership in the other sets. This reduces iterations from O(|first|)
to O(|smallest|). Also replaces rest.every() closure per element with
a labeled loop to avoid per-element closure allocation, and adds early
returns for length-1 input and empty smallest set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Short-circuit when prev === next (same reference), and when either set
is empty, to avoid iterating when the result is trivially known.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… path

Replace Object.fromEntries() keyed map with a simple array of sets,
avoiding the intermediate entries array and object allocation. Add a
fast path that returns the single set directly when there is only one
query predicate, skipping the intersection entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cache the result of Map.get() in a local variable instead of calling
has() then get() separately, eliminating one hash lookup per record
in the index initialization path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace objectMatchesQuery() calls in the ids() incremental path with
a compiled matcher. The query is compiled once into an array of
{parts, kind, value} descriptors, then matched via a tight loop with
a switch statement and getNestedValue(). This avoids repeated
isQueryValueMatcher() checks and Object.entries() allocations on
every record during incremental diff processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@huppy-bot huppy-bot bot added the improvement Product improvement label Mar 14, 2026
@vercel
Copy link

vercel bot commented Mar 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
examples Ready Ready Preview Mar 14, 2026 8:17pm
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
analytics Ignored Ignored Preview Mar 14, 2026 8:17pm
chat-template Ignored Ignored Preview Mar 14, 2026 8:17pm
tldraw-docs Ignored Ignored Preview Mar 14, 2026 8:17pm
tldraw-shader Ignored Ignored Preview Mar 14, 2026 8:17pm
workflow-template Ignored Ignored Preview Mar 14, 2026 8:17pm

Request Review

Remove usage of objectMapValues/objectMapKeys and use explicit for-in loops to build toPut and toRemove arrays from diff.added, diff.updated and diff.removed. Preserve existing ignoreEphemeralKeys logic while switching to index-based access (updated entries use diff.updated[id][1]) and add explicit type annotations for arrays. Clean up imports to remove now-unused helpers and apply minor formatting/indent adjustments. No behavioral changes intended.

for (const [k, v] of Object.entries(properties)) {
if (v !== undefined) {
result[k] = v
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create() mutates object returned by createDefaultProperties()

Low Severity

The old code used { ...this.createDefaultProperties(), id: ... } which always created a fresh object via spread, making create() safe regardless of what createDefaultProperties() returns. The new code directly mutates the returned object by assigning id, typeName, and all properties onto it. If a consumer's createDefaultProperties function returns a shared, cached, or frozen object, subsequent create() calls would corrupt previously created records or throw in strict mode.

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createDefaultProperties is a factory function that always returns a fresh object literal — there are no shared or cached instances in the codebase. This is safe.

When a nested property path resolves to undefined (e.g. null intermediate),
the neq matcher incorrectly treated the record as matching because
undefined === value is always false. Match the behavior of
objectMatchesQuery which returns false for unresolvable paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

break
case 'neq':
if (value === undefined || value === c.value) return false
break
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiled neq matcher rejects undefined values differently

Low Severity

The compiled neq matcher adds a value === undefined guard that the original objectMatchesQuery did not have. For top-level properties that are undefined, objectMatchesQuery would consider undefined !== targetValue as matching, but the compiled matcher returns false. This changes observable incremental query results for records with missing or undefined properties when using neq queries.

Fix in Cursor Fix in Web

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

Labels

improvement Product improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant