Skip to content

fix: resolve variable modes independently on export for Tokens Studio…#3927

Open
akshay-gupta7 wants to merge 3 commits into
mainfrom
akshay/fix-variables-export-studio
Open

fix: resolve variable modes independently on export for Tokens Studio…#3927
akshay-gupta7 wants to merge 3 commits into
mainfrom
akshay/fix-variables-export-studio

Conversation

@akshay-gupta7

@akshay-gupta7 akshay-gupta7 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

… OAuth

Why does this PR exist?

Closes #0000

When exporting tokens to Figma Variables from a theme group that has multiple options (modes), every mode ended up with the same values instead of resolving distinctly per mode. Only Tokens Studio OAuth projects were affected — the server-resolved delta used to override local resolution was fetched once for the currently active theme and then applied to every mode in the export loop, clobbering per-mode values.

What does this pull request do?

Core fix — per-theme resolution in the variable export path

  • serverResolvedTokens on CREATE_LOCAL_VARIABLES is now a per-theme map (Record<themeId, Record<tokenName, value>>) instead of a single flat delta.
  • New fetchServerResolvedTokensPerTheme utility issues one server request per selected theme in parallel and returns the map keyed by theme.id.
  • createVariablesFromThemes in useTokens builds the per-theme map before dispatch (OAuth projects only). Non-OAuth providers still pass null and are unaffected.
  • The plugin loop (createLocalVariablesInPlugin) slices serverResolvedTokens?.[theme.id] at each iteration via a deltaFor() helper — every mode gets its own delta or falls back to local resolution.

Same-class bug in the WITHOUT_MODES path

  • createVariablesFromSets also used the flat active-theme delta for every exported set. That would leak active-theme values into unrelated sets under OAuth. Fixed by passing null for the WITHOUT_MODES path — per-set exports have no theme dimension, so local resolution is authoritative there.

Determinism and robustness (review round 2)

  • Per-theme selections only include the target theme's own group. Exports no longer depend on transient UI state (activeTheme is not layered in).
  • Fetch is all-or-nothing: if any theme's fetch fails, the whole map is null and every mode falls back to local resolution. Prevents silent mixing of server-resolved and local values across modes.
  • try/finally around fetch + dispatch so a throw can't strand the infinite UI_CREATEVARIABLES spinner.
  • Snapshot the resolver context up front and bail on projectId mismatch after the fetch resolves — an in-flight project switch can no longer write A's deltas against B's Redux state.

Tests

  • New fetchServerResolvedTokensPerTheme.test.ts: target-group selections, ungrouped-theme fallback, ENABLED-set filter, all-or-nothing failure semantics.
  • New createLocalVariablesInPlugin.perThemeDelta.test.ts: real regression at the plugin-loop layer — asserts each per-theme call receives its own slice, null for absent themes, and null for a null map.
  • Existing generateTokensToCreate test kept but re-scoped honestly (leaf-level pass-through).

Testing this change

Requires a Tokens Studio OAuth project with a theme group containing 2+ modes.

  1. Connect to a Tokens Studio OAuth project.
  2. Open Manage Styles & Variables → Export by themes.
  3. Select all themes in a multi-mode group and create variables.
  4. In Figma, open the generated variable collection and confirm each mode has the correct per-mode value (before this fix, every mode shared the currently active theme's values).
  5. Repeat for the Export by sets path — sets not included in the currently active theme should now produce their own values, not active-theme values.
  6. Non-OAuth providers (GitHub, GitLab, local): behavior unchanged.

Automated coverage:

@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: da703de

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tokens-studio/figma-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@akshay-gupta7
akshay-gupta7 marked this pull request as draft July 21, 2026 15:53
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

⤵️ 📦 ✨ The artifact was successfully created! Want to test it? Download it here 👀 🎁

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Commit SHA:43e301cc10fb028d303af35aa687dc16448351fd

Test coverage results 🧪

Code coverage diff between base branch:main and head branch: akshay/fix-variables-export-studio 
Status File % Stmts % Branch % Funcs % Lines
🟢 total 60.01 (0.18) 51.89 (0.13) 57.99 (0.15) 60.51 (0.18)
🔴 packages/tokens-studio-for-figma/src/app/store/useTokens.tsx 54.47 (-1.78) 36.87 (-0.47) 60 (-0.29) 55.06 (-1.9)
🟢 packages/tokens-studio-for-figma/src/plugin/createLocalVariablesInPlugin.ts 69.23 (42.67) 59.45 (36.6) 55.55 (30.55) 70.96 (44.74)
🟢 packages/tokens-studio-for-figma/src/plugin/mergeVariableReferences.ts 81.81 (9.09) 66.66 (8.33) 83.33 (16.67) 82.35 (5.88)
✨ 🆕 packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts 100 100 100 100

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Commit SHA:43e301cc10fb028d303af35aa687dc16448351fd
Current PR reduces the test coverage percentage by 1 for some tests

@akshay-gupta7

Copy link
Copy Markdown
Contributor Author

Code Review — Per-Theme Server-Resolved Tokens Fix

Branch: akshay/fix-variables-export-studio
Scope: per-theme fetch of server-resolved tokens for multi-mode variable export (Tokens Studio OAuth).
Method: 5 parallel finder angles × up to 8 candidates → per-candidate verify → gap sweep. 15 findings survived.


CONFIRMED (fix before merge)

1. WITHOUT_MODES export path has the same bug

File: packages/tokens-studio-for-figma/src/app/store/useTokens.tsx:701 (createVariablesFromSets)

createVariablesFromSets still passes the flat Redux serverResolvedTokens (built for the currently active theme + usedTokenSet) into CREATE_LOCAL_VARIABLES_WITHOUT_MODES for every selected set. This is the same class of bug the themes path was just fixed for.

Failure scenario: OAuth user with active theme Dark (usedTokenSet={core, dark}) picks "Export by sets" with sets [core, brand-a, brand-b]. Redux delta reflects only Dark+core+dark, but createLocalVariablesWithoutModesInPlugin overlays it on every set — brand-a/brand-b variables get Dark-context values instead of what those sets should resolve to standalone.


2. Per-theme fetch depends on transient UI state

File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts:30 (buildSelectionsForTheme)

The helper layers the currently active theme's selections for OTHER groups. Exported variable values therefore depend on which theme the user happens to have active in the plugin, not on the export selection.

Failure scenario: Groups Mode {Light, Dark} and Brand {A, B}. User has Mode=Light, Brand=A active, then exports only Brand.B → server gets {Mode: 'Light', Brand: 'B'}. Same checkbox selection with Mode=Dark active sends {Mode: 'Dark', Brand: 'B'}. Any Brand.B token referencing a Mode-dependent alias resolves to different values in the exported variable depending on transient UI state.


3. Regression test doesn't guard the actual bug site

File: packages/tokens-studio-for-figma/src/plugin/generateTokensToCreate.test.ts:47

The new test hands pre-sliced per-theme deltas directly to generateTokensToCreate. It never exercises the caller's slicing (serverResolvedTokens?.[theme.id]) in createLocalVariablesInPlugin, which is where the bug lived.

Failure scenario: A future refactor reverts createLocalVariablesInPlugin to pass one shared flat map to every iteration (reintroducing the original clobber). This test still passes — it only re-verifies that generateTokensToCreate applies whatever delta the caller supplies, which was never in question.


PLAUSIBLE (correctness — worth addressing)

4. Silent partial-failure mixing

File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts:66

anyResolved short-circuits to true as soon as one theme's fetch succeeds; failed themes are silently omitted from the map, and the plugin falls back to local resolution only for those. An export can end up with server-resolved values for some modes and local-only values for others — with no warning.

Failure scenario: Export Light + Dark + HighContrast. HighContrast fetch hits a transient 502 → null. Result is { light: {...}, dark: {...} }. HighContrast mode falls back to local resolution while the other two use server delta; user sees inconsistent values across modes.


5. Lost fallback to Redux cache on transient failure

File: packages/tokens-studio-for-figma/src/app/store/useTokens.tsx:730

The old path used the debounced Redux serverResolvedTokens (populated ~150ms after any relevant change). The new path always fetches fresh and silently returns null on any failure — so a temporary 5xx / 401 / rate-limit now drops to purely local resolution instead of using a still-valid cached delta.

Failure scenario: Redux delta was populated 30 s ago and is still fresh. Network briefly flaps. Old behavior: exports with the cached delta → correct. New behavior: fresh fetch returns null → local-only values for any token whose server-side resolution the client can't reproduce.


6. No try/finally around fetch → stuck spinner on error

File: packages/tokens-studio-for-figma/src/app/store/useTokens.tsx:734

The fetch + dispatch chain isn't wrapped. Any throw leaves the infinite UI_CREATEVARIABLES spinner running forever (completeJob on line 778 never fires).

Failure scenario: fetchServerResolvedTokensPerTheme, wrapTransaction, or the CREATE_LOCAL_VARIABLES round-trip throws. User's only recourse is to reload the plugin.


7. Fallback param name uses internal groupId

File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts:27

selections[match.group || groupId] = match.name — when a theme has no group field, this uses activeTheme's internal groupId as the URL parameter name, which the server doesn't recognize.

Failure scenario: Ungrouped project. Server sees ?<internal-id>=ThemeName, ignores the param, returns empty delta or 400 → null. Export silently degrades to local resolution.


8. No requestId / abort guard around the fetch

File: packages/tokens-studio-for-figma/src/app/store/useTokens.tsx:774

If the user switches projects or triggers another export mid-fetch, the in-flight closure still completes and dispatches against newer Redux state.

Failure scenario: User clicks Export in project A. While fetches are pending, they switch to project B. The A-closure completes, sends CREATE_LOCAL_VARIABLES with A-derived deltas keyed by A's theme.ids, then assignVariableIdsToTheme runs against project B's Redux state.


9. Missing plugin-loop test for the actual fix

File: packages/tokens-studio-for-figma/src/plugin/createLocalVariablesInPlugin.ts:148

Companion to finding #3. The per-theme slicing serverResolvedTokens?.[theme.id] at three call sites is uncovered by any test.

Failure scenario: A future change swaps the key from theme.id to theme.name or a composite in useTokens.tsx, while the plugin still reads theme.id. Every lookup returns undefined; the export silently regresses to local resolution.


10. Silently drops stale activeTheme entries

File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts:24

themes.find(t => t.id === optionId) returns undefined if the option was renamed/deleted and Redux hasn't reconciled; the whole group is dropped from themeSelections with no warning. Server resolves against its default for that dimension.


11. Dropped dep is a footgun for future edits

File: packages/tokens-studio-for-figma/src/app/store/useTokens.tsx:660

The unused createVariables path removed storageType from its useCallback deps. Safe today (body no longer reads it), but if a maintainer later re-adds a provider check inside that callback, the closure will silently use the storageType captured at mount.


PLAUSIBLE (efficiency / UX / coverage)

12. N parallel HTTP requests, no cache reuse, no dedup, no budget

File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts:49

Every export click fires one request per selected theme with no interaction with the debounced Redux fetch already running. Perf and rate-limit regression vs the old zero-extra-request path.

Scenario: 20-theme color-scheme group + "Export all themes" → 20 parallel GETs; any per-user rate limit may 429 some (each 429 becomes a silent null in the map).


13. Fetch runs under 'Creating variables' spinner with no cancel

File: packages/tokens-studio-for-figma/src/app/store/useTokens.tsx:719

startJob('createVariables') fires before the fetch. On slow networks, the entire round-trip is spent under an infinite spinner labeled 'Creating variables' when nothing is being created yet.

Scenario: 10 themes, ~8s tail → user may assume the plugin is stuck and reload mid-export.


14. Duplicated per-theme lookup at three call sites

File: packages/tokens-studio-for-figma/src/plugin/createLocalVariablesInPlugin.ts:66

serverResolvedTokens?.[theme.id] ?? null is inlined at lines 66, 115, 148 with no shared helper. A future refactor that changes the key convention has to update all three.


15. Unit tests don't cover end-to-end failure recovery

File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.test.ts:1

Coverage exercises the utility in isolation but never asserts that a Studio-side failure results in the export path falling back gracefully. WITHOUT_MODES equivalent is also uncovered.


Recommended next steps

  1. 1 — apply the same per-theme (or per-set) fetch shape to createVariablesFromSets / createLocalVariablesWithoutModesInPlugin, or explicitly document why the sets path is exempt.
  2. 2 + 10 + 7 — reconsider buildSelectionsForTheme: for a targeted theme export, either use only that theme's own group (and rely on server defaults elsewhere) or resolve every group's selection deterministically from the export selection, not from activeTheme.
  3. 3 + 9 — add a real integration test around createLocalVariablesInPlugin that passes a per-theme map with distinct values and asserts distinct writes per mode.
  4. 4 + 5 + 6 + 8 — decide on failure semantics: fail loud, fall back to the Redux cache, or make partial-success explicit. Wrap dispatch in try/finally either way.
  5. 12 + 13 — surface fetch progress separately from variable creation, and consider debouncing/reusing the existing Redux delta when export selection matches active state.

@akshay-gupta7

Copy link
Copy Markdown
Contributor Author

Per-Theme Variable Export Fix — Review Follow-up Status

Branch: akshay/fix-variables-export-studio
Review findings tracked from code-review-per-theme-fix.md (15 total).


Fixed (10 findings)

CONFIRMED — all three addressed

# File What changed
1 src/app/store/useTokens.tsx:702 createVariablesFromSets (WITHOUT_MODES path) now passes serverResolvedTokens: null. Per-set exports have no theme dimension; local resolution is the source of truth.
2 src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts:23 buildSelectionsForTheme now uses ONLY the target theme's own group. Removed activeTheme and themes args. Export result no longer depends on transient UI state.
3 src/plugin/createLocalVariablesInPlugin.perThemeDelta.test.ts (new) Real regression test at the plugin-loop layer. Mocks generateTokensToCreate and asserts each per-theme call gets its own slice (or null). Weak leaf-level test in generateTokensToCreate.test.ts re-scoped honestly.

PLAUSIBLE — seven addressed

# File What changed
4 fetchServerResolvedTokensPerTheme.ts:56 All-or-nothing: if ANY theme's fetch returns null, the whole map is null → every mode falls back to local resolution (no silent mixing).
6 useTokens.tsx:727-793 Fetch + dispatch wrapped in try/finally around completeJob. Stuck-spinner class eliminated.
7 fetchServerResolvedTokensPerTheme.ts:23 Moot after #2 — no more activeTheme walk. New test covers ungrouped-theme fallback (`theme.group
8 useTokens.tsx:736,753-755 Snapshots serverResolverContext up front; bails on projectId mismatch after the fetch resolves. In-flight project switch no longer writes A's deltas against B's Redux.
9 (same as #3) Plugin-loop test now covers the actual fix site (createLocalVariablesInPlugin's per-theme lookup).
10 fetchServerResolvedTokensPerTheme.ts Moot after #2 — no longer walks activeTheme, so stale entries can't leak.
14 createLocalVariablesInPlugin.ts:37 Extracted deltaFor() helper; three call sites (lines 76, 125, 158) now go through it.

Deferred (5 findings — documented, low ROI now)

# File Why deferred
5 useTokens.tsx:730 Fallback to Redux cache on transient fetch failure. Larger design call — mixing fresh vs stale deltas has its own hazards. Current all-or-nothing → local resolution is at least deterministic and correct. Revisit if OAuth users report degraded exports on flaky networks.
11 useTokens.tsx:660 createVariables callback dropped storageType from deps. Safe today (body reads nothing from storageType). Only a footgun if someone re-adds a provider check inside — trivial to fix at that time.
12 fetchServerResolvedTokensPerTheme.ts:49 N parallel HTTP requests per export click, no dedup with the debounced Redux fetcher. Real perf concern for projects with many themes but requires either server-side batching or a shared cache with useServerTokenResolver. Out of scope for this fix.
13 useTokens.tsx:719 Fetch runs under 'Creating variables' spinner rather than a distinct 'Preparing' one. Cosmetic. Introducing a separate UI_PREPARING_VARIABLES job here risks double-jobbing since the plugin later uses the same job name.
15 fetchServerResolvedTokensPerTheme.test.ts End-to-end failure-recovery test. Indirectly covered by the new plugin-loop test + all-or-nothing utility test. Full E2E would need to stand up the whole createVariablesFromThemes React callback.

Verification

  • Tests: 17/17 pass across fetchServerResolvedTokensPerTheme.test.ts, generateTokensToCreate.test.ts, createLocalVariablesInPlugin.perThemeDelta.test.ts, createLocalVariablesInPlugin.test.ts, createLocalVariablesWithoutModesInPlugin.test.ts.
  • Typecheck delta: clean (only the pre-existing unrelated tests/config/setupTest.tsx error remains).

Files touched (round 2)

  • src/app/store/useTokens.tsx — per-set null, try/finally, snapshot guard, dropped unused imports
  • src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts — target-group-only selections, all-or-nothing, dropped activeTheme args
  • src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.test.ts — updated to new signature, added ungrouped-theme + all-or-nothing tests
  • src/plugin/createLocalVariablesInPlugin.tsdeltaFor() helper, three sites routed through it
  • src/plugin/createLocalVariablesInPlugin.perThemeDelta.test.ts — new plugin-loop regression test
  • src/plugin/generateTokensToCreate.test.ts — leaf-level test re-scoped honestly

@akshay-gupta7 akshay-gupta7 self-assigned this Jul 21, 2026
@akshay-gupta7
akshay-gupta7 requested a review from mck July 21, 2026 17:18
@akshay-gupta7
akshay-gupta7 marked this pull request as ready for review July 21, 2026 17:31
@anathaniel-TS

Copy link
Copy Markdown

🛡️ Hyma Compliance Check

No compliance-relevant changes detected

8 file(s) scanned · 0 findings

Note: adds per-theme fetches to the existing Tokens Studio API using the existing OAuth access token — no new external domain/subprocessor and design-token data only.

ℹ️ This check is informational.
🛡️ Hyma Compliance Check · automated

@anathaniel-TS

Copy link
Copy Markdown

🛡️ Hyma Compliance Check

No compliance-relevant changes detected

4 file(s) scanned · 0 findings

ℹ️ This check is informational.
🛡️ Hyma Compliance Check · automated

@anathaniel-TS

Copy link
Copy Markdown

🛡️ Hyma Compliance Check

⚠️ 1 finding(s) across 1 file(s)

Severity Category File Headline
🟢 LOW Access Control packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts OAuth access token forwarded in new per-theme token-resolution requests
Full finding details

[Access Control] OAuth access token forwarded in new per-theme token-resolution requests

  • File: packages/tokens-studio-for-figma/src/utils/tokensStudio/fetchServerResolvedTokensPerTheme.ts (lines ~40–64; caller src/app/store/useTokens.tsx lines ~726–745)
  • Implication: A new utility reads the Tokens Studio OAuth access token (oauthTokens.accessToken via useAuthStore.getState()) and forwards it as authToken on one server request per selected theme to the existing Tokens Studio resolver API. This is a new credential-handling code path that also increases request volume / data egress to the existing first-party backend (design-token data only; no personal data). No new external subprocessor or domain is introduced.
  • Recommended action: Confirm the Tokens Studio resolver endpoint is covered by the existing subprocessor/DPA record, verify the access token is sent only over TLS and is never logged, and confirm the per-theme fan-out does not broaden the data shared beyond the prior active-theme baseline.

ℹ️ This check is informational — findings do not block merging.
🛡️ Hyma Compliance Check · automated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants