Skip to content

perf: compute snapshots when they are read, not after every action - #2308

Open
lavrton wants to merge 1 commit into
mobxjs:masterfrom
lavrton:perf/lazy-snapshot
Open

lavrton wants to merge 1 commit into
mobxjs:masterfrom
lavrton:perf/lazy-snapshot

Conversation

@lavrton

@lavrton lavrton commented Oct 15, 2026

Copy link
Copy Markdown

What does this PR do and why?

After every action, each MST root rebuilds its snapshot, even when the app never calls getSnapshot or onSnapshot. This PR rebuilds the snapshot only when something reads it.

Our use case. A design is an MST tree: pages with elements, and each element has about 60 properties (position, size, rotation, fills, text settings, …). When a user drags many selected elements, every mouse move updates x and y of all of them. We keep these positions in the model during the drag, not only on the canvas, because other code depends on them live: snapping and alignment guides compare the dragged elements with the others, constraints check the new positions, and other parts of the UI show them. So dragging 600 elements is one action per mouse move that changes 1,200 properties, and MST then rebuilds the snapshot of all 600 elements (about 36,000 properties). Nothing reads it.

A pure MST version of that drag:

const props = { id: types.identifier, x: 0, y: 0 }
for (let i = 0; i < 58; i++) props["p" + i] = i % 2 ? 0 : ""
const Element = types.model(props)
const Page = types.model({ elements: types.array(Element) }).actions(self => ({
    moveAll(dx, dy) {
        for (const el of self.elements) {
            el.x += dx
            el.y += dy
        }
    }
}))

const page = Page.create({ elements: Array.from({ length: 600 }, (_, i) => ({ id: "e" + i })) })
page.moveAll(1, 1) // one drag step

dist/mobx-state-tree.min.js, Node 26, median of 50 runs:

  • One drag step: 28 ms -> 2.4 ms. At 60 fps a frame has 16 ms, so today MST alone takes more than a frame.
  • One drag step, then getSnapshot: unchanged (~27 ms). The snapshot is now built when it is read, not before.
  • With an onSnapshot listener: unchanged. A listener still needs a new snapshot after every action.

Why it happens. Since #902 (2018), each root creates reaction(() => this.snapshot, ...) when it is created (before that, every node did). It was kept on roots so that the snapshot computed stays observed and therefore cached. But a reaction also recomputes the snapshot after every change.

Changes

  • The snapshot computed uses { keepAlive: true }, so it stays cached without an observer, and the eager root reaction is removed. onSnapshot still adds its reaction.
  • onSnapshot takes its first snapshot when the listener is added (untracked, fireImmediately, skip if unchanged), so a listener added inside an action still gets the changes made after it.
  • A kept-alive computed does not stop by itself, so it is recomputed when a node dies (so it stops observing what it read; a dead node returns the snapshot stored at death) and when snapshotProcessor replaces getSnapshot of an existing node.

Behaviour changes

  • A root onSnapshot listener added in the middle of an action no longer gets the changes made earlier in that action. Non-root listeners already work like this.
  • A non-root onSnapshot listener added in an action now gets the changes made later in that action.
  • A snapshot that throws (for example a custom type's toSnapshot) throws when it is read, not at the end of every action.
  • A removed node that was part of the last read snapshot can stay in memory until the snapshot is read again and one more action runs.

Every root node created a reaction on its snapshot, whether or not anyone
called onSnapshot. It existed to keep the snapshot computeds observed so
they stay cached, but it also made every action recompute the snapshot of
each changed node and all its ancestors.

The snapshot computed is now kept alive instead. It stays cached, but it
is only recomputed when it is read. onSnapshot still adds the reaction.

onSnapshot now creates that reaction, so inside an action it first runs
when the action ends. It takes its baseline when the listener is added
and fires immediately, emitting only if the snapshot changed, so a
listener added in an action still gets the changes made after it.

A kept-alive computed never suspends, so it is re-run:
- when a snapshot processor replaces getSnapshot of an existing node
  (a detached node moved into a processed array);
- when the node dies, so it stops observing what it read. A dead node's
  computed returns the snapshot taken upon death, so this does not call
  the post-processor on the dead node.

This branch has not been deployed

No deployments
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.

1 participant