Skip to content

fix(studio): force-reload sdk session after undo/redo bypasses suppress window#1460

Open
vanceingalls wants to merge 45 commits into
mainfrom
sdk-stage7-studio-s7step4
Open

fix(studio): force-reload sdk session after undo/redo bypasses suppress window#1460
vanceingalls wants to merge 45 commits into
mainfrom
sdk-stage7-studio-s7step4

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes a stale-session bug after undo/redo: after a successful undo, the SDK in-memory document was left on the pre-undo (post-edit) content instead of the reverted content.

Why

writeHistoryFile (called by undo/redo to write the before-content to disk) sets domEditSaveTimestampRef.current = Date.now(), which arms the 2-second self-write suppress window in useSdkSession. That suppress window exists to prevent echo-reloads after SDK cutover writes, but it also swallows the file-change event triggered by the undo write — so the SDK session never reloaded to reflect the reverted file.

How

  • useSdkSession now returns SdkSessionHandle { session, forceReload } instead of Composition | null
  • forceReload() bumps reloadToken directly, bypassing the suppress window entirely
  • useAppHotkeys.applyHistory calls forceReloadSdkSession() after a successful undo/redo that includes the active composition path in result.paths
  • App.tsx passes activeCompPath and forceReloadSdkSession into useAppHotkeys

Test plan

  • useSdkSession.test.ts: shouldReloadSdkSession pure function tests (path match, no active comp, no path in payload)
  • Integration covered by usePersistentEditHistory.test.ts — verifies undo writes correct before-content to disk; the force-reload path is React hook internals not unit-testable without a render environment

vanceingalls and others added 30 commits June 14, 2026 21:55
…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>
Documents the recast-baseline trust relationship and clarifies that
motionPath parity tests live in the Phase 3b commit (PR #1379) since
the acorn motionPath parser is also added there.

Addresses #1370 R1-N1 (Rames).

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>
- 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>
… scoped ids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Expose the concrete adapter factories so consumers no longer reach into deep
adapter paths:
- createHeadlessAdapter — no-op PreviewAdapter for agents/CI/SSR (no browser)
- createMemoryAdapter — in-memory PersistAdapter for tests/headless open
- createFsAdapter (+ FsAdapterOptions) — node fs PersistAdapter for local dev

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds fully-qualified scoped ids for addressing elements inside inlined
sub-compositions, so callers can target "hf-HOST/hf-LEAF" unambiguously
even when bare hf-ids collide across sub-composition boundaries.

Changes:
- model.ts: resolveScoped() traverses id segments through nested subtrees;
  isNewHostBoundary() detects host boundaries (dcf ≠ parent dcf handles
  outerHTML innerRoot edge case)
- types.ts: HyperFramesElement gains scopedId field
- document.ts: buildElement carries scopePrefix, propagates childPrefix
  at host boundaries; buildRoots starts with ""
- patches.ts: RFC 6902 escapeIdForPath / decodePathSegment for scoped ids
  containing "/"; all path builders and pathToKey/keyToPath updated
- session.ts: getElement() matches by scopedId; find() returns scopedIds;
  orphan cleanup decodes RFC 6902 before key comparison, preserves removal
  markers, purges property sub-keys for both bare and scoped ids
- mutate.ts: all element handlers use resolveScoped instead of findById;
  handleRemoveElement collects full subtree hf-ids before removal for
  complete GSAP animation cascade (Q3 fix); validateOp uses resolveScoped

20 new contract tests in session.subcomp.test.ts covering resolveScoped,
scopedId propagation, dispatch to scoped targets, RFC 6902 patch encoding,
override-set key format, orphan purge, and serialize stability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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>
vanceingalls and others added 13 commits June 15, 2026 00:23
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>
* 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>
…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>
@vanceingalls vanceingalls changed the title feat(core): acorn GSAP read path with T6b differential corpus tests fix(studio): force-reload sdk session after undo/redo bypasses suppress window Jun 15, 2026
@vanceingalls vanceingalls changed the base branch from main to sdk-stage7-studio-s7step3c June 15, 2026 17:10
@vanceingalls vanceingalls changed the base branch from sdk-stage7-studio-s7step3c to graphite-base/1460 June 15, 2026 17:58
@vanceingalls vanceingalls changed the base branch from graphite-base/1460 to main June 15, 2026 17:58
@vanceingalls vanceingalls changed the base branch from main to sdk-stage7-studio-s7step3c June 15, 2026 18:03
@vanceingalls vanceingalls changed the base branch from sdk-stage7-studio-s7step3c to graphite-base/1460 June 15, 2026 18:48
@vanceingalls vanceingalls changed the base branch from graphite-base/1460 to main June 15, 2026 18:48
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