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 +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 +122 -22
- package/dist/storage.js +1 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/react/index.ts +2 -1
- package/src/react/provider.tsx +162 -20
- package/src/storage.ts +1 -1
- 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.1');
|
|
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,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
|
-
|
|
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() {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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.1');
|
|
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,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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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={
|
|
425
|
+
<TransDuckContext.Provider value={contextValue}>
|
|
284
426
|
{children}
|
|
285
427
|
</TransDuckContext.Provider>
|
|
286
428
|
);
|
package/src/storage.ts
CHANGED
|
@@ -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
|
});
|