Skip to content

wip(zero-client): Immutable apply change#3688

Closed
arv wants to merge 1 commit intomainfrom
arv/view-apply-change-immutable
Closed

wip(zero-client): Immutable apply change#3688
arv wants to merge 1 commit intomainfrom
arv/view-apply-change-immutable

Conversation

@arv
Copy link
Contributor

@arv arv commented Feb 3, 2025

This is an old branch that I had that changes applyChange to treat the data as immutable and it copies the minimal amount of objects to create a new object.

It has bit rotted a bit but the goal is to remove the deepClone which was initially only used in React but now it is used in Solid too. Doing a deepClone copies everything. This branch only copies the objects that change.

@arv arv requested a review from grgbkr February 3, 2025 10:37
@vercel
Copy link

vercel bot commented Feb 3, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
replicache-docs ❌ Failed (Inspect) Feb 3, 2025 10:37am
zbugs ❌ Failed (Inspect) Feb 3, 2025 10:37am

@aboodman aboodman closed this Aug 27, 2025
github-merge-queue bot pushed a commit that referenced this pull request Feb 12, 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

```tsx
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

- Follows up on #5455
- Revives #3688 (original immutable attempt)
- Fixes zero/3036

---------

Co-authored-by: Alp <alp@Alps-MacBook-Pro.local>
Co-authored-by: Alp <alp@Alps-MBP.localdomain>
Co-authored-by: Alp <alp@mac.mynetworksettings.com>
Co-authored-by: Matt Wonlaw <matt.wonlaw@gmail.com>
Co-authored-by: Erik Arvidsson <arv@roci.dev>
Co-authored-by: Erik Arvidsson <erik.arvidsson@gmail.com>
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.

2 participants