From b14bccc03c0cfd700f8d5f800bf9236fe571e7a2 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Mon, 26 Oct 2026 18:02:48 -0700 Subject: [PATCH] perf: sort scoped variable cache keys without localeCompare buildScopedVariablesContext sorts the merged variables with localeCompare to build the cache key. On Hermes for Android, each localeCompare call creates and frees a Java Collator through JNI, so a ScopedVariables with 60 variables made 354 calls and took about 10 ms to build on low-end Android devices. The key only needs the same order every time, and CSS custom property names are ASCII, so sort by code unit instead. The key's text can change order for names that differ in case, but it stays the same for the same variables, and only the native style cache reads it. On the same devices the build dropped to about 0.4 ms. --- .../src/components/ScopedVariables/utils.ts | 3 +- .../components/scoped-variables.test.tsx | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/uniwind/src/components/ScopedVariables/utils.ts b/packages/uniwind/src/components/ScopedVariables/utils.ts index 7705e112..bff7ff59 100644 --- a/packages/uniwind/src/components/ScopedVariables/utils.ts +++ b/packages/uniwind/src/components/ScopedVariables/utils.ts @@ -30,8 +30,9 @@ export const buildScopedVariablesContext = ( ...validateVariables(variables), } as UniwindContextCSSVariables delete mergedVariables.__uniwindVariablesCacheKey + // The key only needs a stable order. Code-unit order avoids localeCompare, which creates a collator per call on Android const variablesCacheKey = JSON.stringify( - Object.entries(mergedVariables).sort(([a], [b]) => a.localeCompare(b)), + Object.entries(mergedVariables).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), ) mergedVariables.__uniwindVariablesCacheKey = variablesCacheKey diff --git a/packages/uniwind/tests/native/components/scoped-variables.test.tsx b/packages/uniwind/tests/native/components/scoped-variables.test.tsx index 26c3b263..88eb8315 100644 --- a/packages/uniwind/tests/native/components/scoped-variables.test.tsx +++ b/packages/uniwind/tests/native/components/scoped-variables.test.tsx @@ -311,6 +311,38 @@ describe('ScopedVariables', () => { expect(ambiguous.mock.calls[0][0]).not.toEqual(plain.mock.calls[0][0]) }) + test('cache key ignores variable order without locale collation', () => { + const localeCompare = jest.spyOn(String.prototype, 'localeCompare') + const Probe = (props: { test: jest.Mock }) => { + props.test(useUniwindContext().variables?.__uniwindVariablesCacheKey) + + return null + } + + const first = jest.fn() + const second = jest.fn() + + try { + renderUniwind( + + + + + + + + , + ) + + expect(first).toHaveBeenCalledWith('[["--color-primary","#ff0000"],["--gap",8]]') + expect(second).toHaveBeenCalledWith('[["--color-primary","#ff0000"],["--gap",8]]') + // Hermes on Android creates a collator for every localeCompare call + expect(localeCompare).not.toHaveBeenCalled() + } finally { + localeCompare.mockRestore() + } + }) + test('updating the variables prop does not serve stale cached styles', () => { const Wrapper = ({ color }: { color: string }) => (