Skip to content
Open
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
51 changes: 51 additions & 0 deletions packages/core/src/studio-api/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,57 @@ describe("registerPreviewRoutes", () => {
expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply"));
});

it("injects the GSAP MotionPathPlugin when the composition uses a motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head><body><div id="card" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#card", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] }, duration: 1 }, 0);
window.__timelines = { index: tl };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));

const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();

expect(response.status).toBe(200);
// Plugin version is derived from the composition's own gsap (gsap@3 here).
expect(html).toContain("gsap@3/dist/MotionPathPlugin.min.js");
// Plugin must load AFTER the core gsap script so it can register onto it.
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("MotionPathPlugin.min.js"));
});

it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head><body><div id="card" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 100, duration: 1 }, 0);
window.__timelines = { index: tl };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));

const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();

expect(response.status).toBe(200);
expect(html).not.toContain("MotionPathPlugin.min.js");
});

it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
Expand Down
43 changes: 41 additions & 2 deletions packages/core/src/studio-api/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;

function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
Expand Down Expand Up @@ -86,6 +87,42 @@ function htmlHasCustomEase(html: string): boolean {
);
}

// A composition that drives motion via GSAP's `motionPath` (e.g. a studio-created
// motion path written into the single-source timeline) needs MotionPathPlugin
// registered before the timeline first renders — otherwise the initial seek
// throws "Invalid property motionPath ... Missing plugin?". Detect it anywhere in
// the bundle (the plugin registers globally, so sub-composition usage counts too).
function htmlUsesMotionPath(html: string): boolean {
return /motionPath\s*[:{]/.test(html);
}

function htmlHasMotionPathPlugin(html: string): boolean {
return (
/<script\b[^>]*src=["'][^"']*MotionPathPlugin/i.test(html) ||
/\bwindow\.MotionPathPlugin\b/.test(html) ||
/\bMotionPathPlugin\s*=\s*/.test(html)
);
}

function injectMotionPathPluginIfNeeded(html: string): string {
if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html;
// The plugin registers onto an already-loaded gsap, so it must come AFTER the
// core gsap script — which often lives at body-end, not <head>. Insert it
// directly after the gsap script tag; only fall back to <head> if none is found
// (e.g. gsap is inlined).
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
const match = html.match(gsapScript);
if (match) {
// Match the plugin version to the composition's own gsap so the plugin
// registers cleanly (a minor-version skew triggers a GSAP compatibility warning).
const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION;
const pluginTag = `<script src="https://cdn.jsdelivr.net/npm/gsap@${version}/dist/MotionPathPlugin.min.js"></script>`;
const end = html.indexOf(match[0]) + match[0].length;
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
}
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
}

function injectStudioMotionDependencies(html: string, manifestContent: string): string {
const manifest = parseStudioMotionManifestContent(manifestContent);
if (!manifest.hasMotion) return html;
Expand Down Expand Up @@ -149,8 +186,10 @@ function injectStudioPreviewAugmentations(
activeCompositionPath: string,
): string {
return injectStudioMotionScript(
injectGsapCdnFallback(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
injectMotionPathPluginIfNeeded(
injectGsapCdnFallback(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
),
),
projectDir,
activeCompositionPath,
Expand Down
30 changes: 21 additions & 9 deletions packages/studio/src/components/editor/SourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
highlightActiveLine,
highlightActiveLineGutter,
} from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { EditorState, Annotation } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
Expand All @@ -18,6 +18,11 @@ import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";

// Marks a programmatic doc sync (external content push — e.g. a manual-edit
// commit writing the source) so the update listener doesn't mistake it for a
// user keystroke and trigger a re-save + preview reload.
const ExternalSync = Annotation.define<boolean>();

const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
html: () => html(),
css: () => css(),
Expand Down Expand Up @@ -89,9 +94,10 @@ export const SourceEditor = memo(function SourceEditor({
const lang = language ?? (filePath ? detectLanguage(filePath) : "html");

const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && onChangeRef.current) {
onChangeRef.current(update.state.doc.toString());
}
if (!update.docChanged || !onChangeRef.current) return;
// Ignore programmatic external syncs — only real user edits should save.
if (update.transactions.some((tr) => tr.annotation(ExternalSync))) return;
onChangeRef.current(update.state.doc.toString());
});

const state = EditorState.create({
Expand Down Expand Up @@ -130,11 +136,17 @@ export const SourceEditor = memo(function SourceEditor({
const view = editorRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== content) {
view.dispatch({
changes: { from: 0, to: current.length, insert: content },
});
}
if (current === content) return;
// If the user is actively typing (editor focused), a programmatic replace
// would clobber their in-flight keystrokes — the ExternalSync annotation
// suppresses onChange, so those edits would be silently lost. Skip the
// external sync while focused; it re-runs on the next `content` change after
// they blur (or when a later commit lands with the editor unfocused).
if (view.hasFocus) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: content },
annotations: [ExternalSync.of(true)],
});
}, [content]);

useEffect(() => {
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";

interface NLELayoutProps {
projectId: string;
Expand Down Expand Up @@ -159,6 +160,10 @@ export const NLELayout = memo(function NLELayout({

const onIframeLoad = useCallback(() => {
baseOnIframeLoad();
// Pre-load + register MotionPathPlugin once so adding a motion path in the
// studio doesn't take the async plugin-load flash path on the first soft
// reload (the comp may not ship the plugin until it actually uses one).
ensureMotionPathPluginLoaded(iframeRef.current);
onIframeRef?.(iframeRef.current);
}, [baseOnIframeLoad, iframeRef, onIframeRef]);

Expand Down
98 changes: 86 additions & 12 deletions packages/studio/src/hooks/useGsapScriptCommits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// the existing fallback. `extractGsapScriptText` is re-exported from the same
// module and used elsewhere in the hook — keep it a harmless stub.
const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
const applySoftReload = vi.fn<(...args: unknown[]) => boolean>();
const applySoftReload = vi.fn<(...args: unknown[]) => string>();
const trackStudioEvent = vi.fn();

vi.mock("./gsapRuntimePatch", () => ({
patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
Expand All @@ -17,6 +18,9 @@ vi.mock("../utils/gsapSoftReload", () => ({
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
extractGsapScriptText: () => "",
}));
vi.mock("../utils/studioTelemetry", () => ({
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
}));

// Tell React this is an act-capable environment so act(...) flushes effects
// without warning (React reads this global at call time).
Expand All @@ -38,6 +42,7 @@ describe("applyPreviewSync", () => {
beforeEach(() => {
patchRuntimeTweenInPlace.mockReset();
applySoftReload.mockReset();
trackStudioEvent.mockReset();
});

it("instantPatch + patch succeeds: skips both soft reload and full reload", () => {
Expand Down Expand Up @@ -65,7 +70,7 @@ describe("applyPreviewSync", () => {

it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => {
patchRuntimeTweenInPlace.mockReturnValue(false);
applySoftReload.mockReturnValue(true);
applySoftReload.mockReturnValue("applied");
const reloadPreview = vi.fn();

applyPreviewSync(
Expand All @@ -83,11 +88,16 @@ describe("applyPreviewSync", () => {
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
expect(reloadPreview).not.toHaveBeenCalled();
// A successful instant patch is the fast path; here it missed → fallback event.
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_instant_patch_fallback",
expect.objectContaining({ selector: "#a" }),
);
});

it("instantPatch + patch fails + soft reload returns false: does NOT sync-escalate (U4)", () => {
it('instantPatch + patch fails + soft reload "verify-failed": transient, does NOT escalate (U4)', () => {
patchRuntimeTweenInPlace.mockReturnValue(false);
applySoftReload.mockReturnValue(false);
applySoftReload.mockReturnValue("verify-failed");
const reloadPreview = vi.fn();

applyPreviewSync(
Expand All @@ -101,14 +111,52 @@ describe("applyPreviewSync", () => {
reloadPreview,
);

// U4: the synchronous false return means the soft reload couldn't run, NOT
// that the preview is broken — escalation happens only via onAsyncFailure.
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
// is correct, so we must NOT escalate to a full reload.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
expect(reloadPreview).not.toHaveBeenCalled();
// Telemetry records the suppressed transient (escalated: false).
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
expect.objectContaining({
origin: "preview_sync",
result: "verify-failed",
escalated: false,
}),
);
});

it('instantPatch + patch fails + soft reload "cannot-soft-reload": escalates to full reload', () => {
patchRuntimeTweenInPlace.mockReturnValue(false);
applySoftReload.mockReturnValue("cannot-soft-reload");
const reloadPreview = vi.fn();

applyPreviewSync(
FAKE_IFRAME,
result({ scriptText: "SCRIPT" }),
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
reloadPreview,
);

// Structural failure: the preview is genuinely stale/broken → full reload.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
expect.objectContaining({
origin: "preview_sync",
result: "cannot-soft-reload",
escalated: true,
}),
);
});

it("no instantPatch + softReload + scriptText: soft reloads, passing onAsyncFailure", () => {
applySoftReload.mockReturnValue(true);
applySoftReload.mockReturnValue("applied");
const reloadPreview = vi.fn();

applyPreviewSync(
Expand All @@ -121,10 +169,12 @@ describe("applyPreviewSync", () => {
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
expect(reloadPreview).not.toHaveBeenCalled();
// "applied" emits no telemetry (only the failure paths do).
expect(trackStudioEvent).not.toHaveBeenCalled();
});

it("no instantPatch + softReload that returns false: does NOT sync-escalate (U4)", () => {
applySoftReload.mockReturnValue(false);
it('no instantPatch + softReload "verify-failed": transient, does NOT escalate (U4)', () => {
applySoftReload.mockReturnValue("verify-failed");
const reloadPreview = vi.fn();

applyPreviewSync(
Expand All @@ -134,9 +184,32 @@ describe("applyPreviewSync", () => {
reloadPreview,
);

// onAsyncFailure is wired, but the sync false return does not trigger it.
// onAsyncFailure is wired, but the transient result does not trigger it.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
expect(reloadPreview).not.toHaveBeenCalled();
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
expect.objectContaining({ result: "verify-failed", escalated: false }),
);
});

it('no instantPatch + softReload "cannot-soft-reload": escalates to full reload', () => {
applySoftReload.mockReturnValue("cannot-soft-reload");
const reloadPreview = vi.fn();

applyPreviewSync(
FAKE_IFRAME,
result({ scriptText: "SCRIPT" }),
{ label: "x", softReload: true },
reloadPreview,
);

expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
expect.objectContaining({ result: "cannot-soft-reload", escalated: true }),
);
});

it("no instantPatch + no softReload: full reload (today's behavior)", () => {
Expand Down Expand Up @@ -221,6 +294,7 @@ describe("runCommit — instantPatch wiring", () => {
beforeEach(() => {
patchRuntimeTweenInPlace.mockReset();
applySoftReload.mockReset();
trackStudioEvent.mockReset();
});
afterEach(() => {
cleanup?.();
Expand Down Expand Up @@ -254,7 +328,7 @@ describe("runCommit — instantPatch wiring", () => {

it("instantPatch fails: persists AND falls back to soft reload", async () => {
patchRuntimeTweenInPlace.mockReturnValue(false);
applySoftReload.mockReturnValue(true);
applySoftReload.mockReturnValue("applied");
mockFetchResult();
const deps = renderCommitHook();

Expand All @@ -277,7 +351,7 @@ describe("runCommit — instantPatch wiring", () => {
});

it("no instantPatch: identical to today — soft reload when softReload+scriptText", async () => {
applySoftReload.mockReturnValue(true);
applySoftReload.mockReturnValue("applied");
mockFetchResult();
const deps = renderCommitHook();

Expand Down
Loading
Loading