Skip to content

feat(zql): make applyChange immutable for React.memo optimization#5462

Merged
arv merged 39 commits intorocicorp:mainfrom
Karavil:feat/immutable-apply-change
Feb 12, 2026
Merged

feat(zql): make applyChange immutable for React.memo optimization#5462
arv merged 39 commits intorocicorp:mainfrom
Karavil:feat/immutable-apply-change

Conversation

@Karavil
Copy link
Contributor

@Karavil Karavil commented Jan 23, 2026

Immutable applyChange with O(N + K) Batching

Makes applyChange return new objects instead of mutating in place. Unchanged rows keep their object reference, changed rows get new references. This lets React.memo and Solid reactivity actually work.

Right now Zero deep clones every snapshot because applyChange mutates in place. Every row gets a new reference on every sync, even if nothing changed.

Current behavior (mutable + deepClone):

  DB: UPDATE row A         Consumer receives snapshot
  ┌──────────────┐         ┌──────────────────────────┐
  │ A = "new"    │         │ A' ← new ref (changed)   │
  │ B = "same"   │  ────►  │ B' ← new ref (unchanged) │  ← React.memo fails
  │ C = "same"   │         │ C' ← new ref (unchanged) │  ← everything re-renders
  └──────────────┘         └──────────────────────────┘

With immutable applyChange, unchanged rows keep their reference:

New behavior (immutable):

  DB: UPDATE row A         Consumer receives snapshot
  ┌──────────────┐         ┌──────────────────────────┐
  │ A = "new"    │         │ A' ← new ref (changed)   │  ← re-render
  │ B = "same"   │  ────►  │ B  ← same ref            │  ← memo skips
  │ C = "same"   │         │ C  ← same ref            │  ← memo skips
  └──────────────┘         └──────────────────────────┘

Performance

Started with a naive immutable implementation. @tantaman pointed out it was O(K × N) for K changes to an N-item array:

Naive immutable (O(K × N)):

  push(change₁) ──► [...array]  ─┐
  push(change₂) ──► [...array]   │  K rebuilds
  push(change₃) ──► [...array]   │  of N items
  ...                            │
  push(changeₖ) ──► [...array]  ─┘

Ran benchmarks, it was ~4x slower at scale. Still under 0.1ms but felt like we could do better.

Tried batching but hit a blocker: tests expected .data to reflect changes immediately after push(). Solution: auto-flush in the .data getter.

Batched immutable (O(N + K)):

  push(change₁) ──► buffer  ─┐
  push(change₂) ──► buffer   │  O(K) to buffer
  push(change₃) ──► buffer   │
  ...                        │
  push(changeₖ) ──► buffer  ─┘
                     │
                     ▼
  flush() ─────► sorted merge ──► one new array
                     │
              O(N + K log K)

The .data getter auto-flushes if there are pending changes, so existing code just works.

Benchmarks (time in ms):

  N=2000, K=200
  ─────────────────────────────────────────────────────────
  Mutable O(K)      │████████████████████████  0.023ms
  Immutable O(K×N)  │████████████████████████████████████████████████████████  0.109ms
  Batched O(N+K)    │███████████████  0.015ms  ◄── faster than mutable!
  ─────────────────────────────────────────────────────────

At larger scales batched beats mutable because we do not have to copy the objects over and
over

Changes

applyChange()
├── returns Entry instead of void
├── uses toSpliced()/with() for immutable array ops
└── uses spread for immutable object ops

ArrayView
├── push() buffers changes
├── flush() applies in one sorted merge pass
└── .data getter auto-flushes for backwards compat

Consumers
├── React: removes deepClone
└── Solid: uses produce with in-place mutation

Manual Testing: React (zbugs)

To verify React.memo actually works with stable references, I modified zbugs' list page:

  1. Extracted IssueRow outside the component and wrapped with React.memo
  2. Added custom comparator (default shallow compare fails because style is new object each render)
  3. Added render count tracking to each row
const areIssueRowPropsEqual = (prev, next) => {
  if (prev.issue !== next.issue) return false;           // reference check
  if (prev.style?.transform !== next.style?.transform)   // value check
    return false;
  // ... other props
  return true;
};

const IssueRow = memo(function IssueRow({ issue, style, ... }) {
  // render count tracking for testing
}, areIssueRowPropsEqual);

Then ran the app and updated rows directly via psql while watching console:

┌───────────────────────────────────────────────────────────────────────┐
│ Test 1: Update title only (no sort position change)                   │
├───────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  psql> UPDATE issue SET title = 'TITLE ONLY UPDATE'                   │
│        WHERE id = '_6cIk1pH24n6nmJ5sI6Ar';                            │
│                                                                       │
│  Console output after Zero sync:                                      │
│  ┌───────────────────────────────────────────────────────────────┐    │
│  │ [render #3] IssueRow: _6cIk1pH - "TITLE ONLY UPDATE"          │    │
│  │                                              (sameRef: false) │    │
│  └───────────────────────────────────────────────────────────────┘    │
│                                                                       │
│  Result: ONLY the updated row re-rendered. Other 99 rows skipped.     │
│                                                                       │
└───────────────────────────────────────────────────────────────────────┘

┌───────────────────────────────────────────────────────────────────────┐
│ Test 2: Update title + modified (causes sort position change)         │
├───────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  psql> UPDATE issue SET title = 'POSITION CHANGE',                    │
│                         modified = 1769210037000                      │
│        WHERE id = 'dSHOxE86nkbGafajdd1ew';                            │
│                                                                       │
│  Console output after Zero sync:                                      │
│  ┌────────────────────────────────────────────────────────────────┐   │
│  │ [render #3] IssueRow: dSHOxE86 - "POSITION CHANGE"             │   │
│  │ [render #3] IssueRow: oGP3NonM - "Some other issue"            │   │
│  └────────────────────────────────────────────────────────────────┘   │
│                                                                       │
│  Result: Updated row + displaced row re-rendered. Expected behavior   │
│  since style.transform changed for rows that moved.                   │
│                                                                       │
└───────────────────────────────────────────────────────────────────────┘

Key insight: Zero maintains stable object references for unchanged issues. The sameRef: true/false logging confirmed this. When only the title changes, that row gets a new reference, but all other rows keep their original reference.

Manual Testing: Solid (zbugs-lite-solid)

(this is no longer part of the PR)

Created a minimal Solid app that imports Zero schema/queries from zbugs. Added render count badges to each row.

┌────────────────────────────────────────────────────────────────────────┐
│ Test: Update one issue while watching render counts                    │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  psql> UPDATE issue SET title = 'MODIFIED BY PSQL'                     │
│        WHERE id = 'HdpMkgbH...';                                       │
│                                                                        │
│  Before:                          After:                               │
│  ┌──────────────────────────┐    ┌──────────────────────────┐          │
│  │ [1] Leaking listeners... │    │ [1] MODIFIED BY PSQL     │ ← +0     │
│  │ [1] Sort and remove...   │    │ [1] Sort and remove...   │          │
│  │ [1] UnknownError: Int... │    │ [1] UnknownError: Int... │          │
│  │ [1] Fix: race condition  │    │ [1] Fix: race condition  │          │
│  │ [1] Add support for...   │    │ [1] Add support for...   │          │
│  └──────────────────────────┘    └──────────────────────────┘          │
│                                                                        │
│  Items keep their reference so Solid never rerenders.                  │
│  All other rows stayed at 1.                                           │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘

Solid's fine-grained reactivity handles the in-place updates efficiently via produce. No special memo config needed.

Related

@vercel
Copy link

vercel bot commented Jan 23, 2026

Someone is attempting to deploy a commit to the Rocicorp Team on Vercel.

A member of the Team first needs to authorize it.

@aboodman
Copy link
Contributor

Thanks, amazing. Have you actually done the manual test yet?

This is going to require careful review. These files are super sensitive and have been a source of subtle bugs in the past. One of us is going to have to set aside a few hours to work through it. Maybe next week?

Appreciate the contribution.

@Karavil
Copy link
Contributor Author

Karavil commented Jan 23, 2026

Thanks, amazing. Have you actually done the manual test yet?

This is going to require careful review. These files are super sensitive and have been a source of subtle bugs in the past. One of us is going to have to set aside a few hours to work through it. Maybe next week?

Appreciate the contribution.

Nope, was giving this a shot using Ralph loop and Claude hahaha. Not ready for review yet! Will do manual reviews. I'm not super comfortable with Solid so it definitely needs some testing on that front (there are bits in the code that I don't understand too well).

@tantaman
Copy link
Contributor

tantaman commented Jan 23, 2026

How does this handle the case where every single row changes? Does it intelligently replace the view or does it path-copy for every single modification? It looks like it does the latter.

On a quick scan (didn't think too hard since the PR is still draft), it looks like the current PR is in this situation --

The parent spreads at each level:

  return {...parentEntry, [relationship]: newView};                                                                         

So for a single change at depth D in a tree with array sizes N₁, N₂, ... Nᴅ:

  • Old: O(1) mutation at leaf
  • New: O(N₁) + O(N₂) + ... + O(Nᴅ) array copies up the path

For K changes in one transaction, each touching the same level-1 array of size N:

  • Old: O(K) mutations + O(total_nodes) deep clone
  • New: O(K × N) array copies

@Karavil
Copy link
Contributor Author

Karavil commented Jan 23, 2026

How does this handle the case where every single row changes? Does it intelligently replace the view or does it path-copy for every single modification? It looks like it does the latter.

On a quick scan (didn't think too hard since the PR is still draft), it looks like the current PR is in this situation --

The parent spreads at each level:

  return {...parentEntry, [relationship]: newView};                                                                         

So for a single change at depth D in a tree with array sizes N₁, N₂, ... Nᴅ:

  • Old: O(1) mutation at leaf
  • New: O(N₁) + O(N₂) + ... + O(Nᴅ) array copies up the path

For K changes in one transaction, each touching the same level-1 array of size N:

  • Old: O(K) mutations + O(total_nodes) deep clone
  • New: O(K × N) array copies

Ah, good point! I think I can optimize this by batching mutations.

@Karavil
Copy link
Contributor Author

Karavil commented Jan 23, 2026

How does this handle the case where every single row changes? Does it intelligently replace the view or does it path-copy for every single modification? It looks like it does the latter.

For K changes in one transaction, each touching the same level-1 array of size N:

  • Old: O(K) mutations + O(total_nodes) deep clone
  • New: O(K × N) array copies

I tried to implement batching to get O(N + K) complexity but ran into a blocker.

The issue is that .data seems to be expected to return current state immediately after push(). Found tests doing this:

consume(source.push({type: 'add', ...}));
expect(view.data).toEqual(...);  // no flush() call

True batching would mean .data is stale until flush(), which feels like a breaking change. Maybe there's a way around this I'm not seeing?

That said, I ran some benchmarks and the O(K × N) cost might be fine in practice:

Scenario Mutable Immutable Ratio
N=100, K=10 0.001ms 0.001ms 1.1x
N=500, K=50 0.003ms 0.005ms 1.8x
N=1000, K=100 0.008ms 0.019ms 2.5x
N=2000, K=200 0.024ms 0.097ms 4.1x

Even the worst case (N=2000, K=200) is under 0.1ms, which is way under the 16ms frame budget. On a slow device (10x multiplier), still under 1ms.

The upside is that immutability preserves reference identity for unchanged items, so React.memo / Solid reactivity can skip re-rendering rows that didn't change. Not sure if that fully justifies the complexity tradeoff, but it seems reasonable?

@Karavil
Copy link
Contributor Author

Karavil commented Jan 23, 2026

Hey @tantaman, figured out a way to make batching work!

Added an auto-flush safety net in the .data getter so existing code still works (reads trigger flush if there are pending changes). This means:

  • push() buffers changes
  • flush() applies them all in one O(N + K) pass
  • .data auto-flushes for backwards compatibility

Ran benchmarks comparing all 3 approaches:

N, K 1. Mutable O(K) 2. Imm O(K×N) 3. Batched O(N+K)
100, 10 0.000ms 0.001ms 0.003ms
500, 50 0.003ms 0.006ms 0.003ms
1000, 100 0.007ms 0.020ms 0.007ms
2000, 200 0.023ms 0.109ms 0.015ms

At larger scales the batched approach actually beats mutable because it avoids K splice operations that each shift elements. Wasn't expecting that!

All 2202 zql tests pass with the new implementation.

Alp and others added 21 commits January 23, 2026 17:39
Enables React.memo with shallow comparison by preserving object identity
for unchanged rows. Removes the need for deepClone in React consumer.

Changes:
- applyChange now returns Entry instead of void
- Uses toSpliced/with for immutable array operations
- Uses spread for immutable object operations
- Unchanged rows keep their reference identity
- ArrayView stores returned value from applyChange
- React: removes deepClone, unchanged rows preserve identity
- Solid: uses reconcile instead of produce

Tests:
- Added 8 object identity tests using toBe (reference equality)
- All 1184 zql tests passing
- All 37 React tests passing
- All 15 Solid tests passing

Closes rocicorp#3036
Why: Make the immutable update pattern easier to understand for future
maintainers and improve type safety by reducing unchecked casts.

* Add ASCII diagrams explaining immutable propagation from leaf to root
* Add ASCII diagrams showing object identity preservation for React.memo
* Add inline comments explaining each change type (add/remove/edit/child)
* Create asMutableArray() helper for TypeScript's readonly array limitation
* Create getOptionalSingularEntry() type guard to reduce `as` casts
* Create getChildEntryList() helper with proper typing
Why: Runtime type guards (isMetaEntry, assertMetaEntry, etc.) add
function call overhead on every operation. Since we control all entry
creation paths, direct casts are safe and faster.

* Replace helper functions with inline `as` casts throughout
* Add ES2023.Array to tsconfig for with()/toSpliced() types
* Add detailed comments explaining previous vs current Solid behavior
* Document cast safety rationale in file header comment
* Add entries() and at() test helpers to avoid repetitive casts
* Replace all (x['key'] as Entry[])[i] patterns with at(x, 'key', i)
Remove unnecessary readonly modifier from MetaEntryList since
toSpliced/with methods aren't defined on ReadonlyArray in TS.
This eliminates ~8 casts throughout the file.
The test was using the apply() helper function but it wasn't defined
in that test block's scope.
Improve performance and code clarity based on oracle review:

* Return pos from add() to eliminate O(n) indexOf scan after insertion
* Optimize initializeRelationships: build arrays in-place with mutable
  splice for non-hidden plural relationships, avoiding intermediate
  parent object allocations on large fan-out
* Condense comments throughout: keep ASCII visualizations, remove
  verbose explanations, make helper docs single-line
* Add tests for add with initialized relationships to verify pos-based
  array updates work correctly
Remove before/after comparisons, keep compact explanations with inline
ASCII example showing how reconcile merges properties.
solid-view.ts:
* Add flowchart showing Solid update flow (push → commit → reconcile)
* Remove React.memo reference (wrong framework)

view-apply-change.ts:
* Make doc framework-agnostic (React.memo, Solid, etc.)
* Add ASCII state diagram for refCount lifecycle in ADD
* Improve EDIT comment with "why ghost stays" explanation
* Better immutable propagation path visualization
Remove verbose before/after explanation, keep compact "why" focused comment.
* Remove "runtime assertions" meta-comment (noise)
* Restore good original comments for special case and move logic
* Keep ASCII viz that adds value
view-apply-change.test.ts:
* Add section headers with ASCII diagrams explaining test categories
* Add per-test ASCII diagrams showing state transitions
* Visualize refCount lifecycle, identity preservation, edit moves

solid-view.ts:
* Add context explaining the challenge (batching for perf)
* Document the two code paths with concrete perf numbers
* Simplify the ASCII flowchart
Test app demonstrating that immutable applyChange works with SolidJS.
Includes render count badges to visually verify only modified rows
re-render when data changes.

Results documented in RENDER-TEST-RESULTS.md showing:
- Modified row: render count 1 → 2
- Other rows: render count stays at 1
- Rename app from zsolid to zbugs-lite-solid
- Remove local shared/ folder with duplicate schema
- Create src/zero.ts that imports schema, mutators, queries from zbugs
- App is now a pure Solid rendering layer on top of zbugs Zero infra
Buffer changes in push() and apply them all at once in flush(), achieving
O(N + K) complexity instead of O(K × N) when K changes arrive in one transaction.

Key changes:
- Add #pendingChanges buffer to queue ViewChanges
- Eagerly expand lazy relationship generators at push time to capture source state
- Apply batched changes in flush() using applyChanges()
- Add auto-flush safety net in .data getter for backwards compatibility
- Export ExpandedNode/ViewNode types and getChildNodes helper

The auto-flush ensures existing code that reads .data without explicit flush()
continues to work, while code that follows the push/flush pattern benefits from
O(N + K) batching.
@Karavil Karavil force-pushed the feat/immutable-apply-change branch from 9f92632 to ad7386c Compare January 23, 2026 22:39
@Karavil Karavil marked this pull request as ready for review January 23, 2026 22:51
@arv
Copy link
Contributor

arv commented Feb 11, 2026

Update:

@Karavil @grgbkr I added the flag to applyChange.

I updated the types and the tests.

I need to go back to the old SolidJS behavior.

@arv
Copy link
Contributor

arv commented Feb 12, 2026

CleanShot.2026-02-12.at.10.54.15.mp4

This works great. Look at those render counts!!!

@arv arv enabled auto-merge February 12, 2026 14:34
@vercel
Copy link

vercel bot commented Feb 12, 2026

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

Project Deployment Actions Updated (UTC)
replicache-docs Ready Ready Preview, Comment Feb 12, 2026 4:26pm
zbugs Ready Ready Preview Feb 12, 2026 4:26pm

Request Review

@arv arv added this pull request to the merge queue Feb 12, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Feb 12, 2026
@arv arv added this pull request to the merge queue Feb 12, 2026
Merged via the queue into rocicorp:main with commit a40a69b Feb 12, 2026
41 of 43 checks passed
@arv
Copy link
Contributor

arv commented Feb 24, 2026

@devagrawal09 FYI

@devagrawal09
Copy link

Thanks for the ping @arv. Happy to see that the produce mutate behavior is intact for optimal performance with Solid.

Karavil pushed a commit to Karavil/mono that referenced this pull request Feb 25, 2026
Remove the recursive eager expansion of relationship thunks that was
introduced in rocicorp#5462. Instead of buffering expanded changes and applying
them in batch at flush time, apply each change immediately in push()
using the immutable applyChange. This preserves correctness (thunks are
evaluated while source state is current) and eliminates the O(tree)
recursive expansion overhead.

The getChildNodes() helper in view-apply-change.ts already handles
lazy thunks natively, so no changes are needed there.

* Remove expandNode and expandChange functions
* Remove mapValues import (only used by expandNode)
* Remove change buffering (#pendingChanges array, #applyPendingChanges)
* Apply changes immediately in push() via applyChange (immutable)
* Update test: singular format error now throws at push time, not flush
* All 2212 ZQL tests pass (132 files, 2 pre-existing skips)
grgbkr added a commit that referenced this pull request Feb 25, 2026
grgbkr added a commit that referenced this pull request Feb 25, 2026
github-merge-queue bot pushed a commit that referenced this pull request Feb 26, 2026
…tion (#5462)" (#5616)

This reverts commit a40a69b.

Per regressions were discovered. We will work to fix those and reland
this with better benchmarks for confidence.


https://rocicorp.slack.com/archives/C0A8NUDMDM1/p1771983306782649?thread_ts=1771969137.598129&cid=C0A8NUDMDM1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants