transduck 0.3.1 → 0.4.0

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.0');
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,58 @@
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() {
26
38
  _state = {
27
39
  language: '',
28
40
  sourceLang: 'EN',
29
41
  endpoint: '/api/translations',
42
+ projectName: 'default',
30
43
  cache: new Map(),
31
44
  pluralCache: new Map(),
32
45
  pendingStrings: new Set(),
33
46
  pendingPlurals: new Set(),
47
+ knownKeys: new Set(),
48
+ knownPlurals: new Set(),
49
+ isLanguageSwitch: false,
34
50
  triggerFetch: null,
35
51
  };
36
52
  }
53
+ export function _getReactState() {
54
+ return _state;
55
+ }
37
56
  function interpolateVars(text, vars) {
38
57
  if (!vars)
39
58
  return text;
@@ -45,11 +64,12 @@ function interpolateVars(text, vars) {
45
64
  }
46
65
  // --- Stable exported functions ---
47
66
  export function t(sourceText, context, vars) {
67
+ const key = `${sourceText}||${context ?? ''}`;
68
+ _state.knownKeys.add(key);
48
69
  // Same language — return source
49
70
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
50
71
  return interpolateVars(sourceText, vars);
51
72
  }
52
- const key = `${sourceText}||${context ?? ''}`;
53
73
  // Cache hit
54
74
  const cached = _state.cache.get(key);
55
75
  if (cached !== undefined) {
@@ -72,14 +92,15 @@ export function tPlural(one, other, count, opts) {
72
92
  else {
73
93
  vars = { ...opts.vars };
74
94
  }
95
+ const sourceKey = `${one}\x00${other}`;
96
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
97
+ _state.knownPlurals.add(cacheKey);
75
98
  // Same language
76
99
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
77
100
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
78
101
  const form = rules.select(count) === 'one' ? one : other;
79
102
  return interpolateVars(form, vars);
80
103
  }
81
- const sourceKey = `${one}\x00${other}`;
82
- const cacheKey = `${sourceKey}||${context ?? ''}`;
83
104
  // Cache hit
84
105
  const cachedForms = _state.pluralCache.get(cacheKey);
85
106
  if (cachedForms) {
@@ -136,18 +157,52 @@ function saveToLocalStorage(projectName, language) {
136
157
  }
137
158
  export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/api/translations', projectName = 'default', children, }) {
138
159
  const [version, setVersion] = useState(0);
160
+ const [languageState, setLanguageState] = useState(language.toUpperCase());
161
+ const [isLoading, setIsLoading] = useState(false);
139
162
  // Update module-level state synchronously so t() works during first render
140
163
  _state.endpoint = endpoint;
141
164
  _state.sourceLang = sourceLang;
165
+ _state.projectName = projectName;
166
+ // Synchronous first-render initialization only.
167
+ // Subsequent language changes are handled by switchLanguage / useEffect prop sync.
142
168
  const upperLang = language.toUpperCase();
143
- if (_state.language !== upperLang) {
169
+ if (!_state.language) {
144
170
  _state.language = upperLang;
145
- _state.cache.clear();
146
- _state.pluralCache.clear();
147
171
  loadFromLocalStorage(projectName, upperLang);
148
172
  }
173
+ const switchLanguage = useCallback((lang) => {
174
+ const upper = lang.toUpperCase();
175
+ // Same-language no-op
176
+ if (upper === _state.language)
177
+ return;
178
+ // Update React state
179
+ setLanguageState(upper);
180
+ // Update module-level state synchronously
181
+ _state.language = upper;
182
+ _state.cache.clear();
183
+ _state.pluralCache.clear();
184
+ // Source-language guard: skip fetch, t() short-circuits
185
+ if (upper === _state.sourceLang.toUpperCase()) {
186
+ setVersion(v => v + 1);
187
+ return;
188
+ }
189
+ // Load cached translations from localStorage
190
+ loadFromLocalStorage(_state.projectName, upper);
191
+ // Mark as language switch and re-queue known keys
192
+ _state.isLanguageSwitch = true;
193
+ setIsLoading(true);
194
+ for (const key of _state.knownKeys) {
195
+ _state.pendingStrings.add(key);
196
+ }
197
+ for (const key of _state.knownPlurals) {
198
+ _state.pendingPlurals.add(key);
199
+ }
200
+ // Bump version to trigger re-render and useEffect flush
201
+ setVersion(v => v + 1);
202
+ }, []);
149
203
  // Set up trigger
150
204
  const doFetch = useCallback(async () => {
205
+ const fetchLanguage = _state.language;
151
206
  const stringsToFetch = new Set(_state.pendingStrings);
152
207
  const pluralsToFetch = new Set(_state.pendingPlurals);
153
208
  _state.pendingStrings.clear();
@@ -161,8 +216,13 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
161
216
  if (_state.pluralCache.has(key))
162
217
  pluralsToFetch.delete(key);
163
218
  }
164
- if (stringsToFetch.size === 0 && pluralsToFetch.size === 0)
219
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
220
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
221
+ _state.isLanguageSwitch = false;
222
+ setIsLoading(false);
223
+ }
165
224
  return;
225
+ }
166
226
  // Build request body
167
227
  const strings = Array.from(stringsToFetch).map(key => {
168
228
  const [text, context] = key.split('||');
@@ -188,6 +248,9 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
188
248
  return;
189
249
  }
190
250
  const data = await response.json();
251
+ // Stale fetch protection: language changed while we were fetching
252
+ if (_state.language !== fetchLanguage)
253
+ return;
191
254
  // Store translations
192
255
  if (data.translations) {
193
256
  for (const [key, value] of Object.entries(data.translations)) {
@@ -199,20 +262,41 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
199
262
  _state.pluralCache.set(key, value);
200
263
  }
201
264
  }
202
- saveToLocalStorage(projectName, _state.language);
265
+ saveToLocalStorage(_state.projectName, _state.language);
203
266
  setVersion(v => v + 1);
204
267
  }
205
268
  catch (err) {
206
269
  console.warn('[transduck] Translation fetch error:', err);
207
270
  }
208
- }, [endpoint, projectName]);
271
+ finally {
272
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
273
+ _state.isLanguageSwitch = false;
274
+ setIsLoading(false);
275
+ }
276
+ }
277
+ }, [endpoint]);
209
278
  // Register trigger
210
279
  _state.triggerFetch = doFetch;
280
+ // Sync language prop changes — only when the prop itself changes
281
+ const prevPropRef = React.useRef(upperLang);
282
+ useEffect(() => {
283
+ const upper = language.toUpperCase();
284
+ if (upper !== prevPropRef.current) {
285
+ prevPropRef.current = upper;
286
+ switchLanguage(upper);
287
+ }
288
+ }, [language, switchLanguage]);
211
289
  // Flush pending after render
212
290
  useEffect(() => {
213
291
  if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
214
292
  doFetch();
215
293
  }
216
294
  });
217
- return (_jsx(TransDuckContext.Provider, { value: version, children: children }));
295
+ const contextValue = {
296
+ version,
297
+ language: languageState,
298
+ setLanguage: switchLanguage,
299
+ isLoading,
300
+ };
301
+ return (_jsx(TransDuckContext.Provider, { value: contextValue, children: children }));
218
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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.0');
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,24 +23,62 @@ 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 {
@@ -44,14 +86,22 @@ export function _resetReactState(): void {
44
86
  language: '',
45
87
  sourceLang: 'EN',
46
88
  endpoint: '/api/translations',
89
+ projectName: 'default',
47
90
  cache: new Map(),
48
91
  pluralCache: new Map(),
49
92
  pendingStrings: new Set(),
50
93
  pendingPlurals: new Set(),
94
+ knownKeys: new Set(),
95
+ knownPlurals: new Set(),
96
+ isLanguageSwitch: false,
51
97
  triggerFetch: null,
52
98
  };
53
99
  }
54
100
 
101
+ export function _getReactState(): ReactState {
102
+ return _state;
103
+ }
104
+
55
105
  function interpolateVars(text: string, vars?: Record<string, string | number> | null): string {
56
106
  if (!vars) return text;
57
107
  let result = text;
@@ -68,13 +118,14 @@ export function t(
68
118
  context?: string,
69
119
  vars?: Record<string, string | number>,
70
120
  ): string {
121
+ const key = `${sourceText}||${context ?? ''}`;
122
+ _state.knownKeys.add(key);
123
+
71
124
  // Same language — return source
72
125
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
73
126
  return interpolateVars(sourceText, vars);
74
127
  }
75
128
 
76
- const key = `${sourceText}||${context ?? ''}`;
77
-
78
129
  // Cache hit
79
130
  const cached = _state.cache.get(key);
80
131
  if (cached !== undefined) {
@@ -104,6 +155,10 @@ export function tPlural(
104
155
  vars = { ...opts.vars };
105
156
  }
106
157
 
158
+ const sourceKey = `${one}\x00${other}`;
159
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
160
+ _state.knownPlurals.add(cacheKey);
161
+
107
162
  // Same language
108
163
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
109
164
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
@@ -111,9 +166,6 @@ export function tPlural(
111
166
  return interpolateVars(form, vars);
112
167
  }
113
168
 
114
- const sourceKey = `${one}\x00${other}`;
115
- const cacheKey = `${sourceKey}||${context ?? ''}`;
116
-
117
169
  // Cache hit
118
170
  const cachedForms = _state.pluralCache.get(cacheKey);
119
171
  if (cachedForms) {
@@ -191,21 +243,62 @@ export function TransDuckProvider({
191
243
  children,
192
244
  }: TransDuckProviderProps) {
193
245
  const [version, setVersion] = useState(0);
246
+ const [languageState, setLanguageState] = useState(language.toUpperCase());
247
+ const [isLoading, setIsLoading] = useState(false);
194
248
 
195
249
  // Update module-level state synchronously so t() works during first render
196
250
  _state.endpoint = endpoint;
197
251
  _state.sourceLang = sourceLang;
252
+ _state.projectName = projectName;
198
253
 
254
+ // Synchronous first-render initialization only.
255
+ // Subsequent language changes are handled by switchLanguage / useEffect prop sync.
199
256
  const upperLang = language.toUpperCase();
200
- if (_state.language !== upperLang) {
257
+ if (!_state.language) {
201
258
  _state.language = upperLang;
202
- _state.cache.clear();
203
- _state.pluralCache.clear();
204
259
  loadFromLocalStorage(projectName, upperLang);
205
260
  }
206
261
 
262
+ const switchLanguage = useCallback((lang: string) => {
263
+ const upper = lang.toUpperCase();
264
+
265
+ // Same-language no-op
266
+ if (upper === _state.language) return;
267
+
268
+ // Update React state
269
+ setLanguageState(upper);
270
+
271
+ // Update module-level state synchronously
272
+ _state.language = upper;
273
+ _state.cache.clear();
274
+ _state.pluralCache.clear();
275
+
276
+ // Source-language guard: skip fetch, t() short-circuits
277
+ if (upper === _state.sourceLang.toUpperCase()) {
278
+ setVersion(v => v + 1);
279
+ return;
280
+ }
281
+
282
+ // Load cached translations from localStorage
283
+ loadFromLocalStorage(_state.projectName, upper);
284
+
285
+ // Mark as language switch and re-queue known keys
286
+ _state.isLanguageSwitch = true;
287
+ setIsLoading(true);
288
+ for (const key of _state.knownKeys) {
289
+ _state.pendingStrings.add(key);
290
+ }
291
+ for (const key of _state.knownPlurals) {
292
+ _state.pendingPlurals.add(key);
293
+ }
294
+
295
+ // Bump version to trigger re-render and useEffect flush
296
+ setVersion(v => v + 1);
297
+ }, []);
298
+
207
299
  // Set up trigger
208
300
  const doFetch = useCallback(async () => {
301
+ const fetchLanguage = _state.language;
209
302
  const stringsToFetch = new Set(_state.pendingStrings);
210
303
  const pluralsToFetch = new Set(_state.pendingPlurals);
211
304
  _state.pendingStrings.clear();
@@ -219,7 +312,13 @@ export function TransDuckProvider({
219
312
  if (_state.pluralCache.has(key)) pluralsToFetch.delete(key);
220
313
  }
221
314
 
222
- if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) return;
315
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
316
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
317
+ _state.isLanguageSwitch = false;
318
+ setIsLoading(false);
319
+ }
320
+ return;
321
+ }
223
322
 
224
323
  // Build request body
225
324
  const strings = Array.from(stringsToFetch).map(key => {
@@ -250,6 +349,9 @@ export function TransDuckProvider({
250
349
 
251
350
  const data = await response.json();
252
351
 
352
+ // Stale fetch protection: language changed while we were fetching
353
+ if (_state.language !== fetchLanguage) return;
354
+
253
355
  // Store translations
254
356
  if (data.translations) {
255
357
  for (const [key, value] of Object.entries(data.translations)) {
@@ -262,16 +364,31 @@ export function TransDuckProvider({
262
364
  }
263
365
  }
264
366
 
265
- saveToLocalStorage(projectName, _state.language);
367
+ saveToLocalStorage(_state.projectName, _state.language);
266
368
  setVersion(v => v + 1);
267
369
  } catch (err) {
268
370
  console.warn('[transduck] Translation fetch error:', err);
371
+ } finally {
372
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
373
+ _state.isLanguageSwitch = false;
374
+ setIsLoading(false);
375
+ }
269
376
  }
270
- }, [endpoint, projectName]);
377
+ }, [endpoint]);
271
378
 
272
379
  // Register trigger
273
380
  _state.triggerFetch = doFetch;
274
381
 
382
+ // Sync language prop changes — only when the prop itself changes
383
+ const prevPropRef = React.useRef(upperLang);
384
+ useEffect(() => {
385
+ const upper = language.toUpperCase();
386
+ if (upper !== prevPropRef.current) {
387
+ prevPropRef.current = upper;
388
+ switchLanguage(upper);
389
+ }
390
+ }, [language, switchLanguage]);
391
+
275
392
  // Flush pending after render
276
393
  useEffect(() => {
277
394
  if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
@@ -279,8 +396,15 @@ export function TransDuckProvider({
279
396
  }
280
397
  });
281
398
 
399
+ const contextValue: TransDuckContextValue = {
400
+ version,
401
+ language: languageState,
402
+ setLanguage: switchLanguage,
403
+ isLoading,
404
+ };
405
+
282
406
  return (
283
- <TransDuckContext.Provider value={version}>
407
+ <TransDuckContext.Provider value={contextValue}>
284
408
  {children}
285
409
  </TransDuckContext.Provider>
286
410
  );
@@ -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
  });