From e57f4b328f32e8d7cd3acee8ba9505b4828308e9 Mon Sep 17 00:00:00 2001 From: Jan Six Date: Thu, 21 May 2026 08:36:39 +0100 Subject: [PATCH] fix: dedupe Tokens Studio OAuth sync providers Tokens Studio OAuth providers are derived live from the user's organizations on every load. A regression in BranchSelector was persisting them to clientStorage when switching branches, which caused the same workspace to appear twice in Sync Settings (one live row + one stale persisted row) and pinned sync operations to the stale entry. - Short-circuit updateCredentials for TOKENS_STUDIO_OAUTH so OAuth providers are never persisted. - Strip any legacy persisted OAuth entries when reading apiProviders to clean up existing users without requiring manual deletion. - Defensively dedupe apiProviders against live studioProviders by internalId in SyncSettings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dedupe-studio-oauth-sync-providers.md | 5 +++++ .../src/app/components/SyncSettings.tsx | 17 +++++++++++++++-- .../src/figmaStorage/ApiProvidersProperty.ts | 15 +++++++++++---- .../src/utils/credentials.test.ts | 14 ++++++++++++++ .../src/utils/credentials.ts | 8 ++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 .changeset/dedupe-studio-oauth-sync-providers.md diff --git a/.changeset/dedupe-studio-oauth-sync-providers.md b/.changeset/dedupe-studio-oauth-sync-providers.md new file mode 100644 index 0000000000..5f0d502a1d --- /dev/null +++ b/.changeset/dedupe-studio-oauth-sync-providers.md @@ -0,0 +1,5 @@ +--- +"@tokens-studio/figma-plugin": patch +--- + +Fix duplicate Tokens Studio sync providers appearing in Sync Settings. Tokens Studio OAuth providers are derived live from the user's organizations and are no longer persisted to client storage. Any legacy persisted OAuth entries are stripped on read, and the Sync Settings list now defensively dedupes against live OAuth providers. diff --git a/packages/tokens-studio-for-figma/src/app/components/SyncSettings.tsx b/packages/tokens-studio-for-figma/src/app/components/SyncSettings.tsx index 394c08177c..40f0643e24 100644 --- a/packages/tokens-studio-for-figma/src/app/components/SyncSettings.tsx +++ b/packages/tokens-studio-for-figma/src/app/components/SyncSettings.tsx @@ -83,6 +83,19 @@ const SyncSettings = () => { return []; }, [isAuthenticated, organizations]); + // Defensive: never render an apiProvider that is a Tokens Studio OAuth entry + // (those are live-derived above and should not be persisted) or one whose + // internalId already matches a live studioProvider — both would create + // duplicate rows in Sync Settings. + const visibleApiProviders = React.useMemo(() => { + const studioInternalIds = new Set(studioProviders.map((p) => p.internalId).filter(Boolean)); + return apiProviders.filter((item) => { + if (item?.provider === StorageProviderType.TOKENS_STUDIO_OAUTH) return false; + if (item?.internalId && studioInternalIds.has(item.internalId)) return false; + return true; + }); + }, [apiProviders, studioProviders]); + const [open, setOpen] = React.useState(false); // Track when user opens the create new sync provider dialog @@ -229,13 +242,13 @@ const SyncSettings = () => { /> ))} - {apiProviders.length > 0 && ( + {visibleApiProviders.length > 0 && ( )} - {apiProviders.length > 0 && apiProviders.map((item) => ( + {visibleApiProviders.length > 0 && visibleApiProviders.map((item) => ( { type PossibleIncomingType = StorageTypeCredentials[] | Record; const result = tryParseJson(outgoing) ?? []; - if (Array.isArray(result)) return result; - return Object.values(result).filter((value) => ( - typeof value === 'object' - )); + const asArray = Array.isArray(result) + ? result + : Object.values(result).filter((value) => ( + typeof value === 'object' + )); + // Tokens Studio OAuth providers are derived live from the user's organizations + // and must never be persisted. Strip any legacy entries that were saved by + // older builds (e.g. when switching branches in an OAuth sync) so we don't + // render duplicate rows in Sync Settings. + return asArray.filter((value) => value?.provider !== StorageProviderType.TOKENS_STUDIO_OAUTH); }, ); diff --git a/packages/tokens-studio-for-figma/src/utils/credentials.test.ts b/packages/tokens-studio-for-figma/src/utils/credentials.test.ts index 4b2cb93c0b..1c9682fa72 100644 --- a/packages/tokens-studio-for-figma/src/utils/credentials.test.ts +++ b/packages/tokens-studio-for-figma/src/utils/credentials.test.ts @@ -100,6 +100,20 @@ describe('updateCredentials', () => { await updateCredentials(newObject); expect(figma.clientStorage.setAsync).toHaveBeenCalledWith('apiProviders', JSON.stringify(newArray)); }); + + it('does not persist Tokens Studio OAuth providers', async () => { + const oauthCredential = { + id: 'project-1', + orgId: 'org-1', + name: "admin's workspace", + provider: StorageProviderType.TOKENS_STUDIO_OAUTH, + internalId: 'tokens-studio-org-1', + }; + await updateCredentials(oauthCredential); + + expect(figma.clientStorage.getAsync).not.toHaveBeenCalled(); + expect(figma.clientStorage.setAsync).not.toHaveBeenCalled(); + }); }); describe('removeSingleCredential', () => { diff --git a/packages/tokens-studio-for-figma/src/utils/credentials.ts b/packages/tokens-studio-for-figma/src/utils/credentials.ts index f935510dbd..a1bc7ab17e 100644 --- a/packages/tokens-studio-for-figma/src/utils/credentials.ts +++ b/packages/tokens-studio-for-figma/src/utils/credentials.ts @@ -3,9 +3,17 @@ import { notifyAPIProviders, notifyUI } from '@/plugin/notifiers'; import isSameCredentials from './isSameCredentials'; import { ApiProvidersProperty } from '@/figmaStorage'; import { StorageTypeCredentials } from '@/types/StorageType'; +import { StorageProviderType } from '@/constants/StorageProviderType'; import { generateId } from './generateId'; export async function updateCredentials(context: StorageTypeCredentials) { + // Tokens Studio OAuth providers are derived live from the user's organizations + // on every load and must never be persisted to clientStorage. Persisting them + // creates duplicate rows in Sync Settings (a live OAuth-derived row + a stale + // persisted row) and can pin sync to a stale token/branch context. + if (context.provider === StorageProviderType.TOKENS_STUDIO_OAUTH) { + return; + } try { const data = await ApiProvidersProperty.read(); let existingProviders: NonNullable = [];