@hua-labs/i18n-loaders 1.1.1 → 2.0.2

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/README.md CHANGED
@@ -1,93 +1,78 @@
1
1
  # @hua-labs/i18n-loaders
2
2
 
3
- Translation loaders with caching and preloading for @hua-labs/i18n-core.
4
- 캐싱 및 프리로딩 기능을 갖춘 번역 로더.
3
+ Production-ready translation loaders with built-in TTL caching, duplicate request prevention, and namespace preloading. Designed to work seamlessly with @hua-labs/i18n-core. Supports both server and client environments.
5
4
 
6
5
  [![npm version](https://img.shields.io/npm/v/@hua-labs/i18n-loaders.svg)](https://www.npmjs.com/package/@hua-labs/i18n-loaders)
7
- [![npm downloads](https://img.shields.io/npm/dw/@hua-labs/i18n-loaders.svg)](https://www.npmjs.com/package/@hua-labs/i18n-loaders)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@hua-labs/i18n-loaders.svg)](https://www.npmjs.com/package/@hua-labs/i18n-loaders)
8
7
  [![license](https://img.shields.io/npm/l/@hua-labs/i18n-loaders.svg)](https://github.com/HUA-Labs/HUA-Labs-public/blob/main/LICENSE)
9
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/)
10
9
  [![React](https://img.shields.io/badge/React-19-blue)](https://reactjs.org/)
11
10
 
12
- > **Alpha**: APIs may change before stable release. | **알파**: 안정 릴리스 전 API가 변경될 수 있습니다.
13
-
14
- ## Overview | 개요
15
-
16
- Production-ready translation loaders with built-in TTL caching, duplicate request prevention, and namespace preloading. Designed to work seamlessly with @hua-labs/i18n-core. Supports both server and client environments.
17
-
18
- TTL 캐싱, 중복 요청 방지, 네임스페이스 프리로딩이 내장된 프로덕션 레디 번역 로더입니다. @hua-labs/i18n-core와 원활하게 작동하도록 설계되었습니다. 서버/클라이언트 환경 모두 지원합니다.
19
-
20
11
  ## Features
21
12
 
22
- - **API loader**`createApiTranslationLoader` with configurable endpoints
23
- - **TTL caching** — Time-based cache with global cache support
24
- - **Duplicate prevention** — Deduplicates concurrent requests for the same resource
25
- - **Preloading** — Warm up namespaces and fallback languages at startup
26
- - **Default merging** — Merge API translations with bundled defaults
13
+ - **API loader — createApiTranslationLoader with configurable endpoints**
14
+ - **TTL caching — Time-based cache with global cache support**
15
+ - **Duplicate prevention — Deduplicates concurrent requests for the same resource**
16
+ - **Preloading — Warm up namespaces and fallback languages at startup**
17
+ - **Default merging — Merge API translations with bundled defaults**
27
18
 
28
- ## Installation | 설치
19
+ ## Installation
29
20
 
30
21
  ```bash
31
22
  pnpm add @hua-labs/i18n-loaders
32
23
  ```
33
24
 
34
- Peer dependency: `react >= 19.0.0`
25
+ > Peer dependencies: react >=19.0.0
35
26
 
36
- ## Quick Start | 빠른 시작
27
+ ## Quick Start
37
28
 
38
29
  ```tsx
39
30
  import { createCoreI18n } from '@hua-labs/i18n-core';
40
31
  import { createApiTranslationLoader, preloadNamespaces } from '@hua-labs/i18n-loaders';
41
32
 
42
- const loadTranslations = createApiTranslationLoader({
33
+ const loader = createApiTranslationLoader({
43
34
  translationApiPath: '/api/translations',
44
35
  cacheTtlMs: 60_000,
45
- enableGlobalCache: true,
36
+ retryCount: 2,
46
37
  });
47
38
 
48
39
  // Preload at startup
49
- preloadNamespaces('ko', ['common', 'dashboard'], loadTranslations);
40
+ preloadNamespaces('ko', ['common', 'dashboard'], loader);
50
41
 
51
42
  // Use with i18n-core
52
43
  const I18nProvider = createCoreI18n({
53
44
  defaultLanguage: 'ko',
54
- fallbackLanguage: 'en',
55
- namespaces: ['common', 'dashboard'],
56
45
  translationLoader: 'custom',
57
- loadTranslations,
46
+ loadTranslations: loader,
58
47
  });
59
- ```
60
-
61
- ## API Overview | API 개요
62
-
63
- | Function | Description |
64
- |----------|-------------|
65
- | `createApiTranslationLoader(config)` | Create an API-based translation loader |
66
- | `preloadNamespaces(lang, namespaces, loader)` | Preload translation namespaces |
67
- | `withDefaultTranslations(loader, defaults)` | Merge API results with bundled defaults |
68
48
 
69
- **Loader config:**
70
-
71
- | Option | Type | Default | Description |
72
- |--------|------|---------|-------------|
73
- | `translationApiPath` | `string` | — | API endpoint path |
74
- | `cacheTtlMs` | `number` | `300000` | Cache TTL in ms |
75
- | `enableGlobalCache` | `boolean` | `true` | Enable global cache |
49
+ ```
76
50
 
77
- ## Documentation | 문서
51
+ ## API
78
52
 
79
- - [📚 Documentation Site | 문서 사이트](https://docs.hua-labs.com)
53
+ | Export | Type | Description |
54
+ |--------|------|-------------|
55
+ | `createApiTranslationLoader` | function | Create API-based loader with caching and retry |
56
+ | `preloadNamespaces` | function | Preload translation namespaces |
57
+ | `warmFallbackLanguages` | function | Warm up fallback language caches |
58
+ | `withDefaultTranslations` | function | Merge API results with bundled defaults |
59
+ | `ApiLoaderOptions` | type | |
60
+ | `CacheInvalidation` | type | |
61
+ | `DefaultTranslations` | type | |
62
+ | `PreloadOptions` | type | |
63
+ | `TranslationLoader` | type | |
64
+ | `TranslationRecord` | type | |
80
65
 
81
- ## Related Packages | 관련 패키지
66
+ ## Documentation
82
67
 
83
- - [`@hua-labs/i18n-core`](https://www.npmjs.com/package/@hua-labs/i18n-core) — Core i18n library
84
- - [`@hua-labs/i18n-core-zustand`](https://www.npmjs.com/package/@hua-labs/i18n-core-zustand) — Zustand state adapter
85
- - [`@hua-labs/i18n-formatters`](https://www.npmjs.com/package/@hua-labs/i18n-formatters) — Date, number, currency formatters
68
+ [Full Documentation](https://docs.hua-labs.com)
86
69
 
87
- ## Requirements | 요구사항
70
+ ## Related Packages
88
71
 
89
- React >= 19.0.0 · TypeScript >= 5.9
72
+ - [`@hua-labs/i18n-core`](https://www.npmjs.com/package/@hua-labs/i18n-core)
73
+ - [`@hua-labs/i18n-core-zustand`](https://www.npmjs.com/package/@hua-labs/i18n-core-zustand)
74
+ - [`@hua-labs/i18n-formatters`](https://www.npmjs.com/package/@hua-labs/i18n-formatters)
90
75
 
91
76
  ## License
92
77
 
93
- MIT — [HUA Labs](https://github.com/HUA-Labs/HUA-Labs-public)
78
+ MIT — [HUA Labs](https://hua-labs.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hua-labs/i18n-loaders",
3
- "version": "1.1.1",
3
+ "version": "2.0.2",
4
4
  "description": "Production-ready loaders, caching, and preloading helpers for @hua-labs/i18n-core.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -17,19 +17,23 @@
17
17
  }
18
18
  },
19
19
  "sideEffects": false,
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
20
23
  "dependencies": {
21
- "@hua-labs/i18n-core": "^1.1.0"
24
+ "@hua-labs/i18n-core": "latest"
22
25
  },
23
26
  "peerDependencies": {
24
27
  "react": ">=19.0.0"
25
28
  },
26
29
  "devDependencies": {
27
- "@types/node": "^25.0.3",
28
- "@types/react": "^19.2.7",
29
- "eslint": "^9.39.1",
30
- "react": "^19.2.0",
30
+ "@types/node": "^25.2.0",
31
+ "@types/react": "^19.2.10",
32
+ "eslint": "^10.0.0",
33
+ "react": "^19.2.4",
31
34
  "tsup": "^8.5.1",
32
- "typescript": "^5.9.3"
35
+ "typescript": "^5.9.3",
36
+ "vitest": "^3.2.1"
33
37
  },
34
38
  "keywords": [
35
39
  "hua-labs",
@@ -60,7 +64,8 @@
60
64
  "build": "tsup",
61
65
  "dev": "tsup --watch",
62
66
  "clean": "rm -rf dist",
63
- "test": "echo \"No tests defined\"",
67
+ "test": "vitest run",
68
+ "test:watch": "vitest",
64
69
  "lint": "echo 'Skipping lint for hua-i18n-loaders (TypeScript only, linted by tsc)'",
65
70
  "type-check": "tsc --noEmit"
66
71
  }
@@ -0,0 +1,610 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createApiTranslationLoader } from '../api-loader';
3
+ import type { TranslationRecord } from '../types';
4
+
5
+ describe('createApiTranslationLoader', () => {
6
+ let mockFetch: ReturnType<typeof vi.fn>;
7
+
8
+ beforeEach(() => {
9
+ mockFetch = vi.fn();
10
+ global.fetch = mockFetch;
11
+ vi.useFakeTimers();
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ describe('Basic functionality', () => {
20
+ it('should fetch and return translations', async () => {
21
+ const translations: TranslationRecord = { hello: 'world' };
22
+ mockFetch.mockResolvedValueOnce({
23
+ ok: true,
24
+ json: async () => translations,
25
+ });
26
+
27
+ const loader = createApiTranslationLoader({
28
+ baseUrl: 'https://example.com',
29
+ translationApiPath: '/api/translations',
30
+ });
31
+
32
+ const result = await loader('en', 'common');
33
+
34
+ expect(result).toEqual(translations);
35
+ expect(mockFetch).toHaveBeenCalledWith(
36
+ 'https://example.com/api/translations/en/common',
37
+ expect.objectContaining({
38
+ cache: 'no-store',
39
+ })
40
+ );
41
+ });
42
+
43
+ it('should throw error on failed fetch', async () => {
44
+ mockFetch.mockResolvedValueOnce({
45
+ ok: false,
46
+ status: 404,
47
+ });
48
+
49
+ const loader = createApiTranslationLoader();
50
+
51
+ await expect(loader('en', 'common')).rejects.toThrow(
52
+ '[i18n-loaders] Failed to load en/common (404)'
53
+ );
54
+ });
55
+ });
56
+
57
+ describe('Caching', () => {
58
+ it('should return cached data on subsequent requests', async () => {
59
+ const translations: TranslationRecord = { hello: 'world' };
60
+ mockFetch.mockResolvedValueOnce({
61
+ ok: true,
62
+ json: async () => translations,
63
+ });
64
+
65
+ const loader = createApiTranslationLoader({
66
+ cacheTtlMs: 5 * 60 * 1000,
67
+ });
68
+
69
+ const result1 = await loader('en', 'common');
70
+ const result2 = await loader('en', 'common');
71
+
72
+ expect(result1).toEqual(translations);
73
+ expect(result2).toEqual(translations);
74
+ expect(mockFetch).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('should refetch after cache TTL expires', async () => {
78
+ const translations1: TranslationRecord = { hello: 'world' };
79
+ const translations2: TranslationRecord = { hello: 'updated' };
80
+
81
+ mockFetch
82
+ .mockResolvedValueOnce({
83
+ ok: true,
84
+ json: async () => translations1,
85
+ })
86
+ .mockResolvedValueOnce({
87
+ ok: true,
88
+ json: async () => translations2,
89
+ });
90
+
91
+ const loader = createApiTranslationLoader({
92
+ cacheTtlMs: 1000,
93
+ });
94
+
95
+ const result1 = await loader('en', 'common');
96
+ expect(result1).toEqual(translations1);
97
+
98
+ // Advance time past TTL
99
+ vi.advanceTimersByTime(1001);
100
+
101
+ const result2 = await loader('en', 'common');
102
+ expect(result2).toEqual(translations2);
103
+ expect(mockFetch).toHaveBeenCalledTimes(2);
104
+ });
105
+
106
+ it('should skip cache when disableCache is true', async () => {
107
+ const translations: TranslationRecord = { hello: 'world' };
108
+ mockFetch.mockResolvedValue({
109
+ ok: true,
110
+ json: async () => translations,
111
+ });
112
+
113
+ const loader = createApiTranslationLoader({
114
+ disableCache: true,
115
+ });
116
+
117
+ await loader('en', 'common');
118
+ await loader('en', 'common');
119
+
120
+ expect(mockFetch).toHaveBeenCalledTimes(2);
121
+ });
122
+ });
123
+
124
+ describe('In-flight request deduplication', () => {
125
+ it('should deduplicate concurrent requests', async () => {
126
+ vi.useRealTimers(); // Use real timers for this test
127
+
128
+ const translations: TranslationRecord = { hello: 'world' };
129
+ mockFetch.mockImplementation(
130
+ () =>
131
+ new Promise((resolve) => {
132
+ setTimeout(() => {
133
+ resolve({
134
+ ok: true,
135
+ json: async () => translations,
136
+ });
137
+ }, 10);
138
+ })
139
+ );
140
+
141
+ const loader = createApiTranslationLoader({
142
+ baseUrl: 'https://example.com',
143
+ });
144
+
145
+ const [result1, result2, result3] = await Promise.all([
146
+ loader('en', 'common'),
147
+ loader('en', 'common'),
148
+ loader('en', 'common'),
149
+ ]);
150
+
151
+ expect(result1).toEqual(translations);
152
+ expect(result2).toEqual(translations);
153
+ expect(result3).toEqual(translations);
154
+ expect(mockFetch).toHaveBeenCalledTimes(1);
155
+
156
+ vi.useFakeTimers(); // Restore fake timers
157
+ });
158
+
159
+ it('should not deduplicate different language/namespace combinations', async () => {
160
+ const translations1: TranslationRecord = { hello: 'world' };
161
+ const translations2: TranslationRecord = { hello: 'mundo' };
162
+
163
+ mockFetch.mockImplementation((url) => {
164
+ const urlStr = typeof url === 'string' ? url : url.toString();
165
+ if (urlStr.includes('/en/')) {
166
+ return Promise.resolve({
167
+ ok: true,
168
+ json: async () => translations1,
169
+ });
170
+ }
171
+ return Promise.resolve({
172
+ ok: true,
173
+ json: async () => translations2,
174
+ });
175
+ });
176
+
177
+ const loader = createApiTranslationLoader({
178
+ baseUrl: 'https://example.com',
179
+ });
180
+
181
+ const [result1, result2] = await Promise.all([
182
+ loader('en', 'common'),
183
+ loader('es', 'common'),
184
+ ]);
185
+
186
+ expect(result1).toEqual(translations1);
187
+ expect(result2).toEqual(translations2);
188
+ expect(mockFetch).toHaveBeenCalledTimes(2);
189
+ });
190
+ });
191
+
192
+ describe('Retry logic', () => {
193
+ it('should retry on 5xx errors with exponential backoff', async () => {
194
+ vi.useRealTimers(); // Use real timers for retry test
195
+
196
+ const translations: TranslationRecord = { hello: 'world' };
197
+ let callCount = 0;
198
+
199
+ mockFetch.mockImplementation(async () => {
200
+ callCount++;
201
+ if (callCount < 3) {
202
+ const error = new Error('Internal Server Error') as Error & { status: number };
203
+ error.status = 500;
204
+ throw error;
205
+ }
206
+ return {
207
+ ok: true,
208
+ json: async () => translations,
209
+ } as Response;
210
+ });
211
+
212
+ const loader = createApiTranslationLoader({
213
+ baseUrl: 'https://example.com',
214
+ retryCount: 2,
215
+ retryDelay: 10, // Short delay for faster test
216
+ });
217
+
218
+ const result = await loader('en', 'common');
219
+
220
+ expect(result).toEqual(translations);
221
+ expect(callCount).toBe(3);
222
+
223
+ vi.useFakeTimers(); // Restore fake timers
224
+ });
225
+
226
+ it('should retry on network errors', async () => {
227
+ vi.useRealTimers(); // Use real timers for retry test
228
+
229
+ const translations: TranslationRecord = { hello: 'world' };
230
+ let callCount = 0;
231
+
232
+ mockFetch.mockImplementation(() => {
233
+ callCount++;
234
+ if (callCount === 1) {
235
+ return Promise.reject(new TypeError('Failed to fetch'));
236
+ }
237
+ return Promise.resolve({
238
+ ok: true,
239
+ json: async () => translations,
240
+ });
241
+ });
242
+
243
+ const loader = createApiTranslationLoader({
244
+ baseUrl: 'https://example.com',
245
+ retryCount: 1,
246
+ retryDelay: 10, // Short delay for faster test
247
+ });
248
+
249
+ const result = await loader('en', 'common');
250
+
251
+ expect(result).toEqual(translations);
252
+ expect(callCount).toBe(2);
253
+
254
+ vi.useFakeTimers(); // Restore fake timers
255
+ });
256
+
257
+ it('should not retry on 4xx errors', async () => {
258
+ mockFetch.mockResolvedValue({
259
+ ok: false,
260
+ status: 404,
261
+ });
262
+
263
+ const loader = createApiTranslationLoader({
264
+ retryCount: 2,
265
+ retryDelay: 100,
266
+ });
267
+
268
+ await expect(loader('en', 'common')).rejects.toThrow();
269
+ expect(mockFetch).toHaveBeenCalledTimes(1);
270
+ });
271
+
272
+ it('should throw error after max retries exceeded', async () => {
273
+ vi.useRealTimers(); // Use real timers for retry test
274
+
275
+ let callCount = 0;
276
+ mockFetch.mockImplementation(async () => {
277
+ callCount++;
278
+ const error = new Error('Internal Server Error') as Error & { status: number };
279
+ error.status = 500;
280
+ throw error;
281
+ });
282
+
283
+ const logger = {
284
+ log: vi.fn(),
285
+ warn: vi.fn(),
286
+ error: vi.fn(),
287
+ };
288
+
289
+ const loader = createApiTranslationLoader({
290
+ baseUrl: 'https://example.com',
291
+ retryCount: 2,
292
+ retryDelay: 10, // Short delay for faster test
293
+ logger,
294
+ });
295
+
296
+ await expect(loader('en', 'common')).rejects.toThrow();
297
+ expect(callCount).toBe(3); // Initial + 2 retries
298
+ expect(logger.warn).toHaveBeenCalled();
299
+
300
+ vi.useFakeTimers(); // Restore fake timers
301
+ });
302
+ });
303
+
304
+ describe('URL building', () => {
305
+ it('should use baseUrl in server environment', async () => {
306
+ const translations: TranslationRecord = { hello: 'world' };
307
+ mockFetch.mockResolvedValueOnce({
308
+ ok: true,
309
+ json: async () => translations,
310
+ });
311
+
312
+ const loader = createApiTranslationLoader({
313
+ baseUrl: 'https://example.com',
314
+ translationApiPath: '/api/translations',
315
+ });
316
+
317
+ await loader('en', 'common');
318
+
319
+ expect(mockFetch).toHaveBeenCalledWith(
320
+ 'https://example.com/api/translations/en/common',
321
+ expect.any(Object)
322
+ );
323
+ });
324
+
325
+ it('should sanitize namespace in URL', async () => {
326
+ const translations: TranslationRecord = { hello: 'world' };
327
+ mockFetch.mockResolvedValueOnce({
328
+ ok: true,
329
+ json: async () => translations,
330
+ });
331
+
332
+ const loader = createApiTranslationLoader({
333
+ baseUrl: 'https://example.com',
334
+ });
335
+
336
+ await loader('en', 'my@namespace#test');
337
+
338
+ expect(mockFetch).toHaveBeenCalledWith(
339
+ 'https://example.com/api/translations/en/mynamespacetest',
340
+ expect.any(Object)
341
+ );
342
+ });
343
+
344
+ it('should fall back to NEXT_PUBLIC_SITE_URL', async () => {
345
+ const originalEnv = process.env.NEXT_PUBLIC_SITE_URL;
346
+ process.env.NEXT_PUBLIC_SITE_URL = 'https://site.com';
347
+
348
+ const translations: TranslationRecord = { hello: 'world' };
349
+ mockFetch.mockResolvedValueOnce({
350
+ ok: true,
351
+ json: async () => translations,
352
+ });
353
+
354
+ const loader = createApiTranslationLoader();
355
+
356
+ await loader('en', 'common');
357
+
358
+ expect(mockFetch).toHaveBeenCalledWith(
359
+ 'https://site.com/api/translations/en/common',
360
+ expect.any(Object)
361
+ );
362
+
363
+ process.env.NEXT_PUBLIC_SITE_URL = originalEnv;
364
+ });
365
+
366
+ it('should fall back to VERCEL_URL', async () => {
367
+ const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL;
368
+ const originalVercelUrl = process.env.VERCEL_URL;
369
+
370
+ delete process.env.NEXT_PUBLIC_SITE_URL;
371
+ process.env.VERCEL_URL = 'vercel-app.vercel.app';
372
+
373
+ const translations: TranslationRecord = { hello: 'world' };
374
+ mockFetch.mockResolvedValueOnce({
375
+ ok: true,
376
+ json: async () => translations,
377
+ });
378
+
379
+ const loader = createApiTranslationLoader();
380
+
381
+ await loader('en', 'common');
382
+
383
+ expect(mockFetch).toHaveBeenCalledWith(
384
+ 'https://vercel-app.vercel.app/api/translations/en/common',
385
+ expect.any(Object)
386
+ );
387
+
388
+ process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl;
389
+ process.env.VERCEL_URL = originalVercelUrl;
390
+ });
391
+
392
+ it('should use localFallbackBaseUrl as final fallback', async () => {
393
+ const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL;
394
+ const originalVercelUrl = process.env.VERCEL_URL;
395
+
396
+ delete process.env.NEXT_PUBLIC_SITE_URL;
397
+ delete process.env.VERCEL_URL;
398
+
399
+ const translations: TranslationRecord = { hello: 'world' };
400
+ mockFetch.mockResolvedValueOnce({
401
+ ok: true,
402
+ json: async () => translations,
403
+ });
404
+
405
+ const loader = createApiTranslationLoader({
406
+ localFallbackBaseUrl: 'http://localhost:4000',
407
+ });
408
+
409
+ await loader('en', 'common');
410
+
411
+ expect(mockFetch).toHaveBeenCalledWith(
412
+ 'http://localhost:4000/api/translations/en/common',
413
+ expect.any(Object)
414
+ );
415
+
416
+ process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl;
417
+ process.env.VERCEL_URL = originalVercelUrl;
418
+ });
419
+ });
420
+
421
+ describe('Cache invalidation', () => {
422
+ it('should invalidate specific language/namespace', async () => {
423
+ const translations1: TranslationRecord = { hello: 'world' };
424
+ const translations2: TranslationRecord = { hello: 'updated' };
425
+
426
+ mockFetch
427
+ .mockResolvedValueOnce({
428
+ ok: true,
429
+ json: async () => translations1,
430
+ })
431
+ .mockResolvedValueOnce({
432
+ ok: true,
433
+ json: async () => translations2,
434
+ });
435
+
436
+ const loader = createApiTranslationLoader();
437
+
438
+ const result1 = await loader('en', 'common');
439
+ expect(result1).toEqual(translations1);
440
+
441
+ loader.invalidate('en', 'common');
442
+
443
+ const result2 = await loader('en', 'common');
444
+ expect(result2).toEqual(translations2);
445
+ expect(mockFetch).toHaveBeenCalledTimes(2);
446
+ });
447
+
448
+ it('should invalidate all namespaces for a language', async () => {
449
+ const translations: TranslationRecord = { hello: 'world' };
450
+ mockFetch.mockResolvedValue({
451
+ ok: true,
452
+ json: async () => translations,
453
+ });
454
+
455
+ const loader = createApiTranslationLoader();
456
+
457
+ await loader('en', 'common');
458
+ await loader('en', 'auth');
459
+ expect(mockFetch).toHaveBeenCalledTimes(2);
460
+
461
+ loader.invalidate('en');
462
+
463
+ await loader('en', 'common');
464
+ await loader('en', 'auth');
465
+ expect(mockFetch).toHaveBeenCalledTimes(4);
466
+ });
467
+
468
+ it('should invalidate specific namespace across all languages', async () => {
469
+ const translations: TranslationRecord = { hello: 'world' };
470
+ mockFetch.mockResolvedValue({
471
+ ok: true,
472
+ json: async () => translations,
473
+ });
474
+
475
+ const loader = createApiTranslationLoader();
476
+
477
+ await loader('en', 'common');
478
+ await loader('es', 'common');
479
+ expect(mockFetch).toHaveBeenCalledTimes(2);
480
+
481
+ loader.invalidate(undefined, 'common');
482
+
483
+ await loader('en', 'common');
484
+ await loader('es', 'common');
485
+ expect(mockFetch).toHaveBeenCalledTimes(4);
486
+ });
487
+
488
+ it('should clear entire cache', async () => {
489
+ const translations: TranslationRecord = { hello: 'world' };
490
+ mockFetch.mockResolvedValue({
491
+ ok: true,
492
+ json: async () => translations,
493
+ });
494
+
495
+ const loader = createApiTranslationLoader();
496
+
497
+ await loader('en', 'common');
498
+ await loader('en', 'auth');
499
+ await loader('es', 'common');
500
+ expect(mockFetch).toHaveBeenCalledTimes(3);
501
+
502
+ loader.clear();
503
+
504
+ await loader('en', 'common');
505
+ await loader('en', 'auth');
506
+ await loader('es', 'common');
507
+ expect(mockFetch).toHaveBeenCalledTimes(6);
508
+ });
509
+ });
510
+
511
+ describe('Custom fetcher', () => {
512
+ it('should use custom fetcher when provided', async () => {
513
+ const translations: TranslationRecord = { hello: 'world' };
514
+ const customFetcher = vi.fn().mockResolvedValue({
515
+ ok: true,
516
+ json: async () => translations,
517
+ });
518
+
519
+ const loader = createApiTranslationLoader({
520
+ fetcher: customFetcher,
521
+ });
522
+
523
+ await loader('en', 'common');
524
+
525
+ expect(customFetcher).toHaveBeenCalledTimes(1);
526
+ expect(mockFetch).not.toHaveBeenCalled();
527
+ });
528
+ });
529
+
530
+ describe('Custom requestInit', () => {
531
+ it('should use static requestInit', async () => {
532
+ const translations: TranslationRecord = { hello: 'world' };
533
+ mockFetch.mockResolvedValueOnce({
534
+ ok: true,
535
+ json: async () => translations,
536
+ });
537
+
538
+ const loader = createApiTranslationLoader({
539
+ requestInit: {
540
+ headers: {
541
+ 'X-Custom-Header': 'test',
542
+ },
543
+ },
544
+ });
545
+
546
+ await loader('en', 'common');
547
+
548
+ expect(mockFetch).toHaveBeenCalledWith(
549
+ expect.any(String),
550
+ expect.objectContaining({
551
+ headers: {
552
+ 'X-Custom-Header': 'test',
553
+ },
554
+ })
555
+ );
556
+ });
557
+
558
+ it('should use function-based requestInit', async () => {
559
+ const translations: TranslationRecord = { hello: 'world' };
560
+ mockFetch.mockResolvedValueOnce({
561
+ ok: true,
562
+ json: async () => translations,
563
+ });
564
+
565
+ const loader = createApiTranslationLoader({
566
+ requestInit: (language, namespace) => ({
567
+ headers: {
568
+ 'X-Language': language,
569
+ 'X-Namespace': namespace,
570
+ },
571
+ }),
572
+ });
573
+
574
+ await loader('en', 'common');
575
+
576
+ expect(mockFetch).toHaveBeenCalledWith(
577
+ expect.any(String),
578
+ expect.objectContaining({
579
+ headers: {
580
+ 'X-Language': 'en',
581
+ 'X-Namespace': 'common',
582
+ },
583
+ })
584
+ );
585
+ });
586
+ });
587
+
588
+ describe('Logger', () => {
589
+ it('should use custom logger', async () => {
590
+ const logger = {
591
+ log: vi.fn(),
592
+ warn: vi.fn(),
593
+ error: vi.fn(),
594
+ };
595
+
596
+ mockFetch.mockResolvedValue({
597
+ ok: false,
598
+ status: 404,
599
+ });
600
+
601
+ const loader = createApiTranslationLoader({
602
+ logger,
603
+ });
604
+
605
+ await expect(loader('en', 'common')).rejects.toThrow();
606
+
607
+ expect(logger.warn).toHaveBeenCalled();
608
+ });
609
+ });
610
+ });
@@ -0,0 +1,327 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { withDefaultTranslations } from '../defaults';
3
+ import type { TranslationLoader, TranslationRecord, DefaultTranslations } from '../types';
4
+
5
+ describe('withDefaultTranslations', () => {
6
+ let mockLoader: TranslationLoader;
7
+
8
+ beforeEach(() => {
9
+ mockLoader = vi.fn();
10
+ });
11
+
12
+ it('should return merged translations when loader succeeds', async () => {
13
+ const remoteTranslations: TranslationRecord = {
14
+ hello: 'world',
15
+ goodbye: 'farewell',
16
+ };
17
+
18
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
19
+
20
+ const defaults: DefaultTranslations = {
21
+ en: {
22
+ common: {
23
+ hello: 'default hello',
24
+ other: 'default other',
25
+ },
26
+ },
27
+ };
28
+
29
+ const loader = withDefaultTranslations(mockLoader, defaults);
30
+ const result = await loader('en', 'common');
31
+
32
+ expect(result).toEqual({
33
+ hello: 'world', // Remote overrides default
34
+ goodbye: 'farewell', // From remote
35
+ other: 'default other', // From defaults
36
+ });
37
+ });
38
+
39
+ it('should merge remote translations with defaults', async () => {
40
+ const remoteTranslations: TranslationRecord = {
41
+ hello: 'remote hello',
42
+ };
43
+
44
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
45
+
46
+ const defaults: DefaultTranslations = {
47
+ en: {
48
+ common: {
49
+ hello: 'default hello',
50
+ goodbye: 'default goodbye',
51
+ },
52
+ },
53
+ };
54
+
55
+ const loader = withDefaultTranslations(mockLoader, defaults);
56
+ const result = await loader('en', 'common');
57
+
58
+ expect(result).toEqual({
59
+ hello: 'remote hello', // Remote overrides default
60
+ goodbye: 'default goodbye', // Default is preserved
61
+ });
62
+ });
63
+
64
+ it('should return defaults when loader fails', async () => {
65
+ const error = new Error('Failed to load');
66
+ (mockLoader as ReturnType<typeof vi.fn>).mockRejectedValue(error);
67
+
68
+ const defaults: DefaultTranslations = {
69
+ en: {
70
+ common: {
71
+ hello: 'default hello',
72
+ },
73
+ },
74
+ };
75
+
76
+ const loader = withDefaultTranslations(mockLoader, defaults);
77
+ const result = await loader('en', 'common');
78
+
79
+ expect(result).toEqual({
80
+ hello: 'default hello',
81
+ });
82
+ });
83
+
84
+ it('should throw error when loader fails and no defaults available', async () => {
85
+ const error = new Error('Failed to load');
86
+ (mockLoader as ReturnType<typeof vi.fn>).mockRejectedValue(error);
87
+
88
+ const defaults: DefaultTranslations = {
89
+ es: {
90
+ common: {
91
+ hola: 'mundo',
92
+ },
93
+ },
94
+ };
95
+
96
+ const loader = withDefaultTranslations(mockLoader, defaults);
97
+
98
+ await expect(loader('en', 'common')).rejects.toThrow('Failed to load');
99
+ });
100
+
101
+ it('should return defaults when remote returns empty object', async () => {
102
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue({});
103
+
104
+ const defaults: DefaultTranslations = {
105
+ en: {
106
+ common: {
107
+ hello: 'default hello',
108
+ },
109
+ },
110
+ };
111
+
112
+ const loader = withDefaultTranslations(mockLoader, defaults);
113
+ const result = await loader('en', 'common');
114
+
115
+ expect(result).toEqual({
116
+ hello: 'default hello',
117
+ });
118
+ });
119
+
120
+ it('should return defaults when remote returns null', async () => {
121
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(null);
122
+
123
+ const defaults: DefaultTranslations = {
124
+ en: {
125
+ common: {
126
+ hello: 'default hello',
127
+ },
128
+ },
129
+ };
130
+
131
+ const loader = withDefaultTranslations(mockLoader, defaults);
132
+ const result = await loader('en', 'common');
133
+
134
+ expect(result).toEqual({
135
+ hello: 'default hello',
136
+ });
137
+ });
138
+
139
+ it('should return empty object when both remote and defaults are empty', async () => {
140
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue({});
141
+
142
+ const defaults: DefaultTranslations = {};
143
+
144
+ const loader = withDefaultTranslations(mockLoader, defaults);
145
+ const result = await loader('en', 'common');
146
+
147
+ expect(result).toEqual({});
148
+ });
149
+
150
+ it('should deep merge nested translations', async () => {
151
+ const remoteTranslations: TranslationRecord = {
152
+ user: {
153
+ profile: {
154
+ name: 'remote name',
155
+ },
156
+ },
157
+ };
158
+
159
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
160
+
161
+ const defaults: DefaultTranslations = {
162
+ en: {
163
+ common: {
164
+ user: {
165
+ profile: {
166
+ name: 'default name',
167
+ email: 'default email',
168
+ },
169
+ settings: {
170
+ theme: 'default theme',
171
+ },
172
+ },
173
+ },
174
+ },
175
+ };
176
+
177
+ const loader = withDefaultTranslations(mockLoader, defaults);
178
+ const result = await loader('en', 'common');
179
+
180
+ expect(result).toEqual({
181
+ user: {
182
+ profile: {
183
+ name: 'remote name', // Overridden by remote
184
+ email: 'default email', // From defaults
185
+ },
186
+ settings: {
187
+ theme: 'default theme', // From defaults
188
+ },
189
+ },
190
+ });
191
+ });
192
+
193
+ it('should handle arrays as leaf values', async () => {
194
+ const remoteTranslations: TranslationRecord = {
195
+ items: ['remote1', 'remote2'],
196
+ };
197
+
198
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
199
+
200
+ const defaults: DefaultTranslations = {
201
+ en: {
202
+ common: {
203
+ items: ['default1', 'default2', 'default3'],
204
+ },
205
+ },
206
+ };
207
+
208
+ const loader = withDefaultTranslations(mockLoader, defaults);
209
+ const result = await loader('en', 'common');
210
+
211
+ // Arrays are replaced, not merged
212
+ expect(result).toEqual({
213
+ items: ['remote1', 'remote2'],
214
+ });
215
+ });
216
+
217
+ it('should handle mixed types between defaults and remote', async () => {
218
+ const remoteTranslations: TranslationRecord = {
219
+ value: {
220
+ nested: 'remote nested',
221
+ },
222
+ };
223
+
224
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
225
+
226
+ const defaults: DefaultTranslations = {
227
+ en: {
228
+ common: {
229
+ value: 'default string',
230
+ },
231
+ },
232
+ };
233
+
234
+ const loader = withDefaultTranslations(mockLoader, defaults);
235
+ const result = await loader('en', 'common');
236
+
237
+ // Remote value replaces default entirely
238
+ expect(result).toEqual({
239
+ value: {
240
+ nested: 'remote nested',
241
+ },
242
+ });
243
+ });
244
+
245
+ it('should not mutate original defaults', async () => {
246
+ const remoteTranslations: TranslationRecord = {
247
+ hello: 'remote hello',
248
+ };
249
+
250
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
251
+
252
+ const defaults: DefaultTranslations = {
253
+ en: {
254
+ common: {
255
+ hello: 'default hello',
256
+ goodbye: 'default goodbye',
257
+ },
258
+ },
259
+ };
260
+
261
+ const defaultsCopy = JSON.parse(JSON.stringify(defaults));
262
+
263
+ const loader = withDefaultTranslations(mockLoader, defaults);
264
+ await loader('en', 'common');
265
+
266
+ // Defaults should remain unchanged
267
+ expect(defaults).toEqual(defaultsCopy);
268
+ });
269
+
270
+ it('should handle multiple namespaces', async () => {
271
+ const translations1: TranslationRecord = { hello: 'world' };
272
+ const translations2: TranslationRecord = { login: 'sign in' };
273
+
274
+ (mockLoader as ReturnType<typeof vi.fn>)
275
+ .mockResolvedValueOnce(translations1)
276
+ .mockResolvedValueOnce(translations2);
277
+
278
+ const defaults: DefaultTranslations = {
279
+ en: {
280
+ common: {
281
+ hello: 'default hello',
282
+ },
283
+ auth: {
284
+ login: 'default login',
285
+ },
286
+ },
287
+ };
288
+
289
+ const loader = withDefaultTranslations(mockLoader, defaults);
290
+
291
+ const result1 = await loader('en', 'common');
292
+ const result2 = await loader('en', 'auth');
293
+
294
+ expect(result1).toEqual({ hello: 'world' });
295
+ expect(result2).toEqual({ login: 'sign in' });
296
+ });
297
+
298
+ it('should handle multiple languages', async () => {
299
+ const enTranslations: TranslationRecord = { hello: 'world' };
300
+ const esTranslations: TranslationRecord = { hello: 'mundo' };
301
+
302
+ (mockLoader as ReturnType<typeof vi.fn>)
303
+ .mockResolvedValueOnce(enTranslations)
304
+ .mockResolvedValueOnce(esTranslations);
305
+
306
+ const defaults: DefaultTranslations = {
307
+ en: {
308
+ common: {
309
+ hello: 'default hello',
310
+ },
311
+ },
312
+ es: {
313
+ common: {
314
+ hello: 'default hola',
315
+ },
316
+ },
317
+ };
318
+
319
+ const loader = withDefaultTranslations(mockLoader, defaults);
320
+
321
+ const enResult = await loader('en', 'common');
322
+ const esResult = await loader('es', 'common');
323
+
324
+ expect(enResult).toEqual({ hello: 'world' });
325
+ expect(esResult).toEqual({ hello: 'mundo' });
326
+ });
327
+ });
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { preloadNamespaces, warmFallbackLanguages } from '../preload';
3
+ import type { TranslationLoader, TranslationRecord } from '../types';
4
+
5
+ describe('preloadNamespaces', () => {
6
+ let mockLoader: TranslationLoader;
7
+
8
+ beforeEach(() => {
9
+ mockLoader = vi.fn();
10
+ });
11
+
12
+ it('should preload all namespaces successfully', async () => {
13
+ const translations: TranslationRecord = { hello: 'world' };
14
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(translations);
15
+
16
+ const namespaces = ['common', 'auth', 'dashboard'];
17
+ const result = await preloadNamespaces('en', namespaces, mockLoader);
18
+
19
+ expect(result.fulfilled).toEqual(namespaces);
20
+ expect(result.rejected).toEqual([]);
21
+ expect(mockLoader).toHaveBeenCalledTimes(3);
22
+ expect(mockLoader).toHaveBeenCalledWith('en', 'common');
23
+ expect(mockLoader).toHaveBeenCalledWith('en', 'auth');
24
+ expect(mockLoader).toHaveBeenCalledWith('en', 'dashboard');
25
+ });
26
+
27
+ it('should handle partial failures', async () => {
28
+ const translations: TranslationRecord = { hello: 'world' };
29
+ const error = new Error('Failed to load');
30
+
31
+ (mockLoader as ReturnType<typeof vi.fn>)
32
+ .mockResolvedValueOnce(translations) // common succeeds
33
+ .mockRejectedValueOnce(error) // auth fails
34
+ .mockResolvedValueOnce(translations); // dashboard succeeds
35
+
36
+ const namespaces = ['common', 'auth', 'dashboard'];
37
+ const result = await preloadNamespaces('en', namespaces, mockLoader);
38
+
39
+ expect(result.fulfilled).toEqual(['common', 'dashboard']);
40
+ expect(result.rejected).toHaveLength(1);
41
+ expect(result.rejected[0]).toBe(error);
42
+ });
43
+
44
+ it('should log success messages', async () => {
45
+ const translations: TranslationRecord = { hello: 'world' };
46
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(translations);
47
+
48
+ const logger = {
49
+ log: vi.fn(),
50
+ warn: vi.fn(),
51
+ };
52
+
53
+ const namespaces = ['common', 'auth'];
54
+ await preloadNamespaces('en', namespaces, mockLoader, { logger });
55
+
56
+ expect(logger.log).toHaveBeenCalledWith(
57
+ '[i18n-loaders] Preloaded 2/2 namespaces for en'
58
+ );
59
+ });
60
+
61
+ it('should log warning messages for failures', async () => {
62
+ const error = new Error('Failed to load');
63
+ (mockLoader as ReturnType<typeof vi.fn>)
64
+ .mockResolvedValueOnce({ hello: 'world' })
65
+ .mockRejectedValueOnce(error);
66
+
67
+ const logger = {
68
+ log: vi.fn(),
69
+ warn: vi.fn(),
70
+ };
71
+
72
+ const namespaces = ['common', 'auth'];
73
+ await preloadNamespaces('en', namespaces, mockLoader, { logger });
74
+
75
+ expect(logger.log).toHaveBeenCalled();
76
+ expect(logger.warn).toHaveBeenCalledWith(
77
+ '[i18n-loaders] Failed to preload 1 namespaces for en'
78
+ );
79
+ });
80
+
81
+ it('should suppress error warnings when option is set', async () => {
82
+ const error = new Error('Failed to load');
83
+ (mockLoader as ReturnType<typeof vi.fn>).mockRejectedValue(error);
84
+
85
+ const logger = {
86
+ log: vi.fn(),
87
+ warn: vi.fn(),
88
+ };
89
+
90
+ const namespaces = ['common', 'auth'];
91
+ await preloadNamespaces('en', namespaces, mockLoader, {
92
+ logger,
93
+ suppressErrors: true,
94
+ });
95
+
96
+ expect(logger.warn).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('should handle empty namespace list', async () => {
100
+ const result = await preloadNamespaces('en', [], mockLoader);
101
+
102
+ expect(result.fulfilled).toEqual([]);
103
+ expect(result.rejected).toEqual([]);
104
+ expect(mockLoader).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('should call loader concurrently', async () => {
108
+ const callOrder: string[] = [];
109
+
110
+ (mockLoader as ReturnType<typeof vi.fn>).mockImplementation(
111
+ async (language: string, namespace: string) => {
112
+ callOrder.push(`start-${namespace}`);
113
+ await new Promise((resolve) => setTimeout(resolve, 10));
114
+ callOrder.push(`end-${namespace}`);
115
+ return {};
116
+ }
117
+ );
118
+
119
+ const namespaces = ['common', 'auth', 'dashboard'];
120
+ await preloadNamespaces('en', namespaces, mockLoader);
121
+
122
+ // All starts should happen before any ends (concurrent execution)
123
+ const firstEndIndex = callOrder.findIndex((item) => item.startsWith('end-'));
124
+ const lastStartIndex = callOrder.reduce(
125
+ (acc, item, idx) => (item.startsWith('start-') ? idx : acc),
126
+ -1
127
+ );
128
+
129
+ expect(firstEndIndex).toBeGreaterThan(lastStartIndex);
130
+ });
131
+ });
132
+
133
+ describe('warmFallbackLanguages', () => {
134
+ let mockLoader: TranslationLoader;
135
+
136
+ beforeEach(() => {
137
+ mockLoader = vi.fn();
138
+ });
139
+
140
+ it('should preload all fallback languages', async () => {
141
+ const translations: TranslationRecord = { hello: 'world' };
142
+ (mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(translations);
143
+
144
+ const languages = ['en', 'es', 'fr'];
145
+ const namespaces = ['common', 'auth'];
146
+
147
+ await warmFallbackLanguages('en', languages, namespaces, mockLoader);
148
+
149
+ // Should preload es and fr (not en, as it's the current language)
150
+ expect(mockLoader).toHaveBeenCalledTimes(4); // 2 languages * 2 namespaces
151
+ expect(mockLoader).toHaveBeenCalledWith('es', 'common');
152
+ expect(mockLoader).toHaveBeenCalledWith('es', 'auth');
153
+ expect(mockLoader).toHaveBeenCalledWith('fr', 'common');
154
+ expect(mockLoader).toHaveBeenCalledWith('fr', 'auth');
155
+ expect(mockLoader).not.toHaveBeenCalledWith('en', expect.any(String));
156
+ });
157
+
158
+ it('should return empty array when no fallback languages', async () => {
159
+ const languages = ['en'];
160
+ const namespaces = ['common'];
161
+
162
+ const result = await warmFallbackLanguages(
163
+ 'en',
164
+ languages,
165
+ namespaces,
166
+ mockLoader
167
+ );
168
+
169
+ expect(result).toEqual([]);
170
+ expect(mockLoader).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it('should handle all languages being current language', async () => {
174
+ const languages = ['en'];
175
+ const namespaces = ['common', 'auth'];
176
+
177
+ const result = await warmFallbackLanguages(
178
+ 'en',
179
+ languages,
180
+ namespaces,
181
+ mockLoader
182
+ );
183
+
184
+ expect(result).toEqual([]);
185
+ expect(mockLoader).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('should pass options to preloadNamespaces', async () => {
189
+ const translations: TranslationRecord = { hello: 'world' };
190
+ const error = new Error('Failed to load');
191
+
192
+ (mockLoader as ReturnType<typeof vi.fn>)
193
+ .mockResolvedValueOnce(translations)
194
+ .mockRejectedValueOnce(error);
195
+
196
+ const logger = {
197
+ log: vi.fn(),
198
+ warn: vi.fn(),
199
+ };
200
+
201
+ const languages = ['en', 'es'];
202
+ const namespaces = ['common'];
203
+
204
+ await warmFallbackLanguages('en', languages, namespaces, mockLoader, {
205
+ logger,
206
+ suppressErrors: true,
207
+ });
208
+
209
+ expect(logger.log).toHaveBeenCalled();
210
+ expect(logger.warn).not.toHaveBeenCalled(); // suppressErrors is true
211
+ });
212
+
213
+ it('should return results for each fallback language', async () => {
214
+ const translations: TranslationRecord = { hello: 'world' };
215
+ const error = new Error('Failed to load');
216
+
217
+ (mockLoader as ReturnType<typeof vi.fn>)
218
+ .mockResolvedValueOnce(translations) // es/common succeeds
219
+ .mockRejectedValueOnce(error) // fr/common fails
220
+ .mockResolvedValueOnce(translations); // ko/common succeeds
221
+
222
+ const languages = ['en', 'es', 'fr', 'ko'];
223
+ const namespaces = ['common'];
224
+
225
+ const results = await warmFallbackLanguages(
226
+ 'en',
227
+ languages,
228
+ namespaces,
229
+ mockLoader,
230
+ { suppressErrors: true }
231
+ );
232
+
233
+ expect(results).toHaveLength(3); // es, fr, ko (excluding current language 'en')
234
+ expect(results[0].fulfilled).toEqual(['common']);
235
+ expect(results[1].rejected).toHaveLength(1);
236
+ expect(results[2].fulfilled).toEqual(['common']);
237
+ });
238
+
239
+ it('should execute preloads concurrently for different languages', async () => {
240
+ const callOrder: string[] = [];
241
+
242
+ (mockLoader as ReturnType<typeof vi.fn>).mockImplementation(
243
+ async (language: string) => {
244
+ callOrder.push(`start-${language}`);
245
+ await new Promise((resolve) => setTimeout(resolve, 10));
246
+ callOrder.push(`end-${language}`);
247
+ return {};
248
+ }
249
+ );
250
+
251
+ const languages = ['en', 'es', 'fr'];
252
+ const namespaces = ['common'];
253
+
254
+ await warmFallbackLanguages('en', languages, namespaces, mockLoader);
255
+
256
+ // All starts should happen before any ends (concurrent execution)
257
+ const firstEndIndex = callOrder.findIndex((item) => item.startsWith('end-'));
258
+ const lastStartIndex = callOrder.reduce(
259
+ (acc, item, idx) => (item.startsWith('start-') ? idx : acc),
260
+ -1
261
+ );
262
+
263
+ expect(firstEndIndex).toBeGreaterThan(lastStartIndex);
264
+ });
265
+ });
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 HUA Labs Team
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.