fix(studio): persist studio state in project URLs (#836)

This commit is contained in:
Phuong Le
2026-05-14 16:56:13 +02:00
committed by GitHub
parent 3aa5cf3ab3
commit e59089bf75
15 changed files with 850 additions and 46 deletions
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { useTimelinePlayer } from "./useTimelinePlayer";
import { liveTime, usePlayerStore } from "../store/playerStore";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function resetPlayerStore() {
usePlayerStore.getState().reset();
usePlayerStore.setState({ requestedSeekTime: null });
}
function TimelinePlayerHarness({
onValue,
}: {
onValue: (value: ReturnType<typeof useTimelinePlayer>) => void;
}) {
const value = useTimelinePlayer();
useEffect(() => {
onValue(value);
}, [onValue, value]);
return null;
}
afterEach(() => {
document.body.innerHTML = "";
resetPlayerStore();
});
describe("useTimelinePlayer seek hydration", () => {
it("keeps an external seek request until the iframe adapter is ready", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const observedTimes: number[] = [];
const unsubscribe = liveTime.subscribe((time) => {
observedTimes.push(time);
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
act(() => {
usePlayerStore.getState().requestSeek(4.2);
});
expect(api).not.toBeNull();
expect(usePlayerStore.getState().currentTime).toBe(0);
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
const iframe = document.createElement("iframe");
let currentTime = 0;
const adapter = {
play: () => {},
pause: () => {},
seek: (time: number) => {
currentTime = time;
},
getTime: () => currentTime,
getDuration: () => 30,
isPlaying: () => false,
};
Object.defineProperty(iframe, "contentWindow", {
value: {
__player: adapter,
postMessage: () => {},
scrollTo: () => {},
addEventListener: () => {},
removeEventListener: () => {},
},
configurable: true,
});
Object.defineProperty(iframe, "contentDocument", {
value: document.implementation.createHTMLDocument("preview"),
configurable: true,
});
act(() => {
api!.iframeRef.current = iframe;
api!.onIframeLoad();
});
expect(currentTime).toBe(4.2);
expect(usePlayerStore.getState().currentTime).toBe(4.2);
expect(usePlayerStore.getState().timelineReady).toBe(true);
expect(observedTimes).toContain(4.2);
act(() => {
root.unmount();
});
unsubscribe();
});
});
@@ -324,7 +324,10 @@ export function useTimelinePlayer() {
(time: number) => {
stopReverseLoop();
const adapter = getAdapter();
if (!adapter) return;
if (!adapter) {
pendingSeekRef.current = Math.max(0, time);
return false;
}
const duration = Math.max(0, adapter.getDuration());
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
adapter.seek(nextTime);
@@ -334,8 +337,9 @@ export function useTimelinePlayer() {
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
shuttleDirectionRef.current = null;
shuttleSpeedIndexRef.current = 0;
return true;
},
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
[getAdapter, pendingSeekRef, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
);
// Handle seek requests from outside the player loop (e.g. LayersPanel).
@@ -9,7 +9,7 @@
*/
import { useCallback } from "react";
import { usePlayerStore } from "../store/playerStore";
import { liveTime, usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
import {
@@ -158,6 +158,9 @@ export function useTimelineSyncCallbacks({
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
adapter.seek(startTime);
// Keep non-React listeners such as the capture link and time display in sync
// with the initial adapter seek on iframe load.
liveTime.notify(startTime);
const adapterDur = adapter.getDuration();
if (
Number.isFinite(adapterDur) &&