diff --git a/packages/studio/src/components/editor/overlayFrameLoop.test.ts b/packages/studio/src/components/editor/overlayFrameLoop.test.ts index 7601e0555c..8d137b131b 100644 --- a/packages/studio/src/components/editor/overlayFrameLoop.test.ts +++ b/packages/studio/src/components/editor/overlayFrameLoop.test.ts @@ -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")); diff --git a/packages/studio/src/components/editor/overlayFrameLoop.ts b/packages/studio/src/components/editor/overlayFrameLoop.ts index c770523368..cf7b591c78 100644 --- a/packages/studio/src/components/editor/overlayFrameLoop.ts +++ b/packages/studio/src/components/editor/overlayFrameLoop.ts @@ -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; diff --git a/packages/studio/src/components/editor/propertyPanelFont.test.tsx b/packages/studio/src/components/editor/propertyPanelFont.test.tsx index 32afedc3a3..bff75ca821 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.test.tsx @@ -8,7 +8,11 @@ import { sortFontOptions } from "./propertyPanelHelpers"; vi.mock("./propertyPanelHelpers", async (importOriginal) => { const actual = await importOriginal(); - 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(); 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('[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(); + const toggle = () => + host.querySelector('[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(); + } + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index 0f0c99569b..071b0296b7 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -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; + 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 | null = null; +const fontListListeners = new Set<() => void>(); + +async function fetchFontList(url: string): Promise { + 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(); + 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([]); const [localFontData, setLocalFontData] = useState([]); - const [googleFonts, setGoogleFonts] = useState(() => [...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(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; - }; - }, []); - - 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; - }; - }, []); + if (open) loadFontLists(); + }, [open]); 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); diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index c97ee0b3bd..753675968e 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -125,8 +125,7 @@ export function localFontSortScore(font: LocalFontData): number { return 3; } -export function uniqueFontFamilies(values: string[]): string[] { - const seen = new Set(); +export function uniqueFontFamilies(values: string[], seen = new Set()): string[] { return values.reduce((result, value) => { const family = value.trim(); if (!family) return result;