From ede9dbe8e6217d52dd31b34f870c78df67f0380b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Tue, 12 May 2026 16:40:42 +0200 Subject: [PATCH 1/6] perf: native node scan and module-level font cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hot-path optimisations backported from the v2 rewrite: 1. getConnectedNodes (page-wide): replace manual JS recursion + per-node getPluginData() with findAllWithCriteria({ pluginData }) so the Figma runtime does the filtering natively — O(all nodes) → O(connected nodes), zero bridge crossings for unrelated nodes. 2. formatText: hoist the availableFonts cache to module scope so listAvailableFontsAsync() is called at most once per plugin session rather than once per node during a multi-node pull. Co-Authored-By: Claude Sonnet 4.6 --- src/main/endpoints/formatText.ts | 12 ++++++---- src/main/endpoints/getConnectedNodes.ts | 31 ++++++++++++++++++++----- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/main/endpoints/formatText.ts b/src/main/endpoints/formatText.ts index 0566f8d9..e51ac3c2 100644 --- a/src/main/endpoints/formatText.ts +++ b/src/main/endpoints/formatText.ts @@ -1,6 +1,11 @@ import { NodeInfo } from "../../types"; import { createEndpoint } from "../utils/createEndpoint"; +// Cached across all formatText calls in a session. listAvailableFontsAsync is +// one of the most expensive Figma API calls; calling it once per node during +// a multi-node pull would multiply the cost unnecessarily. +let availableFontsCache: Font[] | null = null; + export type FormatTextEndpointArgs = { /** The text that is to be displayed in the textNode. Can contain some basic html tags that will be replaced */ formatted: string; @@ -64,12 +69,11 @@ export const formatText = async ({ return ranges; }; - let cachedAvailableFonts: Font[] | null = null; const getAvailableFonts = async () => { - if (!cachedAvailableFonts) { - cachedAvailableFonts = await figma.listAvailableFontsAsync(); + if (!availableFontsCache) { + availableFontsCache = await figma.listAvailableFontsAsync(); } - return cachedAvailableFonts; + return availableFontsCache; }; const getFontsOfFamily = async (font: FontName) => diff --git a/src/main/endpoints/getConnectedNodes.ts b/src/main/endpoints/getConnectedNodes.ts index 65622352..a3c22538 100644 --- a/src/main/endpoints/getConnectedNodes.ts +++ b/src/main/endpoints/getConnectedNodes.ts @@ -1,6 +1,7 @@ +import { TOLGEE_NODE_INFO } from "@/constants"; import { NodeInfo } from "@/types"; import { createEndpoint } from "../utils/createEndpoint"; -import { findTextNodesInfo } from "../utils/nodeTools"; +import { findTextNodesInfo, getNodeInfo } from "../utils/nodeTools"; export type ConnectedNodesProps = { ignoreSelection: boolean; @@ -12,11 +13,29 @@ export const getConnectedNodesEndpoint = createEndpoint< >("GET_CONNECTED_NODES", async ({ ignoreSelection }) => { const basedOnSelection = !ignoreSelection && figma.currentPage.selection.length > 0; - const items = basedOnSelection - ? figma.currentPage.selection - : figma.currentPage.children; + + if (basedOnSelection) { + // Selection path: still walk manually since selection may be a subtree of + // any depth and `findAllWithCriteria` only operates on the whole page. + return { + items: findTextNodesInfo(figma.currentPage.selection).filter( + ({ key }) => key, + ), + basedOnSelection: true, + }; + } + + // Page-wide path: let the Figma runtime filter by plugin-data key natively. + // This avoids recursing through every node in JS and calling getPluginData + // on every TextNode — O(all nodes) → O(connected nodes). + await figma.currentPage.loadAsync(); + const connected = figma.currentPage.findAllWithCriteria({ + types: ["TEXT"], + pluginData: { keys: [TOLGEE_NODE_INFO] }, + }) as TextNode[]; + return { - items: findTextNodesInfo(items).filter(({ key }) => key), - basedOnSelection, + items: connected.map(getNodeInfo), + basedOnSelection: false, }; }); From c7d680fd7827887ee2a17e44b1f9ddf2621e278f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Tue, 12 May 2026 16:46:06 +0200 Subject: [PATCH 2/6] perf: eliminate full-page rescan after connect/disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After any setNodesData call (connect, disconnect, pull), the mutation's onSuccess patched only the stale marker, causing a full-page findAll scan every time the user returned to Index. On large Figma files this was the source of the ~20-second connect operation delay. Fix with three coordinated changes: - useSetNodesDataMutation: replace invalidateQueries with a direct cache patch (setQueryData) that adds, updates, or removes only the affected nodes — no bridge round-trip needed. - useConnectedNodes: set staleTime:30s so the freshly-patched cache is still considered valid when the Index route-change effect fires. - Index: guard allNodes.refetch() behind allNodes.isStale, so it only rescans when the cache is genuinely outdated (e.g. after Push/Pull which use explicit invalidation, or on first mount). Co-Authored-By: Claude Sonnet 4.6 --- src/ui/hooks/useConnectedNodes.ts | 8 ++++- src/ui/hooks/useSetNodesDataMutation.ts | 43 +++++++++++++++++++------ src/ui/views/Index/Index.tsx | 6 +++- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/ui/hooks/useConnectedNodes.ts b/src/ui/hooks/useConnectedNodes.ts index 291a8d93..e3b885c9 100644 --- a/src/ui/hooks/useConnectedNodes.ts +++ b/src/ui/hooks/useConnectedNodes.ts @@ -13,6 +13,12 @@ export const useConnectedNodes = (props: ConnectedNodesProps) => { return useQuery( queryKey, delayed(() => getConnectedNodesEndpoint.call(props)), - { select: (data) => ({ ...data, items: data.items.filter((n) => n.key) }) }, + { + select: (data) => ({ ...data, items: data.items.filter((n) => n.key) }), + // Keeps data "fresh" long enough that an optimistic cache patch written + // by useSetNodesDataMutation.onSuccess prevents an unnecessary full-page + // rescan when returning to Index right after a connect/disconnect. + staleTime: 30_000, + }, ); }; diff --git a/src/ui/hooks/useSetNodesDataMutation.ts b/src/ui/hooks/useSetNodesDataMutation.ts index 7d6d3286..5d3f8c63 100644 --- a/src/ui/hooks/useSetNodesDataMutation.ts +++ b/src/ui/hooks/useSetNodesDataMutation.ts @@ -4,24 +4,47 @@ import { } from "@/main/endpoints/setNodesData"; import { getConnectedNodesEndpoint } from "@/main/endpoints/getConnectedNodes"; import { delayed } from "@/main/utils/delayed"; +import { NodeInfo } from "@/types"; import { useMutation, useQueryClient } from "react-query"; +type CachedNodes = { items: NodeInfo[]; basedOnSelection: boolean }; + export const useSetNodesDataMutation = () => { const queryClient = useQueryClient(); const result = useMutation( [setNodesDataEndpoint.name], delayed((props: SetNodesDataProps) => setNodesDataEndpoint.call(props)), { - onSuccess: () => { - // Mark connected-nodes data stale without triggering an immediate - // refetch. Refetching on every keystroke walks the entire page tree - // (see getConnectedNodes with ignoreSelection: true) and froze the UI - // while typing. Stale data is refetched on the next mount, e.g. when - // navigating back to Index/Pull/Push after Connect. - queryClient.invalidateQueries([getConnectedNodesEndpoint.name], { - refetchActive: false, - refetchInactive: false, - }); + onSuccess: (_, { nodes }) => { + // Patch the page-wide cache directly so returning to Index does not + // trigger a full-page rescan. useConnectedNodes uses staleTime:30s, + // so fresh data here prevents the redundant allNodes.refetch() call. + const key = [getConnectedNodesEndpoint.name, true] as const; + const old = queryClient.getQueryData(key); + if (old) { + const patch = new Map(nodes.map((n) => [n.id, n])); + const updated = old.items + .map((item) => + patch.has(item.id) + ? { ...item, ...patch.get(item.id)! } + : item, + ) + .filter((item) => item.key && item.connected !== false); + // Add newly connected nodes that were not tracked before + for (const n of nodes) { + if ( + n.key && + n.connected !== false && + !old.items.some((i) => i.id === n.id) + ) { + updated.push(n); + } + } + queryClient.setQueryData(key, { + ...old, + items: updated, + }); + } }, }, ); diff --git a/src/ui/views/Index/Index.tsx b/src/ui/views/Index/Index.tsx index b3eaf8bb..3d9a9fc4 100644 --- a/src/ui/views/Index/Index.tsx +++ b/src/ui/views/Index/Index.tsx @@ -109,7 +109,11 @@ export const Index = () => { } if (route[0] === "index") { selectionLoadable.refetch(); - allNodes.refetch(); + // Skip the page-wide rescan when the mutation already patched the cache + // (useSetNodesDataMutation.onSuccess + staleTime:30s keeps it fresh). + if (allNodes.isStale) { + allNodes.refetch(); + } } }, [route]); From 1e28075636cead473e08940b877d7e64b19c99c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Tue, 12 May 2026 16:47:34 +0200 Subject: [PATCH 3/6] fix: use nullish coalescing for translation fallback in Connect component Updated the translation assignment in the Connect component to use nullish coalescing (??) instead of logical OR (||) for better handling of undefined values. This change ensures that the fallback to node.characters only occurs when resolvedTranslation is null or undefined. --- src/ui/views/Connect/Connect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/views/Connect/Connect.tsx b/src/ui/views/Connect/Connect.tsx index f29f2d49..46bacd3d 100644 --- a/src/ui/views/Connect/Connect.tsx +++ b/src/ui/views/Connect/Connect.tsx @@ -94,7 +94,7 @@ export const Connect = ({ node }: Props) => { nodes: [ { ...node, - translation: resolvedTranslation || node.characters, + translation: resolvedTranslation ?? node.characters, isPlural: isPlural ?? node.isPlural, pluralParamValue: pluralParamValue ?? node.pluralParamValue, key, From aa9f4fefbf1e3c44f027c7301a5b405a89b78729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Tue, 12 May 2026 17:04:42 +0200 Subject: [PATCH 4/6] fix: optimize font retrieval with in-flight promise handling Enhanced the font retrieval logic in formatText by introducing an in-flight promise to prevent concurrent requests for available fonts. This change ensures that the listAvailableFontsAsync function is called only once while the promise is pending, improving performance and reducing unnecessary API calls. --- src/main/endpoints/formatText.ts | 8 ++++++-- src/ui/hooks/useSetNodesDataMutation.ts | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/endpoints/formatText.ts b/src/main/endpoints/formatText.ts index e51ac3c2..2c46b7a9 100644 --- a/src/main/endpoints/formatText.ts +++ b/src/main/endpoints/formatText.ts @@ -5,6 +5,8 @@ import { createEndpoint } from "../utils/createEndpoint"; // one of the most expensive Figma API calls; calling it once per node during // a multi-node pull would multiply the cost unnecessarily. let availableFontsCache: Font[] | null = null; +// In-flight promise prevents concurrent callers from each issuing the request. +let availableFontsPromise: Promise | null = null; export type FormatTextEndpointArgs = { /** The text that is to be displayed in the textNode. Can contain some basic html tags that will be replaced */ @@ -70,9 +72,11 @@ export const formatText = async ({ }; const getAvailableFonts = async () => { - if (!availableFontsCache) { - availableFontsCache = await figma.listAvailableFontsAsync(); + if (availableFontsCache) return availableFontsCache; + if (!availableFontsPromise) { + availableFontsPromise = figma.listAvailableFontsAsync(); } + availableFontsCache = await availableFontsPromise; return availableFontsCache; }; diff --git a/src/ui/hooks/useSetNodesDataMutation.ts b/src/ui/hooks/useSetNodesDataMutation.ts index 5d3f8c63..03b783c3 100644 --- a/src/ui/hooks/useSetNodesDataMutation.ts +++ b/src/ui/hooks/useSetNodesDataMutation.ts @@ -25,9 +25,7 @@ export const useSetNodesDataMutation = () => { const patch = new Map(nodes.map((n) => [n.id, n])); const updated = old.items .map((item) => - patch.has(item.id) - ? { ...item, ...patch.get(item.id)! } - : item, + patch.has(item.id) ? { ...item, ...patch.get(item.id)! } : item, ) .filter((item) => item.key && item.connected !== false); // Add newly connected nodes that were not tracked before From 3888c4bbf105b5e7ee67c500ad515f06e6040cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Tue, 12 May 2026 17:59:57 +0200 Subject: [PATCH 5/6] fix: invalidate selectedNodes cache after pull so Index shows updated characters When Index remounts after navigating back from Pull, mountedRef resets to false and the route-change effect treats the mount as the first render, skipping selectionLoadable.refetch(). Marking the selectedNodes query stale in onSuccess ensures useQuery triggers a background refetch on remount so the updated node characters are visible. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/hooks/useSetNodesDataMutation.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ui/hooks/useSetNodesDataMutation.ts b/src/ui/hooks/useSetNodesDataMutation.ts index 03b783c3..d1d6324a 100644 --- a/src/ui/hooks/useSetNodesDataMutation.ts +++ b/src/ui/hooks/useSetNodesDataMutation.ts @@ -3,6 +3,7 @@ import { setNodesDataEndpoint, } from "@/main/endpoints/setNodesData"; import { getConnectedNodesEndpoint } from "@/main/endpoints/getConnectedNodes"; +import { getSelectedNodesEndpoint } from "@/main/endpoints/getSelectedNodes"; import { delayed } from "@/main/utils/delayed"; import { NodeInfo } from "@/types"; import { useMutation, useQueryClient } from "react-query"; @@ -43,6 +44,13 @@ export const useSetNodesDataMutation = () => { items: updated, }); } + // Mark selection stale so Index re-fetches node characters after + // navigating back from Pull (Index remounts, mountedRef resets to + // false, so the route-change effect never fires on that remount). + queryClient.invalidateQueries([getSelectedNodesEndpoint.name], { + refetchActive: false, + refetchInactive: false, + }); }, }, ); From cf8f9c9799856e5f85ef70abac64e3702c2912de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Tue, 12 May 2026 18:17:38 +0200 Subject: [PATCH 6/6] refactor: enhance getGlobalSettings function for better data handling Updated the getGlobalSettings function to return a Promise with a Partial type. Improved error handling for clientStorage data retrieval by checking for both string and already-parsed object formats, ensuring robust data parsing and reducing potential errors. --- src/main/utils/settingsTools.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/utils/settingsTools.ts b/src/main/utils/settingsTools.ts index 40f00d0e..d00be799 100644 --- a/src/main/utils/settingsTools.ts +++ b/src/main/utils/settingsTools.ts @@ -9,18 +9,25 @@ import { import { emit } from "@create-figma-plugin/utilities"; import { setPageData } from "./pages"; -const getGlobalSettings = async () => { +const getGlobalSettings = async (): Promise> => { const pluginData = await figma.clientStorage.getAsync( TOLGEE_PLUGIN_CONFIG_NAME, ); - return pluginData ? (JSON.parse(pluginData) as Partial) : {}; + if (!pluginData) return {}; + // clientStorage may return an already-parsed object (Figma auto-parses JSON + // strings, or old data was stored without JSON.stringify). Handle both. + if (typeof pluginData === "string") { + try { + return JSON.parse(pluginData) as Partial; + } catch { + return {}; + } + } + return pluginData as Partial; }; const setGlobalSettings = async (data: Partial) => { - await figma.clientStorage.setAsync( - TOLGEE_PLUGIN_CONFIG_NAME, - JSON.stringify(data), - ); + await figma.clientStorage.setAsync(TOLGEE_PLUGIN_CONFIG_NAME, data); }; export const deleteGlobalSettings = async () => {