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 +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +1 -1
- package/dist/react/provider.d.ts +27 -3
- package/dist/react/provider.js +104 -20
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/react/index.ts +2 -1
- package/src/react/provider.tsx +142 -18
- package/tests/react-provider.test.tsx +363 -2
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.
|
|
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 () => {
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/react/index.js
CHANGED
|
@@ -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';
|
package/dist/react/provider.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
17
|
+
* Hook that subscribes to translation updates and returns translation
|
|
18
|
+
* functions along with language state.
|
|
5
19
|
*/
|
|
6
|
-
export
|
|
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;
|
package/dist/react/provider.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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')
|
package/src/react/index.ts
CHANGED
|
@@ -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';
|
package/src/react/provider.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
36
|
-
*
|
|
58
|
+
* Hook that subscribes to translation updates and returns translation
|
|
59
|
+
* functions along with language state.
|
|
37
60
|
*/
|
|
38
|
-
export
|
|
39
|
-
|
|
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
|
|
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)
|
|
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
|
|
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={
|
|
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
|
});
|