feat(studio): stage 7 step 3c — sdk cutover for inline-style ops#1452
feat(studio): stage 7 step 3c — sdk cutover for inline-style ops#1452vanceingalls wants to merge 44 commits into
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Verified at HEAD 2a3008e.
Verdict: concerns — this is the actual cut, and the architecture is sound: flag-gated, fall-through to server patch on shouldUseSdkCutover=false, try/catch with telemetry fallback on SDK throw, self-write suppression at the reload listener. But a few load-bearing things need scrutiny before flipping STUDIO_SDK_CUTOVER_ENABLED in prod. Calling these out individually below.
Blockers — none that stop merge with the flag off; the items below are flip-gate blockers.
Concerns
packages/studio/src/utils/sdkCutover.ts:69-80— GSAP-script preservation throughsdkSession.serialize()is not asserted by any test in this PR. The cutover dispatchessetStyle/setText/setAttributeto the SDK session, then writessdkSession.serialize()as the full document back to disk. For comps that have a<script>GSAP block (and HF#1448's<script data-position-mode="relative">sentinel), the round-trip through linkedom must preserve the script tag and itsdata-position-modeattribute byte-identical — otherwise the cutover silently strips it for inline-style commits on non-animated elements that share the comp. The sdkShadow.ts file comment explicitly names this as "blocker B: linkedom whole-doc serialize." That blocker hasn't been validated here. Recommend an integration test insdkCutover.test.tsthat opens a comp with the position-mode sentinel + a GSAP script block, dispatches asetStyleon a non-animated element, and asserts the serialized output still contains both. Without that test, this PR is a one-line linkedom regression away from breaking #1448.packages/studio/src/hooks/useDomEditCommits.ts:185-200— the cutover path runsawait onTrySdkPersist(...)BEFORE theoriginalContentread happens via fetch (which it does — read happens at line 184). But the SDK session was opened at session creation time from a potentially earlier snapshot of the file. If a user edits in code-tab → SDK session reloads → user edits via DOM-overlay before the reload completes,sdkSession.serialize()could write a stale base. The self-write suppression window (2000ms in #1449's updateduseSdkSession.ts:24) actively masks any external reload that would have refreshed the SDK base in that window. Worth verifying: what's the SDK session's "freshness" relative tooriginalContentat the momentsdkCutoverPersistruns? If they can diverge, the cutover silently writes a stale doc and the user loses an edit. A defensive guard: beforeserialize(), the cutover could re-openComposition(originalContent, ...)to guarantee freshness — at the cost of session-state loss. Or simpler: cross-checksdkSession.serialize()'s pre-dispatch state againstoriginalContentand fall back if they diverge.packages/studio/src/hooks/useSdkSession.ts:43-49(the 2000ms self-write suppression) — the magic number is fine, but its semantics conflate "I just self-wrote" with "no external write should fire a reload." If an external save (agent / git pull / code-tab edit) genuinely lands within 2000ms of a cutover write, the SDK session won't reload — meaning the SDK doc and the on-disk file diverge until the next cutover oractiveCompPathchange. Recommend tracking the last-self-write path (not just timestamp) so external writes to other paths still pass through, and external writes to the same path within the window are explicitly compared (e.g. by content hash) before suppression. Today it's "suppress all reloads for 2s after any save" — coarse.packages/sdk/src/engine/mutate.ts:586-588, 627-629, 655-657, 713-715— GSAP ops now throw when no GSAP script block exists, instead of returningEMPTY. That's a behaviour change for any SDK consumer outside this Graphite stack. The corresponding.can(op)shape also changed fromboolto{ok: bool}(examples inpackages/sdk/examples/*.tswere migrated). For external SDK users (anyone consuming@hyperframes/sdkoutside the monorepo), this is a breaking API change. Is there a SDK CHANGELOG entry? Are external consumers (if any) notified? Not strictly cutover-related, but it landed in this PR's diff, so this PR is the gate.
Questions
packages/studio/src/utils/sdkCutover.ts:60-72— theeditHistory.recordEditstores{ before: originalContent, after: sdkSession.serialize() }. Undo replaysbeforevia the server's normal file-write path (presumably). After undo, the server file isoriginalContentagain — but the SDK session still has the dispatched ops applied in-memory. Self-write suppression on the undo's file-change event will also suppress (within the 2s window), so the SDK session won't reload to undo state. Next cutover write then re-serializes from the SDK's "redone" state, silently undoing the undo. Have you walked through undo-immediately-after-cutover-write? If undo triggersreloadPreview()but not a re-open of the SDK session, the SDK doc is wrong from that point forward.packages/studio/src/hooks/useDomEditSession.ts:271-290—sdkSessionRef.current = sdkSession ?? nullis set on every render (nouseEffect), so a re-render in the middle of an in-flightsdkCutoverPersistcould swapsdkSessionRef.currentto a different session mid-dispatch. The dispatch loop is sync, but theawait deps.writeProjectFile(...)between dispatch and reload-preview is async. Probably fine because the closure captures the originalsdkSession, but worth a unit test that simulates a session swap mid-persist.packages/sdk/src/engine/mutate.ts:511-521—selectorMatchesIdnow matches bare leaf id for scoped ids (sub-composition support from #1434). This is a behaviour widening — a selector[data-hf-id="hf-leaf"]in a GSAP script now matcheshf-host/hf-leaftoo. Is there a collision risk if two scoped ids share a leaf name (e.g.hf-a/hf-leafandhf-b/hf-leaf)? The animation would target both, which may not be the author's intent.
Nits
packages/studio/src/hooks/useDomEditCommits.ts:54-59, 600-628etc. — large block of comment-section-header deletions (// ── Helpers ──,// ── Types ──, etc.). They were navigation aids in a 400+ line file. Mild loss of readability for no behaviour change. (nit, push back if intentional simplification.)packages/studio/src/utils/sdkShadow.ts:38, 100-107— thedata-prefix double-check fix is correct (matches the test added fordata-hf-studio-path-offset). Good fix-hoist to the tip PR.
What I didn't verify
- Linkedom's preservation of the HF#1448
<script data-position-mode="relative">sentinel through a fullparse → mutate (non-script element) → serializecycle. This is THE load-bearing question for the cutover's safety. - Whether the SDK session's in-memory doc and
originalContent(re-fetched per commit) can diverge in the window between session open and commit. - Behaviour of undo immediately after a cutover write — specifically, whether undo re-opens the SDK session or leaves it stale.
- Whether
/api/projects/.../patch-element/...(the non-cutover path) and the cutover'swriteProjectFile(...)emit the samehf:file-changeenvelope, so the reload-on-change handler treats them identically.
Review by Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
CI blocked by the root oxfmt failure in #1423. The cutover implementation is substantial — full audit below.
Strengths:
packages/studio/src/utils/sdkCutover.tssdkCutoverPersist— the fallback-to-server path (returnsfalse) on any thrown error is correct. TheonTrySdkPersistwrapper inuseDomEditCommitscatches and falls through to the server patch path, so no user-visible regression if SDK dispatch fails.packages/studio/src/hooks/useSdkSession.ts— race fix (compRef.current+ immediate dispose ifcancelled) correctly prevents the stale session from being used after the effect cleanup fires. ThesetSession(null)on effect entry clears the stale session before the async open completes.SELF_WRITE_SUPPRESS_MS = 2000withdomEditSaveTimestampRefsuppression inuseSdkSessioncorrectly avoids reload-on-own-write loops. The timestamp is set beforewriteProjectFile(insdkCutoverPersist) and the SSE event typically arrives well within 2 seconds.r.okcheck added in the fetch response path (noted as missing in #1443's review) — confirmed fixed here.
Blocker:
packages/studio/src/utils/sdkCutover.tssdkCutoverPersist— theforloop dispatches each op individually (sdkSession.dispatch(editOp)) without wrapping insession.batch(). If any dispatch throws after one or more earlier dispatches have already mutated the session document, the session is left in a partially-mutated state. The catch block returnsfalse(falling through to the server patch), which is correct for the persist path — but the SDK session's in-memory document now reflects a partial edit. The next file-change SSE event will reload the session and fix it, but in the window between the failed dispatch and the reload, any shadow-mode or debug reads of the session will see corrupted data. Fix: wrap the dispatch loop insdkSession.batch(() => { for (...) sdkSession.dispatch(editOp); })so that a throw inside the batch triggers the existing batch rollback (confirmed in #1426 batch-rollback tests).
Important:
-
packages/studio/src/utils/sdkCutover.tssdkCutoverPersist—sdkSession.serialize()is called immediately after the dispatch loop. Ifserialize()itself throws (rare but possible if the linkedom document is in an inconsistent state), the error is caught andfalseis returned — the DOM attribute update happened,writeProjectFileis skipped, and the in-memory session is stale. With thebatch()fix above, serialize would run inside the try only after all dispatches succeeded. -
packages/studio/src/hooks/useSdkSession.tsSELF_WRITE_SUPPRESS_MS = 2000— this magic constant is racy in slow-network or CI environments where SSE latency could exceed 2s. Consider making it configurable (env var or a passed option) for testing. Not a production blocker given typical latency.
Nit:
packages/studio/src/utils/sdkCutover.test.ts— the "returns false and does not throw on dispatch error" test uses a single-op payload. A test with a multi-op payload where the second dispatch throws (confirming partial-mutation scenario) would document the current behavior and make the batch-fix PR easier to write.
Verdict: REQUEST CHANGES
Reasoning: The unbatched dispatch loop leaves the SDK session partially mutated on mid-loop dispatch failure. Wrap in session.batch() before merging to prevent session corruption in the error path.
— magi
…rops onStart, onUpdate, onRepeat were missing from the assertion; only onComplete was checked. All four are listed in DROPPED_VAR_KEYS so all four belong here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- gsapParserAcorn: top-level variable targets now resolved via program-scope
null-key fallback in lookupBindingFromAncestors (const el = querySelector...)
- gsapParserAcorn: fromTo guard requires args.length >= 3, preventing undefined
args[2]/args[3] access when fewer args supplied
- gsapWriterAcorn: remove fuzzing fallback in removeAnimationFromScript that
silently deleted the wrong animation (from→to ID conversion)
- gsapWriterAcorn: valueToCode guards NaN → "0" to avoid broken tween props;
safeKey regex aligned to ASCII-only (matching gsapSerialize)
- mutate: handleSetGsapTween now includes stagger in extras (was in addGsapTween
but missing from setGsapTween)
- apply-patches: script case now mirrors stylesheet — op=remove calls
setGsapScript("") instead of silently ignoring the patch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4c3c04b to
1e0035b
Compare
|
Thanks @james-russo-rames-d-jusso and @miguel-heygen. Fixed:
Design / acknowledged:
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
- setGsapScript: remove element when newScript="" (fixes undo/redo duplicate-script bug)
- parseDeclarations: track quotes so ; inside CSS values (data URIs) doesn't split
- handleRemoveGsapKeyframe: guard against duplicate-percentage ambiguity (return EMPTY)
- resolveKeyframe: return kfs so callers can check uniqueness
- handleSetClassStyle: emit op:"add" (not "replace") when no prior <style> element
- FsAdapter listVersions: Number(f.split("_")[0]) — was NaN due to underscore in key
- FsAdapter doWrite: split try/catch so appendVersion failure doesn't fire error handlers
- FileAdapter playground: add content:"" field to satisfy PersistVersionEntry contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… result.code Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
…rride-set cleanup
… scoped ids Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Closes the last headless-testable Stage 6 gap (F9 workstream C).
`find({ composition: "hf-host" })` returns all scopedIds whose prefix
matches the given host id — i.e. every element mounted inside that
sub-composition, at any depth. Combinable with other FindQuery fields
(tag, text, name, track). 3 new contract tests in session.subcomp.test.ts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Browser-fetch-based PersistAdapter for Studio REST file API. Uses fetch-only (no node:fs) so it is safe to bundle in Vite. - packages/sdk/src/adapters/http.ts — HttpAdapter class + createHttpAdapter factory - packages/sdk/src/adapters/http.test.ts — 14 unit tests (fetch mocked via vi.stubGlobal) - packages/sdk/src/index.ts — re-export createHttpAdapter + HttpAdapterOptions - packages/sdk/package.json — ./adapters/http subpath export (dev + publishConfig) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add per-path promise queue so rapid successive writes to the same composition file cannot race at the server. Different paths still write concurrently. Two new tests (RED→GREEN verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lush-drains-two test - Add optional headers?: HeadersInit | (() => HeadersInit) to HttpAdapterOptions for cross-origin / CLI / auth injection (function form refreshes on each PUT) - Document listVersions/loadFrom as intentional no-ops (server versioning not exposed) - Add flush-drains-two-concurrent-writes test to mirror fs adapter T13 coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Adds setSelection(ids: string[]) to Composition interface and CompositionImpl. Fires selectionchange; does not touch undo stack or patch stream. 11 contract tests: get/set/clear, event firing, copy semantics, no undo/patch side-effects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Skip event dispatch when ids are identical (same length, same order) to prevent double-firing selectionchange from callers that call setSelection with the same list. Two new tests (RED→GREEN verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Reviewer noted it is dead surface in this stack — no caller uses it. Add comment explaining it is wired up in stage 8 when the preview host pushes selection events up to the SDK session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Creates useSdkSession hook: fetches active composition HTML, opens an SDK Composition backed by createHttpAdapter, disposes on comp/project change. Session is idle (no dispatch routed yet) — Step 3 wires edit ops through it. Also removes createFsAdapter from SDK main entry (Node-only; subpath-only: @hyperframes/sdk/adapters/fs). Required for Studio typecheck to pass when importing @hyperframes/sdk — fs.ts uses node:fs/promises which Studio's tsconfig does not include. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
useSdkSelectionSync: effect that calls session.setSelection(hfIds) whenever domEditSelection or domEditGroupSelections changes. Maps each entry's hfId; skips entries without one. Pure additive — no existing hook modified. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Build the HttpAdapter first, then call adapter.read(activeCompPath) instead of duplicating URL construction with a raw fetch. Eliminates the /files/encode duplication already in HttpAdapter.read(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Reviewer found a race: if the effect cleanup runs while openComposition is awaited, comp is null so cleanup is a no-op, but the composition is then set and never disposed. Add an explicit check after the await so any composition opened after cancellation is disposed immediately. Also wire the missing useSdkSession call in App.tsx (sdkSession was referenced but never declared — pre-existing typecheck failure), move the stableRenderQueue memo into useRenderQueue so App.tsx stays under the 600-line architecture gate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
…on-change Stage 7 Step 3a — SDK plumbing for routing Studio commits through the SDK session. No behavior change: the session stays idle (no op routed yet). SDK: - Add OpenCompositionOptions.persistPath; thread to createPersistQueue so the persist queue writes back to the composition's real path instead of the "composition.html" default (blocker A). Studio (useSdkSession): - Pass persistPath = activeCompPath so a future dispatch persists the right file. - Re-open the session when the active composition file changes on disk (HMR hf:file-change / SSE file-change), scoped to activeCompPath, so the in-memory linkedom document never goes stale under code-editor/agent/server edits (blocker C). Re-opening is additive while the session is idle; 3c must add self-write suppression once dispatch writes. Tests: SDK persistPath default + override; shouldReloadSdkSession path-match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Wire onDomEditPersisted callback from useDomEditCommits into useDomEditSession, calling reportShadowDispatch (flag-gated via VITE_STUDIO_SDK_SHADOW_ENABLED) to dispatch equivalent SDK ops alongside the server patch path and emit sdk_shadow_dispatch telemetry with mismatch details. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ismatch Wrap the dispatch loop in try/catch so a throwing SDK dispatch never propagates to Studio UX. Returns dispatched:false with kind="dispatch_error" and the error message for telemetry. One new TDD test (RED→GREEN verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…chOperation import Wrap the shadow dispatch loop in session.batch() so a mid-loop throw cannot leave the SDK session in a partially-applied state. Without the batch boundary, one failing op would update some elements but not others, diverging the shadow session from the real one. Rename reportShadowDispatch → runShadowDispatch to eliminate the misleading 'report' prefix — the function mutates the SDK session, it is not read-only. Update the only caller (useDomEditSession). Add missing PatchOperation import to useDomEditCommits (the type was already used in the onDomEditPersisted interface but never imported). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
- http adapter: throw on 5xx instead of silently returning undefined - fs adapter: serialize appendVersion to prevent concurrent pruning race - mutate: GSAP handlers throw (not silent EMPTY) when no GSAP script block - mutate: selectorMatchesId supports scoped hf-HOST/hf-LEAF ids - sdkShadow: fix double data- prefix on attribute patch ops - sdkShadow: call onDomEditPersisted on SDK cutover success path - useSdkSession: fix compRef closure race on dispose; suppress self-write echo - App.tsx: pass domEditSaveTimestampRef to useSdkSession for echo suppression - examples: fix dead !comp.can(op) guard (needs .ok); remove stale id field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… review Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…omicity On multi-op payloads, a mid-loop dispatch failure left the SDK session partially mutated. batch() rolls back all ops on throw. Adds: batch-is-called test, multi-op-throw test, GSAP script preservation integration test (linkedom round-trip verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing TS errors Reviewer noted the 2 s suppress window is a footgun. Add a comment explaining the trade-off (short = echo fires anyway; long = masks real edits) and naming the long-term fix (sequence number / content hash on the persist event). Also fix three pre-existing issues in useDomEditCommits.ts on this branch: - PatchOperation was used in UseDomEditCommitsParams but not imported - onTrySdkPersist was called in persistDomEditOperations but missing from both the interface and the function's destructure pattern - onTrySdkPersist missing from useCallback deps (react-hooks/exhaustive-deps) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Cherry-pick of af1d91b (dispose race fix) added a second useSdkSession call at the original insertion point from s7step1. s7step3c already had the correct call (with domEditSaveTimestampRef for self-write suppression) at line 156. Remove the duplicate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
22b1761 to
fb249f6
Compare
6e0c3ae to
c2d6a51
Compare
* docs(sdk-playground): accurate README with stage coverage and CanResult * feat(sdk-playground): showcase stage 4 — canUndo/canRedo, removeElement cascade - header undo/redo buttons disabled when canUndo()/canRedo() returns false - History/inspect op section shows live canUndo/canRedo badges (update on every patch) - removeElement logs override-set after removal to demonstrate cascade + orphan cleanup - README: stage 4 row updated to full coverage; Danger section + Ops table annotated - mutate.ts: add fallow-ignore-file code-duplication (structural handler boilerplate) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sdk-playground): stage 5+6 — adapter exports, scoped ids, find(composition) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sdk-playground): stage 7 — HTTP adapter + setSelection, update README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-review of fix commits 7c2d9d5, f047361, c2d6a51 (plus 3f742d9, e33dd14, 47db28f which shipped alongside or just after) against my prior REQUEST CHANGES.
✅ Fixed — blocker resolved
sdkCutoverPersist dispatch loop wrapped in session.batch() (7c2d9d5 — sdkCutover.ts:67-71):
sdkSession.batch(() => {
for (const editOp of patchOpsToSdkEditOps(hfId, ops)) {
sdkSession.dispatch(editOp);
}
});Exactly what was asked for. The catch block around the whole thing means a mid-loop throw exits before serialize() and writeProjectFile() are called, so no partial-mutation content reaches disk.
Two new tests pin this:
"wraps all dispatches in session.batch() for atomic rollback"— assertsbatchcalled exactly once"returns false when second dispatch throws (batch prevents partial mutation)"— confirmswriteProjectFileandreloadPrevieware NOT called when a mid-loop dispatch throws
Integration test with a real openComposition + createMemoryAdapter also verifies GSAP script block survives a setStyle dispatch — strong signal the serialize path is correct end-to-end.
✅ Fixed — other concerns
SELF_WRITE_SUPPRESS_MS heuristic documented (f047361 — useSdkSession.ts:31-37): the footgun (too-short suppresses legitimate external edits, too-long doesn't; long-term shape is a sequence number/hash) is now in a block comment. Transparent about the tradeoff.
onTrySdkPersist type documented (f047361 — useDomEditCommits.ts:81-86): JSDoc clarifies "called before the server-side patch path; returns true if SDK handled it" — previously undocumented in the interface.
Duplicate useSdkSession call removed (c2d6a51 — App.tsx:-1): one-line cleanup, correct.
CI
Preflight fails on packages/core/src/parsers/gsapSerialize.ts — same pre-existing base-branch format issue that's been tracked across the stack. main passes Preflight cleanly; this is not introduced by #1452. preview-regression is the stacked-PR gate (PREVIEW_PARITY_RESULT: skipped), not a test failure. All code tests, typecheck, lint, SDK unit/contract/smoke pass.
Verdict: APPROVE
Reasoning: The one blocker (unbatched dispatch loop) is resolved with correct implementation and tests. Pre-existing Preflight failure is the base-branch gsapSerialize.ts formatting issue, unrelated to this PR's code.
— magi
2446080 to
04fd6d7
Compare

What
Stage 7 step 3c: SDK cutover — route DOM-edit persists through the SDK session instead of the server-side patch API when
STUDIO_SDK_CUTOVER_ENABLEDis set.Why
The SDK Composition is the single source of truth for element state. Routing persists through it (dispatch → linkedom mutate → serialize → write) instead of patching HTML text closes the gap between the in-memory SDK doc and the on-disk file — a prerequisite for SDK-driven undo/redo and collaborative sync.
How
sdkCutoverPersist(session, selection, ops)insdkCutover.ts:session.batch()(prevents partial mutation on mid-loop throw)session.serialize()to disk viawriteProjectFilereturn false) on any SDK erroronTrySdkPersisthook wired intouseDomEditCommits.persistDomEditOperations: if the cutover returnstrue, skip the server-patch pathdomEditSaveTimestampRefset beforewriteProjectFile;useSdkSession's reload listener ignoreshf:file-changeevents within 2000ms of a self-write to prevent echo-reloadsSTUDIO_SDK_CUTOVER_ENABLEDflag (default off)Test plan
sdkCutover.test.ts:<script data-hf-gsap data-position-mode="relative">+<script data-hf-gsap>survive asetStyleround-trip throughopenComposition → dispatch → serializeuseDomEditCommits(existing tests coveronTrySdkPersistwiring)STUDIO_SDK_CUTOVER_ENABLED=true, confirmed the file was written and preview reloaded correctly