From 578d6202b4e0918d71744f85a0e0f5b0e1d1981c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 15 Jul 2026 18:42:11 -0700 Subject: [PATCH] fix(studio): resync the shared SDK session after a Design-panel variable promote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/studio/src/App.tsx | 11 ++--- .../components/DesignPanelPromoteProvider.tsx | 28 +++++++++++- .../src/components/StudioRightPanel.tsx | 15 +++++++ .../src/hooks/useAddAssetAtPlayhead.test.ts | 44 +++++++++++++++++++ .../studio/src/hooks/useAddAssetAtPlayhead.ts | 21 +++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 packages/studio/src/hooks/useAddAssetAtPlayhead.test.ts create mode 100644 packages/studio/src/hooks/useAddAssetAtPlayhead.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6af2bbe936..6b2df53800 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} diff --git a/packages/studio/src/components/DesignPanelPromoteProvider.tsx b/packages/studio/src/components/DesignPanelPromoteProvider.tsx index 4213f13104..9c33544044 100644 --- a/packages/studio/src/components/DesignPanelPromoteProvider.tsx +++ b/packages/studio/src/components/DesignPanelPromoteProvider.tsx @@ -10,6 +10,7 @@ */ import { useCallback, type ReactNode } from "react"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "./editor/domEditingTypes"; import { useSdkSession } from "../hooks/useSdkSession"; import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist"; @@ -24,6 +25,7 @@ export function DesignPanelPromoteProvider({ projectId, activeCompPath, showToast, + forceReloadSharedSdkSession, children, ...persistDeps }: PersistDeps & { @@ -31,16 +33,40 @@ export function DesignPanelPromoteProvider({ projectId: string | null; activeCompPath: string | null; showToast: (message: string, tone?: "error" | "info") => void; + /** + * Forces the app's SHARED SDK session (Variables tab, Slideshow, etc.) to + * re-open from disk. This provider opens its OWN session, separate from + * that shared one, so a persist through it never fires the shared session's + * own "change" event. When the target happens to be the same file the + * shared session already has open, that session is left holding stale + * in-memory content after a successful persist — worse, the self-write-echo + * registry that would normally reload it on the next file-change + * notification is keyed by file path only (not by session instance), so it + * mistakes this provider's write for its own echo and stays stale + * indefinitely. Called unconditionally (not gated on targetPath matching + * activeCompPath): re-opening a file that didn't change is a harmless + * no-op re-parse, cheaper than the bug class a subtly-wrong path guard + * could reintroduce. + */ + forceReloadSharedSdkSession?: () => void; children: ReactNode; }) { const targetPath = selection?.sourceFile || activeCompPath || "index.html"; const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef); - const persist = useVariablesPersist({ + const rawPersist = useVariablesPersist({ ...persistDeps, sdkSession: handle.session, publishSdkSession: handle.publish, activeCompPath: targetPath, }); + const persist = useCallback( + async (label: string, mutate: (session: Composition) => void) => { + const committed = await rawPersist(label, mutate); + if (committed) forceReloadSharedSdkSession?.(); + return committed; + }, + [rawPersist, forceReloadSharedSdkSession], + ); const handlePersistError = useCallback( (error: unknown) => showToast(getStudioSaveErrorMessage(error), "error"), [showToast], diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index a63ee3a74b..3d68a15f45 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -51,6 +51,19 @@ export interface StudioRightPanelProps { /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ sdkSession: Composition | null; publishSdkSession: NonNullable; + /** + * Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider + * opens its own separate SDK session scoped to the selected element's own + * file (needed so promoting inside a sub-composition binds a variable there, + * not on the host) — for a top-level selection that's the SAME file this + * session already has open, so a write through that other session leaves + * this one holding stale in-memory content. The self-write-echo registry + * that normally suppresses redundant reloads is keyed by file path only, not + * by session instance, so it wrongly treats the sibling session's write as + * "our own echo" and never reloads on its own — this must be called + * explicitly after such a write. + */ + forceReloadSdkSession?: () => void; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; recordEdit: (entry: { @@ -71,6 +84,7 @@ export function StudioRightPanel({ onToggleRecording, sdkSession, publishSdkSession, + forceReloadSdkSession, reloadPreview, domEditSaveTimestampRef, recordEdit, @@ -336,6 +350,7 @@ export function StudioRightPanel({ recordEdit={recordEdit} reloadPreview={reloadPreview} domEditSaveTimestampRef={domEditSaveTimestampRef} + forceReloadSharedSdkSession={forceReloadSdkSession} > { + document.body.innerHTML = ""; +}); + +describe("useAddAssetAtPlayhead", () => { + it("drops the asset on track 0 at the current playhead time", () => { + usePlayerStore.getState().setCurrentTime(4.5); + const handleTimelineAssetDrop = vi.fn(); + let addAsset: (assetPath: string) => unknown = () => undefined; + + function Harness() { + addAsset = useAddAssetAtPlayhead(handleTimelineAssetDrop); + return null; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Harness)); + }); + + act(() => { + addAsset("assets/clip.mp4"); + }); + + expect(handleTimelineAssetDrop).toHaveBeenCalledWith("assets/clip.mp4", { + start: 4.5, + track: 0, + }); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useAddAssetAtPlayhead.ts b/packages/studio/src/hooks/useAddAssetAtPlayhead.ts new file mode 100644 index 0000000000..7b50c7a329 --- /dev/null +++ b/packages/studio/src/hooks/useAddAssetAtPlayhead.ts @@ -0,0 +1,21 @@ +import { useCallback } from "react"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; + +/** Drops an asset onto track 0 at the current playhead time. */ +export function useAddAssetAtPlayhead( + handleTimelineAssetDrop: ( + assetPath: string, + placement: Pick, + durationOverride?: number, + ) => unknown, +) { + return useCallback( + (assetPath: string) => + handleTimelineAssetDrop(assetPath, { + start: usePlayerStore.getState().currentTime, + track: 0, + }), + [handleTimelineAssetDrop], + ); +}