mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
perf(studio): stop font list work from landing in drag frames (#4794)
* perf(studio): load the font lists when the font picker first opens Selecting an element no longer fetches and dedupes the 1.7k-name Google font list, so the response cannot land inside a drag frame. The lists are fetched once per session on the first open. * test(studio): expect the font lists to load on the first picker open * perf(studio): load the font lists at session start and process them when input is idle * style(studio): drop a comment the font list names already carry * perf(studio): process the font lists in idle slices of at most 3 ms * perf(studio): join the font lists without iterating them when they arrive
This commit is contained in:
@@ -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";
|
||||
@@ -71,6 +73,31 @@ describe("overlay frame loop", () => {
|
||||
expect(runs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("runs idle work in capped slices, only while no input arrives", () => {
|
||||
const budgets: number[] = [];
|
||||
requestOverlayFrames();
|
||||
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) requestOverlayFrames();
|
||||
return slices.length === 2;
|
||||
});
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(slices).toHaveLength(2);
|
||||
expect(slices[1] - slices[0]).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("runs every subscriber on one frame, not one frame each", () => {
|
||||
const calls: string[] = [];
|
||||
subscribeOverlayFrame(() => calls.push("a"));
|
||||
|
||||
@@ -143,6 +143,27 @@ 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 no input has woken the loop 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 {
|
||||
const wait = awakeUntil - 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() < awakeUntil || !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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -71,3 +76,75 @@ 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 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
googleFontStylesheetUrl,
|
||||
POPULAR_GOOGLE_FONT_FAMILIES,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type LocalFontData,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { runWhenInputIdle } from "./overlayFrameLoop";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Font helper functions */
|
||||
@@ -117,6 +118,62 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
type FontLists = {
|
||||
loaded: boolean;
|
||||
google: readonly string[];
|
||||
googleKeys: ReadonlySet<string>;
|
||||
installed: string[];
|
||||
};
|
||||
|
||||
let fontLists: FontLists = {
|
||||
loaded: false,
|
||||
google: POPULAR_GOOGLE_FONT_FAMILIES,
|
||||
googleKeys: new Set(POPULAR_GOOGLE_FONT_FAMILIES.map((f) => f.toLowerCase())),
|
||||
installed: [],
|
||||
};
|
||||
let fontListsRequest: Promise<void> | null = null;
|
||||
const fontListListeners = new Set<() => void>();
|
||||
|
||||
async function fetchFontList(url: string): Promise<string[]> {
|
||||
const data = (await (await fetch(url)).json()) as { fonts?: unknown };
|
||||
if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
|
||||
return data.fonts as string[];
|
||||
}
|
||||
|
||||
function loadFontLists(): void {
|
||||
if (fontLists.loaded) return;
|
||||
fontListsRequest ??= Promise.all([
|
||||
fetchFontList("/api/fonts"),
|
||||
fetchFontList("/api/fonts/google"),
|
||||
])
|
||||
.then(([installed, google]) => {
|
||||
const names = google.concat(POPULAR_GOOGLE_FONT_FAMILIES);
|
||||
const families: string[] = [];
|
||||
const googleKeys = new Set<string>();
|
||||
let next = 0;
|
||||
runWhenInputIdle((timeLeft) => {
|
||||
while (next < names.length && timeLeft() > 0) {
|
||||
const batch = names.slice(next, next + 200);
|
||||
next += batch.length;
|
||||
families.push(...uniqueFontFamilies(batch, googleKeys));
|
||||
}
|
||||
if (next < names.length) return false;
|
||||
fontLists = { loaded: true, google: families, googleKeys, installed };
|
||||
for (const listener of fontListListeners) listener();
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
fontListsRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeFontLists(listener: () => void): () => void {
|
||||
fontListListeners.add(listener);
|
||||
loadFontLists();
|
||||
return () => fontListListeners.delete(listener);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* FontFamilyField */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -146,9 +203,8 @@ export function FontFamilyField({
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
||||
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
||||
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
|
||||
const lists = useSyncExternalStore(subscribeFontLists, () => fontLists);
|
||||
const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
|
||||
const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
|
||||
const [importingFonts, setImportingFonts] = useState(false);
|
||||
const [fontNotice, setFontNotice] = useState<string | null>(null);
|
||||
const canQueryLocalFonts =
|
||||
@@ -175,46 +231,18 @@ export function FontFamilyField({
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetch("/api/fonts")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { fonts?: string[] } | null) => {
|
||||
if (cancelled || !Array.isArray(data?.fonts)) return;
|
||||
setLocalFonts((cur) => uniqueFontFamilies([...cur, ...data.fonts!]));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
if (open) loadFontLists();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingGoogleFonts(true);
|
||||
void fetch("/api/fonts/google")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { fonts?: string[] } | null) => {
|
||||
if (cancelled || !Array.isArray(data?.fonts)) return;
|
||||
setGoogleFonts(uniqueFontFamilies([...data.fonts!, ...POPULAR_GOOGLE_FONT_FAMILIES]));
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingGoogleFonts(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (googleFonts.some((f) => f.toLowerCase() === currentFamily.toLowerCase())) {
|
||||
if (lists.googleKeys.has(currentFamily.toLowerCase())) {
|
||||
loadGoogleFontStylesheet(currentFamily);
|
||||
}
|
||||
const imported = importedFonts.find(
|
||||
(f) => f.family.toLowerCase() === currentFamily.toLowerCase(),
|
||||
);
|
||||
if (imported) loadImportedFontStylesheet(imported);
|
||||
}, [currentFamily, googleFonts, importedFonts]);
|
||||
}, [currentFamily, lists, importedFonts]);
|
||||
|
||||
const loadBrowserLocalFonts = async () => {
|
||||
if (!canQueryLocalFonts || !window.queryLocalFonts) {
|
||||
@@ -278,24 +306,19 @@ export function FontFamilyField({
|
||||
const options = useMemo(() => {
|
||||
if (!open) return [];
|
||||
const documentFonts = collectDocumentFontFamilies();
|
||||
const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
|
||||
const taggedLocal = localFonts.map(
|
||||
(family): FontOption => ({
|
||||
family,
|
||||
source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
|
||||
}),
|
||||
);
|
||||
return sortFontOptions(
|
||||
uniqueFontOptions([
|
||||
{ family: currentFamily, source: "Current" },
|
||||
...documentFonts.map((f): FontOption => ({ family: f, source: "Document" })),
|
||||
...projectFontAssets,
|
||||
...googleFonts.map((f): FontOption => ({ family: f, source: "Google" })),
|
||||
...taggedLocal,
|
||||
...lists.google.map((f): FontOption => ({ family: f, source: "Google" })),
|
||||
...[...lists.installed, ...localFonts].map(
|
||||
(f): FontOption => ({ family: f, source: "Local" }),
|
||||
),
|
||||
...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
|
||||
]),
|
||||
);
|
||||
}, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
|
||||
}, [open, currentFamily, lists, localFonts, projectFontAssets]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const matches = options.filter((o) => fontMatchesQuery(o.family, query));
|
||||
@@ -392,7 +415,7 @@ export function FontFamilyField({
|
||||
type="text"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
|
||||
placeholder={lists.loaded ? "Search fonts" : "Loading Google Fonts..."}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(-1);
|
||||
|
||||
@@ -125,8 +125,7 @@ export function localFontSortScore(font: LocalFontData): number {
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function uniqueFontFamilies(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
export function uniqueFontFamilies(values: string[], seen = new Set<string>()): string[] {
|
||||
return values.reduce<string[]>((result, value) => {
|
||||
const family = value.trim();
|
||||
if (!family) return result;
|
||||
|
||||
Reference in New Issue
Block a user