From 5df775ed217f3ea13809266039e67c0f544966dc Mon Sep 17 00:00:00 2001 From: Jan Six Date: Tue, 12 May 2026 10:06:39 +0200 Subject: [PATCH 1/6] feat: round-trip and via REST/OAuth - Extend ThemeObject with $themeGroupId and $themeOptionId for server-side UUID caching - Fix REST read: source figma_variable_references from theme group (not option) - Add PATCH helpers for variable refs (group) and style refs (option) - Add resolveChangeSetId helper for branch-aware writes - Wire tokenState middleware for TOKENS_STUDIO_OAUTH provider (refs-only actions) - Add unit tests for PATCH helpers and orchestrator (14 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/theme-refs-round-trip.md | 5 + .../src/app/store/middlewares/tokenState.ts | 20 ++ .../src/storage/schemas/themeObjectSchema.ts | 2 + .../src/types/ThemeObject.ts | 3 + .../tokensStudio/fetchBranchesListRest.ts | 27 +++ .../tokensStudio/fetchProjectDataRest.ts | 9 +- .../tokensStudio/pushThemeRefsRest.test.ts | 102 ++++++++++ .../utils/tokensStudio/pushThemeRefsRest.ts | 73 +++++++ .../updateThemeRefsViaRestApi.test.ts | 180 ++++++++++++++++++ .../tokensStudio/updateThemeRefsViaRestApi.ts | 113 +++++++++++ 10 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 .changeset/theme-refs-round-trip.md create mode 100644 packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts create mode 100644 packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts create mode 100644 packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts create mode 100644 packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts diff --git a/.changeset/theme-refs-round-trip.md b/.changeset/theme-refs-round-trip.md new file mode 100644 index 0000000000..23ff307e2a --- /dev/null +++ b/.changeset/theme-refs-round-trip.md @@ -0,0 +1,5 @@ +--- +"@tokens-studio/figma-plugin": patch +--- + +Added support for saving variable and style references for Studio projects diff --git a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts index 4dd185db14..effb1f08d0 100644 --- a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts +++ b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts @@ -1,5 +1,6 @@ import { StorageProviderType } from '@/constants/StorageProviderType'; import { updateThemeGroupsInTokensStudio } from '@/storage/tokensStudio/updateThemeGroupsInTokensStudio'; +import { updateThemeRefsViaRestApi } from '@/utils/tokensStudio/updateThemeRefsViaRestApi'; const actionsToTriggerUpdateInTokensStudio = [ 'tokenState/assignVariableIdsToCurrentTheme', @@ -14,6 +15,15 @@ const actionsToTriggerUpdateInTokensStudio = [ 'tokenState/disconnectStyleFromTheme', ]; +const refOnlyActions = [ + 'tokenState/assignVariableIdsToCurrentTheme', + 'tokenState/assignVariableIdsToTheme', + 'tokenState/assignStyleIdsToCurrentTheme', + 'tokenState/assignStyleIdsToTheme', + 'tokenState/disconnectVariableFromTheme', + 'tokenState/disconnectStyleFromTheme', +]; + export const tokenStateMiddleware = (store) => (next) => (action) => { const prevState = store.getState(); next(action); @@ -30,4 +40,14 @@ export const tokenStateMiddleware = (store) => (next) => (action) => { dispatch: store.dispatch, }); } + + if ( + nextState.uiState.api?.provider === StorageProviderType.TOKENS_STUDIO_OAUTH + && refOnlyActions.includes(action.type) + ) { + updateThemeRefsViaRestApi({ + prevState, + rootState: nextState, + }); + } }; diff --git a/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts b/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts index fd47335373..791c83b279 100644 --- a/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts +++ b/packages/tokens-studio-for-figma/src/storage/schemas/themeObjectSchema.ts @@ -14,4 +14,6 @@ export const themeObjectSchema = z.object({ $figmaVariableReferences: z.record(z.string()).optional(), $figmaCollectionId: z.string().optional(), $figmaModeId: z.string().optional(), + $themeGroupId: z.string().optional(), + $themeOptionId: z.string().optional(), }); diff --git a/packages/tokens-studio-for-figma/src/types/ThemeObject.ts b/packages/tokens-studio-for-figma/src/types/ThemeObject.ts index 564b079975..5f190874cd 100644 --- a/packages/tokens-studio-for-figma/src/types/ThemeObject.ts +++ b/packages/tokens-studio-for-figma/src/types/ThemeObject.ts @@ -15,4 +15,7 @@ export type ThemeObject = { $figmaCollectionId?: string; $figmaModeId?: string; $figmaVariableReferences?: Record; + // Server IDs for REST API round-trip (Studio-on-Rails) + $themeGroupId?: string; + $themeOptionId?: string; }; diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts index c150128ea9..bd0412bc21 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchBranchesListRest.ts @@ -25,6 +25,33 @@ export function parseBranchesFromResponse(branchesData: any): RestBranch[] { return branches; } +export async function resolveChangeSetId( + authToken: string, + apiBaseUrl: string, + projectId: string, + branchName: string, +): Promise { + const headers = { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }; + + try { + const branchesRes = await fetch(`${apiBaseUrl}/api/v1/projects/${projectId}/branches`, { headers }); + if (!branchesRes.ok) throw new Error(`Failed to fetch branches: ${branchesRes.statusText}`); + const branchesData = await branchesRes.json(); + + const branches = parseBranchesFromResponse(branchesData); + const branch = branches.find((b) => b.name === branchName) + || branches.find((b) => b.is_default); + + return branch?.change_set_id || null; + } catch (error) { + console.error('Error resolving change_set_id:', error); + return null; + } +} + export async function fetchBranchesListRest( authToken: string, apiBaseUrl: string, diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts index 075cc87f79..b01992b659 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts @@ -20,7 +20,6 @@ interface RestThemeOption { theme_group_id: string; selected_token_sets: Record; figmaStyleReferences?: Record; - figmaVariableReferences?: Record; figmaCollectionId?: string; figmaModeId?: string; } @@ -219,7 +218,6 @@ export async function fetchProjectDataRest( theme_group_id: item.relationships?.theme_group?.data?.id || '', selected_token_sets: item.attributes?.selected_token_sets || {}, figmaStyleReferences: item.attributes?.figma_style_references, - figmaVariableReferences: item.attributes?.figma_variable_references, figmaCollectionId: item.attributes?.figma_collection_id, figmaModeId: item.attributes?.figma_mode_id, }); @@ -232,12 +230,13 @@ export async function fetchProjectDataRest( optionsByGroupId[opt.theme_group_id].push(opt); }); - // Parse theme groups + // Parse theme groups — variable refs live at the group level const themes: ThemeObjectsList = []; if (themeGroupsData.data && Array.isArray(themeGroupsData.data)) { themeGroupsData.data.forEach((group: any) => { const { id } = group; const name = group.attributes?.name || id; + const groupVariableRefs: Record | undefined = group.attributes?.figma_variable_references; const options = optionsByGroupId[id] || []; options.forEach((opt) => { @@ -255,10 +254,12 @@ export async function fetchProjectDataRest( name: opt.name, group: name, selectedTokenSets: selectedTokenSetsByName as any, + $themeGroupId: id, + $themeOptionId: opt.id, }; if (opt.figmaStyleReferences !== undefined) themeObj.$figmaStyleReferences = opt.figmaStyleReferences; - if (opt.figmaVariableReferences !== undefined) themeObj.$figmaVariableReferences = opt.figmaVariableReferences; + if (groupVariableRefs !== undefined) themeObj.$figmaVariableReferences = groupVariableRefs; if (opt.figmaCollectionId !== undefined) themeObj.$figmaCollectionId = opt.figmaCollectionId; if (opt.figmaModeId !== undefined) themeObj.$figmaModeId = opt.figmaModeId; diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts new file mode 100644 index 0000000000..ac42f572a9 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.test.ts @@ -0,0 +1,102 @@ +import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; + +describe('pushThemeRefsRest', () => { + const mockFetch = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + const authToken = 'test-token'; + const apiBaseUrl = 'https://studio.example.com'; + const projectId = 'proj-123'; + const changeSetId = 'cs-456'; + + describe('patchThemeGroupVariableRefs', () => { + it('should skip PATCH when refs is undefined (preserve semantics)', async () => { + await patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', undefined); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should PATCH with empty object to clear refs', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', {}); + + expect(mockFetch).toHaveBeenCalledWith( + `${apiBaseUrl}/api/v1/projects/${projectId}/theme_groups/group-1?change_set_id=${encodeURIComponent(changeSetId)}`, + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ theme_group: { figma_variable_references: {} } }), + }), + ); + }); + + it('should PATCH with refs map', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const refs = { 'color.brand.500': 'VariableID:123:456' }; + + await patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', refs); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.theme_group.figma_variable_references).toEqual(refs); + }); + + it('should throw on non-OK response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 422, text: () => Promise.resolve('Unprocessable') }); + + await expect( + patchThemeGroupVariableRefs(authToken, apiBaseUrl, projectId, changeSetId, 'group-1', {}), + ).rejects.toThrow('Failed to PATCH theme_group variable refs (422)'); + }); + }); + + describe('patchThemeOptionStyleRefs', () => { + it('should skip PATCH when refs is undefined (preserve semantics)', async () => { + await patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', undefined); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should PATCH with empty object to clear refs', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', {}); + + expect(mockFetch).toHaveBeenCalledWith( + `${apiBaseUrl}/api/v1/projects/${projectId}/theme_options/opt-1?change_set_id=${encodeURIComponent(changeSetId)}`, + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ theme_option: { figma_style_references: {} } }), + }), + ); + }); + + it('should PATCH with style refs verbatim (S: prefix included)', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const refs = { 'hint.text': 'S:81e4c301abc,' }; + + await patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', refs); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.theme_option.figma_style_references).toEqual(refs); + }); + + it('should throw on non-OK response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('Internal') }); + + await expect( + patchThemeOptionStyleRefs(authToken, apiBaseUrl, projectId, changeSetId, 'opt-1', {}), + ).rejects.toThrow('Failed to PATCH theme_option style refs (500)'); + }); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts new file mode 100644 index 0000000000..5806ee2d03 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/pushThemeRefsRest.ts @@ -0,0 +1,73 @@ +/** + * REST PATCH helpers for syncing $figmaVariableReferences and $figmaStyleReferences + * with Studio-on-Rails theme_groups and theme_options endpoints. + * + * Semantics: + * - undefined → omit key from payload (preserve existing refs on server) + * - {} → explicitly clear all refs + * - never send null + */ + +export async function patchThemeGroupVariableRefs( + authToken: string, + apiBaseUrl: string, + projectId: string, + changeSetId: string, + themeGroupId: string, + refs: Record | undefined, +): Promise { + if (refs === undefined) return; + + const url = `${apiBaseUrl}/api/v1/projects/${projectId}/theme_groups/${themeGroupId}?change_set_id=${encodeURIComponent(changeSetId)}`; + const body = { + theme_group: { + figma_variable_references: refs, + }, + }; + + const res = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(`Failed to PATCH theme_group variable refs (${res.status}): ${errorText}`); + } +} + +export async function patchThemeOptionStyleRefs( + authToken: string, + apiBaseUrl: string, + projectId: string, + changeSetId: string, + themeOptionId: string, + refs: Record | undefined, +): Promise { + if (refs === undefined) return; + + const url = `${apiBaseUrl}/api/v1/projects/${projectId}/theme_options/${themeOptionId}?change_set_id=${encodeURIComponent(changeSetId)}`; + const body = { + theme_option: { + figma_style_references: refs, + }, + }; + + const res = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(`Failed to PATCH theme_option style refs (${res.status}): ${errorText}`); + } +} diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts new file mode 100644 index 0000000000..389abf6b72 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts @@ -0,0 +1,180 @@ +import { updateThemeRefsViaRestApi } from './updateThemeRefsViaRestApi'; + +// Mock dependencies +jest.mock('@/app/store/useAuthStore', () => ({ + useAuthStore: { + getState: jest.fn(), + }, +})); + +jest.mock('@/app/services/OAuthService', () => ({ + OAuthService: { + getApiBaseUrl: jest.fn(() => 'https://api.studio.example.com'), + }, +})); + +jest.mock('@/constants/TokensStudio', () => ({ + TOKENS_STUDIO_APP_URL: 'https://studio.example.com', +})); + +jest.mock('./fetchBranchesListRest', () => ({ + resolveChangeSetId: jest.fn(), +})); + +jest.mock('./pushThemeRefsRest', () => ({ + patchThemeGroupVariableRefs: jest.fn(), + patchThemeOptionStyleRefs: jest.fn(), +})); + +import { useAuthStore } from '@/app/store/useAuthStore'; +import { resolveChangeSetId } from './fetchBranchesListRest'; +import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; + +describe('updateThemeRefsViaRestApi', () => { + const mockGetState = useAuthStore.getState as jest.Mock; + const mockResolveChangeSetId = resolveChangeSetId as jest.Mock; + const mockPatchGroup = patchThemeGroupVariableRefs as jest.Mock; + const mockPatchOption = patchThemeOptionStyleRefs as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetState.mockReturnValue({ + oauthTokens: { accessToken: 'test-token' }, + }); + mockResolveChangeSetId.mockResolvedValue('cs-789'); + mockPatchGroup.mockResolvedValue(undefined); + mockPatchOption.mockResolvedValue(undefined); + }); + + const makeState = (themes: any[], api?: any) => ({ + tokenState: { themes }, + uiState: { api: api || { provider: 'tokensstudio-oauth', id: 'proj-1', branch: 'main' } }, + }); + + it('should not call PATCH when no refs changed', async () => { + const themes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: { 'color.bg': 'VarID:1:1' }, + $figmaStyleReferences: { 'text.body': 'S:abc,' }, + }]; + + await updateThemeRefsViaRestApi({ + prevState: makeState(themes) as any, + rootState: makeState(themes) as any, + }); + + expect(mockPatchGroup).not.toHaveBeenCalled(); + expect(mockPatchOption).not.toHaveBeenCalled(); + }); + + it('should PATCH variable refs when they change (group-level)', async () => { + const prevThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: { 'color.bg': 'VarID:1:1' }, + }]; + const nextThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: { 'color.bg': 'VarID:1:1', 'color.fg': 'VarID:2:2' }, + }]; + + await updateThemeRefsViaRestApi({ + prevState: makeState(prevThemes) as any, + rootState: makeState(nextThemes) as any, + }); + + expect(mockPatchGroup).toHaveBeenCalledWith( + 'test-token', + 'https://api.studio.example.com', + 'proj-1', + 'cs-789', + 'grp-1', + { 'color.bg': 'VarID:1:1', 'color.fg': 'VarID:2:2' }, + ); + }); + + it('should PATCH style refs when they change (option-level)', async () => { + const prevThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaStyleReferences: { 'text.body': 'S:abc,' }, + }]; + const nextThemes = [{ + id: 'opt-1', + name: 'Light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaStyleReferences: { 'text.body': 'S:abc,', 'text.heading': 'S:def,' }, + }]; + + await updateThemeRefsViaRestApi({ + prevState: makeState(prevThemes) as any, + rootState: makeState(nextThemes) as any, + }); + + expect(mockPatchOption).toHaveBeenCalledWith( + 'test-token', + 'https://api.studio.example.com', + 'proj-1', + 'cs-789', + 'opt-1', + { 'text.body': 'S:abc,', 'text.heading': 'S:def,' }, + ); + }); + + it('should only PATCH group once even if multiple options share it', async () => { + const prevThemes = [ + { id: 'opt-1', name: 'Light', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1', $figmaVariableReferences: {} }, + { id: 'opt-2', name: 'Dark', $themeGroupId: 'grp-1', $themeOptionId: 'opt-2', $figmaVariableReferences: {} }, + ]; + const nextThemes = [ + { id: 'opt-1', name: 'Light', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1', $figmaVariableReferences: { 'x': 'y' } }, + { id: 'opt-2', name: 'Dark', $themeGroupId: 'grp-1', $themeOptionId: 'opt-2', $figmaVariableReferences: { 'x': 'y' } }, + ]; + + await updateThemeRefsViaRestApi({ + prevState: makeState(prevThemes) as any, + rootState: makeState(nextThemes) as any, + }); + + expect(mockPatchGroup).toHaveBeenCalledTimes(1); + }); + + it('should abort if no OAuth token', async () => { + mockGetState.mockReturnValue({ oauthTokens: null }); + + await updateThemeRefsViaRestApi({ + prevState: makeState([]) as any, + rootState: makeState([]) as any, + }); + + expect(mockResolveChangeSetId).not.toHaveBeenCalled(); + }); + + it('should abort if changeSetId cannot be resolved', async () => { + mockResolveChangeSetId.mockResolvedValue(null); + + const themes = [{ + id: 'opt-1', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1', + $figmaVariableReferences: { a: 'b' }, + }]; + + await updateThemeRefsViaRestApi({ + prevState: makeState([{ id: 'opt-1', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1' }]) as any, + rootState: makeState(themes) as any, + }); + + expect(mockPatchGroup).not.toHaveBeenCalled(); + expect(mockPatchOption).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts new file mode 100644 index 0000000000..9c9763c537 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts @@ -0,0 +1,113 @@ +import { RematchRootState } from '@rematch/core'; +import { RootModel } from '@/types/RootModel'; +import { ThemeObjectsList } from '@/types'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { OAuthService } from '@/app/services/OAuthService'; +import { TOKENS_STUDIO_APP_URL } from '@/constants/TokensStudio'; +import { resolveChangeSetId } from './fetchBranchesListRest'; +import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; + +interface UpdateThemeRefsPayload { + prevState: RematchRootState>; + rootState: RematchRootState>; +} + +/** + * Diffs prev vs next themes and PATCHes changed refs to Studio-on-Rails. + * Only called for ref-mutation actions on the OAuth provider path. + */ +export async function updateThemeRefsViaRestApi({ + prevState, + rootState, +}: UpdateThemeRefsPayload): Promise { + const { oauthTokens } = useAuthStore.getState(); + if (!oauthTokens?.accessToken) return; + + const context = rootState.uiState.api; + if (!context || !('id' in context)) return; + + const projectId = (context as any).id; + const branchName = (context as any).branch || 'main'; + + const apiBaseUrl = OAuthService.getApiBaseUrl(TOKENS_STUDIO_APP_URL); + + const changeSetId = await resolveChangeSetId( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + branchName, + ); + if (!changeSetId) { + console.error('[updateThemeRefsViaRestApi] Could not resolve change_set_id, aborting write'); + return; + } + + const prevThemes: ThemeObjectsList = prevState.tokenState.themes; + const nextThemes: ThemeObjectsList = rootState.tokenState.themes; + + // Track which groups have already been patched (variable refs are group-level) + const patchedGroups = new Set(); + + for (const nextTheme of nextThemes) { + const prevTheme = prevThemes.find((t) => t.id === nextTheme.id); + if (!prevTheme) continue; + + const themeGroupId = nextTheme.$themeGroupId; + const themeOptionId = nextTheme.$themeOptionId; + + // Variable refs — group-level, only PATCH once per group + if (themeGroupId && !patchedGroups.has(themeGroupId)) { + const prevVarRefs = prevTheme.$figmaVariableReferences; + const nextVarRefs = nextTheme.$figmaVariableReferences; + + if (!shallowEqual(prevVarRefs, nextVarRefs)) { + patchedGroups.add(themeGroupId); + try { + await patchThemeGroupVariableRefs( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + changeSetId, + themeGroupId, + nextVarRefs ?? {}, + ); + } catch (err) { + console.error('[updateThemeRefsViaRestApi] Failed to patch variable refs:', err); + } + } + } + + // Style refs — option-level + if (themeOptionId) { + const prevStyleRefs = prevTheme.$figmaStyleReferences; + const nextStyleRefs = nextTheme.$figmaStyleReferences; + + if (!shallowEqual(prevStyleRefs, nextStyleRefs)) { + try { + await patchThemeOptionStyleRefs( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + changeSetId, + themeOptionId, + nextStyleRefs ?? {}, + ); + } catch (err) { + console.error('[updateThemeRefsViaRestApi] Failed to patch style refs:', err); + } + } + } + } +} + +function shallowEqual( + a: Record | undefined, + b: Record | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => a[k] === b[k]); +} From 6450549b27ce08b408493260632a5df8f5412187 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Tue, 12 May 2026 20:19:26 +0530 Subject: [PATCH 2/6] fix lint --- .../tokensStudio/updateThemeRefsViaRestApi.ts | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts index 9c9763c537..6b366a3b94 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts @@ -12,6 +12,18 @@ interface UpdateThemeRefsPayload { rootState: RematchRootState>; } +function shallowEqual( + a: Record | undefined, + b: Record | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => a[k] === b[k]); +} + /** * Diffs prev vs next themes and PATCHes changed refs to Studio-on-Rails. * Only called for ref-mutation actions on the OAuth provider path. @@ -50,64 +62,52 @@ export async function updateThemeRefsViaRestApi({ for (const nextTheme of nextThemes) { const prevTheme = prevThemes.find((t) => t.id === nextTheme.id); - if (!prevTheme) continue; - - const themeGroupId = nextTheme.$themeGroupId; - const themeOptionId = nextTheme.$themeOptionId; + if (prevTheme) { + const themeGroupId = nextTheme.$themeGroupId; + const themeOptionId = nextTheme.$themeOptionId; - // Variable refs — group-level, only PATCH once per group - if (themeGroupId && !patchedGroups.has(themeGroupId)) { - const prevVarRefs = prevTheme.$figmaVariableReferences; - const nextVarRefs = nextTheme.$figmaVariableReferences; + // Variable refs — group-level, only PATCH once per group + if (themeGroupId && !patchedGroups.has(themeGroupId)) { + const prevVarRefs = prevTheme.$figmaVariableReferences; + const nextVarRefs = nextTheme.$figmaVariableReferences; - if (!shallowEqual(prevVarRefs, nextVarRefs)) { - patchedGroups.add(themeGroupId); - try { - await patchThemeGroupVariableRefs( - oauthTokens.accessToken, - apiBaseUrl, - projectId, - changeSetId, - themeGroupId, - nextVarRefs ?? {}, - ); - } catch (err) { - console.error('[updateThemeRefsViaRestApi] Failed to patch variable refs:', err); + if (!shallowEqual(prevVarRefs, nextVarRefs)) { + patchedGroups.add(themeGroupId); + try { + await patchThemeGroupVariableRefs( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + changeSetId, + themeGroupId, + nextVarRefs ?? {}, + ); + } catch (err) { + console.error('[updateThemeRefsViaRestApi] Failed to patch variable refs:', err); + } } } - } - // Style refs — option-level - if (themeOptionId) { - const prevStyleRefs = prevTheme.$figmaStyleReferences; - const nextStyleRefs = nextTheme.$figmaStyleReferences; + // Style refs — option-level + if (themeOptionId) { + const prevStyleRefs = prevTheme.$figmaStyleReferences; + const nextStyleRefs = nextTheme.$figmaStyleReferences; - if (!shallowEqual(prevStyleRefs, nextStyleRefs)) { - try { - await patchThemeOptionStyleRefs( - oauthTokens.accessToken, - apiBaseUrl, - projectId, - changeSetId, - themeOptionId, - nextStyleRefs ?? {}, - ); - } catch (err) { - console.error('[updateThemeRefsViaRestApi] Failed to patch style refs:', err); + if (!shallowEqual(prevStyleRefs, nextStyleRefs)) { + try { + await patchThemeOptionStyleRefs( + oauthTokens.accessToken, + apiBaseUrl, + projectId, + changeSetId, + themeOptionId, + nextStyleRefs ?? {}, + ); + } catch (err) { + console.error('[updateThemeRefsViaRestApi] Failed to patch style refs:', err); + } } } } } } - -function shallowEqual( - a: Record | undefined, - b: Record | undefined, -): boolean { - if (a === b) return true; - if (!a || !b) return false; - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; - return keysA.every((k) => a[k] === b[k]); -} From ad3a9f283ca815882b97e26ea0786d376169e080 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Tue, 12 May 2026 21:07:04 +0530 Subject: [PATCH 3/6] fix(oauth): add missing ref-mutation actions to REST sync and improve robustness --- .../src/app/store/middlewares/tokenState.ts | 9 ++++++++- .../tokensStudio/updateThemeRefsViaRestApi.test.ts | 2 ++ .../utils/tokensStudio/updateThemeRefsViaRestApi.ts | 10 ++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts index effb1f08d0..b89269bf51 100644 --- a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts +++ b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts @@ -22,6 +22,13 @@ const refOnlyActions = [ 'tokenState/assignStyleIdsToTheme', 'tokenState/disconnectVariableFromTheme', 'tokenState/disconnectStyleFromTheme', + 'tokenState/renameVariableIdsToTheme', + 'tokenState/renameStyleIdsToCurrentTheme', + 'tokenState/renameVariableNamesToThemes', + 'tokenState/renameStyleNamesToCurrentTheme', + 'tokenState/removeVariableNamesFromThemes', + 'tokenState/removeStyleNamesFromThemes', + 'tokenState/removeStyleIdsFromThemes', ]; export const tokenStateMiddleware = (store) => (next) => (action) => { @@ -48,6 +55,6 @@ export const tokenStateMiddleware = (store) => (next) => (action) => { updateThemeRefsViaRestApi({ prevState, rootState: nextState, - }); + }).catch((err) => console.error('[tokenStateMiddleware] REST ref sync failed:', err)); } }; diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts index 389abf6b72..ff2125f356 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts @@ -162,6 +162,7 @@ describe('updateThemeRefsViaRestApi', () => { }); it('should abort if changeSetId cannot be resolved', async () => { + const spy = jest.spyOn(console, 'error').mockImplementation(); mockResolveChangeSetId.mockResolvedValue(null); const themes = [{ @@ -176,5 +177,6 @@ describe('updateThemeRefsViaRestApi', () => { expect(mockPatchGroup).not.toHaveBeenCalled(); expect(mockPatchOption).not.toHaveBeenCalled(); + spy.mockRestore(); }); }); diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts index 6b366a3b94..e9dfbc4c9f 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts @@ -1,6 +1,7 @@ import { RematchRootState } from '@rematch/core'; import { RootModel } from '@/types/RootModel'; import { ThemeObjectsList } from '@/types'; +import { TokensStudioOAuthStorageType } from '@/types/StorageType'; import { useAuthStore } from '@/app/store/useAuthStore'; import { OAuthService } from '@/app/services/OAuthService'; import { TOKENS_STUDIO_APP_URL } from '@/constants/TokensStudio'; @@ -13,8 +14,8 @@ interface UpdateThemeRefsPayload { } function shallowEqual( - a: Record | undefined, - b: Record | undefined, + a: Record | null | undefined, + b: Record | null | undefined, ): boolean { if (a === b) return true; if (!a || !b) return false; @@ -38,8 +39,9 @@ export async function updateThemeRefsViaRestApi({ const context = rootState.uiState.api; if (!context || !('id' in context)) return; - const projectId = (context as any).id; - const branchName = (context as any).branch || 'main'; + const oauthContext = context as TokensStudioOAuthStorageType; + const projectId = oauthContext.id; + const branchName = oauthContext.branch || 'main'; const apiBaseUrl = OAuthService.getApiBaseUrl(TOKENS_STUDIO_APP_URL); From 40315e8c33da4ae199fa41159456e8074db49cc4 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Tue, 12 May 2026 21:58:15 +0530 Subject: [PATCH 4/6] fix: clone group variable refs to prevent shared-reference mutation across theme options --- .../src/utils/tokensStudio/fetchProjectDataRest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts index b01992b659..0d214af65e 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchProjectDataRest.ts @@ -259,7 +259,7 @@ export async function fetchProjectDataRest( }; if (opt.figmaStyleReferences !== undefined) themeObj.$figmaStyleReferences = opt.figmaStyleReferences; - if (groupVariableRefs !== undefined) themeObj.$figmaVariableReferences = groupVariableRefs; + if (groupVariableRefs !== undefined) themeObj.$figmaVariableReferences = { ...groupVariableRefs }; if (opt.figmaCollectionId !== undefined) themeObj.$figmaCollectionId = opt.figmaCollectionId; if (opt.figmaModeId !== undefined) themeObj.$figmaModeId = opt.figmaModeId; From 7c1a787e66d51f839ca2eb40dde543700358d56e Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Tue, 12 May 2026 22:29:04 +0530 Subject: [PATCH 5/6] fix: implement pre-reducer snapshots to detect theme ref changes despite in-place mutations --- .../src/app/store/middlewares/tokenState.ts | 17 +++++---- .../tokensStudio/updateThemeRefsViaRestApi.ts | 35 +++++++++++++++---- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts index b89269bf51..5288ba8e8a 100644 --- a/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts +++ b/packages/tokens-studio-for-figma/src/app/store/middlewares/tokenState.ts @@ -1,6 +1,6 @@ import { StorageProviderType } from '@/constants/StorageProviderType'; import { updateThemeGroupsInTokensStudio } from '@/storage/tokensStudio/updateThemeGroupsInTokensStudio'; -import { updateThemeRefsViaRestApi } from '@/utils/tokensStudio/updateThemeRefsViaRestApi'; +import { updateThemeRefsViaRestApi, snapshotThemeRefs } from '@/utils/tokensStudio/updateThemeRefsViaRestApi'; const actionsToTriggerUpdateInTokensStudio = [ 'tokenState/assignVariableIdsToCurrentTheme', @@ -33,6 +33,14 @@ const refOnlyActions = [ export const tokenStateMiddleware = (store) => (next) => (action) => { const prevState = store.getState(); + + // Capture refs snapshot BEFORE the action runs + const isOAuthRefAction = prevState.uiState.api?.provider === StorageProviderType.TOKENS_STUDIO_OAUTH + && refOnlyActions.includes(action.type); + const prevThemeRefs = isOAuthRefAction + ? snapshotThemeRefs(prevState.tokenState.themes) + : undefined; + next(action); const nextState = store.getState(); @@ -48,12 +56,9 @@ export const tokenStateMiddleware = (store) => (next) => (action) => { }); } - if ( - nextState.uiState.api?.provider === StorageProviderType.TOKENS_STUDIO_OAUTH - && refOnlyActions.includes(action.type) - ) { + if (prevThemeRefs) { updateThemeRefsViaRestApi({ - prevState, + prevThemeRefs, rootState: nextState, }).catch((err) => console.error('[tokenStateMiddleware] REST ref sync failed:', err)); } diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts index e9dfbc4c9f..066633aabe 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.ts @@ -8,11 +8,33 @@ import { TOKENS_STUDIO_APP_URL } from '@/constants/TokensStudio'; import { resolveChangeSetId } from './fetchBranchesListRest'; import { patchThemeGroupVariableRefs, patchThemeOptionStyleRefs } from './pushThemeRefsRest'; +/** Pre-action snapshot of each theme's refs, keyed by theme id. */ +export type ThemeRefsSnapshot = Map; + styleRefs?: Record; +}>; + interface UpdateThemeRefsPayload { - prevState: RematchRootState>; + /** Cloned refs captured *before* the reducer ran (immune to in-place mutation). */ + prevThemeRefs: ThemeRefsSnapshot; rootState: RematchRootState>; } +/** + * Shallow-clone every theme's ref maps to create a snapshot that is immune + * to in-place mutations performed by some reducers. + */ +export function snapshotThemeRefs(themes: ThemeObjectsList): ThemeRefsSnapshot { + const snapshot: ThemeRefsSnapshot = new Map(); + themes.forEach((theme) => { + snapshot.set(theme.id, { + varRefs: theme.$figmaVariableReferences ? { ...theme.$figmaVariableReferences } : undefined, + styleRefs: theme.$figmaStyleReferences ? { ...theme.$figmaStyleReferences } : undefined, + }); + }); + return snapshot; +} + function shallowEqual( a: Record | null | undefined, b: Record | null | undefined, @@ -30,7 +52,7 @@ function shallowEqual( * Only called for ref-mutation actions on the OAuth provider path. */ export async function updateThemeRefsViaRestApi({ - prevState, + prevThemeRefs, rootState, }: UpdateThemeRefsPayload): Promise { const { oauthTokens } = useAuthStore.getState(); @@ -56,21 +78,20 @@ export async function updateThemeRefsViaRestApi({ return; } - const prevThemes: ThemeObjectsList = prevState.tokenState.themes; const nextThemes: ThemeObjectsList = rootState.tokenState.themes; // Track which groups have already been patched (variable refs are group-level) const patchedGroups = new Set(); for (const nextTheme of nextThemes) { - const prevTheme = prevThemes.find((t) => t.id === nextTheme.id); - if (prevTheme) { + const prev = prevThemeRefs.get(nextTheme.id); + if (prev) { const themeGroupId = nextTheme.$themeGroupId; const themeOptionId = nextTheme.$themeOptionId; // Variable refs — group-level, only PATCH once per group if (themeGroupId && !patchedGroups.has(themeGroupId)) { - const prevVarRefs = prevTheme.$figmaVariableReferences; + const prevVarRefs = prev.varRefs; const nextVarRefs = nextTheme.$figmaVariableReferences; if (!shallowEqual(prevVarRefs, nextVarRefs)) { @@ -92,7 +113,7 @@ export async function updateThemeRefsViaRestApi({ // Style refs — option-level if (themeOptionId) { - const prevStyleRefs = prevTheme.$figmaStyleReferences; + const prevStyleRefs = prev.styleRefs; const nextStyleRefs = nextTheme.$figmaStyleReferences; if (!shallowEqual(prevStyleRefs, nextStyleRefs)) { From 57cc01d7b107860931c068f2d9a45ce90cc7396a Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Tue, 12 May 2026 23:53:44 +0530 Subject: [PATCH 6/6] fix tests --- .../updateThemeRefsViaRestApi.test.ts | 70 +++++++++++++++---- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts index ff2125f356..9e04bae5e6 100644 --- a/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts +++ b/packages/tokens-studio-for-figma/src/utils/tokensStudio/updateThemeRefsViaRestApi.test.ts @@ -1,4 +1,4 @@ -import { updateThemeRefsViaRestApi } from './updateThemeRefsViaRestApi'; +import { updateThemeRefsViaRestApi, snapshotThemeRefs } from './updateThemeRefsViaRestApi'; // Mock dependencies jest.mock('@/app/store/useAuthStore', () => ({ @@ -46,7 +46,7 @@ describe('updateThemeRefsViaRestApi', () => { mockPatchOption.mockResolvedValue(undefined); }); - const makeState = (themes: any[], api?: any) => ({ + const makeRootState = (themes: any[], api?: any) => ({ tokenState: { themes }, uiState: { api: api || { provider: 'tokensstudio-oauth', id: 'proj-1', branch: 'main' } }, }); @@ -62,8 +62,8 @@ describe('updateThemeRefsViaRestApi', () => { }]; await updateThemeRefsViaRestApi({ - prevState: makeState(themes) as any, - rootState: makeState(themes) as any, + prevThemeRefs: snapshotThemeRefs(themes as any), + rootState: makeRootState(themes) as any, }); expect(mockPatchGroup).not.toHaveBeenCalled(); @@ -87,8 +87,8 @@ describe('updateThemeRefsViaRestApi', () => { }]; await updateThemeRefsViaRestApi({ - prevState: makeState(prevThemes) as any, - rootState: makeState(nextThemes) as any, + prevThemeRefs: snapshotThemeRefs(prevThemes as any), + rootState: makeRootState(nextThemes) as any, }); expect(mockPatchGroup).toHaveBeenCalledWith( @@ -118,8 +118,8 @@ describe('updateThemeRefsViaRestApi', () => { }]; await updateThemeRefsViaRestApi({ - prevState: makeState(prevThemes) as any, - rootState: makeState(nextThemes) as any, + prevThemeRefs: snapshotThemeRefs(prevThemes as any), + rootState: makeRootState(nextThemes) as any, }); expect(mockPatchOption).toHaveBeenCalledWith( @@ -143,8 +143,8 @@ describe('updateThemeRefsViaRestApi', () => { ]; await updateThemeRefsViaRestApi({ - prevState: makeState(prevThemes) as any, - rootState: makeState(nextThemes) as any, + prevThemeRefs: snapshotThemeRefs(prevThemes as any), + rootState: makeRootState(nextThemes) as any, }); expect(mockPatchGroup).toHaveBeenCalledTimes(1); @@ -154,8 +154,8 @@ describe('updateThemeRefsViaRestApi', () => { mockGetState.mockReturnValue({ oauthTokens: null }); await updateThemeRefsViaRestApi({ - prevState: makeState([]) as any, - rootState: makeState([]) as any, + prevThemeRefs: new Map(), + rootState: makeRootState([]) as any, }); expect(mockResolveChangeSetId).not.toHaveBeenCalled(); @@ -171,12 +171,54 @@ describe('updateThemeRefsViaRestApi', () => { }]; await updateThemeRefsViaRestApi({ - prevState: makeState([{ id: 'opt-1', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1' }]) as any, - rootState: makeState(themes) as any, + prevThemeRefs: snapshotThemeRefs([{ id: 'opt-1', $themeGroupId: 'grp-1', $themeOptionId: 'opt-1' }] as any), + rootState: makeRootState(themes) as any, }); expect(mockPatchGroup).not.toHaveBeenCalled(); expect(mockPatchOption).not.toHaveBeenCalled(); spy.mockRestore(); }); + + it('should correctly detect changes even when refs are mutated in-place by utilizing snapshots', async () => { + // Simulate what the current reducers do: mutate refs in-place + const sharedVarRefs = { 'color.old': 'VarID:1' }; + + const themesBeforeMutation = [{ + id: 'light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: sharedVarRefs, + }]; + + // Capture snapshot BEFORE the mutation + const prevThemeRefs = snapshotThemeRefs(themesBeforeMutation as any); + + // Now mutate in-place (simulating the reducer) + sharedVarRefs['color.new'] = 'VarID:1'; + delete sharedVarRefs['color.old']; + + // nextState themes point to the mutated sharedVarRefs object + const nextThemes = [{ + id: 'light', + $themeGroupId: 'grp-1', + $themeOptionId: 'opt-1', + $figmaVariableReferences: sharedVarRefs, + }]; + + await updateThemeRefsViaRestApi({ + prevThemeRefs, + rootState: makeRootState(nextThemes) as any, + }); + + // The diff should succeed because it compares against the snapshot + expect(mockPatchGroup).toHaveBeenCalledWith( + 'test-token', + 'https://api.studio.example.com', + 'proj-1', + 'cs-789', + 'grp-1', + { 'color.new': 'VarID:1' }, + ); + }); });