diff --git a/src/main/endpoints/formatText.ts b/src/main/endpoints/formatText.ts index 0566f8d..2c46b7a 100644 --- a/src/main/endpoints/formatText.ts +++ b/src/main/endpoints/formatText.ts @@ -1,6 +1,13 @@ 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; +// 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 */ formatted: string; @@ -64,12 +71,13 @@ export const formatText = async ({ return ranges; }; - let cachedAvailableFonts: Font[] | null = null; const getAvailableFonts = async () => { - if (!cachedAvailableFonts) { - cachedAvailableFonts = await figma.listAvailableFontsAsync(); + if (availableFontsCache) return availableFontsCache; + if (!availableFontsPromise) { + availableFontsPromise = figma.listAvailableFontsAsync(); } - return cachedAvailableFonts; + availableFontsCache = await availableFontsPromise; + return availableFontsCache; }; const getFontsOfFamily = async (font: FontName) => diff --git a/src/main/endpoints/getConnectedNodes.ts b/src/main/endpoints/getConnectedNodes.ts index 6562235..a3c2253 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, }; }); diff --git a/src/main/utils/settingsTools.ts b/src/main/utils/settingsTools.ts index 40f00d0..d00be79 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 () => { diff --git a/src/ui/hooks/useConnectedNodes.ts b/src/ui/hooks/useConnectedNodes.ts index 291a8d9..e3b885c 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 7d6d328..d1d6324 100644 --- a/src/ui/hooks/useSetNodesDataMutation.ts +++ b/src/ui/hooks/useSetNodesDataMutation.ts @@ -3,22 +3,51 @@ 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"; +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], { + 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, + }); + } + // 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, }); diff --git a/src/ui/views/Connect/Connect.tsx b/src/ui/views/Connect/Connect.tsx index f29f2d4..46bacd3 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, diff --git a/src/ui/views/Index/Index.tsx b/src/ui/views/Index/Index.tsx index b3eaf8b..3d9a9fc 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]);