Skip to content

[HSF-476] fix: batch snapshot notifications to stop React #185 on write bursts - #52

Merged
andrewkrippner merged 5 commits into
masterfrom
hsf-476/coalesce-snapshot-notify
Oct 29, 2026
Merged

andrewkrippner merged 5 commits into
masterfrom
hsf-476/coalesce-snapshot-notify

Conversation

@andrewkrippner

@andrewkrippner andrewkrippner commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

  • After Auto Takeoff, onSpaceWrite writes to 100+ spaces one at a time. Firestore then delivers that many snapshots in one task, and each one called notify(). That gave one synchronous useSyncExternalStore commit per snapshot, and React 19 threw #185 (Maximum update depth) once 50+ commits ran back to back while an effect kept scheduling work. This is the HSF-476 stack: handleSnapshot → notify → forceStoreRerender.
  • New createSnapshotBatcher (src/core/snapshot-batcher.ts). notify() is split into publishState() (updates getState()/getHandle() at once) and notifySubscribers(). For snapshots, the first one in a task notifies synchronously, and the rest of that task's snapshots fold into one setTimeout(0) notify. One batcher is shared by every subscription on a store, so a burst spread across many document subscriptions (one snapshot each) folds the same way as a burst on one collection. stop() drops the subscription's pending notify.
  • Local writes (update/set/add/remove/sync) still notify synchronously, and a single snapshot behaves as before.

Risk: low. At most one task of delay before subscribers see the end of a burst. State reads always return the latest data.

How to verify

  • pnpm typecheck && pnpm test. The new src/__tests__/snapshot-batching.test.ts fails on master: a burst of 100 snapshots, sent sync or through a microtask chain, notifies twice instead of 100 times, on one subscription or spread across 100 document subscriptions.
  • I checked this against React 19.2 (jsdom, real createRoot, in hvakr). 60 rapid notifies plus a consumer useEffect(() => setState(value), [value]) throw #185 at notify 52. Routed through createSnapshotBatcher, there is no error and the final value renders. That was a scratch test and is not committed.
  • A second scratch check in this repo (React 19.2, real hooks): 80 updates on one collection hook and one update each on 80 document hooks both throw #185 on master. With the per-subscription batcher only the collection case passed; with the store-wide batcher both pass.
  • Follow-up in hvakr: release firestate and bump @hvakr/firestate in packages/app.

Link to Devin session: https://app.devin.ai/sessions/6640d72ea84e43d4aa4b46cf0c1f16bd
Open in Devin Desktop: https://app.devin.ai/desktop/session/6640d72ea84e43d4aa4b46cf0c1f16bd?variant=devin
Requested by: @andrewkrippner


&lt;source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3.svg"&gt;&lt;source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v3.svg"&gt;&lt;img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png" width="162" height="28"&gt; <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1795792068&installation_model_id=6841&pr_number=52&ref=codesmith_pr_footer&repository=flowcircuits%2Ffirestate&return_to=https%3A%2F%2Fgithub.com%2Fflowcircuits%2Ffirestate%2Fpull%2F52&signature=1a9993cf938b5931ec41570695985694c7d14c00ee8a0e40e06211ea2c41e217"&gt;&lt;picture&gt;&lt;source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"&gt;&lt;source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"&gt;&lt;img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png" width="190" height="28"&gt;
Need help on this PR? Tag @codesmith-bot with what you need.


&lt;source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"&gt;&lt;img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"&gt;

…te bursts

Co-Authored-By: Andrew Krippner <andrewkrip@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

Original prompt from Andrew

HSF task HSF-476: Fix React #185 render loop on the Spaces canvas after Auto Takeoff

Full task body:

Finding from the Bug routine (CAR-2840). Not fixed: root cause not proven, and no local repro was possible (emulators unavailable on the agent box).
#``# Evidence (PostHog $exception, last 45 days)

  • ~40 Minified React error #185 (Maximum update depth) events, ~15 distinct users, 2026-09-01 to 2026-10-09. Most are on /projects/&lt;id&gt;/building-model/spaces. A smaller set on /bod/space has a different stack (Table cell keydown, see closed #7338) and is out of scope here.
  • Every Spaces-page event has the same stack, all library frames, no app frame:
    Firestore onSnapshot observer next → ``@hvakr/firestate collection `handleSnapshot` → `notify` → subscribers `forEach` → React `useSyncExternalStore` `forceStoreRerender` → `getRootForUpdatedFiber` throws `#`185.
  • Strong correlation with Auto Takeoff: most events fire 10 s to 3 min after the same user's ai_space_takeoff_completed. One user also hit it while a takeoff was still in flight.
    #``# What the stack means
    React 19 throws #185 in getRootForUpdatedFiber when nestedUpdateCount &gt; 50. The count rises on each commit that leaves Sync/Default work pending and resets only on a clean commit. The throw site is a fresh Firestore snapshot task, so the 50+ commits ran before it: something on the Spaces canvas keeps scheduling work after each commit while the spaces collection changes (server batch writes of suggested spaces, firestate autosave/rebase, suggestion reveal effects).
    #``# Ruled out (static read)
  • SpaceSuggestor effects: each sets state only on a transition (0→N suggestions, reveal start/end).
  • useMeasuredBBox and ScopeLabelView: both bail out on an unchanged size.
  • SheetView processing shine: SMIL animation, no React state.
  • useSpacesToDraw: reports each invalid space once.
    #``# Suggested next step
    Reproduce in the emulator: open the Spaces canvas, write 100+ suggested: true spaces to `proj... (1209 chars truncated...)

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin resolved all 2 findings on 7a58a0a

Fixed by Devin (2)

  • One subscriber blocks unrelated notifications
  • Local updates trigger duplicate subscriber callbacks

View all findings in Devin Review

Devin Review

devin-ai-integration Bot and others added 4 commits October 28, 2026 15:10
Co-Authored-By: Andrew Krippner <andrewkrip@gmail.com>
The batcher was per subscription, so a burst that lands as one snapshot on
each of many document subscriptions still notified every one synchronously
and still threw React #185. One batcher per store now folds them into one
trailing notify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwc3zMrUoB5ykW8WFppENr
The shared trailing notify ran every deferred subscription in one loop, so
a throw from one subscriber (React #185 is thrown from inside the callback)
skipped the rest and left their consumers stale. Each one now runs, and the
first error is rethrown afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwc3zMrUoB5ykW8WFppENr
If the first snapshot's notify threw (React #185 is thrown from inside the
subscriber), the window never opened and every later snapshot in the burst
notified synchronously, the exact pattern the batcher exists to stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwc3zMrUoB5ykW8WFppENr
@andrewkrippner
andrewkrippner merged commit 8d14600 into master Oct 29, 2026
2 checks passed
@andrewkrippner
andrewkrippner deleted the hsf-476/coalesce-snapshot-notify branch October 29, 2026 05:39
andrewkrippner added a commit that referenced this pull request Oct 29, 2026
Bump @hvakr/firestate from 0.2.0 to 0.2.1 to release #52.

Co-authored-by: Cody (HVAKR bot) <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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