Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,10 @@ function foldElementPatches(
/**
* The single commit owner for element patch batches. All files are resolved,
* read, and folded before the first write; any unmatched target refuses the
* whole request. The final snapshots/writes are synchronous, so another route
* cannot interleave once the commit section begins.
* whole request. Studio Server is intentionally single-process; within that
* process the final snapshots/writes are synchronous, so another route cannot
* interleave once the commit section begins. A multi-process deployment must
* replace this process-local guarantee with a shared per-project file lock.
*/
export function commitElementPatchBatches(
projectDir: string,
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,12 @@ export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState();

useEffect(() => {
if (resolving || waitingForServer) return;
if (hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);

const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null,
Expand Down Expand Up @@ -176,6 +174,7 @@ export function StudioApp() {
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
sdkSession: editFlowSdkSession,
publishSdkSession: sdkHandle.publish,
forceReloadSdkSession: sdkHandle.forceReload,
handleDomZIndexReorderCommitRef,
});
Expand Down Expand Up @@ -317,6 +316,7 @@ export function StudioApp() {
selectSidebarTab: sidebarTabRef.current.select,
getSidebarTab: sidebarTabRef.current.get,
sdkSession: editFlowSdkSession,
publishSdkSession: sdkHandle.publish,
forceReloadSdkSession: sdkHandle.forceReload,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
Expand All @@ -331,7 +331,6 @@ export function StudioApp() {
domEditSession.domEditSelection,
domEditSession.domEditGroupSelections,
);

useCaptionDetection({
projectId,
activeCompPath,
Expand Down Expand Up @@ -533,6 +532,7 @@ export function StudioApp() {
recordingDuration={gestureRecording.recordingDuration}
onToggleRecording={recordingToggle}
sdkSession={sdkHandle.session}
publishSdkSession={sdkHandle.publish}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit}
Expand Down
17 changes: 15 additions & 2 deletions packages/studio/src/components/DesignPanelPromoteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
* composition, so behavior there is unchanged.
*/

import type { ReactNode } from "react";
import { useCallback, type ReactNode } from "react";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { useSdkSession } from "../hooks/useSdkSession";
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
import { VariablePromoteProvider } from "../contexts/VariablePromoteContext";
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";

/** Persist wiring minus the target — this provider derives the target from the selection. */
type PersistDeps = Omit<UseVariablesPersistParams, "sdkSession" | "activeCompPath">;
Expand All @@ -22,23 +23,35 @@ export function DesignPanelPromoteProvider({
selection,
projectId,
activeCompPath,
showToast,
children,
...persistDeps
}: PersistDeps & {
selection: DomEditSelection | null;
projectId: string | null;
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
children: ReactNode;
}) {
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
const persist = useVariablesPersist({
...persistDeps,
sdkSession: handle.session,
publishSdkSession: handle.publish,
activeCompPath: targetPath,
});
const handlePersistError = useCallback(
(error: unknown) => showToast(getStudioSaveErrorMessage(error), "error"),
[showToast],
);
return (
Comment thread
jrusso1020 marked this conversation as resolved.
<VariablePromoteProvider session={handle.session} selection={selection} persist={persist}>
<VariablePromoteProvider
session={handle.session}
selection={selection}
persist={persist}
onPersistError={handlePersistError}
>
{children}
</VariablePromoteProvider>
);
Expand Down
9 changes: 7 additions & 2 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { IframeWindow } from "../player/lib/playbackTypes";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";

import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
Expand Down Expand Up @@ -47,6 +47,7 @@ export interface StudioRightPanelProps {
onToggleRecording?: () => void;
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
sdkSession: Composition | null;
publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
Expand All @@ -66,6 +67,7 @@ export function StudioRightPanel({
recordingDuration,
onToggleRecording,
sdkSession,
publishSdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
Expand Down Expand Up @@ -160,6 +162,7 @@ export function StudioRightPanel({
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
publishSdkSession,
});

// Notes path: persists are debounced in SlideshowPanel; coalesceKey ensures
Expand All @@ -172,6 +175,7 @@ export function StudioRightPanel({
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
publishSdkSession,
coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes",
});

Expand Down Expand Up @@ -305,7 +309,6 @@ export function StudioRightPanel({
},
[projectId, refreshFileTree, showToast],
);

const handleHideAllSelected = () => {
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
Expand All @@ -316,6 +319,7 @@ export function StudioRightPanel({
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
projectId={projectId}
activeCompPath={activeCompPath}
showToast={showToast}
readProjectFile={readProjectFile}
writeProjectFile={writeProjectFile}
recordEdit={recordEdit}
Expand Down Expand Up @@ -507,6 +511,7 @@ export function StudioRightPanel({
) : rightPanelTab === "variables" ? (
<VariablesPanel
sdkSession={sdkSession}
publishSdkSession={publishSdkSession}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={recordEdit}
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/components/panels/VariablesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
VariableValidationIssue,
} from "@hyperframes/sdk";
import type { EditHistoryKind } from "../../utils/editHistory";
import type { PublishSdkSession } from "../../utils/sdkCutover";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
Expand All @@ -32,6 +33,7 @@ function shellSingleQuote(value: string): string {

interface VariablesPanelProps {
sdkSession: Composition | null;
publishSdkSession: PublishSdkSession;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
Expand Down Expand Up @@ -247,6 +249,7 @@ const EMPTY_STATE = (
// fallow-ignore-next-line complexity
export const VariablesPanel = memo(function VariablesPanel({
sdkSession,
publishSdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
Expand Down Expand Up @@ -285,6 +288,7 @@ export const VariablesPanel = memo(function VariablesPanel({
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
publishSdkSession,
});

const declarations = useMemo(
Expand Down
67 changes: 67 additions & 0 deletions packages/studio/src/contexts/VariablePromoteContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
VariablePromoteProvider,
useVariablePromoteChannel,
type ChannelPromote,
} from "./VariablePromoteContext";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

describe("VariablePromoteProvider save failures", () => {
it("reports a rejected promote instead of leaving an unhandled promise", async () => {
const snapshot = {
attributes: { id: "box" },
inlineStyles: {},
text: "",
children: [],
};
const session = {
getElement: vi.fn().mockReturnValue(snapshot),
getVariableDeclarations: vi.fn().mockReturnValue([]),
on: vi.fn().mockReturnValue(() => {}),
} as unknown as Composition;
const selection = {
hfId: "hf-box",
tagName: "div",
label: "Box",
capabilities: { canEditStyles: true },
computedStyles: { color: "rgb(255, 0, 0)" },
} as unknown as DomEditSelection;
const error = new Error("write failed");
const persist = vi.fn().mockRejectedValue(error);
const onPersistError = vi.fn();
let channel: ChannelPromote | null = null;

function Consumer() {
channel = useVariablePromoteChannel({ kind: "style", prop: "color" });
return null;
}

const root = createRoot(document.createElement("div"));
act(() => {
root.render(
<VariablePromoteProvider
session={session}
selection={selection}
persist={persist}
onPersistError={onPersistError}
>
<Consumer />
</VariablePromoteProvider>,
);
});
act(() => channel?.promote());
await act(async () => {
await Promise.resolve();
});

expect(persist).toHaveBeenCalledOnce();
expect(onPersistError).toHaveBeenCalledWith(error);
act(() => root.unmount());
});
});
15 changes: 10 additions & 5 deletions packages/studio/src/contexts/VariablePromoteContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface VariablePromoteContextValue {
actions: BindAction[];
declarations: CompositionVariable[];
persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
onPersistError: (error: unknown) => void;
}

const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
Expand All @@ -58,11 +59,13 @@ export function VariablePromoteProvider({
session,
selection,
persist,
onPersistError,
children,
}: {
session: Composition | null;
selection: DomEditSelection | null;
persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
onPersistError: (error: unknown) => void;
children: React.ReactNode;
}) {
// Re-derive actions/bindings after each persisted schema edit.
Expand All @@ -85,8 +88,8 @@ export function VariablePromoteProvider({
}, [session, revision]);

const value = useMemo<VariablePromoteContextValue>(
() => ({ session, selection, actions, declarations, persist }),
[session, selection, actions, declarations, persist],
() => ({ session, selection, actions, declarations, persist, onPersistError }),
[session, selection, actions, declarations, persist, onPersistError],
);

return (
Expand All @@ -105,7 +108,7 @@ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromo

return useMemo(() => {
if (!ctx || !ctx.session || !ctx.selection?.hfId) return null;
const { session, selection, actions, declarations, persist } = ctx;
const { session, selection, actions, declarations, persist, onPersistError } = ctx;
const hfId = selection.hfId!;
const action = matchAction(actions, channel);
const boundId = readBinding(session, hfId, channel);
Expand All @@ -125,12 +128,14 @@ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromo
const id = uniqueId(action.suggestedId, declarations);
void persist(`Bind ${action.label.toLowerCase()} to variable "${id}"`, (s) =>
applyBind(s, hfId, action, id),
);
).catch(onPersistError);
},
setDefault: (raw: string) => {
if (!boundId || !declaration) return;
const next = declaration.type === "color" ? rgbToHex(raw) : raw;
void persist(`Set default for "${boundId}"`, (s) => s.setVariableValue(boundId, next));
void persist(`Set default for "${boundId}"`, (s) =>
s.setVariableValue(boundId, next),
).catch(onPersistError);
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
10 changes: 6 additions & 4 deletions packages/studio/src/hooks/gsapScriptCommitTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { EditHistoryKind } from "../utils/editHistory";
import type { PublishSdkSession } from "../utils/sdkCutover";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";

export interface MutationResult {
Expand All @@ -22,10 +23,9 @@ export interface CommitMutationOptions {
beforeReload?: () => void;
/**
* Serialize this commit against others sharing the same key. Used to chain
* per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
* one file can't interleave — which would pair the shadow fidelity diff with a
* stale server result and report false ease mismatches. Commits without a key
* (and under distinct keys) run concurrently as before.
* per-animationId GSAP meta updates. Every commit independently takes the
* project/file mutation lock, so this key only adds ordering and can never
* bypass whole-file serialization.
*/
serializeKey?: string;
/**
Expand Down Expand Up @@ -87,6 +87,8 @@ export interface GsapScriptCommitsParams {
showToast: (message: string, tone?: "error" | "info") => void;
/** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
sdkSession?: Composition | null;
/** Publish a fully persisted candidate SDK session. */
publishSdkSession?: PublishSdkSession;
writeProjectFile?: (path: string, content: string) => Promise<void>;
/** Resync the in-memory SDK session after a server-authoritative write. */
forceReloadSdkSession?: () => void;
Expand Down
Loading
Loading