From cf0d0b39e4111a3a2c7cacc80e620a404905d254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 16:40:30 -0400 Subject: [PATCH 1/6] 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. --- .../editor/propertyPanelFont.test.tsx | 43 +++++++++++- .../components/editor/propertyPanelFont.tsx | 70 +++++++++++-------- 2 files changed, 83 insertions(+), 30 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFont.test.tsx b/packages/studio/src/components/editor/propertyPanelFont.test.tsx index 32afedc3a3..ebed23d0a2 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,7 +60,7 @@ 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(); @@ -71,3 +75,38 @@ describe("FontFamilyField font list", () => { } }); }); + +describe("FontFamilyField session font lists", () => { + it("fetches the lists on the first open, once per session, and never on selection", async () => { + vi.resetModules(); + const { FontFamilyField: Field } = await import("./propertyPanelFont"); + const { uniqueFontFamilies } = await import("./propertyPanelHelpers"); + const host = document.createElement("div"); + document.body.append(host); + let root = createRoot(host); + const render = (value: string) => + root.render(); + const toggle = () => + host.querySelector('[data-flat-font-trigger="true"]')?.click(); + try { + await act(async () => render("Arial")); + await act(async () => render("Georgia")); + expect(fetch).not.toHaveBeenCalled(); + expect(uniqueFontFamilies).not.toHaveBeenCalled(); + + await act(async () => toggle()); + expect(fetch).toHaveBeenCalledTimes(2); + expect(host.textContent).toContain("Roboto Slab"); + + act(() => root.unmount()); + root = createRoot(host); + await act(async () => render("Arial")); + await act(async () => toggle()); + expect(fetch).toHaveBeenCalledTimes(2); + expect(host.textContent).toContain("Roboto Slab"); + } finally { + act(() => root.unmount()); + host.remove(); + } + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index 0f0c99569b..43c6e47164 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -117,6 +117,36 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void { document.head.appendChild(style); } +let installedFontsRequest: Promise | null = null; +let googleFontsRequest: Promise | null = null; +let googleFontKeys: ReadonlySet = new Set( + POPULAR_GOOGLE_FONT_FAMILIES.map((f) => f.toLowerCase()), +); + +async function fetchFontList(url: string): Promise { + try { + const response = await fetch(url); + const data = (response.ok ? await response.json() : null) as { fonts?: string[] } | null; + return Array.isArray(data?.fonts) ? data.fonts : []; + } catch { + return []; + } +} + +function sessionInstalledFonts(): Promise { + installedFontsRequest ??= fetchFontList("/api/fonts"); + return installedFontsRequest; +} + +function sessionGoogleFonts(): Promise { + googleFontsRequest ??= fetchFontList("/api/fonts/google").then((fonts) => { + const families = uniqueFontFamilies([...fonts, ...POPULAR_GOOGLE_FONT_FAMILIES]); + googleFontKeys = new Set(families.map((f) => f.toLowerCase())); + return families; + }); + return googleFontsRequest; +} + /* ------------------------------------------------------------------ */ /* FontFamilyField */ /* ------------------------------------------------------------------ */ @@ -175,39 +205,24 @@ export function FontFamilyField({ }, [open]); useEffect(() => { + if (!open) return; 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; + void sessionInstalledFonts().then((fonts) => { + if (!cancelled) setLocalFonts((cur) => uniqueFontFamilies([...cur, ...fonts])); + }); 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); - }); + void sessionGoogleFonts().then((fonts) => { + if (cancelled) return; + setGoogleFonts(fonts); + setLoadingGoogleFonts(false); + }); return () => { cancelled = true; }; - }, []); + }, [open]); useEffect(() => { - if (googleFonts.some((f) => f.toLowerCase() === currentFamily.toLowerCase())) { + if (googleFontKeys.has(currentFamily.toLowerCase())) { loadGoogleFontStylesheet(currentFamily); } const imported = importedFonts.find( @@ -278,11 +293,10 @@ 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", + source: googleFontKeys.has(family.toLowerCase()) ? "Google" : "Local", }), ); return sortFontOptions( From 6710de8dee5aa24171e1117d7ba8871134620281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 17:00:53 -0400 Subject: [PATCH 2/6] test(studio): expect the font lists to load on the first picker open --- .../studio/src/components/editor/propertyPanelFont.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFont.test.tsx b/packages/studio/src/components/editor/propertyPanelFont.test.tsx index ebed23d0a2..6cf6022db4 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.test.tsx @@ -66,7 +66,7 @@ describe("FontFamilyField font list", () => { const trigger = host.querySelector('[data-flat-font-trigger="true"]'); await act(async () => trigger?.click()); - expect(sortFontOptions).toHaveBeenCalledTimes(1); + expect(sortFontOptions).toHaveBeenCalled(); expect(host.textContent).toContain("Roboto Slab"); expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull(); } finally { @@ -81,6 +81,7 @@ describe("FontFamilyField session font lists", () => { vi.resetModules(); const { FontFamilyField: Field } = await import("./propertyPanelFont"); const { uniqueFontFamilies } = await import("./propertyPanelHelpers"); + vi.mocked(uniqueFontFamilies).mockClear(); const host = document.createElement("div"); document.body.append(host); let root = createRoot(host); From 01427ce94c0bae620250e782aaa91084785457ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 17:17:30 -0400 Subject: [PATCH 3/6] perf(studio): load the font lists at session start and process them when input is idle --- .../src/components/editor/overlayFrameLoop.ts | 11 ++ .../editor/propertyPanelFont.test.tsx | 75 +++++++++---- .../components/editor/propertyPanelFont.tsx | 103 +++++++++--------- 3 files changed, 119 insertions(+), 70 deletions(-) diff --git a/packages/studio/src/components/editor/overlayFrameLoop.ts b/packages/studio/src/components/editor/overlayFrameLoop.ts index c770523368..68c617a615 100644 --- a/packages/studio/src/components/editor/overlayFrameLoop.ts +++ b/packages/studio/src/components/editor/overlayFrameLoop.ts @@ -143,6 +143,17 @@ function schedule(): void { }, IDLE_POLL_MS); } +/** Runs `task` in an idle period once no input has woken the loop for AWAKE_MS, so it never shares a gesture's frames. */ +export function runWhenInputIdle(task: () => void): void { + const wait = awakeUntil - performance.now(); + if (wait > 0) { + setTimeout(() => runWhenInputIdle(task), wait); + return; + } + const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 0)); + idle(() => (performance.now() < awakeUntil ? runWhenInputIdle(task) : task())); +} + /** 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 6cf6022db4..bff75ca821 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.test.tsx @@ -66,6 +66,7 @@ describe("FontFamilyField font list", () => { const trigger = host.querySelector('[data-flat-font-trigger="true"]'); await act(async () => trigger?.click()); + 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(); @@ -77,37 +78,73 @@ describe("FontFamilyField font list", () => { }); describe("FontFamilyField session font lists", () => { - it("fetches the lists on the first open, once per session, and never on selection", async () => { + const fresh = async () => { vi.resetModules(); - const { FontFamilyField: Field } = await import("./propertyPanelFont"); + 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); - let root = createRoot(host); + const root = createRoot(host); const render = (value: string) => - root.render(); + root.render(); const toggle = () => host.querySelector('[data-flat-font-trigger="true"]')?.click(); - try { - await act(async () => render("Arial")); - await act(async () => render("Georgia")); - expect(fetch).not.toHaveBeenCalled(); - expect(uniqueFontFamilies).not.toHaveBeenCalled(); + const done = () => { + act(() => root.unmount()); + host.remove(); + loop.resetOverlayFrameLoopForTests(); + }; + return { uniqueFontFamilies, loop, host, render, toggle, done }; + }; - await act(async () => toggle()); + 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(host.textContent).toContain("Roboto Slab"); + expect(t.uniqueFontFamilies).not.toHaveBeenCalled(); - act(() => root.unmount()); - root = createRoot(host); - await act(async () => render("Arial")); - await act(async () => toggle()); - expect(fetch).toHaveBeenCalledTimes(2); - expect(host.textContent).toContain("Roboto Slab"); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(t.uniqueFontFamilies).toHaveBeenCalledTimes(1); + await act(async () => t.toggle()); + expect(t.host.textContent).toContain("Roboto Slab"); } finally { - act(() => root.unmount()); - host.remove(); + 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 43c6e47164..b090c92862 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,34 +118,52 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void { document.head.appendChild(style); } -let installedFontsRequest: Promise | null = null; -let googleFontsRequest: Promise | null = null; -let googleFontKeys: ReadonlySet = new Set( - POPULAR_GOOGLE_FONT_FAMILIES.map((f) => f.toLowerCase()), -); +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 { - try { - const response = await fetch(url); - const data = (response.ok ? await response.json() : null) as { fonts?: string[] } | null; - return Array.isArray(data?.fonts) ? data.fonts : []; - } catch { - return []; - } + 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 sessionInstalledFonts(): Promise { - installedFontsRequest ??= fetchFontList("/api/fonts"); - return installedFontsRequest; +// Fetched once per session; a failure clears the request so the next mount or open retries. +function loadFontLists(): void { + if (fontLists.loaded) return; + fontListsRequest ??= Promise.all([ + fetchFontList("/api/fonts"), + fetchFontList("/api/fonts/google"), + ]) + .then(([installed, google]) => + runWhenInputIdle(() => { + const families = uniqueFontFamilies([...google, ...POPULAR_GOOGLE_FONT_FAMILIES]); + const googleKeys = new Set(families.map((f) => f.toLowerCase())); + fontLists = { loaded: true, google: families, googleKeys, installed }; + for (const listener of fontListListeners) listener(); + }), + ) + .catch(() => { + fontListsRequest = null; + }); } -function sessionGoogleFonts(): Promise { - googleFontsRequest ??= fetchFontList("/api/fonts/google").then((fonts) => { - const families = uniqueFontFamilies([...fonts, ...POPULAR_GOOGLE_FONT_FAMILIES]); - googleFontKeys = new Set(families.map((f) => f.toLowerCase())); - return families; - }); - return googleFontsRequest; +function subscribeFontLists(listener: () => void): () => void { + fontListListeners.add(listener); + loadFontLists(); + return () => fontListListeners.delete(listener); } /* ------------------------------------------------------------------ */ @@ -176,9 +195,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 = @@ -205,31 +223,18 @@ export function FontFamilyField({ }, [open]); useEffect(() => { - if (!open) return; - let cancelled = false; - void sessionInstalledFonts().then((fonts) => { - if (!cancelled) setLocalFonts((cur) => uniqueFontFamilies([...cur, ...fonts])); - }); - setLoadingGoogleFonts(true); - void sessionGoogleFonts().then((fonts) => { - if (cancelled) return; - setGoogleFonts(fonts); - setLoadingGoogleFonts(false); - }); - return () => { - cancelled = true; - }; + if (open) loadFontLists(); }, [open]); useEffect(() => { - if (googleFontKeys.has(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) { @@ -293,23 +298,19 @@ export function FontFamilyField({ const options = useMemo(() => { if (!open) return []; const documentFonts = collectDocumentFontFamilies(); - const taggedLocal = localFonts.map( - (family): FontOption => ({ - family, - source: googleFontKeys.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)); @@ -406,7 +407,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); From f9564c4532e0e7846434c485a064d90f0d18bac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 17:18:06 -0400 Subject: [PATCH 4/6] style(studio): drop a comment the font list names already carry --- packages/studio/src/components/editor/propertyPanelFont.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index b090c92862..59d480374a 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -140,7 +140,6 @@ async function fetchFontList(url: string): Promise { return data.fonts as string[]; } -// Fetched once per session; a failure clears the request so the next mount or open retries. function loadFontLists(): void { if (fontLists.loaded) return; fontListsRequest ??= Promise.all([ From e0f6e7cd3da23745965f43cf70a9de4d5f1e58a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 18:02:37 -0400 Subject: [PATCH 5/6] perf(studio): process the font lists in idle slices of at most 3 ms --- .../editor/overlayFrameLoop.test.ts | 27 +++++++++++++++++++ .../src/components/editor/overlayFrameLoop.ts | 18 ++++++++++--- .../components/editor/propertyPanelFont.tsx | 21 ++++++++++----- .../components/editor/propertyPanelHelpers.ts | 3 +-- 4 files changed, 57 insertions(+), 12 deletions(-) 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 68c617a615..cf7b591c78 100644 --- a/packages/studio/src/components/editor/overlayFrameLoop.ts +++ b/packages/studio/src/components/editor/overlayFrameLoop.ts @@ -143,15 +143,25 @@ function schedule(): void { }, IDLE_POLL_MS); } -/** Runs `task` in an idle period once no input has woken the loop for AWAKE_MS, so it never shares a gesture's frames. */ -export function runWhenInputIdle(task: () => void): void { +/** 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(task), wait); + setTimeout(() => runWhenInputIdle(step), wait); return; } const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 0)); - idle(() => (performance.now() < awakeUntil ? runWhenInputIdle(task) : task())); + 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. */ diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index 59d480374a..447d5473e6 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -146,14 +146,23 @@ function loadFontLists(): void { fetchFontList("/api/fonts"), fetchFontList("/api/fonts/google"), ]) - .then(([installed, google]) => - runWhenInputIdle(() => { - const families = uniqueFontFamilies([...google, ...POPULAR_GOOGLE_FONT_FAMILIES]); - const googleKeys = new Set(families.map((f) => f.toLowerCase())); + .then(([installed, google]) => { + const names = [...google, ...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; }); 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; From c53de728f86fd12c1a4c03949351a3470439fda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 19:12:39 -0400 Subject: [PATCH 6/6] perf(studio): join the font lists without iterating them when they arrive --- packages/studio/src/components/editor/propertyPanelFont.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index 447d5473e6..071b0296b7 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -147,7 +147,7 @@ function loadFontLists(): void { fetchFontList("/api/fonts/google"), ]) .then(([installed, google]) => { - const names = [...google, ...POPULAR_GOOGLE_FONT_FAMILIES]; + const names = google.concat(POPULAR_GOOGLE_FONT_FAMILIES); const families: string[] = []; const googleKeys = new Set(); let next = 0;