transduck 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -454,7 +454,7 @@ export async function runStats(opts) {
454
454
  }
455
455
  // CLI entry point
456
456
  const program = new Command();
457
- program.name('transduck').description('AI-native translation tool').version('0.3.1');
457
+ program.name('transduck').description('AI-native translation tool').version('0.4.1');
458
458
  program.command('init')
459
459
  .description('Initialize a new transduck project')
460
460
  .action(async () => {
@@ -1 +1,2 @@
1
- export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
1
+ export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from './provider.js';
2
+ export type { UseTransDuckReturn } from './provider.js';
@@ -1,2 +1,2 @@
1
1
  'use client';
2
- export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
2
+ export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from './provider.js';
@@ -1,10 +1,34 @@
1
1
  import { type ReactNode } from 'react';
2
+ interface ReactState {
3
+ language: string;
4
+ sourceLang: string;
5
+ endpoint: string;
6
+ projectName: string;
7
+ cache: Map<string, string>;
8
+ pluralCache: Map<string, Record<string, string>>;
9
+ pendingStrings: Set<string>;
10
+ pendingPlurals: Set<string>;
11
+ knownKeys: Set<string>;
12
+ knownPlurals: Set<string>;
13
+ isLanguageSwitch: boolean;
14
+ triggerFetch: (() => void) | null;
15
+ }
2
16
  /**
3
- * Hook that subscribes to translation updates. Call this in any component
4
- * that uses t()/ait() to ensure it re-renders when translations are loaded.
17
+ * Hook that subscribes to translation updates and returns translation
18
+ * functions along with language state.
5
19
  */
6
- export declare function useTransDuck(): void;
20
+ export interface UseTransDuckReturn {
21
+ t: typeof t;
22
+ tPlural: typeof tPlural;
23
+ ait: typeof t;
24
+ aitPlural: typeof tPlural;
25
+ language: string;
26
+ setLanguage: (lang: string) => void;
27
+ isLoading: boolean;
28
+ }
29
+ export declare function useTransDuck(): UseTransDuckReturn;
7
30
  export declare function _resetReactState(): void;
31
+ export declare function _getReactState(): ReactState;
8
32
  export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): string;
9
33
  export declare function tPlural(one: string, other: string, count: number, opts?: {
10
34
  context?: string;
@@ -1,39 +1,59 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useContext, useEffect, useState, useCallback } from 'react';
3
+ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
4
4
  let _state = {
5
5
  language: '',
6
6
  sourceLang: 'EN',
7
7
  endpoint: '/api/translations',
8
+ projectName: 'default',
8
9
  cache: new Map(),
9
10
  pluralCache: new Map(),
10
11
  pendingStrings: new Set(),
11
12
  pendingPlurals: new Set(),
13
+ knownKeys: new Set(),
14
+ knownPlurals: new Set(),
15
+ isLanguageSwitch: false,
12
16
  triggerFetch: null,
13
17
  };
14
- // --- Context for triggering child re-renders ---
15
- // The context value (a version counter) changes when translations are loaded.
16
- // Components that call useTransDuck() subscribe to this and re-render automatically.
17
- const TransDuckContext = createContext(0);
18
- /**
19
- * Hook that subscribes to translation updates. Call this in any component
20
- * that uses t()/ait() to ensure it re-renders when translations are loaded.
21
- */
18
+ const defaultContext = {
19
+ version: 0,
20
+ language: '',
21
+ setLanguage: () => { },
22
+ isLoading: false,
23
+ };
24
+ const TransDuckContext = createContext(defaultContext);
22
25
  export function useTransDuck() {
23
- useContext(TransDuckContext);
26
+ const ctx = useContext(TransDuckContext);
27
+ return {
28
+ t,
29
+ tPlural,
30
+ ait,
31
+ aitPlural,
32
+ language: ctx.language,
33
+ setLanguage: ctx.setLanguage,
34
+ isLoading: ctx.isLoading,
35
+ };
24
36
  }
25
37
  export function _resetReactState() {
38
+ flushScheduled = false;
26
39
  _state = {
27
40
  language: '',
28
41
  sourceLang: 'EN',
29
42
  endpoint: '/api/translations',
43
+ projectName: 'default',
30
44
  cache: new Map(),
31
45
  pluralCache: new Map(),
32
46
  pendingStrings: new Set(),
33
47
  pendingPlurals: new Set(),
48
+ knownKeys: new Set(),
49
+ knownPlurals: new Set(),
50
+ isLanguageSwitch: false,
34
51
  triggerFetch: null,
35
52
  };
36
53
  }
54
+ export function _getReactState() {
55
+ return _state;
56
+ }
37
57
  function interpolateVars(text, vars) {
38
58
  if (!vars)
39
59
  return text;
@@ -43,20 +63,35 @@ function interpolateVars(text, vars) {
43
63
  }
44
64
  return result;
45
65
  }
66
+ // --- Self-flushing microtask scheduler ---
67
+ // Ensures pending strings are fetched even when the provider doesn't re-render
68
+ // (e.g., client-side navigation in Next.js App Router persistent layouts).
69
+ let flushScheduled = false;
70
+ function schedulePendingFlush() {
71
+ if (flushScheduled || !_state.triggerFetch || _state.isLanguageSwitch)
72
+ return;
73
+ flushScheduled = true;
74
+ queueMicrotask(() => {
75
+ flushScheduled = false;
76
+ _state.triggerFetch?.();
77
+ });
78
+ }
46
79
  // --- Stable exported functions ---
47
80
  export function t(sourceText, context, vars) {
81
+ const key = `${sourceText}||${context ?? ''}`;
82
+ _state.knownKeys.add(key);
48
83
  // Same language — return source
49
84
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
50
85
  return interpolateVars(sourceText, vars);
51
86
  }
52
- const key = `${sourceText}||${context ?? ''}`;
53
87
  // Cache hit
54
88
  const cached = _state.cache.get(key);
55
89
  if (cached !== undefined) {
56
90
  return interpolateVars(cached, vars);
57
91
  }
58
- // Queue for fetch (useEffect in provider will flush after render)
92
+ // Queue for fetch
59
93
  _state.pendingStrings.add(key);
94
+ schedulePendingFlush();
60
95
  // Return source text as fallback
61
96
  return interpolateVars(sourceText, vars);
62
97
  }
@@ -72,14 +107,15 @@ export function tPlural(one, other, count, opts) {
72
107
  else {
73
108
  vars = { ...opts.vars };
74
109
  }
110
+ const sourceKey = `${one}\x00${other}`;
111
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
112
+ _state.knownPlurals.add(cacheKey);
75
113
  // Same language
76
114
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
77
115
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
78
116
  const form = rules.select(count) === 'one' ? one : other;
79
117
  return interpolateVars(form, vars);
80
118
  }
81
- const sourceKey = `${one}\x00${other}`;
82
- const cacheKey = `${sourceKey}||${context ?? ''}`;
83
119
  // Cache hit
84
120
  const cachedForms = _state.pluralCache.get(cacheKey);
85
121
  if (cachedForms) {
@@ -88,8 +124,9 @@ export function tPlural(one, other, count, opts) {
88
124
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
89
125
  return interpolateVars(form, vars);
90
126
  }
91
- // Queue for fetch (useEffect in provider will flush after render)
127
+ // Queue for fetch
92
128
  _state.pendingPlurals.add(cacheKey);
129
+ schedulePendingFlush();
93
130
  // Fallback to source form
94
131
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
95
132
  const form = rules.select(count) === 'one' ? one : other;
@@ -136,18 +173,52 @@ function saveToLocalStorage(projectName, language) {
136
173
  }
137
174
  export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/api/translations', projectName = 'default', children, }) {
138
175
  const [version, setVersion] = useState(0);
176
+ const [languageState, setLanguageState] = useState(language.toUpperCase());
177
+ const [isLoading, setIsLoading] = useState(false);
139
178
  // Update module-level state synchronously so t() works during first render
140
179
  _state.endpoint = endpoint;
141
180
  _state.sourceLang = sourceLang;
181
+ _state.projectName = projectName;
182
+ // Synchronous first-render initialization only.
183
+ // Subsequent language changes are handled by switchLanguage / useEffect prop sync.
142
184
  const upperLang = language.toUpperCase();
143
- if (_state.language !== upperLang) {
185
+ if (!_state.language) {
144
186
  _state.language = upperLang;
145
- _state.cache.clear();
146
- _state.pluralCache.clear();
147
187
  loadFromLocalStorage(projectName, upperLang);
148
188
  }
189
+ const switchLanguage = useCallback((lang) => {
190
+ const upper = lang.toUpperCase();
191
+ // Same-language no-op
192
+ if (upper === _state.language)
193
+ return;
194
+ // Update React state
195
+ setLanguageState(upper);
196
+ // Update module-level state synchronously
197
+ _state.language = upper;
198
+ _state.cache.clear();
199
+ _state.pluralCache.clear();
200
+ // Source-language guard: skip fetch, t() short-circuits
201
+ if (upper === _state.sourceLang.toUpperCase()) {
202
+ setVersion(v => v + 1);
203
+ return;
204
+ }
205
+ // Load cached translations from localStorage
206
+ loadFromLocalStorage(_state.projectName, upper);
207
+ // Mark as language switch and re-queue known keys
208
+ _state.isLanguageSwitch = true;
209
+ setIsLoading(true);
210
+ for (const key of _state.knownKeys) {
211
+ _state.pendingStrings.add(key);
212
+ }
213
+ for (const key of _state.knownPlurals) {
214
+ _state.pendingPlurals.add(key);
215
+ }
216
+ // Bump version to trigger re-render and useEffect flush
217
+ setVersion(v => v + 1);
218
+ }, []);
149
219
  // Set up trigger
150
220
  const doFetch = useCallback(async () => {
221
+ const fetchLanguage = _state.language;
151
222
  const stringsToFetch = new Set(_state.pendingStrings);
152
223
  const pluralsToFetch = new Set(_state.pendingPlurals);
153
224
  _state.pendingStrings.clear();
@@ -161,8 +232,13 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
161
232
  if (_state.pluralCache.has(key))
162
233
  pluralsToFetch.delete(key);
163
234
  }
164
- if (stringsToFetch.size === 0 && pluralsToFetch.size === 0)
235
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
236
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
237
+ _state.isLanguageSwitch = false;
238
+ setIsLoading(false);
239
+ }
165
240
  return;
241
+ }
166
242
  // Build request body
167
243
  const strings = Array.from(stringsToFetch).map(key => {
168
244
  const [text, context] = key.split('||');
@@ -188,6 +264,9 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
188
264
  return;
189
265
  }
190
266
  const data = await response.json();
267
+ // Stale fetch protection: language changed while we were fetching
268
+ if (_state.language !== fetchLanguage)
269
+ return;
191
270
  // Store translations
192
271
  if (data.translations) {
193
272
  for (const [key, value] of Object.entries(data.translations)) {
@@ -199,20 +278,41 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
199
278
  _state.pluralCache.set(key, value);
200
279
  }
201
280
  }
202
- saveToLocalStorage(projectName, _state.language);
281
+ saveToLocalStorage(_state.projectName, _state.language);
203
282
  setVersion(v => v + 1);
204
283
  }
205
284
  catch (err) {
206
285
  console.warn('[transduck] Translation fetch error:', err);
207
286
  }
208
- }, [endpoint, projectName]);
287
+ finally {
288
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
289
+ _state.isLanguageSwitch = false;
290
+ setIsLoading(false);
291
+ }
292
+ }
293
+ }, [endpoint]);
209
294
  // Register trigger
210
295
  _state.triggerFetch = doFetch;
296
+ // Sync language prop changes — only when the prop itself changes
297
+ const prevPropRef = React.useRef(upperLang);
298
+ useEffect(() => {
299
+ const upper = language.toUpperCase();
300
+ if (upper !== prevPropRef.current) {
301
+ prevPropRef.current = upper;
302
+ switchLanguage(upper);
303
+ }
304
+ }, [language, switchLanguage]);
211
305
  // Flush pending after render
212
306
  useEffect(() => {
213
307
  if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
214
308
  doFetch();
215
309
  }
216
310
  });
217
- return (_jsx(TransDuckContext.Provider, { value: version, children: children }));
311
+ const contextValue = {
312
+ version,
313
+ language: languageState,
314
+ setLanguage: switchLanguage,
315
+ isLoading,
316
+ };
317
+ return (_jsx(TransDuckContext.Provider, { value: contextValue, children: children }));
218
318
  }
package/dist/storage.js CHANGED
@@ -29,7 +29,7 @@ export class TranslationStore {
29
29
  this.db = open({
30
30
  path: this.dbPath,
31
31
  mapSize: DEFAULT_MAP_SIZE,
32
- // Use msgpack (default) for efficient storage
32
+ encoding: 'json',
33
33
  });
34
34
  }
35
35
  getDb() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/cli.ts CHANGED
@@ -562,7 +562,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
562
562
  // CLI entry point
563
563
  const program = new Command();
564
564
 
565
- program.name('transduck').description('AI-native translation tool').version('0.3.1');
565
+ program.name('transduck').description('AI-native translation tool').version('0.4.1');
566
566
 
567
567
  program.command('init')
568
568
  .description('Initialize a new transduck project')
@@ -1,3 +1,4 @@
1
1
  'use client';
2
2
 
3
- export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
3
+ export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from './provider.js';
4
+ export type { UseTransDuckReturn } from './provider.js';
@@ -8,10 +8,14 @@ interface ReactState {
8
8
  language: string;
9
9
  sourceLang: string;
10
10
  endpoint: string;
11
+ projectName: string;
11
12
  cache: Map<string, string>;
12
13
  pluralCache: Map<string, Record<string, string>>;
13
14
  pendingStrings: Set<string>; // "text||context"
14
15
  pendingPlurals: Set<string>; // "one\x00other||context"
16
+ knownKeys: Set<string>;
17
+ knownPlurals: Set<string>;
18
+ isLanguageSwitch: boolean;
15
19
  triggerFetch: (() => void) | null;
16
20
  }
17
21
 
@@ -19,39 +23,86 @@ let _state: ReactState = {
19
23
  language: '',
20
24
  sourceLang: 'EN',
21
25
  endpoint: '/api/translations',
26
+ projectName: 'default',
22
27
  cache: new Map(),
23
28
  pluralCache: new Map(),
24
29
  pendingStrings: new Set(),
25
30
  pendingPlurals: new Set(),
31
+ knownKeys: new Set(),
32
+ knownPlurals: new Set(),
33
+ isLanguageSwitch: false,
26
34
  triggerFetch: null,
27
35
  };
28
36
 
29
37
  // --- Context for triggering child re-renders ---
30
- // The context value (a version counter) changes when translations are loaded.
38
+ // The context value changes when translations are loaded or language switches.
31
39
  // Components that call useTransDuck() subscribe to this and re-render automatically.
32
- const TransDuckContext = createContext<number>(0);
40
+
41
+ interface TransDuckContextValue {
42
+ version: number;
43
+ language: string;
44
+ setLanguage: (lang: string) => void;
45
+ isLoading: boolean;
46
+ }
47
+
48
+ const defaultContext: TransDuckContextValue = {
49
+ version: 0,
50
+ language: '',
51
+ setLanguage: () => {},
52
+ isLoading: false,
53
+ };
54
+
55
+ const TransDuckContext = createContext<TransDuckContextValue>(defaultContext);
33
56
 
34
57
  /**
35
- * Hook that subscribes to translation updates. Call this in any component
36
- * that uses t()/ait() to ensure it re-renders when translations are loaded.
58
+ * Hook that subscribes to translation updates and returns translation
59
+ * functions along with language state.
37
60
  */
38
- export function useTransDuck(): void {
39
- useContext(TransDuckContext);
61
+ export interface UseTransDuckReturn {
62
+ t: typeof t;
63
+ tPlural: typeof tPlural;
64
+ ait: typeof t;
65
+ aitPlural: typeof tPlural;
66
+ language: string;
67
+ setLanguage: (lang: string) => void;
68
+ isLoading: boolean;
69
+ }
70
+
71
+ export function useTransDuck(): UseTransDuckReturn {
72
+ const ctx = useContext(TransDuckContext);
73
+ return {
74
+ t,
75
+ tPlural,
76
+ ait,
77
+ aitPlural,
78
+ language: ctx.language,
79
+ setLanguage: ctx.setLanguage,
80
+ isLoading: ctx.isLoading,
81
+ };
40
82
  }
41
83
 
42
84
  export function _resetReactState(): void {
85
+ flushScheduled = false;
43
86
  _state = {
44
87
  language: '',
45
88
  sourceLang: 'EN',
46
89
  endpoint: '/api/translations',
90
+ projectName: 'default',
47
91
  cache: new Map(),
48
92
  pluralCache: new Map(),
49
93
  pendingStrings: new Set(),
50
94
  pendingPlurals: new Set(),
95
+ knownKeys: new Set(),
96
+ knownPlurals: new Set(),
97
+ isLanguageSwitch: false,
51
98
  triggerFetch: null,
52
99
  };
53
100
  }
54
101
 
102
+ export function _getReactState(): ReactState {
103
+ return _state;
104
+ }
105
+
55
106
  function interpolateVars(text: string, vars?: Record<string, string | number> | null): string {
56
107
  if (!vars) return text;
57
108
  let result = text;
@@ -61,6 +112,21 @@ function interpolateVars(text: string, vars?: Record<string, string | number> |
61
112
  return result;
62
113
  }
63
114
 
115
+ // --- Self-flushing microtask scheduler ---
116
+ // Ensures pending strings are fetched even when the provider doesn't re-render
117
+ // (e.g., client-side navigation in Next.js App Router persistent layouts).
118
+
119
+ let flushScheduled = false;
120
+
121
+ function schedulePendingFlush() {
122
+ if (flushScheduled || !_state.triggerFetch || _state.isLanguageSwitch) return;
123
+ flushScheduled = true;
124
+ queueMicrotask(() => {
125
+ flushScheduled = false;
126
+ _state.triggerFetch?.();
127
+ });
128
+ }
129
+
64
130
  // --- Stable exported functions ---
65
131
 
66
132
  export function t(
@@ -68,21 +134,23 @@ export function t(
68
134
  context?: string,
69
135
  vars?: Record<string, string | number>,
70
136
  ): string {
137
+ const key = `${sourceText}||${context ?? ''}`;
138
+ _state.knownKeys.add(key);
139
+
71
140
  // Same language — return source
72
141
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
73
142
  return interpolateVars(sourceText, vars);
74
143
  }
75
144
 
76
- const key = `${sourceText}||${context ?? ''}`;
77
-
78
145
  // Cache hit
79
146
  const cached = _state.cache.get(key);
80
147
  if (cached !== undefined) {
81
148
  return interpolateVars(cached, vars);
82
149
  }
83
150
 
84
- // Queue for fetch (useEffect in provider will flush after render)
151
+ // Queue for fetch
85
152
  _state.pendingStrings.add(key);
153
+ schedulePendingFlush();
86
154
 
87
155
  // Return source text as fallback
88
156
  return interpolateVars(sourceText, vars);
@@ -104,6 +172,10 @@ export function tPlural(
104
172
  vars = { ...opts.vars };
105
173
  }
106
174
 
175
+ const sourceKey = `${one}\x00${other}`;
176
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
177
+ _state.knownPlurals.add(cacheKey);
178
+
107
179
  // Same language
108
180
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
109
181
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
@@ -111,9 +183,6 @@ export function tPlural(
111
183
  return interpolateVars(form, vars);
112
184
  }
113
185
 
114
- const sourceKey = `${one}\x00${other}`;
115
- const cacheKey = `${sourceKey}||${context ?? ''}`;
116
-
117
186
  // Cache hit
118
187
  const cachedForms = _state.pluralCache.get(cacheKey);
119
188
  if (cachedForms) {
@@ -123,8 +192,9 @@ export function tPlural(
123
192
  return interpolateVars(form, vars);
124
193
  }
125
194
 
126
- // Queue for fetch (useEffect in provider will flush after render)
195
+ // Queue for fetch
127
196
  _state.pendingPlurals.add(cacheKey);
197
+ schedulePendingFlush();
128
198
 
129
199
  // Fallback to source form
130
200
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
@@ -191,21 +261,62 @@ export function TransDuckProvider({
191
261
  children,
192
262
  }: TransDuckProviderProps) {
193
263
  const [version, setVersion] = useState(0);
264
+ const [languageState, setLanguageState] = useState(language.toUpperCase());
265
+ const [isLoading, setIsLoading] = useState(false);
194
266
 
195
267
  // Update module-level state synchronously so t() works during first render
196
268
  _state.endpoint = endpoint;
197
269
  _state.sourceLang = sourceLang;
270
+ _state.projectName = projectName;
198
271
 
272
+ // Synchronous first-render initialization only.
273
+ // Subsequent language changes are handled by switchLanguage / useEffect prop sync.
199
274
  const upperLang = language.toUpperCase();
200
- if (_state.language !== upperLang) {
275
+ if (!_state.language) {
201
276
  _state.language = upperLang;
202
- _state.cache.clear();
203
- _state.pluralCache.clear();
204
277
  loadFromLocalStorage(projectName, upperLang);
205
278
  }
206
279
 
280
+ const switchLanguage = useCallback((lang: string) => {
281
+ const upper = lang.toUpperCase();
282
+
283
+ // Same-language no-op
284
+ if (upper === _state.language) return;
285
+
286
+ // Update React state
287
+ setLanguageState(upper);
288
+
289
+ // Update module-level state synchronously
290
+ _state.language = upper;
291
+ _state.cache.clear();
292
+ _state.pluralCache.clear();
293
+
294
+ // Source-language guard: skip fetch, t() short-circuits
295
+ if (upper === _state.sourceLang.toUpperCase()) {
296
+ setVersion(v => v + 1);
297
+ return;
298
+ }
299
+
300
+ // Load cached translations from localStorage
301
+ loadFromLocalStorage(_state.projectName, upper);
302
+
303
+ // Mark as language switch and re-queue known keys
304
+ _state.isLanguageSwitch = true;
305
+ setIsLoading(true);
306
+ for (const key of _state.knownKeys) {
307
+ _state.pendingStrings.add(key);
308
+ }
309
+ for (const key of _state.knownPlurals) {
310
+ _state.pendingPlurals.add(key);
311
+ }
312
+
313
+ // Bump version to trigger re-render and useEffect flush
314
+ setVersion(v => v + 1);
315
+ }, []);
316
+
207
317
  // Set up trigger
208
318
  const doFetch = useCallback(async () => {
319
+ const fetchLanguage = _state.language;
209
320
  const stringsToFetch = new Set(_state.pendingStrings);
210
321
  const pluralsToFetch = new Set(_state.pendingPlurals);
211
322
  _state.pendingStrings.clear();
@@ -219,7 +330,13 @@ export function TransDuckProvider({
219
330
  if (_state.pluralCache.has(key)) pluralsToFetch.delete(key);
220
331
  }
221
332
 
222
- if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) return;
333
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
334
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
335
+ _state.isLanguageSwitch = false;
336
+ setIsLoading(false);
337
+ }
338
+ return;
339
+ }
223
340
 
224
341
  // Build request body
225
342
  const strings = Array.from(stringsToFetch).map(key => {
@@ -250,6 +367,9 @@ export function TransDuckProvider({
250
367
 
251
368
  const data = await response.json();
252
369
 
370
+ // Stale fetch protection: language changed while we were fetching
371
+ if (_state.language !== fetchLanguage) return;
372
+
253
373
  // Store translations
254
374
  if (data.translations) {
255
375
  for (const [key, value] of Object.entries(data.translations)) {
@@ -262,16 +382,31 @@ export function TransDuckProvider({
262
382
  }
263
383
  }
264
384
 
265
- saveToLocalStorage(projectName, _state.language);
385
+ saveToLocalStorage(_state.projectName, _state.language);
266
386
  setVersion(v => v + 1);
267
387
  } catch (err) {
268
388
  console.warn('[transduck] Translation fetch error:', err);
389
+ } finally {
390
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
391
+ _state.isLanguageSwitch = false;
392
+ setIsLoading(false);
393
+ }
269
394
  }
270
- }, [endpoint, projectName]);
395
+ }, [endpoint]);
271
396
 
272
397
  // Register trigger
273
398
  _state.triggerFetch = doFetch;
274
399
 
400
+ // Sync language prop changes — only when the prop itself changes
401
+ const prevPropRef = React.useRef(upperLang);
402
+ useEffect(() => {
403
+ const upper = language.toUpperCase();
404
+ if (upper !== prevPropRef.current) {
405
+ prevPropRef.current = upper;
406
+ switchLanguage(upper);
407
+ }
408
+ }, [language, switchLanguage]);
409
+
275
410
  // Flush pending after render
276
411
  useEffect(() => {
277
412
  if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
@@ -279,8 +414,15 @@ export function TransDuckProvider({
279
414
  }
280
415
  });
281
416
 
417
+ const contextValue: TransDuckContextValue = {
418
+ version,
419
+ language: languageState,
420
+ setLanguage: switchLanguage,
421
+ isLoading,
422
+ };
423
+
282
424
  return (
283
- <TransDuckContext.Provider value={version}>
425
+ <TransDuckContext.Provider value={contextValue}>
284
426
  {children}
285
427
  </TransDuckContext.Provider>
286
428
  );
package/src/storage.ts CHANGED
@@ -73,7 +73,7 @@ export class TranslationStore {
73
73
  this.db = open<StoredEntry, string>({
74
74
  path: this.dbPath,
75
75
  mapSize: DEFAULT_MAP_SIZE,
76
- // Use msgpack (default) for efficient storage
76
+ encoding: 'json',
77
77
  });
78
78
  }
79
79
 
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import React from 'react';
3
- import { render, screen, cleanup, waitFor } from '@testing-library/react';
4
- import { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from '../src/react/index.js';
3
+ import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
4
+ import { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from '../src/react/index.js';
5
+ import type { UseTransDuckReturn } from '../src/react/index.js';
5
6
 
6
7
  // Mock fetch globally
7
8
  const mockFetch = vi.fn();
@@ -159,4 +160,364 @@ describe('TransDuckProvider + t()', () => {
159
160
  expect(mockFetch).not.toHaveBeenCalled();
160
161
  expect(screen.getByTestId('text').textContent).toBe('Hallo');
161
162
  });
163
+
164
+ it('t() tracks called keys in _state.knownKeys', async () => {
165
+ mockFetch.mockResolvedValue({
166
+ ok: true,
167
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
168
+ });
169
+
170
+ function TestComp() {
171
+ useTransDuck();
172
+ return <span>{t('Hello')}</span>;
173
+ }
174
+
175
+ render(
176
+ <TransDuckProvider language="DE">
177
+ <TestComp />
178
+ </TransDuckProvider>
179
+ );
180
+
181
+ const state = _getReactState();
182
+ expect(state.knownKeys.has('Hello||')).toBe(true);
183
+ });
184
+
185
+ it('useTransDuck() returns t, tPlural, ait, aitPlural, language, setLanguage, isLoading', () => {
186
+ let hookResult: ReturnType<typeof useTransDuck> | null = null;
187
+
188
+ function TestComp() {
189
+ hookResult = useTransDuck();
190
+ return <span>test</span>;
191
+ }
192
+
193
+ render(
194
+ <TransDuckProvider language="DE">
195
+ <TestComp />
196
+ </TransDuckProvider>
197
+ );
198
+
199
+ expect(hookResult).not.toBeNull();
200
+ expect(hookResult!.t).toBe(t);
201
+ expect(hookResult!.tPlural).toBe(tPlural);
202
+ expect(hookResult!.ait).toBe(ait);
203
+ expect(hookResult!.aitPlural).toBe(aitPlural);
204
+ expect(hookResult!.language).toBe('DE');
205
+ expect(typeof hookResult!.setLanguage).toBe('function');
206
+ expect(hookResult!.isLoading).toBe(false);
207
+ });
208
+
209
+ it('setLanguage() switches language, clears cache, and re-fetches', async () => {
210
+ // First render in DE
211
+ mockFetch.mockResolvedValueOnce({
212
+ ok: true,
213
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
214
+ });
215
+ // After switch to ES
216
+ mockFetch.mockResolvedValueOnce({
217
+ ok: true,
218
+ json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
219
+ });
220
+
221
+ let hookRef: UseTransDuckReturn | null = null;
222
+
223
+ function TestComp() {
224
+ const hook = useTransDuck();
225
+ hookRef = hook;
226
+ return <span data-testid="text">{hook.t('Hello')}</span>;
227
+ }
228
+
229
+ render(
230
+ <TransDuckProvider language="DE">
231
+ <TestComp />
232
+ </TransDuckProvider>
233
+ );
234
+
235
+ // Wait for DE translations
236
+ await waitFor(() => {
237
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
238
+ });
239
+
240
+ // Switch to ES
241
+ await act(async () => {
242
+ hookRef!.setLanguage('ES');
243
+ });
244
+
245
+ // Wait for ES translations
246
+ await waitFor(() => {
247
+ expect(screen.getByTestId('text').textContent).toBe('Hola');
248
+ });
249
+
250
+ expect(hookRef!.language).toBe('ES');
251
+ });
252
+
253
+ it('setLanguage() with current language is a no-op', async () => {
254
+ mockFetch.mockResolvedValueOnce({
255
+ ok: true,
256
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
257
+ });
258
+
259
+ let hookRef: UseTransDuckReturn | null = null;
260
+
261
+ function TestComp() {
262
+ const hook = useTransDuck();
263
+ hookRef = hook;
264
+ return <span data-testid="text">{hook.t('Hello')}</span>;
265
+ }
266
+
267
+ render(
268
+ <TransDuckProvider language="DE">
269
+ <TestComp />
270
+ </TransDuckProvider>
271
+ );
272
+
273
+ await waitFor(() => {
274
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
275
+ });
276
+
277
+ mockFetch.mockClear();
278
+
279
+ await act(async () => {
280
+ hookRef!.setLanguage('DE');
281
+ });
282
+
283
+ expect(mockFetch).not.toHaveBeenCalled();
284
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
285
+ });
286
+
287
+ it('setLanguage() to source language skips fetch', async () => {
288
+ mockFetch.mockResolvedValueOnce({
289
+ ok: true,
290
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
291
+ });
292
+
293
+ let hookRef: UseTransDuckReturn | null = null;
294
+
295
+ function TestComp() {
296
+ const hook = useTransDuck();
297
+ hookRef = hook;
298
+ return <span data-testid="text">{hook.t('Hello')}</span>;
299
+ }
300
+
301
+ render(
302
+ <TransDuckProvider language="DE" sourceLang="EN">
303
+ <TestComp />
304
+ </TransDuckProvider>
305
+ );
306
+
307
+ await waitFor(() => {
308
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
309
+ });
310
+
311
+ mockFetch.mockClear();
312
+
313
+ await act(async () => {
314
+ hookRef!.setLanguage('EN');
315
+ });
316
+
317
+ await waitFor(() => {
318
+ expect(screen.getByTestId('text').textContent).toBe('Hello');
319
+ });
320
+ expect(mockFetch).not.toHaveBeenCalled();
321
+ });
322
+
323
+ it('isLoading is true during language switch fetch', async () => {
324
+ mockFetch.mockResolvedValueOnce({
325
+ ok: true,
326
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
327
+ });
328
+
329
+ let resolveSecondFetch: ((value: unknown) => void) | null = null;
330
+ mockFetch.mockImplementationOnce(() => new Promise(resolve => {
331
+ resolveSecondFetch = resolve;
332
+ }));
333
+
334
+ let hookRef: UseTransDuckReturn | null = null;
335
+
336
+ function TestComp() {
337
+ const hook = useTransDuck();
338
+ hookRef = hook;
339
+ return <span data-testid="text">{hook.t('Hello')}</span>;
340
+ }
341
+
342
+ render(
343
+ <TransDuckProvider language="DE">
344
+ <TestComp />
345
+ </TransDuckProvider>
346
+ );
347
+
348
+ await waitFor(() => {
349
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
350
+ });
351
+
352
+ await act(async () => {
353
+ hookRef!.setLanguage('ES');
354
+ });
355
+
356
+ expect(hookRef!.isLoading).toBe(true);
357
+
358
+ await act(async () => {
359
+ resolveSecondFetch!({
360
+ ok: true,
361
+ json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
362
+ });
363
+ });
364
+
365
+ await waitFor(() => {
366
+ expect(hookRef!.isLoading).toBe(false);
367
+ expect(screen.getByTestId('text').textContent).toBe('Hola');
368
+ });
369
+ });
370
+
371
+ it('isLoading clears on fetch error during language switch', async () => {
372
+ mockFetch.mockResolvedValueOnce({
373
+ ok: true,
374
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
375
+ });
376
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
377
+
378
+ let hookRef: UseTransDuckReturn | null = null;
379
+
380
+ function TestComp() {
381
+ const hook = useTransDuck();
382
+ hookRef = hook;
383
+ return <span data-testid="text">{hook.t('Hello')}</span>;
384
+ }
385
+
386
+ render(
387
+ <TransDuckProvider language="DE">
388
+ <TestComp />
389
+ </TransDuckProvider>
390
+ );
391
+
392
+ await waitFor(() => {
393
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
394
+ });
395
+
396
+ await act(async () => {
397
+ hookRef!.setLanguage('ES');
398
+ });
399
+
400
+ await waitFor(() => {
401
+ expect(hookRef!.isLoading).toBe(false);
402
+ });
403
+ });
404
+
405
+ it('discards stale fetch response when language changes during fetch', async () => {
406
+ mockFetch.mockResolvedValueOnce({
407
+ ok: true,
408
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
409
+ });
410
+
411
+ let resolveFRFetch: ((value: unknown) => void) | null = null;
412
+ mockFetch.mockImplementationOnce(() => new Promise(resolve => {
413
+ resolveFRFetch = resolve;
414
+ }));
415
+
416
+ mockFetch.mockResolvedValueOnce({
417
+ ok: true,
418
+ json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
419
+ });
420
+
421
+ let hookRef: UseTransDuckReturn | null = null;
422
+
423
+ function TestComp() {
424
+ const hook = useTransDuck();
425
+ hookRef = hook;
426
+ return <span data-testid="text">{hook.t('Hello')}</span>;
427
+ }
428
+
429
+ render(
430
+ <TransDuckProvider language="DE">
431
+ <TestComp />
432
+ </TransDuckProvider>
433
+ );
434
+
435
+ await waitFor(() => {
436
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
437
+ });
438
+
439
+ await act(async () => {
440
+ hookRef!.setLanguage('FR');
441
+ });
442
+
443
+ await act(async () => {
444
+ hookRef!.setLanguage('ES');
445
+ });
446
+
447
+ await waitFor(() => {
448
+ expect(screen.getByTestId('text').textContent).toBe('Hola');
449
+ });
450
+
451
+ await act(async () => {
452
+ resolveFRFetch!({
453
+ ok: true,
454
+ json: async () => ({ translations: { 'Hello||': 'Bonjour' }, plurals: {} }),
455
+ });
456
+ });
457
+
458
+ expect(screen.getByTestId('text').textContent).toBe('Hola');
459
+ expect(hookRef!.language).toBe('ES');
460
+ });
461
+
462
+ it('changing language prop triggers switchLanguage with isLoading', async () => {
463
+ mockFetch.mockResolvedValueOnce({
464
+ ok: true,
465
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
466
+ });
467
+ mockFetch.mockResolvedValueOnce({
468
+ ok: true,
469
+ json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
470
+ });
471
+
472
+ let hookRef: UseTransDuckReturn | null = null;
473
+
474
+ function TestComp() {
475
+ const hook = useTransDuck();
476
+ hookRef = hook;
477
+ return <span data-testid="text">{hook.t('Hello')}</span>;
478
+ }
479
+
480
+ const { rerender } = render(
481
+ <TransDuckProvider language="DE">
482
+ <TestComp />
483
+ </TransDuckProvider>
484
+ );
485
+
486
+ await waitFor(() => {
487
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
488
+ });
489
+
490
+ rerender(
491
+ <TransDuckProvider language="ES">
492
+ <TestComp />
493
+ </TransDuckProvider>
494
+ );
495
+
496
+ await waitFor(() => {
497
+ expect(screen.getByTestId('text').textContent).toBe('Hola');
498
+ expect(hookRef!.language).toBe('ES');
499
+ });
500
+ });
501
+
502
+ it('useTransDuck() still works without destructuring (backward compat)', async () => {
503
+ mockFetch.mockResolvedValue({
504
+ ok: true,
505
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
506
+ });
507
+
508
+ function TestComp() {
509
+ useTransDuck();
510
+ return <span data-testid="text">{t('Hello')}</span>;
511
+ }
512
+
513
+ render(
514
+ <TransDuckProvider language="DE">
515
+ <TestComp />
516
+ </TransDuckProvider>
517
+ );
518
+
519
+ await waitFor(() => {
520
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
521
+ });
522
+ });
162
523
  });