Skip to content

refactor: share binding utilities across react-core and gt-vue via gt-i18n#1784

Open
ErnestM1234 wants to merge 1 commit into
e/odysseus/gt-vuefrom
e/odysseus/i18n-binding-utils
Open

refactor: share binding utilities across react-core and gt-vue via gt-i18n#1784
ErnestM1234 wants to merge 1 commit into
e/odysseus/gt-vuefrom
e/odysseus/i18n-binding-utils

Conversation

@ErnestM1234

@ErnestM1234 ErnestM1234 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1774 (stacked on e/odysseus/gt-vue). gt-vue's initial implementation duplicated a chunk of framework-free logic that already existed in react-core. This PR extracts that logic into gt-i18n's internal entrypoint (packages/i18n/src/binding/) — the natural home, since both bindings already depend on it — and points both bindings at the shared implementations.

Now shared via gt-i18n/internal:

  • getPluralBranch — plural-form branch selection
  • getFormatLocales / getShouldTranslate — format-locale chain resolution
  • getVariableName + baseVariablePrefix — wire-format variable naming
  • computeNum / computeCurrency / computeDateTime / computeRelativeTime — the variable formatting cores
  • createRuntimeTranslationQueue — the dev hot reload primitive (deduped runtime translation requests + settle notification)
  • createMFunctionmsg() resolution
  • resolveDictionaryEntryTranslation / resolveDictionaryObjectTranslation — dictionary resolution (binding supplies its resolvers + miss handling)
  • Cookie names + getCookieValue / setCookieValue / readBrowserLocale

Consumers:

  • react-core: duplicated files become one-line re-exports or thin adapters mapping React props (children, _locale) onto the shared signatures — public surface and behavior unchanged, no serialization/tagging code touched (translation hashes unaffected)
  • gt-react: cookie modules become re-exports (public defaultLocaleCookieName etc. preserved)
  • gt-vue: local copies deleted; consumes the shared implementations directly

Deliberately not shared here: the tree pipeline (tag → serialize → reconcile). It's the same algorithm over different element models and would need an element-adapter abstraction plus a react-core refactor with hash-stability testing — deferred until a third binding needs it.

Testing

  • Full affected graph green: gt-i18n (236), react-core (139), gt-react (12), gt-vue (19), gt-tanstack-start (4); gt-next and gt-react-native build clean
  • Browser re-verification of the linked vite-vue test app against this branch: dev 16/16 (locale selector switching en→fr→zh with no reload, <T>/useGT()/t()/<Plural> translated on the fly through the shared hot reload queue) and prod 15/15 (translations from local files)
  • One test adjustment: BrowserConditionStore.test.ts's full gt-i18n/internal mock now spreads importOriginal (the module gained members)

Notes

  • Changeset included: patch for gt-i18n, @generaltranslation/react-core, gt-react, gt-vue
  • The shared modules live in a dedicated src/binding/ directory but ship through the existing /internal entrypoint — no export-map, lint-boundary, or size-limit changes needed; they can be split into a dedicated gt-i18n/binding entrypoint later if preferred

Greptile Summary

This PR extracts duplicated framework-agnostic binding logic from react-core and gt-vue into a new packages/i18n/src/binding/ directory, exposed through gt-i18n/internal. Both bindings now share plural-branch selection, variable formatting cores, format-locale resolution, the dev hot-reload queue, msg() resolution, dictionary translation helpers, and browser cookie utilities instead of maintaining separate copies.

  • Eight new modules land in gt-i18n/src/binding/ and are re-exported from gt-i18n/internal; react-core files become thin re-exports or one-call adapters with no public surface change.
  • gt-vue local copies are deleted and replaced with direct imports from gt-i18n/internal; gt-react's cookie and cookie-name modules become re-exports.
  • The dev test mock for BrowserConditionStore is updated to spread importOriginal so that the new exports don't break the mock.

Confidence Score: 4/5

Safe to merge; the refactor is a clean deduplication with no changes to public API surfaces, serialization formats, or translation hashes.

All public behaviour is preserved — react-core and gt-vue are thin wrappers over the shared implementations and the logic is equivalent. The two observations (in-flight Set cleanup and cookie-value truncation) are pre-existing bugs being consolidated rather than new defects, and neither affects locale tag values in practice. Tests across the full affected graph pass.

runtimeTranslationQueue.ts and browserCookies.ts carry the pre-existing limitations noted in comments; everything else in the changeset is straightforward.

Important Files Changed

Filename Overview
packages/i18n/src/binding/runtimeTranslationQueue.ts New shared dev hot-reload queue; in-flight Sets are never cleared after a translation settles, so failed fetches cannot be retried within the same session (pre-existing behavior from the old reactivity.ts).
packages/i18n/src/binding/browserCookies.ts New shared cookie helpers; accesses document/navigator via globalThis (safer for SSR than bare globals). Cookie-value parsing via split('=')[1] truncates values containing '=' — pre-existing limitation, now shared.
packages/i18n/src/binding/computeVariables.ts New shared formatting cores; parseDate/parseNumber normalize inputs consistently and return null for invalid values, which is a mild improvement over the old react-core paths that could pass invalid inputs to the GT formatter.
packages/i18n/src/binding/dictionaryTranslation.ts New shared dictionary resolution; correctly abstracts entry/object resolvers and optional onMissingTarget hook. Logic is a clean lift-and-shift from the two consumers.
packages/i18n/src/internal.ts Adds 28 new named exports through the existing /internal entrypoint; all binding utilities correctly funnelled through this file.
packages/react-core/src/hooks/useMessages.ts Replaces inline useCallback body with useMemo(() => createMFunction(gt), [gt]); semantically equivalent — both memoize a function reference keyed on gt.
packages/react-core/src/hooks/useTranslations.ts Both translateEntry and translateObj delegate to shared helpers; onMissingTarget is intentionally omitted (react-core's tracked resolver handles dev hot-reload for dictionary misses internally).
packages/vue/src/internal/reactivity.ts queueRuntimeTranslation/queueRuntimeDictionaryTranslation become one-line aliases to the shared queue; invalidateTranslations wired as onSettled. Semantically identical to the deleted implementation.
packages/vue/src/components/variables.ts resolveConditions now includes locales: props.locales (previously resolved inline in resolveFormatLocales); ?? [] default handled downstream in computeXxx → getFormatLocales.
packages/react/src/condition-store/tests/BrowserConditionStore.test.ts Correct fix: spreads importOriginal so newly-added binding exports from gt-i18n/internal are available in the mock without enumerating them individually.
packages/vue/src/condition-store.ts readCookie/writeCookie local helpers replaced with getCookieValue/setCookieValue from gt-i18n/internal; behavior identical.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph gt-i18n/internal
        B1[getPluralBranch]
        B2[getFormatLocales / getShouldTranslate]
        B3[getVariableName / baseVariablePrefix]
        B4[computeNum / computeCurrency\ncomputeDateTime / computeRelativeTime]
        B5[createRuntimeTranslationQueue]
        B6[createMFunction]
        B7[resolveDictionaryEntryTranslation\nresolveDictionaryObjectTranslation]
        B8[getCookieValue / setCookieValue\nreadBrowserLocale / cookie names]
    end

    subgraph react-core
        RC1[getPluralBranch.ts - re-export]
        RC2[getFormatLocales.ts - re-export]
        RC3[getVariableName.ts - thin adapter]
        RC4[Currency/Num/DateTime/RelativeTime.shared.ts]
        RC5[useMessages.ts]
        RC6[useTranslations.ts]
    end

    subgraph gt-react
        GR1[cookie-names.ts - re-export]
        GR2[cookies.ts - re-export]
        GR3[readBrowserLocale.ts - re-export]
    end

    subgraph gt-vue
        GV1[branches.ts]
        GV2[variables.ts]
        GV3[dictionary.ts]
        GV4[translate.ts]
        GV5[condition-store.ts]
        GV6[reactivity.ts]
    end

    B1 --> RC1
    B2 --> RC2
    B3 --> RC3
    B4 --> RC4
    B6 --> RC5
    B7 --> RC6
    B8 --> GR1
    B8 --> GR2
    B8 --> GR3
    B1 --> GV1
    B2 --> GV1
    B4 --> GV2
    B7 --> GV3
    B6 --> GV4
    B2 --> GV4
    B8 --> GV5
    B5 --> GV6
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    subgraph gt-i18n/internal
        B1[getPluralBranch]
        B2[getFormatLocales / getShouldTranslate]
        B3[getVariableName / baseVariablePrefix]
        B4[computeNum / computeCurrency\ncomputeDateTime / computeRelativeTime]
        B5[createRuntimeTranslationQueue]
        B6[createMFunction]
        B7[resolveDictionaryEntryTranslation\nresolveDictionaryObjectTranslation]
        B8[getCookieValue / setCookieValue\nreadBrowserLocale / cookie names]
    end

    subgraph react-core
        RC1[getPluralBranch.ts - re-export]
        RC2[getFormatLocales.ts - re-export]
        RC3[getVariableName.ts - thin adapter]
        RC4[Currency/Num/DateTime/RelativeTime.shared.ts]
        RC5[useMessages.ts]
        RC6[useTranslations.ts]
    end

    subgraph gt-react
        GR1[cookie-names.ts - re-export]
        GR2[cookies.ts - re-export]
        GR3[readBrowserLocale.ts - re-export]
    end

    subgraph gt-vue
        GV1[branches.ts]
        GV2[variables.ts]
        GV3[dictionary.ts]
        GV4[translate.ts]
        GV5[condition-store.ts]
        GV6[reactivity.ts]
    end

    B1 --> RC1
    B2 --> RC2
    B3 --> RC3
    B4 --> RC4
    B6 --> RC5
    B7 --> RC6
    B8 --> GR1
    B8 --> GR2
    B8 --> GR3
    B1 --> GV1
    B2 --> GV1
    B4 --> GV2
    B7 --> GV3
    B6 --> GV4
    B2 --> GV4
    B8 --> GV5
    B5 --> GV6
Loading

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/i18n/src/binding/runtimeTranslationQueue.ts:41-54
**In-flight keys never removed after settling**

Once a key is added to `inFlightTranslations`, it remains there permanently — including after the promise rejects. If a translation fetch fails transiently in dev (e.g., a momentary network error or a bad API key), the `onSettled` callback fires and triggers a re-render, but subsequent renders silently skip re-queuing because the key is still in the Set. The translation stays missing for the rest of the session with no retry. This was the same behaviour in the old `reactivity.ts`, so it's not a regression introduced here, but now that the logic is shared and more prominent it's worth a deliberate `inFlightTranslations.delete(key)` after settling (at least on the failure path) if retries in dev are desirable.

### Issue 2 of 2
packages/i18n/src/binding/browserCookies.ts:44-46
**Cookie value truncated at first `=`**

`?.split('=')[1]` silently drops everything after the first `=` in the value. For locale tags this is harmless today, but the function is named `getCookieValue` generically and could be called with cookies whose values are base64-encoded or contain query-string-style data (both of which legally contain `=`). A safer parse is `row.slice(cookieName.length + 1)` (after the matched `cookieName=` prefix), or `split('=').slice(1).join('=')`. This bug was present in both the gt-react and gt-vue originals, so it's not new here, but consolidating it into a shared utility is a good time to fix it.

Reviews (1): Last reviewed commit: "refactor: share binding utilities across..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@ErnestM1234
ErnestM1234 requested a review from a team as a code owner July 2, 2026 05:21
Comment on lines +41 to +54
if (inFlightTranslations.has(key)) return;
inFlightTranslations.add(key);
void Promise.resolve()
.then(() =>
getI18nCache().lookupTranslationWithFallback(
lookup.locale,
lookup.message,
lookup.options as Parameters<
ReturnType<typeof getI18nCache>['lookupTranslationWithFallback']
>[2]
)
)
.catch(() => undefined)
.then(() => onSettled());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 In-flight keys never removed after settling

Once a key is added to inFlightTranslations, it remains there permanently — including after the promise rejects. If a translation fetch fails transiently in dev (e.g., a momentary network error or a bad API key), the onSettled callback fires and triggers a re-render, but subsequent renders silently skip re-queuing because the key is still in the Set. The translation stays missing for the rest of the session with no retry. This was the same behaviour in the old reactivity.ts, so it's not a regression introduced here, but now that the logic is shared and more prominent it's worth a deliberate inFlightTranslations.delete(key) after settling (at least on the failure path) if retries in dev are desirable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/i18n/src/binding/runtimeTranslationQueue.ts
Line: 41-54

Comment:
**In-flight keys never removed after settling**

Once a key is added to `inFlightTranslations`, it remains there permanently — including after the promise rejects. If a translation fetch fails transiently in dev (e.g., a momentary network error or a bad API key), the `onSettled` callback fires and triggers a re-render, but subsequent renders silently skip re-queuing because the key is still in the Set. The translation stays missing for the rest of the session with no retry. This was the same behaviour in the old `reactivity.ts`, so it's not a regression introduced here, but now that the logic is shared and more prominent it's worth a deliberate `inFlightTranslations.delete(key)` after settling (at least on the failure path) if retries in dev are desirable.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment on lines +44 to +46
.find((row) => row.startsWith(`${cookieName}=`))
?.split('=')[1];
return rawCookieValue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cookie value truncated at first =

?.split('=')[1] silently drops everything after the first = in the value. For locale tags this is harmless today, but the function is named getCookieValue generically and could be called with cookies whose values are base64-encoded or contain query-string-style data (both of which legally contain =). A safer parse is row.slice(cookieName.length + 1) (after the matched cookieName= prefix), or split('=').slice(1).join('='). This bug was present in both the gt-react and gt-vue originals, so it's not new here, but consolidating it into a shared utility is a good time to fix it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/i18n/src/binding/browserCookies.ts
Line: 44-46

Comment:
**Cookie value truncated at first `=`**

`?.split('=')[1]` silently drops everything after the first `=` in the value. For locale tags this is harmless today, but the function is named `getCookieValue` generically and could be called with cookies whose values are base64-encoded or contain query-string-style data (both of which legally contain `=`). A safer parse is `row.slice(cookieName.length + 1)` (after the matched `cookieName=` prefix), or `split('=').slice(1).join('=')`. This bug was present in both the gt-react and gt-vue originals, so it's not new here, but consolidating it into a shared utility is a good time to fix it.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

…-i18n

Adds a framework-agnostic binding layer to gt-i18n's internal entrypoint
(packages/i18n/src/binding/): plural branch selection, variable naming and
formatting cores (Num/Currency/DateTime/RelativeTime), format-locale
resolution, the dev hot reload runtime translation queue, msg() resolution,
dictionary translation resolution, and browser cookie helpers.

react-core's copies become re-exports/thin adapters (public surface and
behavior unchanged), gt-react's cookie modules re-export, and gt-vue
consumes the shared implementations directly.
@ErnestM1234
ErnestM1234 force-pushed the e/odysseus/i18n-binding-utils branch from c15c36e to 6b1ded2 Compare July 2, 2026 06:08
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.

1 participant