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:
Miguel Ángel
2026-10-01 08:35:28 -07:00
committed by GitHub
parent 07af05c816
commit 8593b01a6d
5 changed files with 198 additions and 51 deletions
@@ -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;