mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user