Skip to content
46 changes: 46 additions & 0 deletions packages/studio/src/components/editor/overlayFrameLoop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
IDLE_POLL_MS,
IDLE_SLICE_MS,
requestOverlayFrames,
resetOverlayFrameLoopForTests,
runWhenInputIdle,
subscribeOverlayFrame,
} from "./overlayFrameLoop";
import { usePlayerStore } from "../../player/store/playerStore";
Expand Down Expand Up @@ -71,6 +73,50 @@ describe("overlay frame loop", () => {
expect(runs).toBeGreaterThan(0);
});

it("runs idle work in capped slices, only while no input arrives", () => {
subscribeOverlayFrame(() => undefined);
const budgets: number[] = [];
window.dispatchEvent(new Event("pointermove"));
runWhenInputIdle((timeLeft) => {
budgets.push(timeLeft());
return budgets.length === 3;
});
vi.advanceTimersByTime(300);
expect(budgets).toEqual([]);
vi.advanceTimersByTime(200);
expect(budgets).toHaveLength(3);
expect(Math.max(...budgets)).toBeLessThanOrEqual(IDLE_SLICE_MS);

// Input during a slice holds the next one until the loop has been idle again.
const slices: number[] = [];
runWhenInputIdle(() => {
slices.push(performance.now());
if (slices.length === 1) window.dispatchEvent(new Event("keydown"));
return slices.length === 2;
});
vi.advanceTimersByTime(1000);
expect(slices).toHaveLength(2);
expect(slices[1] - slices[0]).toBeGreaterThanOrEqual(400);
});

it("holds an idle slice when input lands after it was queued", () => {
const ran: number[] = [];
runWhenInputIdle(() => ran.push(performance.now()) > 0);
window.dispatchEvent(new Event("wheel"));
vi.advanceTimersByTime(1);
expect(ran).toEqual([]);
vi.advanceTimersByTime(500);
expect(ran).toHaveLength(1);
});

it("does not hold idle work for playback frames or programmatic wakes", () => {
const ran: number[] = [];
requestOverlayFrames();
runWhenInputIdle(() => ran.push(performance.now()) > 0);
vi.advanceTimersByTime(1);
expect(ran).toHaveLength(1);
});

it("runs every subscriber on one frame, not one frame each", () => {
const calls: string[] = [];
subscribeOverlayFrame(() => calls.push("a"));
Expand Down
52 changes: 47 additions & 5 deletions packages/studio/src/components/editor/overlayFrameLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,33 @@ let awakeUntil = 0;
let listenersAttached = false;
let stopBootWake: (() => void) | null = null;

/** Wake sources. All passive reads; none of them can be cancelled by us. */
const WINDOW_EVENTS = [
/** The user's own input. Idle work waits only for these, never for playback or programmatic wakes. */
const INPUT_EVENTS = [
"pointerdown",
"pointermove",
"pointerup",
"wheel",
"keydown",
"keyup",
"scroll",
"resize",
"visibilitychange",
] as const;

/** Wake sources. All passive reads; none of them can be cancelled by us. */
const WINDOW_EVENTS = [...INPUT_EVENTS, "scroll", "resize", "visibilitychange"] as const;

let lastInputAt = Number.NEGATIVE_INFINITY;
let inputWatched = false;
const noteInput = () => {
lastInputAt = performance.now();
};

function watchInput(): void {
if (inputWatched || typeof window === "undefined") return;
inputWatched = true;
for (const type of INPUT_EVENTS) {
window.addEventListener(type, noteInput, { capture: true, passive: true });
}
}

// The preview posts a `state` message on a fixed interval even when the
// playhead has not moved — the control bridge's paused heartbeat, which exists
// so a listener can confirm a paused position. Waking on every message would
Expand Down Expand Up @@ -143,6 +157,28 @@ function schedule(): void {
}, IDLE_POLL_MS);
}

/** Longest idle slice, so input that arrives during one waits at most this long for its frame. */
export const IDLE_SLICE_MS = 3;

/**
* Runs `step` in idle slices once there has been no input for AWAKE_MS, so it never shares a gesture's
* frames. `step` works while `timeLeft()` is positive and returns true when it has finished.
*/
export function runWhenInputIdle(step: (timeLeft: () => number) => boolean): void {
watchInput();
const wait = lastInputAt + AWAKE_MS - performance.now();
if (wait > 0) {
setTimeout(() => runWhenInputIdle(step), wait);
return;
}
const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 0));
idle(() => {
const end = performance.now() + IDLE_SLICE_MS;
if (performance.now() < lastInputAt + AWAKE_MS || !step(() => end - performance.now()))
runWhenInputIdle(step);
});
}

/** Something moved, or might have. Run frames at full rate for a moment. */
export function requestOverlayFrames(): void {
awakeUntil = performance.now() + AWAKE_MS;
Expand All @@ -156,6 +192,7 @@ export function requestOverlayFrames(): void {
function attachListeners(): void {
if (listenersAttached || typeof window === "undefined") return;
listenersAttached = true;
watchInput();
for (const type of WINDOW_EVENTS) {
window.addEventListener(type, requestOverlayFrames, { capture: true, passive: true });
}
Expand Down Expand Up @@ -209,6 +246,11 @@ export function resetOverlayFrameLoopForTests(): void {
frameId = null;
idleTimerId = null;
awakeUntil = 0;
lastInputAt = Number.NEGATIVE_INFINITY;
if (inputWatched) {
for (const type of INPUT_EVENTS) window.removeEventListener(type, noteInput, { capture: true });
inputWatched = false;
}
lastPreviewFrame = null;
lastPreviewPlaying = null;
detachListeners();
Expand Down
105 changes: 102 additions & 3 deletions packages/studio/src/components/editor/propertyPanelFont.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { sortFontOptions } from "./propertyPanelHelpers";

vi.mock("./propertyPanelHelpers", async (importOriginal) => {
const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
return {
...actual,
sortFontOptions: vi.fn(actual.sortFontOptions),
uniqueFontFamilies: vi.fn(actual.uniqueFontFamilies),
};
});

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -56,13 +60,14 @@ describe("FontFamilyField font list", () => {
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
try {
await act(async () => render("Arial"));
// The font lists arrive and the value changes while the dropdown is closed, as during a drag.
// The value changes while the dropdown is closed, as during a drag.
await act(async () => render("Georgia"));
expect(sortFontOptions).not.toHaveBeenCalled();

const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
await act(async () => trigger?.click());
expect(sortFontOptions).toHaveBeenCalledTimes(1);
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(sortFontOptions).toHaveBeenCalled();
expect(host.textContent).toContain("Roboto Slab");
expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
} finally {
Expand All @@ -71,3 +76,97 @@ describe("FontFamilyField font list", () => {
}
});
});

describe("FontFamilyField session font lists", () => {
const fresh = async () => {
vi.resetModules();
const { FontFamilyField } = await import("./propertyPanelFont");
const { uniqueFontFamilies } = await import("./propertyPanelHelpers");
const loop = await import("./overlayFrameLoop");
vi.mocked(uniqueFontFamilies).mockClear();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (value: string) =>
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
const toggle = () =>
host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]')?.click();
const done = () => {
act(() => root.unmount());
host.remove();
loop.resetOverlayFrameLoopForTests();
};
return { uniqueFontFamilies, loop, host, render, toggle, done };
};

it("does no list work in a drag's frames when the lists land mid-drag", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
const raf = window.requestAnimationFrame;
window.requestAnimationFrame = (() => 0) as typeof window.requestAnimationFrame;
const lists = vi.mocked(fetch).getMockImplementation()!;
vi.mocked(fetch).mockImplementation(async (url) => {
await new Promise((resolve) => setTimeout(resolve, 200));
return lists(url);
});
const t = await fresh();
const stopLoop = t.loop.subscribeOverlayFrame(() => undefined);
try {
await act(async () => t.render("Georgia"));
for (let elapsed = 0; elapsed < 600; elapsed += 16) {
window.dispatchEvent(new Event("pointermove"));
await act(async () => vi.advanceTimersByTimeAsync(16));
}
expect(fetch).toHaveBeenCalledTimes(2);
expect(t.uniqueFontFamilies).not.toHaveBeenCalled();

await act(async () => vi.advanceTimersByTimeAsync(500));
expect(t.uniqueFontFamilies).toHaveBeenCalledTimes(1);
await act(async () => t.toggle());
expect(t.host.textContent).toContain("Roboto Slab");
} finally {
stopLoop();
t.done();
window.requestAnimationFrame = raf;
vi.useRealTimers();
}
});

it("retries on the next open when processing the lists throws", async () => {
const t = await fresh();
vi.mocked(t.uniqueFontFamilies).mockImplementationOnce(() => {
throw new Error("bad list");
});
const logged = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
await act(async () => t.render("Georgia"));
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(t.host.textContent).not.toContain("Roboto Slab");

await act(async () => t.toggle());
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(fetch).toHaveBeenCalledTimes(4);
expect(t.host.textContent).toContain("Roboto Slab");
expect(logged).toHaveBeenCalledTimes(1);
} finally {
logged.mockRestore();
t.done();
}
});

it("retries a failed list fetch when the picker opens", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
const t = await fresh();
try {
await act(async () => t.render("Georgia"));
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(t.uniqueFontFamilies).not.toHaveBeenCalled();

await act(async () => t.toggle());
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
expect(fetch).toHaveBeenCalledTimes(4);
expect(t.host.textContent).toContain("Roboto Slab");
} finally {
t.done();
}
});
});
Loading
Loading