From 8978ba8e3fb36bcf053be1aa67e846ca0820b666 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Thu, 21 May 2026 00:11:14 +0530 Subject: [PATCH] fix: preserve user-defined reordering of integer token keys in JSON editor --- .../src/app/store/models/tokenState.test.ts | 39 ++++++++ .../src/app/store/models/tokenState.tsx | 23 ++++- .../__tests__/extractJsonKeysOrder.test.ts | 74 +++++++++++++++ .../src/utils/extractJsonKeysOrder.ts | 95 +++++++++++++++++++ 4 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 packages/tokens-studio-for-figma/src/utils/__tests__/extractJsonKeysOrder.test.ts create mode 100644 packages/tokens-studio-for-figma/src/utils/extractJsonKeysOrder.ts diff --git a/packages/tokens-studio-for-figma/src/app/store/models/tokenState.test.ts b/packages/tokens-studio-for-figma/src/app/store/models/tokenState.test.ts index 5c99a9bcae..47ed6e00c5 100644 --- a/packages/tokens-studio-for-figma/src/app/store/models/tokenState.test.ts +++ b/packages/tokens-studio-for-figma/src/app/store/models/tokenState.test.ts @@ -908,6 +908,45 @@ describe('editToken', () => { ]); }); + it('should preserve ordering of numeric keys when saving JSON data', () => { + store.dispatch.tokenState.setJSONData(`{ + "color": { + "base_palette": { + "0950": { + "value": "#095000", + "type": "color" + }, + "1000": { + "value": "#100000", + "type": "color" + } + } + } + }`); + const { tokens } = store.getState().tokenState; + expect(tokens.global[0].name).toEqual('color.base_palette.0950'); + expect(tokens.global[1].name).toEqual('color.base_palette.1000'); + + // Now reverse the order in the JSON string + store.dispatch.tokenState.setJSONData(`{ + "color": { + "base_palette": { + "1000": { + "value": "#100000", + "type": "color" + }, + "0950": { + "value": "#095000", + "type": "color" + } + } + } + }`); + const { tokens: updatedTokens } = store.getState().tokenState; + expect(updatedTokens.global[0].name).toEqual('color.base_palette.1000'); + expect(updatedTokens.global[1].name).toEqual('color.base_palette.0950'); + }); + it('can duplicate token', () => { store.dispatch.tokenState.duplicateToken({ newName: 'primary-copy', diff --git a/packages/tokens-studio-for-figma/src/app/store/models/tokenState.tsx b/packages/tokens-studio-for-figma/src/app/store/models/tokenState.tsx index e2c839206c..63b7535aa3 100644 --- a/packages/tokens-studio-for-figma/src/app/store/models/tokenState.tsx +++ b/packages/tokens-studio-for-figma/src/app/store/models/tokenState.tsx @@ -9,6 +9,7 @@ import * as tokenStateEffects from './effects/tokenState'; import parseTokenValues from '@/utils/parseTokenValues'; import { notifyToUI } from '@/plugin/notifiers'; import parseJson from '@/utils/parseJson'; +import { extractJsonKeysOrder } from '@/utils/extractJsonKeysOrder'; import { TokenData } from '@/types/SecondScreen'; import updateTokensOnSources from '../updateSources'; import { @@ -58,8 +59,6 @@ import { singleTokensToRawTokenSet } from '@/utils/convert'; import { checkStorageSize } from '@/utils/checkStorageSize'; import { compareLastSyncedState } from '@/utils/compareLastSyncedState'; - - /** Context required to call the Studio gRPC-backed resolver endpoint */ export interface ServerResolverContext { projectId: string; @@ -270,6 +269,26 @@ export const tokenState = createModel()({ const parsedTokens = parseJson(payload); parseTokenValues(parsedTokens, true); const values = parseTokenValues({ [state.activeTokenSet]: parsedTokens }, true); + const activeSetTokens = values[state.activeTokenSet]; + if (activeSetTokens && Array.isArray(activeSetTokens)) { + const orderedNames = extractJsonKeysOrder(payload); + const nameToIndex = new Map(); + orderedNames.forEach((name, index) => { + if (!nameToIndex.has(name)) { + nameToIndex.set(name, index); + } + }); + activeSetTokens.sort((a, b) => { + const indexA = nameToIndex.get(a.name); + const indexB = nameToIndex.get(b.name); + if (indexA !== undefined && indexB !== undefined) { + return indexA - indexB; + } + if (indexA !== undefined) return -1; + if (indexB !== undefined) return 1; + return 0; + }); + } return { ...state, tokens: { diff --git a/packages/tokens-studio-for-figma/src/utils/__tests__/extractJsonKeysOrder.test.ts b/packages/tokens-studio-for-figma/src/utils/__tests__/extractJsonKeysOrder.test.ts new file mode 100644 index 0000000000..4f199b9016 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/__tests__/extractJsonKeysOrder.test.ts @@ -0,0 +1,74 @@ +import { extractJsonKeysOrder } from '../extractJsonKeysOrder'; + +describe('extractJsonKeysOrder', () => { + it('should extract keys in order of appearance', () => { + const json = `{ + "color": { + "base_palette": { + "0000": { "value": "#000000" }, + "1000": { "value": "#ffffff" } + } + } + }`; + expect(extractJsonKeysOrder(json)).toEqual([ + 'color', + 'color.base_palette', + 'color.base_palette.0000', + 'color.base_palette.0000.value', + 'color.base_palette.1000', + 'color.base_palette.1000.value', + ]); + }); + + it('should skip comments and whitespaces', () => { + const json = `{ + // a comment here + "color"/* another comment */: { + "base_palette": { + "0000": { "value": "#000000" }, + "1000": { "value": "#ffffff" } + } + } + }`; + expect(extractJsonKeysOrder(json)).toEqual([ + 'color', + 'color.base_palette', + 'color.base_palette.0000', + 'color.base_palette.0000.value', + 'color.base_palette.1000', + 'color.base_palette.1000.value', + ]); + }); + + it('should handle arrays correctly without messing up the stack', () => { + const json = `{ + "shadows": [ + { + "type": "innerShadow", + "color": "#000000" + } + ], + "otherKey": "value" + }`; + expect(extractJsonKeysOrder(json)).toEqual([ + 'shadows', + 'type', + 'color', + 'otherKey', + ]); + }); + + it('should ignore braces and colons inside string values', () => { + const json = `{ + "token": { + "value": "{colors.base_palette.0000}", + "type": "color" + } + }`; + expect(extractJsonKeysOrder(json)).toEqual([ + 'token', + 'token.value', + 'token.type', + ]); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/extractJsonKeysOrder.ts b/packages/tokens-studio-for-figma/src/utils/extractJsonKeysOrder.ts new file mode 100644 index 0000000000..b1ff5c342f --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/extractJsonKeysOrder.ts @@ -0,0 +1,95 @@ +export function extractJsonKeysOrder(json: string): string[] { + const paths: string[] = []; + const stack: string[] = []; + let i = 0; + let arrayDepth = 0; + + const skipWhitespaceAndComments = () => { + while (i < json.length) { + const char = json[i]; + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + i += 1; + } else if (char === '/' && json[i + 1] === '/') { + // Line comment + i += 2; + while (i < json.length && json[i] !== '\n' && json[i] !== '\r') { + i += 1; + } + } else if (char === '/' && json[i + 1] === '*') { + // Block comment + i += 2; + while (i < json.length && !(json[i] === '*' && json[i + 1] === '/')) { + i += 1; + } + if (i < json.length) i += 2; + } else { + break; + } + } + }; + + const parseString = (): string | null => { + const quote = json[i]; + if (quote !== '"' && quote !== "'") return null; + i += 1; // consume quote + let str = ''; + while (i < json.length) { + const char = json[i]; + if (char === quote) { + i += 1; // consume quote + return str; + } + if (char === '\\') { + str += json[i + 1] || ''; + i += 2; + } else { + str += char; + i += 1; + } + } + return str; + }; + + while (i < json.length) { + skipWhitespaceAndComments(); + if (i >= json.length) break; + + const char = json[i]; + if (char === '{') { + i += 1; + } else if (char === '}') { + if (arrayDepth === 0) { + stack.pop(); + } + i += 1; + } else if (char === '[') { + arrayDepth += 1; + i += 1; + } else if (char === ']') { + arrayDepth -= 1; + i += 1; + } else if (char === '"' || char === "'") { + const str = parseString(); + if (str !== null) { + skipWhitespaceAndComments(); + if (json[i] === ':') { + i += 1; // consume ':' + skipWhitespaceAndComments(); + const fullPath = [...stack, str].join('.'); + paths.push(fullPath); + + // Check if this key opens a nested object + if (json[i] === '{' && arrayDepth === 0) { + stack.push(str); + } + } + } else { + i += 1; + } + } else { + i += 1; + } + } + + return paths; +}