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 { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions"; import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAddAssetAtPlayhead } from "./hooks/useAddAssetAtPlayhead";
import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard"; import { useClipboard } from "./hooks/useClipboard";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers"; import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
@@ -195,14 +196,7 @@ export function StudioApp() {
}, },
[timelineEditing.handleTimelineGroupMove], [timelineEditing.handleTimelineGroupMove],
); );
const handleAddAssetAtPlayhead = useCallback( const handleAddAssetAtPlayhead = useAddAssetAtPlayhead(timelineEditing.handleTimelineAssetDrop);
(assetPath: string) =>
timelineEditing.handleTimelineAssetDrop(assetPath, {
start: usePlayerStore.getState().currentTime,
track: 0,
}),
[timelineEditing],
);
const { const {
activeBlockParams, activeBlockParams,
setActiveBlockParams, setActiveBlockParams,
@@ -533,6 +527,7 @@ export function StudioApp() {
onToggleRecording={recordingToggle} onToggleRecording={recordingToggle}
sdkSession={sdkHandle.session} sdkSession={sdkHandle.session}
publishSdkSession={sdkHandle.publish} publishSdkSession={sdkHandle.publish}
forceReloadSdkSession={sdkHandle.forceReload}
reloadPreview={reloadPreview} reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef} domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit} recordEdit={editHistory.recordEdit}
@@ -10,6 +10,7 @@
*/ */
import { useCallback, type ReactNode } from "react"; import { useCallback, type ReactNode } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "./editor/domEditingTypes"; import type { DomEditSelection } from "./editor/domEditingTypes";
import { useSdkSession } from "../hooks/useSdkSession"; import { useSdkSession } from "../hooks/useSdkSession";
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist"; import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
@@ -24,6 +25,7 @@ export function DesignPanelPromoteProvider({
projectId, projectId,
activeCompPath, activeCompPath,
showToast, showToast,
forceReloadSharedSdkSession,
children, children,
...persistDeps ...persistDeps
}: PersistDeps & { }: PersistDeps & {
@@ -31,16 +33,40 @@ export function DesignPanelPromoteProvider({
projectId: string | null; projectId: string | null;
activeCompPath: string | null; activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void; 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; children: ReactNode;
}) { }) {
const targetPath = selection?.sourceFile || activeCompPath || "index.html"; const targetPath = selection?.sourceFile || activeCompPath || "index.html";
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef); const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
const persist = useVariablesPersist({ const rawPersist = useVariablesPersist({
...persistDeps, ...persistDeps,
sdkSession: handle.session, sdkSession: handle.session,
publishSdkSession: handle.publish, publishSdkSession: handle.publish,
activeCompPath: targetPath, 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( const handlePersistError = useCallback(
(error: unknown) => showToast(getStudioSaveErrorMessage(error), "error"), (error: unknown) => showToast(getStudioSaveErrorMessage(error), "error"),
[showToast], [showToast],
@@ -51,6 +51,19 @@ export interface StudioRightPanelProps {
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
sdkSession: Composition | null; sdkSession: Composition | null;
publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>; publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
/**
* 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; reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>; domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: { recordEdit: (entry: {
@@ -71,6 +84,7 @@ export function StudioRightPanel({
onToggleRecording, onToggleRecording,
sdkSession, sdkSession,
publishSdkSession, publishSdkSession,
forceReloadSdkSession,
reloadPreview, reloadPreview,
domEditSaveTimestampRef, domEditSaveTimestampRef,
recordEdit, recordEdit,
@@ -336,6 +350,7 @@ export function StudioRightPanel({
recordEdit={recordEdit} recordEdit={recordEdit}
reloadPreview={reloadPreview} reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef} domEditSaveTimestampRef={domEditSaveTimestampRef}
forceReloadSharedSdkSession={forceReloadSdkSession}
> >
<PropertyPanel <PropertyPanel
projectId={projectId} projectId={projectId}
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player";
import { useAddAssetAtPlayhead } from "./useAddAssetAtPlayhead";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
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());
});
});
@@ -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<TimelineElement, "start" | "track">,
durationOverride?: number,
) => unknown,
) {
return useCallback(
(assetPath: string) =>
handleTimelineAssetDrop(assetPath, {
start: usePlayerStore.getState().currentTime,
track: 0,
}),
[handleTimelineAssetDrop],
);
}