fix(studio): force-reload sdk session after undo/redo bypasses suppress window#1524
Conversation
c40d342 to
eb2371c
Compare
f5b3ddd to
c75a4da
Compare
eb2371c to
e477872
Compare
c75a4da to
5239366
Compare
e477872 to
7a74f44
Compare
5239366 to
e39ef9a
Compare
7a74f44 to
7f188bd
Compare
e39ef9a to
430f138
Compare
7f188bd to
f079020
Compare
430f138 to
0d89bcb
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Review: fix(studio): force-reload sdk session after undo/redo bypasses suppress window
Alright, I have walked through every line of this diff and I have thoughts. Good thoughts, mostly!
The Core Problem and Solution
The problem is beautifully articulated in the PR description — writeHistoryFile stamps domEditSaveTimestampRef.current = Date.now(), which activates the 2s suppress window. From the SDK session's perspective, the Studio undo write is indistinguishable from an SDK self-write, so it gets swallowed. The fix introduces a reloadToken state counter and a forceReload() callback that bumps it, which re-triggers the "Open / re-open the session" useEffect directly — no file-change event needed, no suppress window consulted. Clean bypass.
What I Verified
-
Write-before-reload ordering is correct.
applyHistoryawaitseditHistory[direction]({writeFile: writeHistoryFile}), which means the file is on disk beforeforceReloadSdkSession()fires. TheforceReload()call is synchronous (setReloadToken(t+1)), so by the time React flushes and the effect runsadapter.read(activeCompPath), the reverted content is already there. No race. -
Double-reload scenario is harmless. If the file-change event somehow escapes the suppress window AND
forceReloadalso fires, you get twosetReloadToken(t+1)calls. React batches them, but even if both effects fire, the secondopenCompositioncall just re-reads the same content. Idempotent. No bug. -
The guard is precise.
if (activeCompPath && result.paths?.includes(activeCompPath))— only fires when undo/redo actually wrote back the active composition. Multi-file undo that doesn't touch the active comp correctly skips the reload. Good. -
useCallbackdependency arrays are complete.activeCompPathandforceReloadSdkSessionare both in theapplyHistorydeps array.forceReloaditself has an empty deps array (correct —setReloadTokenis a stable dispatch). No stale closures. -
Type contract is clean.
SdkSessionHandleis a focused interface.HistoryResult.pathsisstring[] | undefined, and the optional chaining inresult.paths?.includes(activeCompPath)handles the undefined case.activeCompPathisstring | null | undefined(optional param inuseAppHotkeys) but the truthiness guard covers both nullish variants. -
Downstream call sites in
App.tsxare updated consistently. All three consumers of the oldsdkSessionvalue (useTimeline,useSdkSelectionSync, anduseDomEditSession) now receivesdkHandle.session. No missed references.
One Observation (Not Blocking)
The test file addition is documentation-as-comments rather than an actual test for forceReload. The PR body explains why (React hook internals need a render environment for useState/useEffect). Fair enough — but if this stack grows a renderHook test harness later, a test that verifies "bumping reloadToken causes openComposition to re-fire" would close the loop nicely. Not asking for it now, just flagging the gap.
Verdict
This is a tight, well-scoped fix. The mechanism is sound, the types are clean, the dependency arrays are correct, and the guard logic is precise. No bugs, no unnecessary complexity, no performance concerns (one extra state counter and one stable useCallback — negligible).
LGTM — pinging @magi for the stamp. <@U0B1J4SL8H3>
— Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
On the matter of #1524 — force-reload sdk session after undo/redo bypasses suppress window.
Layered onto Miga's review (4518581005) at 0d89bcb8ede4958d760418259eced6d16738a60d. Independent pass. Miga's correctness verifications are tight — write-before-reload ordering, idempotent double-reload, guard precision, dep array completeness, downstream call-site coverage all check out. One race I'm flagging that the bounded "force-reload only" framing of this PR makes invisible.
The cleanup-flush race against the undo write
useSdkSession.ts:122 (effect cleanup):
return () => {
cancelled = true;
const c = compRef.current;
if (c) void c.flush().finally(() => c.dispose());
};forceReload works by bumping reloadToken, which re-runs the open-session effect — and the effect's cleanup fires for the OLD session before the new one is opened. That cleanup awaits c.flush(), which drains the SDK's persist queue (the one #1522 wired up via persist: adapter).
Sequence on Cmd-Z when the SDK persist queue has anything queued:
- User edits an inline-style →
sdkCutoverPersistruns →sdkSession.batch(() => dispatch(...))fires the change event → SDK persist queuescheduleWrite()(setTimeout(0)). - Before that
setTimeoutfires,sdkCutoverPersistcontinues — callsserialize(),writeProjectFile,editHistory.recordEdit. - The Studio write lands on disk. Studio history records it. User immediately hits Cmd-Z.
writeHistoryFilewrites the BEFORE content (reverted) to disk.forceReloadSdkSession()bumpsreloadToken.- Open-session effect cleanup runs:
oldSession.flush()— drains the queuedsetTimeout(0)write, which callsadapter.write(activeCompPath, serialize())whereserialize()still returns the POST-edit (pre-undo) content from the old session's in-memory doc. - The flush write overwrites the undo write on disk.
- New session re-reads from disk → loads the post-edit content. The undo is silently lost.
This is the trust-corrosive 🛑 archetype: a user-visible Cmd-Z that silently no-ops because of a race with the SDK persist queue. The setTimeout(0) window is small but not zero, and any subsequent dispatch (including the cutover dispatch itself) adds to the queue.
The race vanishes if #1522 stops running the SDK persist queue (see my #1522 review — option 1). If the persist queue stays, forceReload needs to either:
- Cancel/discard pending persist queue writes before flush (i.e. don't flush on this code path — go straight to dispose). The
flush().finally(dispose)shape is right for normal lifecycle (composition switch, unmount). On force-reload-after-undo, "flush my stale in-memory state to disk" is exactly the wrong thing. - OR, accept the flush but verify the disk content matches the in-memory pre-flush state and skip the write if not (more complex; needs SDK affordance).
Cleanest fix: branch the cleanup path so force-reload-driven re-runs skip flush. Something like a discardOnReload flag set inside forceReload and checked by cleanup.
Persist-during-keystroke race interaction with #1463
onTrySdkPersist (from #1463) is async and awaited inside useDomEditCommits. If the user hits Cmd-Z while onTrySdkPersist is mid-await (between dispatch and await writeProjectFile), forceReload disposes the session that the in-flight closure is operating on. The closure's serialize() already ran (before the await) so the file write still uses the pre-undo content — but the new session loads from disk after the undo write, then the in-flight Studio write may stomp the undo. Same data-loss family as the cleanup-flush race, different entry point.
In-flight cutover writes during undo are racy at multiple layers. The cleanup-flush case is the most concrete; the in-flight onTrySdkPersist case depends on event ordering but is the same shape.
Confirming Miga's checks
- Write-before-reload ordering — agree, correct.
- Double-reload idempotency — agree, harmless.
- Guard precision (
result.paths?.includes(activeCompPath)) — agree, precise. - Dep arrays — agree, complete.
SdkSessionHandletype contract — agree, clean.- App.tsx call-site updates consistent — agree, no missed references.
Verdict
request-changes at 0d89bcb8ede4958d760418259eced6d16738a60d on the cleanup-flush-undo race grounds. The fix needs to interlock with #1522's persist-queue decision — if #1522 keeps the SDK persist queue, this PR needs a discard-on-force-reload path; if #1522 drops the SDK persist queue (the cleaner option), this race goes away and the PR is clean.
The reload-token mechanism itself is the right shape — that's not what needs changing.
Part of the GROUP A review batch. See also #1522 (persist contract), #1462 (clean), #1463 (persist wiring).
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
On the matter of #1524 — force-reload SDK session after undo/redo bypasses the suppress window.
Re-read at HEAD 0d89bcb8, layering atop Miga's review (4518581005) and Rames Jusso's review (4518613991). The prior Via review against an earlier head is stale post-rebase and is hereby superseded.
The reload-token mechanism itself is sound
Miga's six verifications all hold at this HEAD:
- Write-before-reload ordering.
applyHistoryatuseAppHotkeys.ts:344-360awaitseditHistory[direction]({ writeFile: writeHistoryFile })beforeforceReloadSdkSession?.()fires. By the time the open-session effect re-runsadapter.read(activeCompPath)the reverted content is on disk. Correct. - Double-reload idempotency. Two
setReloadToken((t) => t + 1)calls in the same React tick collapse to a single re-render; even an escaped double-effect would just re-adapter.readthe same content. Idempotent. - Guard precision.
if (activeCompPath && result.paths?.includes(activeCompPath))atuseAppHotkeys.ts:363— a multi-file undo that does not touch the active comp correctly skips the reload. Tight. - Dep arrays complete.
forceReloadSdkSessionandactiveCompPathare both inapplyHistory's dep array atuseAppHotkeys.ts:379-381.forceReloaditself usesuseCallback(..., [])and references only the stablesetReloadToken. No stale closures. SdkSessionHandleshape is focused.{ session, forceReload }cleanly replaces the oldComposition | nullreturn; the three downstream consumers (useTimeline,useSdkSelectionSync,useDomEditSession) now uniformly destructuresdkHandle.session. No missed references.- Optional-chain on the no-session path.
forceReloadSdkSession?.()atuseAppHotkeys.ts:367correctly no-ops when no SDK session is active. The brief's concern that this might fire on every undo without a session is unfounded.
The cleanup-flush race against the undo write — Rames's load-bearing finding
Rames flagged this at 0d89bcb8 and I cannot dismiss it. useSdkSession.ts:107-111:
return () => {
cancelled = true;
const c = compRef.current;
if (c) void c.flush().finally(() => c.dispose());
};forceReload works by bumping reloadToken, which re-runs the open-session effect — and the effect's cleanup fires for the OLD session before the new one is opened. That cleanup awaits c.flush(), which drains the SDK persist queue established at #1522.
Sequence on Cmd-Z while the SDK persist queue carries even a single setTimeout(0) write:
- User edits an inline-style →
sdkCutoverPersistruns →sdkSession.batch(() => dispatch(...))fires the change event → SDK persist queuescheduleWrite(). - Before that
setTimeout(0)fires,sdkCutoverPersistcontinues —serialize(),writeProjectFile,editHistory.recordEdit. - The Studio write lands on disk. History records it.
- User immediately hits Cmd-Z.
writeHistoryFilewrites the BEFORE (reverted) content to disk.forceReloadSdkSession()bumpsreloadToken.- Cleanup runs
oldSession.flush()— drains the queuedsetTimeout(0)write, which callsadapter.write(activeCompPath, serialize())whereserialize()still returns the POST-edit (pre-undo) content from the old session's in-memory doc. - The flush write overwrites the undo write on disk.
- New session re-reads from disk → loads the post-edit content. The undo is silently lost.
This is the trust-corrosive archetype: a user-visible Cmd-Z that silently no-ops. The setTimeout(0) window is small but not zero, and any in-flight onTrySdkPersist (per #1463) extends the window across the await on writeProjectFile. Band-aid pattern #2 (contradictory rules in the same component) — forceReload's contract is "discard pending SDK state and re-read from disk," but the cleanup it triggers does the opposite ("flush pending SDK state to disk first").
Two coherent shapes for the fix, parallel to Rames's resolution paths:
- If #1522 retires
persist: adapterand the SDK session stops auto-persisting, the race vanishes by construction — there is nothing to flush, cleanup degenerates todispose()alone, and this PR is clean as-is. This is the cleaner architectural cut. - If the SDK persist queue stays, this PR needs a
discardOnReloadshape — aforceReload-set flag the cleanup branches on so it skipsflush()and goes straight todispose(). Theflush().finally(dispose)ordering is correct for normal lifecycle (composition switch, unmount); on force-reload-after-undo, "flush stale in-memory state to disk" is exactly the wrong thing.
The interlock with #1522 makes the right resolution dependent on which way that PR's persist-contract decision falls. Neither PR is independently fixable; they must be resolved together. Documenting this same-train coupling in both PR bodies would save the next reviewer a half-hour.
Verdict
request-changes at 0d89bcb8, on the cleanup-flush-undo race grounds. The reload-token mechanism itself is the right shape and survives intact; what changes is either #1522's persist-queue ownership or this PR's cleanup branch. Re-verify if HEAD moves before stamp.
Part of the Group A batch on the 18-PR SDK-cutover stack. See also #1522 (persist contract — the load-bearing co-requisite), #1462 (clean teardown), #1463 (persist wiring), #1465/#1466 (delete + timing paths inheriting the same race).
Review by Via
|
The altitude finding here ( |
miguel-heygen
left a comment
There was a problem hiding this comment.
Approved as part of SDK cutover stack. Reviewed by Miga, Rames D Jusso, and Via across R1-R4. LGTM.
The base branch was changed.
jrusso1020
left a comment
There was a problem hiding this comment.
Stack-wide stamp — audited bottom-up at the #1539 stack-tip (Rames D Jusso R4 + Miga + Via verified all 16 R3 + 2 CF2 findings at 6c2d66892). SDK-cutover chain cleared end-to-end. Note: branch shows dirty — author may need a rebase before merge.
…ss window writeHistoryFile arms the 2 s self-write suppress window, so the file-change event for an undo/redo write is swallowed and the SDK in-memory doc stays on pre-undo content. Expose forceReload() from useSdkSession (s7.4) and call it in useAppHotkeys after a successful undo/redo that touched the active composition path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
0d89bcb to
fc897cc
Compare
Summary
Stage 7 Step 4: adds
SdkSessionHandletouseSdkSessionso callers can force an immediate session reload, and wires it throughApp.tsxso undo/redo can re-open the SDK session after reverting the active composition file.Problem solved: After undo/redo, Studio writes the reverted composition back to disk. The SDK session watches for file-change events but suppresses reloads for 2 s after its own writes (to avoid echo-reload races). A Studio-initiated undo write looks identical to an SDK write — so the session stayed stale for up to 2 s, causing the next SDK op to read from the pre-undo snapshot.
packages/studio/src/hooks/useSdkSession.ts: changes return type fromComposition | nulltoSdkSessionHandle { session, forceReload }; adds areloadTokenstate and aforceReloadcallback that bumps it, bypassing the suppress window; addsSdkSessionHandleexportpackages/studio/src/App.tsx: destructures{ session, forceReload }fromuseSdkSession; passesforceReloadintouseAppHotkeysasforceReloadSdkSessionso Cmd-Z/Cmd-Shift-Z trigger an immediate SDK reload after the undo write completesSupersedes #1460.
Test plan
bun test packages/studio— passes🤖 Generated with Claude Code