From 524b8bda74af825f35b158a5a7c4a33ce1a553d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Wed, 20 May 2026 16:01:12 +0200 Subject: [PATCH 1/7] feat(extractor): resolve top-level const string identifiers When useTranslate(NS), , or t('key', { ns: NS }) refers to a top-level `const NS = '...'` (optionally annotated as const), the extractor now substitutes the literal value instead of emitting W_DYNAMIC_NAMESPACE. Lets users keep namespaces in a shared constants file without forcing them to inline string literals everywhere. --- src/extractor/extractor.ts | 1 + src/extractor/parser/extractConstants.ts | 30 +++++++++++ src/extractor/parser/parser.ts | 5 +- src/extractor/parser/tree/getValue.ts | 8 +++ src/extractor/parser/types.ts | 1 + test/unit/extractor/react/tComponent.test.ts | 34 ++++++++++++ .../unit/extractor/react/useTranslate.test.ts | 53 +++++++++++++++++++ 7 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/extractor/parser/extractConstants.ts diff --git a/src/extractor/extractor.ts b/src/extractor/extractor.ts index e84336b5..e14da20f 100644 --- a/src/extractor/extractor.ts +++ b/src/extractor/extractor.ts @@ -48,6 +48,7 @@ export async function extractTreeAndReport( tokens, onAccept, options, + code, }); if (debug) { diff --git a/src/extractor/parser/extractConstants.ts b/src/extractor/parser/extractConstants.ts new file mode 100644 index 00000000..0f4a5400 --- /dev/null +++ b/src/extractor/parser/extractConstants.ts @@ -0,0 +1,30 @@ +/** + * Pre-pass over the source code that builds a map of top-level + * `const NAME = 'literal'` (optionally with `as const`) bindings. + * + * The extractor walks tokens, not the TS AST, so it cannot natively resolve + * an identifier passed to `useTranslate(NS)` or `` back to its + * declaration. This helper produces a small symbol table the parser + * substitutes in when it sees a variable reference. + * + * Only declarations that initialise to a single string literal are recorded. + * Anything dynamic (concatenation, function call, template with interpolation, + * conditional expression, …) is intentionally skipped so the existing + * dynamic-namespace warning still fires for those cases. + */ +const CONST_LITERAL_RE = + /(?:^|[\n\r])[ \t]*(?:export[ \t]+)?const[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=]*)?=[ \t]*(['"`])((?:\\.|(?!\2)[^\\\n\r])*)\2(?:[ \t]+as[ \t]+const)?[ \t]*;?/g; + +export function extractConstants(code: string): Map { + const result = new Map(); + let match: RegExpExecArray | null; + CONST_LITERAL_RE.lastIndex = 0; + while ((match = CONST_LITERAL_RE.exec(code))) { + const [, name, , value] = match; + // First declaration wins; duplicate declarations are a TS error anyway. + if (!result.has(name)) { + result.set(name, value); + } + } + return result; +} diff --git a/src/extractor/parser/parser.ts b/src/extractor/parser/parser.ts index 56010e04..9d7859d9 100644 --- a/src/extractor/parser/parser.ts +++ b/src/extractor/parser/parser.ts @@ -24,6 +24,7 @@ import { closingTagMerger } from './tokenMergers/closingTagMerger.js'; import { typesAsMerger } from './tokenMergers/typesAsMerger.js'; import { typesCastMerger } from './tokenMergers/typesCastMerger.js'; import { customTCallMerger } from './tokenMergers/customTCallMerger.js'; +import { extractConstants } from './extractConstants.js'; export const DEFAULT_BLOCKS = { 'block.begin': ['block.end'], @@ -57,6 +58,7 @@ type ParseOptions = { tokens: Iterable>; onAccept?: IteratorListener; options: ExtractOptions; + code?: string; }; export const Parser = ({ @@ -78,7 +80,7 @@ export const Parser = ({ } return { - parse({ tokens, onAccept, options }: ParseOptions) { + parse({ tokens, onAccept, options, code }: ParseOptions) { for (const t of tokens) { // use first mapper, which gives some result const type = mappers.find((mapper) => mapper(t))?.(t); @@ -124,6 +126,7 @@ export const Parser = ({ withLabel, ruleMap, blocks, + constants: code ? extractConstants(code) : new Map(), }; let depth = 0; diff --git a/src/extractor/parser/tree/getValue.ts b/src/extractor/parser/tree/getValue.ts index b0f8461a..cdf45ecc 100644 --- a/src/extractor/parser/tree/getValue.ts +++ b/src/extractor/parser/tree/getValue.ts @@ -18,6 +18,14 @@ export function getValue( end: ['expression.end'] as T[], }); + case 'variable': { + const resolved = context.constants.get(token.token); + if (resolved !== undefined) { + return { type: 'primitive', line, value: resolved }; + } + return { type: 'expr', line, values: [] }; + } + default: return { type: 'expr', line, values: [] }; } diff --git a/src/extractor/parser/types.ts b/src/extractor/parser/types.ts index e86352b6..bfcb7a28 100644 --- a/src/extractor/parser/types.ts +++ b/src/extractor/parser/types.ts @@ -89,6 +89,7 @@ export type ParserContext = { withLabel: (fn: (...args: S) => U) => (...args: S) => U; ruleMap: RuleMap; blocks: BlocksType; + constants: Map; }; export type ExtractorInternal = ( diff --git a/test/unit/extractor/react/tComponent.test.ts b/test/unit/extractor/react/tComponent.test.ts index 10b9c873..5151623f 100644 --- a/test/unit/extractor/react/tComponent.test.ts +++ b/test/unit/extractor/react/tComponent.test.ts @@ -363,4 +363,38 @@ describe.each(['jsx', 'tsx'])(' (.%s)', (ext) => { expect(extracted.keys).toEqual(expectedKeys); }); }); + + describe('top-level const namespace identifiers', () => { + it('resolves a const used as the `ns` prop of ', async () => { + const code = ` + import { T } from '@tolgee/react' + const NS = 'auth' + function Test () { + return + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'auth', line: 5 }, + ]); + }); + + it('resolves an `as const` declaration used as `ns` prop', async () => { + const code = ` + import { T } from '@tolgee/react' + export const NS = 'billing' as const + function Test () { + return + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'billing', line: 5 }, + ]); + }); + }); }); diff --git a/test/unit/extractor/react/useTranslate.test.ts b/test/unit/extractor/react/useTranslate.test.ts index 03c1ca07..392f5d47 100644 --- a/test/unit/extractor/react/useTranslate.test.ts +++ b/test/unit/extractor/react/useTranslate.test.ts @@ -549,4 +549,57 @@ describe.each(['js', 'ts', 'jsx', 'tsx'])('useTranslate (.%s)', (ext) => { expect(extracted.warnings).toEqual([]); }); }); + + describe('top-level const namespace identifiers', () => { + it('resolves a plain const string to its literal value', async () => { + const code = ` + import '@tolgee/react' + const NS = 'my-namespace' + function Test () { + const { t } = useTranslate(NS) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'my-namespace', line: 6 }, + ]); + }); + + it('resolves an exported `as const` declaration', async () => { + const code = ` + import '@tolgee/react' + export const NS = 'billing' as const + function Test () { + const { t } = useTranslate(NS) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'billing', line: 6 }, + ]); + }); + + it('still emits a warning for identifiers without a const declaration', async () => { + const code = ` + import '@tolgee/react' + function Test () { + const { t } = useTranslate(unknownNs) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([ + { warning: 'W_DYNAMIC_NAMESPACE', line: 4 }, + { warning: 'W_UNRESOLVABLE_NAMESPACE', line: 5 }, + ]); + expect(extracted.keys).toEqual([]); + }); + }); }); From 03284229027026fac91648ca32ea2cda53c3a1d0 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 21 May 2026 09:36:05 +0200 Subject: [PATCH 2/7] feat(extractor): resolve same-file `NS.KEY` member access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on top of the bare-identifier resolution: extractConstants now also flattens `const NS = { K: 'literal' } as const` (and the plain non-`as const` form) into `NS.K -> 'literal'` entries, and getValue consumes `variable + dot + variable` sequences to look them up. Also maps the TextMate scopes used for member access in argument position (`variable.other.constant.object.ts`, `variable.other.constant.property.ts`, `variable.other.property.ts`) to the `variable` customType so they reach the parser at all. Cross-file imports of constants files still warn — Path A is same-file only. --- src/extractor/parser/extractConstants.ts | 56 +++++++++++++++---- src/extractor/parser/generalMapper.ts | 3 + src/extractor/parser/tree/getValue.ts | 19 +++++++ test/unit/extractor/react/tComponent.test.ts | 16 ++++++ .../unit/extractor/react/useTranslate.test.ts | 52 +++++++++++++++++ 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/extractor/parser/extractConstants.ts b/src/extractor/parser/extractConstants.ts index 0f4a5400..969c093a 100644 --- a/src/extractor/parser/extractConstants.ts +++ b/src/extractor/parser/extractConstants.ts @@ -1,30 +1,64 @@ /** - * Pre-pass over the source code that builds a map of top-level - * `const NAME = 'literal'` (optionally with `as const`) bindings. + * Pre-pass over the source code that builds a flat map of top-level constant + * string bindings. * - * The extractor walks tokens, not the TS AST, so it cannot natively resolve - * an identifier passed to `useTranslate(NS)` or `` back to its - * declaration. This helper produces a small symbol table the parser - * substitutes in when it sees a variable reference. + * Two declaration shapes are recognised: * - * Only declarations that initialise to a single string literal are recorded. - * Anything dynamic (concatenation, function call, template with interpolation, - * conditional expression, …) is intentionally skipped so the existing - * dynamic-namespace warning still fires for those cases. + * const NAME = 'literal' + * const NAME = 'literal' as const + * + * and (flat) string-valued object literals: + * + * const NS = { KEY1: 'literal1', KEY2: 'literal2' } as const + * const NS = { KEY1: 'literal1', KEY2: 'literal2' } + * + * Object literals are flattened, so the second example produces entries + * `NS.KEY1 → 'literal1'` and `NS.KEY2 → 'literal2'`. Nested objects are + * intentionally skipped (the existing dynamic-namespace warning still + * fires for those). + * + * The extractor walks tokens, not the TS AST, so it cannot natively + * resolve an identifier passed to `useTranslate(NS)` or `` + * back to its declaration. This helper produces a small symbol table + * the parser substitutes in when it sees a variable reference. */ const CONST_LITERAL_RE = /(?:^|[\n\r])[ \t]*(?:export[ \t]+)?const[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=]*)?=[ \t]*(['"`])((?:\\.|(?!\2)[^\\\n\r])*)\2(?:[ \t]+as[ \t]+const)?[ \t]*;?/g; +// Only matches object literals without nested braces. That's enough for the +// `const NS = { ... } as const` pattern and keeps the regex tractable. +const CONST_OBJECT_RE = + /(?:^|[\n\r])[ \t]*(?:export[ \t]+)?const[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=]*)?=[ \t]*\{([^{}]*)\}(?:[ \t]+as[ \t]+const)?[ \t]*;?/g; + +const OBJECT_PROPERTY_RE = + /(?:^|[,{\s])([A-Za-z_$][\w$]*)[ \t]*:[ \t]*(['"`])((?:\\.|(?!\2)[^\\\n\r])*)\2/g; + export function extractConstants(code: string): Map { const result = new Map(); + let match: RegExpExecArray | null; + CONST_LITERAL_RE.lastIndex = 0; while ((match = CONST_LITERAL_RE.exec(code))) { const [, name, , value] = match; - // First declaration wins; duplicate declarations are a TS error anyway. if (!result.has(name)) { result.set(name, value); } } + + CONST_OBJECT_RE.lastIndex = 0; + while ((match = CONST_OBJECT_RE.exec(code))) { + const [, objectName, body] = match; + OBJECT_PROPERTY_RE.lastIndex = 0; + let propMatch: RegExpExecArray | null; + while ((propMatch = OBJECT_PROPERTY_RE.exec(body))) { + const [, propName, , propValue] = propMatch; + const key = `${objectName}.${propName}`; + if (!result.has(key)) { + result.set(key, propValue); + } + } + } + return result; } diff --git a/src/extractor/parser/generalMapper.ts b/src/extractor/parser/generalMapper.ts index ff1df7ed..1a4132ac 100644 --- a/src/extractor/parser/generalMapper.ts +++ b/src/extractor/parser/generalMapper.ts @@ -37,6 +37,9 @@ export const generalMapper = (token: Token) => { // variables case 'variable.other.object.ts': case 'variable.other.constant.ts': + case 'variable.other.constant.object.ts': + case 'variable.other.constant.property.ts': + case 'variable.other.property.ts': case 'variable.language.this.ts': return 'variable'; diff --git a/src/extractor/parser/tree/getValue.ts b/src/extractor/parser/tree/getValue.ts index cdf45ecc..fcd54139 100644 --- a/src/extractor/parser/tree/getValue.ts +++ b/src/extractor/parser/tree/getValue.ts @@ -19,6 +19,25 @@ export function getValue( }); case 'variable': { + // Look ahead for a same-file `NAME.PROP` member access. The tokens + // are forward-only, but the only way `variable + acessor.dot + + // variable` shows up in a value position is when the user is + // referencing a constants object — consuming those tokens here + // doesn't disrupt any other parse path. + const next = context.tokens.peek(); + if (next?.customType === 'acessor.dot') { + context.tokens.next(); + const afterDot = context.tokens.peek(); + if (afterDot?.customType === 'variable') { + context.tokens.next(); + const memberKey = `${token.token}.${afterDot.token}`; + const memberResolved = context.constants.get(memberKey); + if (memberResolved !== undefined) { + return { type: 'primitive', line, value: memberResolved }; + } + return { type: 'expr', line, values: [] }; + } + } const resolved = context.constants.get(token.token); if (resolved !== undefined) { return { type: 'primitive', line, value: resolved }; diff --git a/test/unit/extractor/react/tComponent.test.ts b/test/unit/extractor/react/tComponent.test.ts index 5151623f..838cc9d1 100644 --- a/test/unit/extractor/react/tComponent.test.ts +++ b/test/unit/extractor/react/tComponent.test.ts @@ -396,5 +396,21 @@ describe.each(['jsx', 'tsx'])(' (.%s)', (ext) => { { keyName: 'key1', namespace: 'billing', line: 5 }, ]); }); + + it('resolves member access on a const object used as `ns` prop', async () => { + const code = ` + import { T } from '@tolgee/react' + const NS = { AUTH: 'auth', NAMESPACED: 'namespaced' } as const + function Test () { + return + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'namespaced', line: 5 }, + ]); + }); }); }); diff --git a/test/unit/extractor/react/useTranslate.test.ts b/test/unit/extractor/react/useTranslate.test.ts index 392f5d47..d0022ac9 100644 --- a/test/unit/extractor/react/useTranslate.test.ts +++ b/test/unit/extractor/react/useTranslate.test.ts @@ -585,6 +585,58 @@ describe.each(['js', 'ts', 'jsx', 'tsx'])('useTranslate (.%s)', (ext) => { ]); }); + it('resolves member access on a const object (`as const`)', async () => { + const code = ` + import '@tolgee/react' + const NS = { AUTH: 'auth', BILLING: 'billing' } as const + function Test () { + const { t } = useTranslate(NS.BILLING) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'billing', line: 6 }, + ]); + }); + + it('resolves member access on a plain const object (no `as const`)', async () => { + const code = ` + import '@tolgee/react' + const NS = { AUTH: 'auth' } + function Test () { + const { t } = useTranslate(NS.AUTH) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'auth', line: 6 }, + ]); + }); + + it('still warns for member access on an unknown property', async () => { + const code = ` + import '@tolgee/react' + const NS = { AUTH: 'auth' } as const + function Test () { + const { t } = useTranslate(NS.BILLING) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([ + { warning: 'W_DYNAMIC_NAMESPACE', line: 5 }, + { warning: 'W_UNRESOLVABLE_NAMESPACE', line: 6 }, + ]); + expect(extracted.keys).toEqual([]); + }); + it('still emits a warning for identifiers without a const declaration', async () => { const code = ` import '@tolgee/react' From 4f5c4251b6b70e14a691dd7c31dfb953aa9868d2 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Sat, 23 May 2026 22:12:58 +0200 Subject: [PATCH 3/7] refactor(extractor): replace const-resolution regex with token walker Walks the already-merged TextMate token stream instead of regex-scanning the raw source. Strings, comments and template literals are no longer ambiguous (TextMate has classified them upstream), and a funcDepth counter ensures we only capture declarations at module top level. This addresses the scope-shadowing concern raised in PR review: a `const NS` declared inside a function body no longer leaks into the top-level constants map. Added a regression test pinning the behaviour. No behavioural change for the documented happy paths; existing 249 react tests plus the new case pass. --- src/extractor/extractor.ts | 1 - src/extractor/parser/extractConstants.ts | 199 +++++++++++++++--- src/extractor/parser/parser.ts | 5 +- .../unit/extractor/react/useTranslate.test.ts | 25 +++ 4 files changed, 193 insertions(+), 37 deletions(-) diff --git a/src/extractor/extractor.ts b/src/extractor/extractor.ts index e14da20f..e84336b5 100644 --- a/src/extractor/extractor.ts +++ b/src/extractor/extractor.ts @@ -48,7 +48,6 @@ export async function extractTreeAndReport( tokens, onAccept, options, - code, }); if (debug) { diff --git a/src/extractor/parser/extractConstants.ts b/src/extractor/parser/extractConstants.ts index 969c093a..fcdbb70f 100644 --- a/src/extractor/parser/extractConstants.ts +++ b/src/extractor/parser/extractConstants.ts @@ -1,6 +1,8 @@ +import { Token } from './types.js'; + /** - * Pre-pass over the source code that builds a flat map of top-level constant - * string bindings. + * Pre-pass over the merged token stream that builds a flat map of + * module-top-level constant string bindings. * * Two declaration shapes are recognised: * @@ -13,50 +15,181 @@ * const NS = { KEY1: 'literal1', KEY2: 'literal2' } * * Object literals are flattened, so the second example produces entries - * `NS.KEY1 → 'literal1'` and `NS.KEY2 → 'literal2'`. Nested objects are - * intentionally skipped (the existing dynamic-namespace warning still - * fires for those). + * `NS.KEY1 -> 'literal1'` and `NS.KEY2 -> 'literal2'`. Nested objects + * are intentionally skipped (the existing dynamic-namespace warning + * still fires for those). * * The extractor walks tokens, not the TS AST, so it cannot natively * resolve an identifier passed to `useTranslate(NS)` or `` - * back to its declaration. This helper produces a small symbol table + * back to its declaration. This helper produces the small symbol table * the parser substitutes in when it sees a variable reference. + * + * Walking the already-merged token stream (rather than running regex on + * the raw source) means strings, comments, template literals and block + * delimiters are already correctly classified by TextMate. In particular + * the `funcDepth` counter below ensures we only capture declarations at + * module top level — a `const X = '...'` nested inside a function body + * or branch is invisible to the capture, which matches the visibility + * the consumer of the identifier would have. */ -const CONST_LITERAL_RE = - /(?:^|[\n\r])[ \t]*(?:export[ \t]+)?const[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=]*)?=[ \t]*(['"`])((?:\\.|(?!\2)[^\\\n\r])*)\2(?:[ \t]+as[ \t]+const)?[ \t]*;?/g; +export function extractConstants( + tokens: ReadonlyArray> +): Map { + const result = new Map(); -// Only matches object literals without nested braces. That's enough for the -// `const NS = { ... } as const` pattern and keeps the regex tractable. -const CONST_OBJECT_RE = - /(?:^|[\n\r])[ \t]*(?:export[ \t]+)?const[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=]*)?=[ \t]*\{([^{}]*)\}(?:[ \t]+as[ \t]+const)?[ \t]*;?/g; + type State = + | 'Idle' + | 'AfterConst' + | 'AfterName' + | 'InTypeAnnotation' + | 'AfterAssign' + | 'InObjectBody' + | 'InObjectAfterKey' + | 'InObjectAfterColon'; -const OBJECT_PROPERTY_RE = - /(?:^|[,{\s])([A-Za-z_$][\w$]*)[ \t]*:[ \t]*(['"`])((?:\\.|(?!\2)[^\\\n\r])*)\2/g; + let state: State = 'Idle'; + // Depth of block/control-flow bodies we're currently inside. While + // > 0, top-level const-detection is suspended; only block.begin/end + // are tracked so we know when we resurface. + let funcDepth = 0; + // Depth of the object literal currently being captured (1 = top of + // the object body). Tracked separately from funcDepth so a nested + // object literal value abandons capture without leaking depth. + let objectDepth = 0; + let captureName = ''; + let captureProps: Array<{ key: string; value: string }> = []; + let currentKey = ''; + let abandonedObject = false; -export function extractConstants(code: string): Map { - const result = new Map(); + const reset = () => { + state = 'Idle'; + captureProps = []; + captureName = ''; + currentKey = ''; + abandonedObject = false; + }; - let match: RegExpExecArray | null; + for (const token of tokens) { + const c = token.customType; - CONST_LITERAL_RE.lastIndex = 0; - while ((match = CONST_LITERAL_RE.exec(code))) { - const [, name, , value] = match; - if (!result.has(name)) { - result.set(name, value); + // Inside a function body / control-flow block — nothing here is at + // module top level so skip until we resurface. + if (funcDepth > 0) { + if (c === 'block.begin') funcDepth++; + else if (c === 'block.end') funcDepth--; + continue; } - } - CONST_OBJECT_RE.lastIndex = 0; - while ((match = CONST_OBJECT_RE.exec(code))) { - const [, objectName, body] = match; - OBJECT_PROPERTY_RE.lastIndex = 0; - let propMatch: RegExpExecArray | null; - while ((propMatch = OBJECT_PROPERTY_RE.exec(body))) { - const [, propName, , propValue] = propMatch; - const key = `${objectName}.${propName}`; - if (!result.has(key)) { - result.set(key, propValue); + // Object literal capture — has its own depth counter. + if (objectDepth > 0) { + if (c === 'block.begin') { + objectDepth++; + abandonedObject = true; + continue; + } + if (c === 'block.end') { + objectDepth--; + if (objectDepth === 0) { + if (!abandonedObject) { + for (const { key, value } of captureProps) { + const composed = `${captureName}.${key}`; + if (!result.has(composed)) { + result.set(composed, value); + } + } + } + reset(); + } + continue; + } + if (abandonedObject) continue; + + switch (state) { + case 'InObjectBody': + if (c === 'object.key') { + currentKey = token.token; + state = 'InObjectAfterKey'; + } + break; + case 'InObjectAfterKey': + if (c === 'acessor.doublecolon') { + state = 'InObjectAfterColon'; + } else { + state = 'InObjectBody'; + } + break; + case 'InObjectAfterColon': + if (c === 'string') { + captureProps.push({ key: currentKey, value: token.token }); + } + state = 'InObjectBody'; + break; } + continue; + } + + // Top-level state machine. + switch (state) { + case 'Idle': + if (c === 'block.begin') { + funcDepth = 1; + } else if (!c && token.token === 'const') { + state = 'AfterConst'; + } + // `export` and other prefixes simply stay in Idle until the + // following `const` token is seen. + break; + + case 'AfterConst': + if (c === 'variable') { + captureName = token.token; + state = 'AfterName'; + } else if (c === 'block.begin' || c === 'list.begin') { + // Destructure pattern like `const { x } = ...` — skip until + // the destructure pattern closes by piggybacking on funcDepth. + funcDepth = 1; + state = 'Idle'; + } else { + state = 'Idle'; + } + break; + + case 'AfterName': + if (c === 'operator.assignment') { + state = 'AfterAssign'; + } else if (c === 'acessor.doublecolon') { + state = 'InTypeAnnotation'; + } else { + state = 'Idle'; + } + break; + + case 'InTypeAnnotation': + if (c === 'operator.assignment') { + state = 'AfterAssign'; + } else if (c === 'block.begin') { + // Type-level block like `const X: { foo: 1 } = ...`. Bail out + // of this declaration; the rest is content we can't reason + // about with this state machine. + funcDepth = 1; + state = 'Idle'; + } + // Otherwise just skip over the type tokens. + break; + + case 'AfterAssign': + if (c === 'string') { + if (!result.has(captureName)) { + result.set(captureName, token.token); + } + reset(); + } else if (c === 'block.begin') { + objectDepth = 1; + state = 'InObjectBody'; + } else { + state = 'Idle'; + } + break; } } diff --git a/src/extractor/parser/parser.ts b/src/extractor/parser/parser.ts index 9d7859d9..75c9be18 100644 --- a/src/extractor/parser/parser.ts +++ b/src/extractor/parser/parser.ts @@ -58,7 +58,6 @@ type ParseOptions = { tokens: Iterable>; onAccept?: IteratorListener; options: ExtractOptions; - code?: string; }; export const Parser = ({ @@ -80,7 +79,7 @@ export const Parser = ({ } return { - parse({ tokens, onAccept, options, code }: ParseOptions) { + parse({ tokens, onAccept, options }: ParseOptions) { for (const t of tokens) { // use first mapper, which gives some result const type = mappers.find((mapper) => mapper(t))?.(t); @@ -126,7 +125,7 @@ export const Parser = ({ withLabel, ruleMap, blocks, - constants: code ? extractConstants(code) : new Map(), + constants: extractConstants(filteredIgnored), }; let depth = 0; diff --git a/test/unit/extractor/react/useTranslate.test.ts b/test/unit/extractor/react/useTranslate.test.ts index d0022ac9..fa17adc1 100644 --- a/test/unit/extractor/react/useTranslate.test.ts +++ b/test/unit/extractor/react/useTranslate.test.ts @@ -619,6 +619,31 @@ describe.each(['js', 'ts', 'jsx', 'tsx'])('useTranslate (.%s)', (ext) => { ]); }); + it('ignores const declarations nested inside function bodies', async () => { + // A function-local `const NS` must not shadow what the consumer + // would see at module scope. Here there is no module-level NS, + // so the call site should produce the existing dynamic-namespace + // warning, not be silently resolved to the inner literal. + const code = ` + import '@tolgee/react' + function helper () { + const NS = 'inner-only' + return NS + } + function Test () { + const { t } = useTranslate(NS) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([ + { warning: 'W_DYNAMIC_NAMESPACE', line: 8 }, + { warning: 'W_UNRESOLVABLE_NAMESPACE', line: 9 }, + ]); + expect(extracted.keys).toEqual([]); + }); + it('still warns for member access on an unknown property', async () => { const code = ` import '@tolgee/react' From 880dfe1243af70617db6569f9c6d779411e88ef7 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Mon, 25 May 2026 09:20:01 +0200 Subject: [PATCH 4/7] refactor(extractor): split extractConstants into smaller helpers Each sub-state of the walker now has its own function: - stepInFunctionBody (skip-and-track block depth) - stepInObjectBody (object literal property capture) - stepAtTopLevel (the main const-declaration FSM) - finalizeObjectCapture / resetCapture / createContext extractConstants() is now a small dispatch loop. No behaviour change; all existing tests pass. --- src/extractor/parser/extractConstants.ts | 328 +++++++++++++---------- 1 file changed, 181 insertions(+), 147 deletions(-) diff --git a/src/extractor/parser/extractConstants.ts b/src/extractor/parser/extractConstants.ts index fcdbb70f..9fbced8a 100644 --- a/src/extractor/parser/extractConstants.ts +++ b/src/extractor/parser/extractConstants.ts @@ -27,171 +27,205 @@ import { Token } from './types.js'; * Walking the already-merged token stream (rather than running regex on * the raw source) means strings, comments, template literals and block * delimiters are already correctly classified by TextMate. In particular - * the `funcDepth` counter below ensures we only capture declarations at - * module top level — a `const X = '...'` nested inside a function body - * or branch is invisible to the capture, which matches the visibility - * the consumer of the identifier would have. + * the `funcDepth` counter ensures we only capture declarations at module + * top level — a `const X = '...'` nested inside a function body or + * branch is invisible to the capture, which matches the visibility the + * consumer of the identifier would have. */ -export function extractConstants( - tokens: ReadonlyArray> -): Map { - const result = new Map(); - - type State = - | 'Idle' - | 'AfterConst' - | 'AfterName' - | 'InTypeAnnotation' - | 'AfterAssign' - | 'InObjectBody' - | 'InObjectAfterKey' - | 'InObjectAfterColon'; - - let state: State = 'Idle'; + +type State = + | 'Idle' + | 'AfterConst' + | 'AfterName' + | 'InTypeAnnotation' + | 'AfterAssign' + | 'InObjectBody' + | 'InObjectAfterKey' + | 'InObjectAfterColon'; + +type AnyToken = Token; + +type Context = { + result: Map; + state: State; // Depth of block/control-flow bodies we're currently inside. While // > 0, top-level const-detection is suspended; only block.begin/end // are tracked so we know when we resurface. - let funcDepth = 0; + funcDepth: number; // Depth of the object literal currently being captured (1 = top of // the object body). Tracked separately from funcDepth so a nested // object literal value abandons capture without leaking depth. - let objectDepth = 0; - let captureName = ''; - let captureProps: Array<{ key: string; value: string }> = []; - let currentKey = ''; - let abandonedObject = false; - - const reset = () => { - state = 'Idle'; - captureProps = []; - captureName = ''; - currentKey = ''; - abandonedObject = false; + objectDepth: number; + captureName: string; + captureProps: Array<{ key: string; value: string }>; + currentKey: string; + // Set when a nested block.begin is seen during object capture; any + // properties already collected for this capture are discarded. + abandonedObject: boolean; +}; + +function createContext(): Context { + return { + result: new Map(), + state: 'Idle', + funcDepth: 0, + objectDepth: 0, + captureName: '', + captureProps: [], + currentKey: '', + abandonedObject: false, }; +} - for (const token of tokens) { - const c = token.customType; - - // Inside a function body / control-flow block — nothing here is at - // module top level so skip until we resurface. - if (funcDepth > 0) { - if (c === 'block.begin') funcDepth++; - else if (c === 'block.end') funcDepth--; - continue; +function resetCapture(ctx: Context): void { + ctx.state = 'Idle'; + ctx.captureProps = []; + ctx.captureName = ''; + ctx.currentKey = ''; + ctx.abandonedObject = false; +} + +function finalizeObjectCapture(ctx: Context): void { + if (!ctx.abandonedObject) { + for (const { key, value } of ctx.captureProps) { + const composed = `${ctx.captureName}.${key}`; + if (!ctx.result.has(composed)) { + ctx.result.set(composed, value); + } } + } + resetCapture(ctx); +} + +/** Track block depth inside a function body / control-flow block. */ +function stepInFunctionBody(ctx: Context, token: AnyToken): void { + if (token.customType === 'block.begin') ctx.funcDepth++; + else if (token.customType === 'block.end') ctx.funcDepth--; +} - // Object literal capture — has its own depth counter. - if (objectDepth > 0) { +/** Walk the body of a `const NS = { ... }` capture. */ +function stepInObjectBody(ctx: Context, token: AnyToken): void { + const c = token.customType; + + if (c === 'block.begin') { + ctx.objectDepth++; + ctx.abandonedObject = true; + return; + } + if (c === 'block.end') { + ctx.objectDepth--; + if (ctx.objectDepth === 0) { + finalizeObjectCapture(ctx); + } + return; + } + if (ctx.abandonedObject) return; + + switch (ctx.state) { + case 'InObjectBody': + if (c === 'object.key') { + ctx.currentKey = token.token; + ctx.state = 'InObjectAfterKey'; + } + break; + case 'InObjectAfterKey': + if (c === 'acessor.doublecolon') { + ctx.state = 'InObjectAfterColon'; + } else { + ctx.state = 'InObjectBody'; + } + break; + case 'InObjectAfterColon': + if (c === 'string') { + ctx.captureProps.push({ key: ctx.currentKey, value: token.token }); + } + ctx.state = 'InObjectBody'; + break; + } +} + +/** Module-top-level state machine — the only place captures originate. */ +function stepAtTopLevel(ctx: Context, token: AnyToken): void { + const c = token.customType; + + switch (ctx.state) { + case 'Idle': if (c === 'block.begin') { - objectDepth++; - abandonedObject = true; - continue; + ctx.funcDepth = 1; + } else if (!c && token.token === 'const') { + ctx.state = 'AfterConst'; } - if (c === 'block.end') { - objectDepth--; - if (objectDepth === 0) { - if (!abandonedObject) { - for (const { key, value } of captureProps) { - const composed = `${captureName}.${key}`; - if (!result.has(composed)) { - result.set(composed, value); - } - } - } - reset(); - } - continue; + // `export` and other prefixes simply stay in Idle until the + // following `const` token is seen. + break; + + case 'AfterConst': + if (c === 'variable') { + ctx.captureName = token.token; + ctx.state = 'AfterName'; + } else if (c === 'block.begin' || c === 'list.begin') { + // Destructure pattern like `const { x } = ...` — skip until + // the destructure pattern closes by piggybacking on funcDepth. + ctx.funcDepth = 1; + ctx.state = 'Idle'; + } else { + ctx.state = 'Idle'; } - if (abandonedObject) continue; - - switch (state) { - case 'InObjectBody': - if (c === 'object.key') { - currentKey = token.token; - state = 'InObjectAfterKey'; - } - break; - case 'InObjectAfterKey': - if (c === 'acessor.doublecolon') { - state = 'InObjectAfterColon'; - } else { - state = 'InObjectBody'; - } - break; - case 'InObjectAfterColon': - if (c === 'string') { - captureProps.push({ key: currentKey, value: token.token }); - } - state = 'InObjectBody'; - break; + break; + + case 'AfterName': + if (c === 'operator.assignment') { + ctx.state = 'AfterAssign'; + } else if (c === 'acessor.doublecolon') { + ctx.state = 'InTypeAnnotation'; + } else { + ctx.state = 'Idle'; } - continue; - } + break; - // Top-level state machine. - switch (state) { - case 'Idle': - if (c === 'block.begin') { - funcDepth = 1; - } else if (!c && token.token === 'const') { - state = 'AfterConst'; - } - // `export` and other prefixes simply stay in Idle until the - // following `const` token is seen. - break; - - case 'AfterConst': - if (c === 'variable') { - captureName = token.token; - state = 'AfterName'; - } else if (c === 'block.begin' || c === 'list.begin') { - // Destructure pattern like `const { x } = ...` — skip until - // the destructure pattern closes by piggybacking on funcDepth. - funcDepth = 1; - state = 'Idle'; - } else { - state = 'Idle'; - } - break; - - case 'AfterName': - if (c === 'operator.assignment') { - state = 'AfterAssign'; - } else if (c === 'acessor.doublecolon') { - state = 'InTypeAnnotation'; - } else { - state = 'Idle'; - } - break; - - case 'InTypeAnnotation': - if (c === 'operator.assignment') { - state = 'AfterAssign'; - } else if (c === 'block.begin') { - // Type-level block like `const X: { foo: 1 } = ...`. Bail out - // of this declaration; the rest is content we can't reason - // about with this state machine. - funcDepth = 1; - state = 'Idle'; - } - // Otherwise just skip over the type tokens. - break; - - case 'AfterAssign': - if (c === 'string') { - if (!result.has(captureName)) { - result.set(captureName, token.token); - } - reset(); - } else if (c === 'block.begin') { - objectDepth = 1; - state = 'InObjectBody'; - } else { - state = 'Idle'; + case 'InTypeAnnotation': + if (c === 'operator.assignment') { + ctx.state = 'AfterAssign'; + } else if (c === 'block.begin') { + // Type-level block like `const X: { foo: 1 } = ...`. Bail out + // of this declaration; the rest is content we can't reason + // about with this state machine. + ctx.funcDepth = 1; + ctx.state = 'Idle'; + } + // Otherwise just skip over the type tokens. + break; + + case 'AfterAssign': + if (c === 'string') { + if (!ctx.result.has(ctx.captureName)) { + ctx.result.set(ctx.captureName, token.token); } - break; + resetCapture(ctx); + } else if (c === 'block.begin') { + ctx.objectDepth = 1; + ctx.state = 'InObjectBody'; + } else { + ctx.state = 'Idle'; + } + break; + } +} + +export function extractConstants( + tokens: ReadonlyArray +): Map { + const ctx = createContext(); + + for (const token of tokens) { + if (ctx.funcDepth > 0) { + stepInFunctionBody(ctx, token); + } else if (ctx.objectDepth > 0) { + stepInObjectBody(ctx, token); + } else { + stepAtTopLevel(ctx, token); } } - return result; + return ctx.result; } From a87ba035162e2d945510483343cd95c949a1fb15 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Mon, 25 May 2026 09:31:19 +0200 Subject: [PATCH 5/7] fix(extractor): last-write-wins for duplicate object literal keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finalize loop was guarding with `!result.has(composed)`, which preserved the first-encountered value for duplicate keys within a single `const NS = { ... }` literal. JS object-literal semantics are last-write-wins, so the extractor's output diverged from what the runtime actually sees. Always assign and let later properties win. The cross-declaration guard in stepAtTopLevel (`const X = '...'` followed by another `const X = '...'`) is unrelated and stays — a re-declaration is a TS error anyway, and first-write is safer. --- src/extractor/parser/extractConstants.ts | 7 +++---- test/unit/extractor/react/useTranslate.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/extractor/parser/extractConstants.ts b/src/extractor/parser/extractConstants.ts index 9fbced8a..323a94e0 100644 --- a/src/extractor/parser/extractConstants.ts +++ b/src/extractor/parser/extractConstants.ts @@ -87,11 +87,10 @@ function resetCapture(ctx: Context): void { function finalizeObjectCapture(ctx: Context): void { if (!ctx.abandonedObject) { + // Mirror JS object-literal semantics: a later property with the same + // name overwrites earlier ones, so always assign. for (const { key, value } of ctx.captureProps) { - const composed = `${ctx.captureName}.${key}`; - if (!ctx.result.has(composed)) { - ctx.result.set(composed, value); - } + ctx.result.set(`${ctx.captureName}.${key}`, value); } } resetCapture(ctx); diff --git a/test/unit/extractor/react/useTranslate.test.ts b/test/unit/extractor/react/useTranslate.test.ts index fa17adc1..3a91c422 100644 --- a/test/unit/extractor/react/useTranslate.test.ts +++ b/test/unit/extractor/react/useTranslate.test.ts @@ -619,6 +619,23 @@ describe.each(['js', 'ts', 'jsx', 'tsx'])('useTranslate (.%s)', (ext) => { ]); }); + it('matches JS last-write-wins for duplicate object keys', async () => { + const code = ` + import '@tolgee/react' + const NS = { K: 'first', K: 'second' } as const + function Test () { + const { t } = useTranslate(NS.K) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'second', line: 6 }, + ]); + }); + it('ignores const declarations nested inside function bodies', async () => { // A function-local `const NS` must not shadow what the consumer // would see at module scope. Here there is no module-level NS, From 1c4a560aa7f858b95fd31712835be8a4dbcf3641 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Mon, 25 May 2026 09:37:36 +0200 Subject: [PATCH 6/7] fix(extractor): track list-begin/end separately from block depth The AfterConst branch reused funcDepth for array-destructure patterns (`const [a, b] = ...`), but stepInFunctionBody only consumes block.begin/end. The matching list.end was therefore ignored, leaving funcDepth permanently > 0 and silently skipping every subsequent top-level const declaration. Track list-bracket depth in its own listDepth counter with a matching stepInListSkip dispatcher. Object destructure (`const { x } = ...`) keeps the existing funcDepth path. Regression test added. --- src/extractor/parser/extractConstants.ts | 28 +++++++++++++++++-- .../unit/extractor/react/useTranslate.test.ts | 21 ++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/extractor/parser/extractConstants.ts b/src/extractor/parser/extractConstants.ts index 323a94e0..d4c3472b 100644 --- a/src/extractor/parser/extractConstants.ts +++ b/src/extractor/parser/extractConstants.ts @@ -52,6 +52,11 @@ type Context = { // > 0, top-level const-detection is suspended; only block.begin/end // are tracked so we know when we resurface. funcDepth: number; + // Depth of a square-bracket pattern (array literal or array + // destructure) we're currently skipping. Tracked separately from + // funcDepth so list.begin/end never leaks into block depth and vice + // versa. + listDepth: number; // Depth of the object literal currently being captured (1 = top of // the object body). Tracked separately from funcDepth so a nested // object literal value abandons capture without leaking depth. @@ -69,6 +74,7 @@ function createContext(): Context { result: new Map(), state: 'Idle', funcDepth: 0, + listDepth: 0, objectDepth: 0, captureName: '', captureProps: [], @@ -102,6 +108,12 @@ function stepInFunctionBody(ctx: Context, token: AnyToken): void { else if (token.customType === 'block.end') ctx.funcDepth--; } +/** Skip an array literal / array destructure pattern at top level. */ +function stepInListSkip(ctx: Context, token: AnyToken): void { + if (token.customType === 'list.begin') ctx.listDepth++; + else if (token.customType === 'list.end') ctx.listDepth--; +} + /** Walk the body of a `const NS = { ... }` capture. */ function stepInObjectBody(ctx: Context, token: AnyToken): void { const c = token.customType; @@ -162,11 +174,19 @@ function stepAtTopLevel(ctx: Context, token: AnyToken): void { if (c === 'variable') { ctx.captureName = token.token; ctx.state = 'AfterName'; - } else if (c === 'block.begin' || c === 'list.begin') { - // Destructure pattern like `const { x } = ...` — skip until - // the destructure pattern closes by piggybacking on funcDepth. + } else if (c === 'block.begin') { + // Object destructure pattern like `const { x } = ...` — skip + // until the matching block.end via funcDepth. ctx.funcDepth = 1; ctx.state = 'Idle'; + } else if (c === 'list.begin') { + // Array destructure pattern like `const [x] = ...` — skip + // until the matching list.end via listDepth. Tracked + // separately from funcDepth because stepInFunctionBody only + // consumes block.begin/end and would never see the closing + // bracket, leaving funcDepth permanently > 0. + ctx.listDepth = 1; + ctx.state = 'Idle'; } else { ctx.state = 'Idle'; } @@ -219,6 +239,8 @@ export function extractConstants( for (const token of tokens) { if (ctx.funcDepth > 0) { stepInFunctionBody(ctx, token); + } else if (ctx.listDepth > 0) { + stepInListSkip(ctx, token); } else if (ctx.objectDepth > 0) { stepInObjectBody(ctx, token); } else { diff --git a/test/unit/extractor/react/useTranslate.test.ts b/test/unit/extractor/react/useTranslate.test.ts index 3a91c422..7815e4fd 100644 --- a/test/unit/extractor/react/useTranslate.test.ts +++ b/test/unit/extractor/react/useTranslate.test.ts @@ -636,6 +636,27 @@ describe.each(['js', 'ts', 'jsx', 'tsx'])('useTranslate (.%s)', (ext) => { ]); }); + it('keeps capturing after a top-level array destructure', async () => { + // Regression: `const [a, b] = ...` is a list-destructure pattern. + // It must not leave the walker stuck in skip mode, otherwise the + // subsequent `const NS = ...` would silently fail to be captured. + const code = ` + import '@tolgee/react' + const [first, second] = ['x', 'y'] + const NS = 'after-destructure' + function Test () { + const { t } = useTranslate(NS) + t('key1') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'after-destructure', line: 7 }, + ]); + }); + it('ignores const declarations nested inside function bodies', async () => { // A function-local `const NS` must not shadow what the consumer // would see at module scope. Here there is no module-level NS, From b23a5855c50f69e40a0080bed8be505f42ccbe4a Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Mon, 25 May 2026 10:17:12 +0200 Subject: [PATCH 7/7] test: order-agnostic key comparison in push.p3 The three `Object.keys(stored).toEqual([...])` assertions in push.p3 rely on the order in which the Tolgee API returns keys, which is not guaranteed and varies between server versions. Compare as a set by sorting both sides; matches the order-agnostic pattern already used in sync.test.ts. --- test/e2e/push.p3.test.ts | 76 +++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/test/e2e/push.p3.test.ts b/test/e2e/push.p3.test.ts index d6d2fc6a..b1978492 100644 --- a/test/e2e/push.p3.test.ts +++ b/test/e2e/push.p3.test.ts @@ -169,17 +169,21 @@ describe('project 3', () => { const stored = tolgeeDataToDict(keys.data); - // Keys in the "food" namespace should not be removed - expect(Object.keys(stored)).toEqual([ - 'table', - 'chair', - 'plate', - 'fork', - 'water', - 'salad', - 'tomato', - 'onions', - ]); + // Keys in the "food" namespace should not be removed. The API does + // not guarantee an ordering for the returned keys, so compare as a + // set (sort both sides). + expect(Object.keys(stored).sort()).toEqual( + [ + 'table', + 'chair', + 'plate', + 'fork', + 'water', + 'salad', + 'tomato', + 'onions', + ].sort() + ); }); it('removes other keys (config)', async () => { @@ -216,17 +220,20 @@ describe('project 3', () => { const stored = tolgeeDataToDict(keys.data); - // Keys in the "food" namespace should not be removed - expect(Object.keys(stored)).toEqual([ - 'table', - 'chair', - 'plate', - 'fork', - 'water', - 'salad', - 'tomato', - 'onions', - ]); + // Keys in the "food" namespace should not be removed. Compare as a + // set (see the args-variant above for rationale). + expect(Object.keys(stored).sort()).toEqual( + [ + 'table', + 'chair', + 'plate', + 'fork', + 'water', + 'salad', + 'tomato', + 'onions', + ].sort() + ); }); it("doesn't remove other keys when filtered by namespace", async () => { @@ -270,16 +277,19 @@ describe('project 3', () => { const stored = tolgeeDataToDict(keys.data); - expect(Object.keys(stored)).toEqual([ - 'table', - 'chair', - 'plate', - 'fork', - 'knife', - 'water', - 'salad', - 'tomato', - 'onions', - ]); + // Compare as a set; API ordering of returned keys is not guaranteed. + expect(Object.keys(stored).sort()).toEqual( + [ + 'table', + 'chair', + 'plate', + 'fork', + 'knife', + 'water', + 'salad', + 'tomato', + 'onions', + ].sort() + ); }); });