fix(studio): resync the shared SDK session after a Design-panel variable promote

Reported as "template variables are broken": binding an element's field to a
variable via the flat inspector's "◇ var" promote chip (or editing an
already-bound field's value) wrote the correct bytes to disk, but the
Variables tab kept showing the pre-edit value until the whole Studio page
was hard-reloaded.

Root cause: DesignPanelPromoteProvider deliberately opens its OWN SDK
session (`useSdkSession(projectId, selection.sourceFile ?? activeCompPath)`)
so that promoting inside a sub-composition binds the variable in the
sub-comp's own file, not the host's. For the common case — a top-level
element, same file as `activeCompPath` — this session is a SEPARATE
in-memory `Composition` instance from the shared one `VariablesPanel`
(Variables tab, Slideshow, etc.) reads. A persist through the promote
provider's session never fires the shared session's own "change" event.

Worse, the shared session's file-change listener runs
`isSelfWriteEcho(path, content)` to decide whether to reload — but
`sdkSelfWriteRegistry` is keyed by file path only, not by session instance
(its own doc comment assumes "the studio process has a single SDK session
lifecycle at a time"). It sees the promote provider's write registered
under the same path and concludes it's its own echo, permanently
suppressing the reload it actually needs.

Threaded `forceReloadSdkSession` (the same mechanism every other
server-side-write path in Studio already uses for exactly this "resync
after a write I didn't make myself" case) from App.tsx through
StudioRightPanel into DesignPanelPromoteProvider, and call it after every
successful promote/setDefault persist — unconditionally, not gated on the
promote target matching activeCompPath, since re-opening a file that
didn't change is a harmless no-op re-parse and a path-equality guard here
already produced one subtly wrong comparison (activeCompPath can be null
while the shared session still defaults to "index.html") before landing on
this simpler version. Verified live: editing a variable-bound field's
value now updates the Variables tab immediately, no reload required.

App.tsx crossed the 600-line file-size gate after threading the new prop;
extracted the tiny handleAddAssetAtPlayhead wrapper into its own
useAddAssetAtPlayhead hook (with a regression test) to bring it back under.

Full studio suite (2639 tests) green against a fresh main; typecheck/
oxlint/oxfmt clean.
This commit is contained in:
Vance Ingalls
2026-07-16 00:18:36 -07:00
parent 21cb722ebd
commit 578d6202b4
5 changed files with 110 additions and 9 deletions
+3 -8
View File
@@ -23,6 +23,7 @@ import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAddAssetAtPlayhead } from "./hooks/useAddAssetAtPlayhead";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
@@ -195,14 +196,7 @@ export function StudioApp() {
},
[timelineEditing.handleTimelineGroupMove],
);
const handleAddAssetAtPlayhead = useCallback(
(assetPath: string) =>
timelineEditing.handleTimelineAssetDrop(assetPath, {
start: usePlayerStore.getState().currentTime,
track: 0,
}),
[timelineEditing],
);
const handleAddAssetAtPlayhead = useAddAssetAtPlayhead(timelineEditing.handleTimelineAssetDrop);
const {
activeBlockParams,
setActiveBlockParams,
@@ -533,6 +527,7 @@ export function StudioApp() {
onToggleRecording={recordingToggle}
sdkSession={sdkHandle.session}
publishSdkSession={sdkHandle.publish}
forceReloadSdkSession={sdkHandle.forceReload}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit}