@unbound-app/api 0.9.0 → 1.1.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.
Files changed (72) hide show
  1. package/ambient/image-modules.d.ts +58 -0
  2. package/package.json +30 -5
  3. package/src/_internal.d.ts +6110 -462
  4. package/src/{utils.d.ts → _types.d.ts} +10 -0
  5. package/src/assets.d.ts +64 -26
  6. package/src/builtins.d.ts +17 -0
  7. package/src/commands.d.ts +331 -0
  8. package/src/dialogs.d.ts +134 -0
  9. package/src/flux.d.ts +317 -0
  10. package/src/fonts.d.ts +51 -0
  11. package/src/fs.d.ts +16 -26
  12. package/src/global.d.ts +514 -31
  13. package/src/i18n.d.ts +33 -31
  14. package/src/iconPacks.d.ts +43 -0
  15. package/src/index.d.ts +16 -0
  16. package/src/marketplace.d.ts +50 -0
  17. package/src/metro/api.d.ts +10 -0
  18. package/src/metro/common.d.ts +34 -5
  19. package/src/metro/components.d.ts +100 -16
  20. package/src/metro/filters.d.ts +31 -16
  21. package/src/metro/index.d.ts +26097 -82
  22. package/src/metro/stores.d.ts +5 -0
  23. package/src/native.d.ts +123 -3
  24. package/src/patcher.d.ts +214 -57
  25. package/src/plugins.d.ts +55 -0
  26. package/src/settings.d.ts +9 -17
  27. package/src/shims.d.ts +5 -0
  28. package/src/storage.d.ts +80 -223
  29. package/src/sync.d.ts +51 -0
  30. package/src/themes.d.ts +43 -0
  31. package/src/toasts.d.ts +62 -9
  32. package/src/ui.d.ts +650 -0
  33. package/src/updates.d.ts +25 -0
  34. package/src/utils/index.d.ts +53 -0
  35. package/src/utils/react/index.d.ts +82 -0
  36. package/testing/fake-api.ts +256 -0
  37. package/testing/fake-components.ts +183 -0
  38. package/testing/fake-feedback.ts +258 -0
  39. package/testing/fake-fs.ts +54 -0
  40. package/testing/fake-java.ts +513 -0
  41. package/testing/fake-locales.ts +85 -0
  42. package/testing/fake-metro.ts +346 -0
  43. package/testing/fake-native-bridge.ts +98 -0
  44. package/testing/fake-native.ts +211 -0
  45. package/testing/fake-objc.ts +388 -0
  46. package/testing/fake-plugins.ts +176 -0
  47. package/testing/fake-react-native-modules.ts +362 -0
  48. package/testing/fake-react-native.ts +180 -0
  49. package/testing/fake-react.ts +54 -0
  50. package/testing/fake-settings-panel.ts +173 -0
  51. package/testing/fake-settings.ts +428 -0
  52. package/testing/fake-timers.ts +74 -0
  53. package/testing/fake-utils.ts +216 -0
  54. package/testing/icu-format.ts +132 -0
  55. package/testing/icu-parse.ts +277 -0
  56. package/testing/index.ts +42 -0
  57. package/testing/install-addon-globals.ts +260 -0
  58. package/testing/listener-registry.ts +39 -0
  59. package/testing/memory-storage.ts +149 -0
  60. package/testing/plugin-files.ts +155 -0
  61. package/testing/plugin-lifecycle.ts +149 -0
  62. package/testing/plugin-types.ts +34 -0
  63. package/testing/preset.ts +263 -0
  64. package/testing/react-patcher.ts +154 -0
  65. package/testing/react-value.ts +3 -0
  66. package/testing/recorders.ts +183 -0
  67. package/testing/recording-patcher.ts +173 -0
  68. package/testing/render.ts +84 -0
  69. package/testing/session.ts +77 -0
  70. package/testing/test-node.ts +163 -0
  71. package/testing/test-renderer.ts +753 -0
  72. package/testing/unfrozen-elements.ts +49 -0
@@ -0,0 +1,513 @@
1
+ import type {
2
+ NativeJavaArgument,
3
+ NativeJavaAssociationKey,
4
+ NativeJavaBatchOperation,
5
+ NativeJavaBridge,
6
+ NativeJavaCallPlan,
7
+ NativeJavaHandle,
8
+ NativeJavaHookEvent,
9
+ NativeJavaHookOptions,
10
+ NativeJavaHooksBridge,
11
+ NativeJavaReference,
12
+ NativeJavaValue,
13
+ } from '../src/native';
14
+
15
+ import {
16
+ brand,
17
+ createCallLog,
18
+ settled,
19
+ withMissingMembers,
20
+ type NativeCallLog,
21
+ } from './fake-native-bridge';
22
+
23
+ /** A value a test's Java method takes or returns: a tagged value or handle as the bridge has it, or a plain string, number, boolean, `null` or list. */
24
+ export type JavaPlain =
25
+ | string
26
+ | number
27
+ | boolean
28
+ | null
29
+ | undefined
30
+ | NativeJavaValue
31
+ | NativeJavaHandle
32
+ | JavaPlain[];
33
+
34
+ /** A method a test registers on a Java class: it receives the object and the call's plain arguments, and returns a plain value. */
35
+ export type JavaMethod = (self: NativeJavaHandle, ...args: JavaPlain[]) => JavaPlain;
36
+
37
+ /** The types a method declares, as `java.describe` lists them. A type is a class name such as `java.lang.String`, or `int`. */
38
+ export type JavaSignature = {
39
+ /** The parameters' class names, in order; none when omitted. */
40
+ parameters?: string[];
41
+ /** The return type's class name; `java.lang.Object` when omitted. */
42
+ returnType?: string;
43
+ };
44
+
45
+ /** What a test registers for a Java class. */
46
+ export type JavaClassSpec = {
47
+ /** The fully qualified name of the class whose members this one inherits. */
48
+ superclass?: string;
49
+ /** Instance methods by name, such as `getWidth`. */
50
+ methods?: Record<string, JavaMethod>;
51
+ /** Static methods by name, such as `parse`; `self` is the class. */
52
+ staticMethods?: Record<string, JavaMethod>;
53
+ /** The instance fields a new object starts with. */
54
+ fields?: Record<string, JavaPlain>;
55
+ /** The static fields, such as `INSTANCE`. */
56
+ staticFields?: Record<string, JavaPlain>;
57
+ /** The types of the methods and static methods, by name, that `java.describe` returns. */
58
+ signatures?: Record<string, JavaSignature>;
59
+ /** The types of the fields and static fields, by name, that `java.describe` returns; `java.lang.Object` when omitted. */
60
+ fieldTypes?: Record<string, string>;
61
+ /** Runs when `java.construct` makes an object, with the constructor's plain arguments. */
62
+ construct?: (self: NativeJavaHandle, ...args: JavaPlain[]) => void;
63
+ };
64
+
65
+ /** The fake Java bridge a plugin calls, its hooks bridge, and what a test uses to fill them. */
66
+ export type FakeJava = {
67
+ /** The `java` a plugin's `context.native` holds. */
68
+ bridge: NativeJavaBridge;
69
+ /** The `hooks` a plugin's `context.native` holds. */
70
+ hooks: NativeJavaHooksBridge;
71
+ /** Adds a class a plugin can find with `java.getClass`, or replaces it. */
72
+ registerClass: (name: string, spec?: JavaClassSpec) => void;
73
+ /** Makes an object of a registered class, with its fields, as the plugin sees it: a tagged `handle` value. */
74
+ create: (className: string, fields?: Record<string, JavaPlain>) => NativeJavaValue;
75
+ /** Forgets every class, object and hook. */
76
+ reset: () => void;
77
+ };
78
+
79
+ type HookEntry = {
80
+ className: string;
81
+ methodName: string;
82
+ options: NativeJavaHookOptions;
83
+ listener?: (event: NativeJavaHookEvent) => void;
84
+ results: Map<string, NativeJavaValue>;
85
+ };
86
+
87
+ // A handle is an opaque brand on device; here it is a real object the fake reads its class and fields from.
88
+ class JavaObject {
89
+ readonly id: string;
90
+ readonly className: string;
91
+ readonly isClass: boolean;
92
+ readonly fields = new Map<string, JavaPlain>();
93
+ readonly associated = new Map<NativeJavaAssociationKey, JavaPlain>();
94
+
95
+ constructor(id: string, className: string, isClass: boolean) {
96
+ this.id = id;
97
+ this.className = className;
98
+ this.isClass = isClass;
99
+ }
100
+ }
101
+
102
+ const TAGGED = new Set([
103
+ 'null',
104
+ 'handle',
105
+ 'string',
106
+ 'boolean',
107
+ 'byte',
108
+ 'short',
109
+ 'int',
110
+ 'long',
111
+ 'float',
112
+ 'double',
113
+ 'char',
114
+ 'array',
115
+ ]);
116
+
117
+ function isTagged(value: JavaPlain): value is NativeJavaValue {
118
+ return (
119
+ typeof value === 'object' &&
120
+ value !== null &&
121
+ !Array.isArray(value) &&
122
+ 'type' in value &&
123
+ typeof value.type === 'string' &&
124
+ TAGGED.has(value.type)
125
+ );
126
+ }
127
+
128
+ function tagKey(value: NativeJavaValue): string {
129
+ return JSON.stringify(value);
130
+ }
131
+
132
+ /**
133
+ * @description Creates the fake `java` and `hooks`: classes and methods a test registers answer `invoke`, `construct`,
134
+ * `getField` and the rest with tagged values as the bridge returns them, and every call is recorded in the log.
135
+ * @param log Where each call is recorded.
136
+ * @returns The bridges and what fills them.
137
+ */
138
+ export function createFakeJava(log: NativeCallLog = createCallLog()): FakeJava {
139
+ const classes = new Map<string, JavaClassSpec>();
140
+ const objects = new Map<string, JavaObject>();
141
+ const hookEntries = new Map<string, HookEntry>();
142
+ let next = 0;
143
+
144
+ function chain(className: string): string[] {
145
+ const names: string[] = [];
146
+
147
+ for (
148
+ let name: string | undefined = className;
149
+ name !== undefined && !names.includes(name);
150
+ name = classes.get(name)?.superclass
151
+ ) {
152
+ names.push(name);
153
+ }
154
+
155
+ return names;
156
+ }
157
+
158
+ function spawn(className: string, isClass: boolean): JavaObject {
159
+ const object = new JavaObject(`java-${++next}`, className, isClass);
160
+
161
+ objects.set(object.id, object);
162
+
163
+ return object;
164
+ }
165
+
166
+ function instance(className: string, fields: Record<string, JavaPlain> = {}): JavaObject {
167
+ const object = spawn(className, false);
168
+
169
+ for (const name of chain(className).toReversed()) {
170
+ for (const [field, value] of Object.entries(classes.get(name)?.fields ?? {})) {
171
+ object.fields.set(field, value);
172
+ }
173
+ }
174
+
175
+ for (const [field, value] of Object.entries(fields)) object.fields.set(field, value);
176
+
177
+ return object;
178
+ }
179
+
180
+ function resolve(reference: NativeJavaReference | JavaObject, call: string): JavaObject {
181
+ const found =
182
+ reference instanceof JavaObject
183
+ ? reference
184
+ : typeof reference === 'object' && reference !== null && 'value' in reference
185
+ ? objects.get(String(reference.value))
186
+ : undefined;
187
+
188
+ if (!found) {
189
+ throw new TypeError(`Failed to call java.${call}: the target is not a Java handle.`);
190
+ }
191
+
192
+ return found;
193
+ }
194
+
195
+ // A plain value a test's method gets for a tagged argument.
196
+ function plain(value: JavaPlain): JavaPlain {
197
+ if (value instanceof JavaObject || !isTagged(value)) {
198
+ return value;
199
+ }
200
+
201
+ switch (value.type) {
202
+ case 'null':
203
+ return null;
204
+ case 'handle':
205
+ return brand<NativeJavaHandle>(objects.get(value.value) ?? {});
206
+ case 'array':
207
+ return value.value.map(plain);
208
+ default:
209
+ return value.value;
210
+ }
211
+ }
212
+
213
+ // The tagged value the bridge returns for what a test's method returned.
214
+ function tagged(value: JavaPlain): NativeJavaValue {
215
+ if (value === null || value === undefined) {
216
+ return { type: 'null' };
217
+ }
218
+
219
+ if (typeof value === 'string') {
220
+ return { type: 'string', value };
221
+ }
222
+
223
+ if (typeof value === 'boolean') {
224
+ return { type: 'boolean', value };
225
+ }
226
+
227
+ if (typeof value === 'number') {
228
+ return { type: Number.isInteger(value) ? 'int' : 'double', value };
229
+ }
230
+
231
+ if (Array.isArray(value)) {
232
+ return { type: 'array', componentType: 'java.lang.Object', value: value.map(tagged) };
233
+ }
234
+
235
+ if (value instanceof JavaObject) {
236
+ return { type: 'handle', value: value.id };
237
+ }
238
+
239
+ return isTagged(value) ? value : { type: 'null' };
240
+ }
241
+
242
+ function memberOf(
243
+ className: string,
244
+ name: string,
245
+ kind: 'methods' | 'staticMethods',
246
+ ): JavaMethod | undefined {
247
+ for (const each of chain(className)) {
248
+ const found = classes.get(each)?.[kind]?.[name];
249
+
250
+ if (found) {
251
+ return found;
252
+ }
253
+ }
254
+ }
255
+
256
+ function notify(
257
+ target: JavaObject,
258
+ name: string,
259
+ args: JavaPlain[],
260
+ phase: 'before' | 'after',
261
+ ) {
262
+ for (const entry of hookEntries.values()) {
263
+ const notifies =
264
+ entry.methodName === name &&
265
+ chain(target.className).includes(entry.className) &&
266
+ entry.options.notify !== false &&
267
+ (entry.options.notify === 'after') === (phase === 'after');
268
+
269
+ if (!notifies) {
270
+ continue;
271
+ }
272
+
273
+ entry.listener?.({
274
+ self: tagged(brand<NativeJavaHandle>(target)),
275
+ args: args.map(tagged),
276
+ argumentIndexes: args.map((_, index) => index),
277
+ phase,
278
+ });
279
+ }
280
+ }
281
+
282
+ function invoke(reference: NativeJavaReference, name: string, args: NativeJavaArgument[] = []) {
283
+ const target = resolve(reference, 'invoke');
284
+
285
+ log.record({ bridge: 'java', method: 'invoke', target: target.className, name, args });
286
+
287
+ const method = memberOf(
288
+ target.className,
289
+ name,
290
+ target.isClass ? 'staticMethods' : 'methods',
291
+ );
292
+
293
+ if (!method) {
294
+ throw new Error(
295
+ `Failed to call ${target.className}.${name}: the test registered no such method. Add it with native.java.registerClass('${target.className}', { ${target.isClass ? 'staticMethods' : 'methods'}: { ${name}: () => … } }).`,
296
+ );
297
+ }
298
+
299
+ const plainArgs = args.map(plain);
300
+
301
+ notify(target, name, plainArgs, 'before');
302
+
303
+ const replaced = [...hookEntries.values()]
304
+ .filter((entry) => entry.options.replace && entry.methodName === name)
305
+ .map((entry) => entry.results.get(tagKey(tagged(brand<NativeJavaHandle>(target)))))
306
+ .find(Boolean);
307
+ const result = replaced ?? tagged(method(brand<NativeJavaHandle>(target), ...plainArgs));
308
+
309
+ notify(target, name, plainArgs, 'after');
310
+
311
+ return result;
312
+ }
313
+
314
+ function getField(reference: NativeJavaReference, name: string): NativeJavaValue {
315
+ const target = resolve(reference, 'getField');
316
+
317
+ log.record({
318
+ bridge: 'java',
319
+ method: 'getField',
320
+ target: target.className,
321
+ name,
322
+ args: [],
323
+ });
324
+
325
+ if (target.isClass) {
326
+ for (const each of chain(target.className)) {
327
+ const fields = classes.get(each)?.staticFields;
328
+
329
+ if (fields && name in fields) {
330
+ return tagged(fields[name]);
331
+ }
332
+ }
333
+ } else if (target.fields.has(name)) {
334
+ return tagged(target.fields.get(name));
335
+ }
336
+
337
+ throw new Error(
338
+ `Failed to read ${target.className}.${name}: the test registered no such field. Add it with native.java.registerClass('${target.className}', { ${target.isClass ? 'staticFields' : 'fields'}: { ${name}: … } }).`,
339
+ );
340
+ }
341
+
342
+ function setField(reference: NativeJavaReference, name: string, value: NativeJavaArgument) {
343
+ const target = resolve(reference, 'setField');
344
+
345
+ log.record({
346
+ bridge: 'java',
347
+ method: 'setField',
348
+ target: target.className,
349
+ name,
350
+ args: [value],
351
+ });
352
+ target.fields.set(name, plain(value));
353
+ }
354
+
355
+ function respondsTo(reference: NativeJavaReference, name: string): NativeJavaValue {
356
+ const target = resolve(reference, 'respondsTo');
357
+
358
+ return {
359
+ type: 'boolean',
360
+ value: Boolean(
361
+ memberOf(target.className, name, target.isClass ? 'staticMethods' : 'methods'),
362
+ ),
363
+ };
364
+ }
365
+
366
+ function operate(operation: NativeJavaBatchOperation): NativeJavaValue {
367
+ switch (operation.op) {
368
+ case 'invoke':
369
+ return invoke(operation.handle, operation.name, operation.args);
370
+ case 'getField':
371
+ return getField(operation.handle, operation.name);
372
+ case 'respondsTo':
373
+ return respondsTo(operation.handle, operation.name);
374
+ case 'setField':
375
+ setField(operation.handle, operation.name, operation.value ?? { type: 'null' });
376
+
377
+ return { type: 'null' };
378
+ }
379
+ }
380
+
381
+ const bridge = withMissingMembers<NativeJavaBridge>('java', {
382
+ getClass(name) {
383
+ log.record({ bridge: 'java', method: 'getClass', target: name, name: '', args: [] });
384
+
385
+ if (!classes.has(name)) {
386
+ throw new Error(`Failed to find Java class ${name}: the test registered none.`);
387
+ }
388
+
389
+ return brand<NativeJavaHandle>(spawn(name, true));
390
+ },
391
+ describe(name) {
392
+ const spec = classes.get(name);
393
+
394
+ if (!spec) {
395
+ throw new Error(`Failed to describe Java class ${name}: the test registered none.`);
396
+ }
397
+
398
+ const describeMethod = (method: string, isStatic: boolean) => ({
399
+ name: method,
400
+ parameters: spec.signatures?.[method]?.parameters ?? [],
401
+ returnType: spec.signatures?.[method]?.returnType ?? 'java.lang.Object',
402
+ static: isStatic,
403
+ });
404
+ const describeField = (field: string, isStatic: boolean) => ({
405
+ name: field,
406
+ type: spec.fieldTypes?.[field] ?? 'java.lang.Object',
407
+ static: isStatic,
408
+ });
409
+
410
+ return {
411
+ name,
412
+ superclass: spec.superclass ?? null,
413
+ interfaces: [],
414
+ constructors: [[]],
415
+ methods: [
416
+ ...Object.keys(spec.methods ?? {}).map((method) =>
417
+ describeMethod(method, false),
418
+ ),
419
+ ...Object.keys(spec.staticMethods ?? {}).map((method) =>
420
+ describeMethod(method, true),
421
+ ),
422
+ ],
423
+ fields: [
424
+ ...Object.keys(spec.fields ?? {}).map((field) => describeField(field, false)),
425
+ ...Object.keys(spec.staticFields ?? {}).map((field) =>
426
+ describeField(field, true),
427
+ ),
428
+ ],
429
+ };
430
+ },
431
+ construct(name, args = []) {
432
+ log.record({ bridge: 'java', method: 'construct', target: name, name: '<init>', args });
433
+
434
+ if (!classes.has(name)) {
435
+ throw new Error(`Failed to find Java class ${name}: the test registered none.`);
436
+ }
437
+
438
+ const object = instance(name);
439
+ const handle = brand<NativeJavaHandle>(object);
440
+
441
+ for (const each of chain(name).toReversed()) {
442
+ classes.get(each)?.construct?.(handle, ...args.map(plain));
443
+ }
444
+
445
+ return handle;
446
+ },
447
+ className: (reference) => ({
448
+ type: 'string',
449
+ value: resolve(reference, 'className').className,
450
+ }),
451
+ respondsTo: (reference, name) => respondsTo(reference, name),
452
+ invoke,
453
+ invokeAsync: (reference, name, args) => settled(() => invoke(reference, name, args)),
454
+ getField,
455
+ setField,
456
+ batch: (operations) => operations.map(operate),
457
+ batchAsync: (operations) => settled(() => operations.map(operate)),
458
+ prepare: (target, name) =>
459
+ brand<NativeJavaCallPlan>(Object.assign(spawn('plan', false), { name })),
460
+ invokePrepared: (plan, target, args) =>
461
+ invoke(target, String(Reflect.get(plan, 'name')), args),
462
+ invokePreparedAsync: (plan, target, args) =>
463
+ settled(() => invoke(target, String(Reflect.get(plan, 'name')), args)),
464
+ disposePlan: () => {},
465
+ createAssociationKey: () => brand<NativeJavaAssociationKey>(spawn('key', false)),
466
+ getAssociatedObject: (reference, key) =>
467
+ tagged(resolve(reference, 'getAssociatedObject').associated.get(key)),
468
+ setAssociatedObject: (reference, key, value) =>
469
+ void resolve(reference, 'setAssociatedObject').associated.set(key, plain(value)),
470
+ release: () => {},
471
+ });
472
+
473
+ const hooks = withMissingMembers<NativeJavaHooksBridge>('hooks', {
474
+ hook(className, methodName, _parameterTypes, options = {}, listener) {
475
+ const id = `hook-${++next}`;
476
+
477
+ log.record({
478
+ bridge: 'hooks',
479
+ method: 'hook',
480
+ target: className,
481
+ name: methodName,
482
+ args: [],
483
+ });
484
+ hookEntries.set(id, {
485
+ className,
486
+ methodName,
487
+ options,
488
+ listener: listener as HookEntry['listener'],
489
+ results: new Map(),
490
+ });
491
+
492
+ return { id };
493
+ },
494
+ setReturnValue: (token, self, _args, value) =>
495
+ void hookEntries.get(token.id)?.results.set(tagKey(self), value),
496
+ removeReturnValue: (token, self) =>
497
+ void hookEntries.get(token.id)?.results.delete(tagKey(self)),
498
+ clearReturnValues: (token) => hookEntries.get(token.id)?.results.clear(),
499
+ remove: (token) => void hookEntries.delete(token.id),
500
+ });
501
+
502
+ return {
503
+ bridge,
504
+ hooks,
505
+ registerClass: (name, spec = {}) => void classes.set(name, spec),
506
+ create: (className, fields) => tagged(brand<NativeJavaHandle>(instance(className, fields))),
507
+ reset() {
508
+ classes.clear();
509
+ objects.clear();
510
+ hookEntries.clear();
511
+ },
512
+ };
513
+ }
@@ -0,0 +1,85 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ import { formatIcu, type IcuVars } from './icu-format';
4
+
5
+ /** One locale's messages, keyed by message key, as a plugin's `i18n/<code>.json` holds them. */
6
+ export type LocaleTable = Record<string, string>;
7
+
8
+ /** The locale every plugin ships, and the one its messages fall back to. */
9
+ export const SOURCE_LOCALE = 'en-US';
10
+
11
+ /** The fake `@unbound-app/i18n`, over a plugin's real tables, and the locale a test switches with `setLocale`. */
12
+ export type FakeLocales = {
13
+ /** Each message in the active locale, falling back to en-US, then the key itself. */
14
+ messages: LocaleTable;
15
+ /** Formats a message in the active locale with a real ICU formatter. */
16
+ format: (key: string, vars?: IcuVars) => string;
17
+ /** Re-renders the component when the locale changes, and returns the active locale code. */
18
+ useMessages: () => string;
19
+ /** Every locale the plugin ships, en-US first. */
20
+ codes: string[];
21
+ /** The active locale code. */
22
+ locale: () => string;
23
+ /** Switches the active locale and re-renders every component that called `useMessages`. */
24
+ setLocale: (code: string) => void;
25
+ };
26
+
27
+ /**
28
+ * @description Creates a plugin's messages over its `i18n/` tables, as the client's `createAddonLocales` serves them.
29
+ * @param tables Each locale's messages by code; en-US is required.
30
+ * @returns The locales.
31
+ */
32
+ export function createFakeLocales(tables: Map<string, LocaleTable>): FakeLocales {
33
+ const fallback = tables.get(SOURCE_LOCALE);
34
+
35
+ if (!fallback) {
36
+ throw new Error(
37
+ `Failed to load the plugin's locales: i18n/${SOURCE_LOCALE}.json is missing.`,
38
+ );
39
+ }
40
+
41
+ const listeners = new Set<() => void>();
42
+ let active = SOURCE_LOCALE;
43
+ let version = 0;
44
+
45
+ const messages = new Proxy<LocaleTable>(
46
+ {},
47
+ {
48
+ get(_, key) {
49
+ if (typeof key !== 'string') return;
50
+
51
+ return tables.get(active)?.[key] ?? fallback[key] ?? key;
52
+ },
53
+ },
54
+ );
55
+
56
+ function subscribe(listener: () => void) {
57
+ listeners.add(listener);
58
+
59
+ return () => void listeners.delete(listener);
60
+ }
61
+
62
+ return {
63
+ messages,
64
+ format: (key, vars) => formatIcu(messages[key] ?? key, vars, active),
65
+ useMessages() {
66
+ // The version, not the locale, is the snapshot, as on device: a table load keeps the locale but re-renders.
67
+ useSyncExternalStore(subscribe, () => version);
68
+
69
+ return active;
70
+ },
71
+ codes: [
72
+ SOURCE_LOCALE,
73
+ ...[...tables.keys()].filter((code) => code !== SOURCE_LOCALE).sort(),
74
+ ],
75
+ locale: () => active,
76
+ setLocale(code) {
77
+ if (code === active) return;
78
+
79
+ active = code;
80
+ version++;
81
+
82
+ for (const listener of listeners) listener();
83
+ },
84
+ };
85
+ }