@bleedingdev/modern-js-i18n-runtime-extensions 3.8.3-ultramodern.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/index.js +76 -0
  3. package/dist/cjs/language-sync/controller.js +241 -0
  4. package/dist/cjs/language-sync/react.js +169 -0
  5. package/dist/cjs/localisedUrls/config.js +49 -0
  6. package/dist/cjs/localisedUrls/index.js +91 -0
  7. package/dist/cjs/localisedUrls/normalise.js +87 -0
  8. package/dist/cjs/localisedUrls/pathname.js +68 -0
  9. package/dist/cjs/localisedUrls/patterns.js +214 -0
  10. package/dist/cjs/localisedUrls/redirect.js +103 -0
  11. package/dist/cjs/localisedUrls/resolve.js +78 -0
  12. package/dist/cjs/localisedUrls/routes.js +202 -0
  13. package/dist/cjs/localisedUrls/types.js +18 -0
  14. package/dist/esm/index.mjs +3 -0
  15. package/dist/esm/language-sync/controller.mjs +203 -0
  16. package/dist/esm/language-sync/react.mjs +118 -0
  17. package/dist/esm/localisedUrls/config.mjs +11 -0
  18. package/dist/esm/localisedUrls/index.mjs +7 -0
  19. package/dist/esm/localisedUrls/normalise.mjs +37 -0
  20. package/dist/esm/localisedUrls/pathname.mjs +24 -0
  21. package/dist/esm/localisedUrls/patterns.mjs +152 -0
  22. package/dist/esm/localisedUrls/redirect.mjs +56 -0
  23. package/dist/esm/localisedUrls/resolve.mjs +37 -0
  24. package/dist/esm/localisedUrls/routes.mjs +161 -0
  25. package/dist/esm/localisedUrls/types.mjs +0 -0
  26. package/dist/esm-node/index.mjs +4 -0
  27. package/dist/esm-node/language-sync/controller.mjs +204 -0
  28. package/dist/esm-node/language-sync/react.mjs +119 -0
  29. package/dist/esm-node/localisedUrls/config.mjs +12 -0
  30. package/dist/esm-node/localisedUrls/index.mjs +8 -0
  31. package/dist/esm-node/localisedUrls/normalise.mjs +38 -0
  32. package/dist/esm-node/localisedUrls/pathname.mjs +25 -0
  33. package/dist/esm-node/localisedUrls/patterns.mjs +153 -0
  34. package/dist/esm-node/localisedUrls/redirect.mjs +57 -0
  35. package/dist/esm-node/localisedUrls/resolve.mjs +38 -0
  36. package/dist/esm-node/localisedUrls/routes.mjs +162 -0
  37. package/dist/esm-node/localisedUrls/types.mjs +1 -0
  38. package/dist/types/index.d.ts +3 -0
  39. package/dist/types/language-sync/controller.d.ts +26 -0
  40. package/dist/types/language-sync/react.d.ts +38 -0
  41. package/dist/types/localisedUrls/config.d.ts +9 -0
  42. package/dist/types/localisedUrls/index.d.ts +9 -0
  43. package/dist/types/localisedUrls/normalise.d.ts +10 -0
  44. package/dist/types/localisedUrls/pathname.d.ts +4 -0
  45. package/dist/types/localisedUrls/patterns.d.ts +11 -0
  46. package/dist/types/localisedUrls/redirect.d.ts +11 -0
  47. package/dist/types/localisedUrls/resolve.d.ts +7 -0
  48. package/dist/types/localisedUrls/routes.d.ts +3 -0
  49. package/dist/types/localisedUrls/types.d.ts +14 -0
  50. package/package.json +71 -0
  51. package/rslib.config.mts +4 -0
  52. package/rstest.config.mts +36 -0
  53. package/src/index.ts +3 -0
  54. package/src/language-sync/controller.ts +386 -0
  55. package/src/language-sync/react.ts +227 -0
  56. package/src/localisedUrls/config.ts +18 -0
  57. package/src/localisedUrls/index.ts +24 -0
  58. package/src/localisedUrls/normalise.ts +62 -0
  59. package/src/localisedUrls/pathname.ts +60 -0
  60. package/src/localisedUrls/patterns.ts +283 -0
  61. package/src/localisedUrls/redirect.ts +93 -0
  62. package/src/localisedUrls/resolve.ts +106 -0
  63. package/src/localisedUrls/routes.ts +422 -0
  64. package/src/localisedUrls/types.ts +18 -0
  65. package/tests/clientRedirectIntegration.test.tsx +74 -0
  66. package/tests/languageSyncController.test.ts +213 -0
  67. package/tests/localisedUrls.test.ts +673 -0
  68. package/tests/reactLanguageSync.test.tsx +117 -0
  69. package/tests/tsconfig.json +11 -0
  70. package/tsconfig.json +11 -0
@@ -0,0 +1,38 @@
1
+ import type React from 'react';
2
+ import { type LanguageSyncFailure, type LanguageSyncPolicy } from './controller';
3
+ export interface LatestLanguageSyncOptions<TTarget extends object> {
4
+ changeLanguage: (target: TTarget, language: string) => PromiseLike<unknown> | unknown;
5
+ commitLanguage: (target: TTarget, language: string) => void;
6
+ desiredLanguage?: string;
7
+ enabled?: boolean;
8
+ policy?: LanguageSyncPolicy;
9
+ readLanguage?: (target: TTarget) => string | undefined;
10
+ reportFailure?: (failure: LanguageSyncFailure) => void;
11
+ target?: TTarget;
12
+ }
13
+ export declare const useLatestLanguageSync: <TTarget extends object>({ changeLanguage, commitLanguage, desiredLanguage, enabled, policy, readLanguage, reportFailure, target, }: LatestLanguageSyncOptions<TTarget>) => (language: string) => void;
14
+ interface LanguageDetectorLike {
15
+ cacheUserLanguage?: (language: string) => void;
16
+ }
17
+ export interface I18nLanguageSyncInstance {
18
+ changeLanguage?: (language?: string) => PromiseLike<unknown> | unknown;
19
+ i18nInstance?: {
20
+ instance?: I18nLanguageSyncInstance;
21
+ };
22
+ isInitialized?: boolean;
23
+ language: string;
24
+ options?: {
25
+ detection?: unknown;
26
+ };
27
+ services?: {
28
+ languageDetector?: LanguageDetectorLike;
29
+ };
30
+ setLang?: (language: string) => PromiseLike<unknown> | unknown;
31
+ }
32
+ /**
33
+ * Compatibility-shaped adapter for the Modern.js i18n provider. The shared
34
+ * coordinator is fork-owned; the upstream hook remains available to direct
35
+ * internal consumers until the equivalent shrink can be upstreamed.
36
+ */
37
+ export declare function useLanguageSync<TInstance extends I18nLanguageSyncInstance>(i18nInstance: TInstance | undefined, localePathRedirect: boolean, languages: string[], pathname: string | undefined, prevLangRef: React.MutableRefObject<string>, setLang: (language: string) => void): (language: string) => void;
38
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { LocalisedUrlsOption, ResolvedLocalisedUrlsConfig } from './types';
2
+ /**
3
+ * Localised URLs are strictly opt-in: only an explicit, non-empty map enables
4
+ * route expansion and validation. `true`, `false`, an empty map and absence
5
+ * all resolve to disabled, so upstream-style configs (`localePathRedirect` +
6
+ * `languages` without a map) keep plain locale-prefix behavior instead of
7
+ * failing the build for every route missing from a map they never wrote.
8
+ */
9
+ export declare const resolveLocalisedUrlsConfig: (option: LocalisedUrlsOption | undefined) => ResolvedLocalisedUrlsConfig;
@@ -0,0 +1,9 @@
1
+ export { resolveLocalisedUrlsConfig } from './config';
2
+ export { normalisePathname, normalisePathPattern } from './normalise';
3
+ export { canonicalTargetPathname, localiseTargetPathname, stripLanguagePrefix, } from './pathname';
4
+ export { buildPathFromPattern, matchPathPattern } from './patterns';
5
+ export type { LocaleRedirectSkipRule } from './redirect';
6
+ export { DEFAULT_LOCALE_REDIRECT_SKIP_RULES, isDefaultLocaleRedirectSkipPath, matchesPathPrefix, shouldSkipLocaleRedirect, } from './redirect';
7
+ export { resolveCanonicalLocalisedPath, resolveLocalisedPath } from './resolve';
8
+ export { applyLocalisedUrlsToRoutes, validateLocalisedUrls } from './routes';
9
+ export type { LocalisedRoute, LocalisedUrlPathMap, LocalisedUrlsMap, LocalisedUrlsOption, ResolvedLocalisedUrlsConfig, } from './types';
@@ -0,0 +1,10 @@
1
+ export declare const normalisePathPattern: (path: string) => string;
2
+ /**
3
+ * Normalise a concrete request pathname: slash cleanup only. Unlike
4
+ * {@link normalisePathPattern} it must not rewrite literal `[x]` segments to
5
+ * `:x` params — pathnames are values, not patterns.
6
+ */
7
+ export declare const normalisePathname: (pathname: string) => string;
8
+ export declare const normaliseRoutePath: (path: string) => string;
9
+ export declare const stripLeadingLocaleParam: (path?: string) => string | undefined;
10
+ export declare const getLeadingLocaleParam: (path?: string) => string | null;
@@ -0,0 +1,4 @@
1
+ import type { LocalisedUrlsOption } from './types';
2
+ export declare const stripLanguagePrefix: (pathname: string, languages: string[]) => string;
3
+ export declare const localiseTargetPathname: (pathname: string, language: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
4
+ export declare const canonicalTargetPathname: (pathname: string, languages: string[], localisedUrls?: LocalisedUrlsOption) => string;
@@ -0,0 +1,11 @@
1
+ export declare const getOrderedPathParamSignature: (pattern: string) => string[];
2
+ export declare const getPathParamSignature: (pattern: string) => string[];
3
+ export declare const getPathPatternValidationIssue: (pattern: string) => string | undefined;
4
+ export declare const getPhysicalPathPattern: (pattern: string) => string;
5
+ export declare const haveEqualPatternSpecificity: (left: string, right: string) => boolean;
6
+ export declare const doPathPatternsOverlap: (left: string, right: string) => boolean;
7
+ export declare const sortPatternsBySpecificity: <T extends {
8
+ pattern: string;
9
+ }>(patterns: T[]) => T[];
10
+ export declare const matchPathPattern: (pathname: string, pattern: string) => Record<string, string> | null;
11
+ export declare const buildPathFromPattern: (pattern: string, params: Record<string, string>) => string;
@@ -0,0 +1,11 @@
1
+ export type LocaleRedirectSkipRule = {
2
+ type: 'exact';
3
+ path: string;
4
+ } | {
5
+ type: 'prefix';
6
+ path: string;
7
+ };
8
+ export declare const DEFAULT_LOCALE_REDIRECT_SKIP_RULES: readonly LocaleRedirectSkipRule[];
9
+ export declare const matchesPathPrefix: (pathname: string, prefix: string) => boolean;
10
+ export declare const isDefaultLocaleRedirectSkipPath: (pathname: string, languages?: string[]) => boolean;
11
+ export declare const shouldSkipLocaleRedirect: (pathname: string, languages?: string[], ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean)) => boolean;
@@ -0,0 +1,7 @@
1
+ import type { LocalisedUrlsMap } from './types';
2
+ export declare const resolveLocalisedPath: (pathname: string, targetLanguage: string, languages: string[], localisedUrls: LocalisedUrlsMap) => string;
3
+ /**
4
+ * Reverse-map language-specific pathnames back to language-agnostic
5
+ * LocalisedUrlsMap keys.
6
+ */
7
+ export declare const resolveCanonicalLocalisedPath: (pathname: string, languages: string[], localisedUrls: LocalisedUrlsMap) => string;
@@ -0,0 +1,3 @@
1
+ import type { LocalisedRoute, LocalisedUrlsMap } from './types';
2
+ export declare const validateLocalisedUrls: (routes: LocalisedRoute[], languages: string[], localisedUrls: LocalisedUrlsMap) => void;
3
+ export declare const applyLocalisedUrlsToRoutes: (routes: LocalisedRoute[], languages: string[], localisedUrls: LocalisedUrlsMap) => LocalisedRoute[];
@@ -0,0 +1,14 @@
1
+ export type LocalisedRoute = {
2
+ type: 'nested' | 'page';
3
+ path?: string;
4
+ id?: string;
5
+ children?: LocalisedRoute[];
6
+ [key: string]: any;
7
+ };
8
+ export type LocalisedUrlPathMap = Record<string, string>;
9
+ export type LocalisedUrlsMap = Record<string, LocalisedUrlPathMap>;
10
+ export type LocalisedUrlsOption = boolean | LocalisedUrlsMap;
11
+ export interface ResolvedLocalisedUrlsConfig {
12
+ enabled: boolean;
13
+ map: LocalisedUrlsMap;
14
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@bleedingdev/modern-js-i18n-runtime-extensions",
3
+ "description": "Fork-owned UltraModern.js i18n runtime coordination and localized-route extensions.",
4
+ "homepage": "https://github.com/BleedingDev/ultramodern.js#readme",
5
+ "bugs": {
6
+ "url": "https://github.com/BleedingDev/ultramodern.js/issues"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/BleedingDev/ultramodern.js.git",
11
+ "directory": "packages/runtime/i18n-extensions"
12
+ },
13
+ "license": "MIT",
14
+ "keywords": [
15
+ "react",
16
+ "framework",
17
+ "modern.js",
18
+ "i18n"
19
+ ],
20
+ "version": "3.8.3-ultramodern.2",
21
+ "types": "./dist/types/index.d.ts",
22
+ "main": "./dist/cjs/index.js",
23
+ "module": "./dist/esm/index.mjs",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/types/index.d.ts",
27
+ "node": {
28
+ "import": "./dist/esm-node/index.mjs",
29
+ "require": "./dist/cjs/index.js"
30
+ },
31
+ "default": "./dist/esm/index.mjs"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "typesVersions": {
36
+ "*": {
37
+ ".": [
38
+ "./dist/types/index.d.ts",
39
+ "./src/index.ts"
40
+ ]
41
+ }
42
+ },
43
+ "engines": {
44
+ "node": ">=26.7.0"
45
+ },
46
+ "dependencies": {
47
+ "i18next-browser-languagedetector": "^8.2.1"
48
+ },
49
+ "peerDependencies": {
50
+ "@modern-js/runtime": "3.8.3-ultramodern.2",
51
+ "react": "^19.2.8"
52
+ },
53
+ "devDependencies": {
54
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.8.3-ultramodern.2",
55
+ "@rslib/core": "0.23.2",
56
+ "@scripts/rstest-config": "2.66.0",
57
+ "@types/node": "^26.2.0",
58
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
59
+ "react": "^19.2.8",
60
+ "react-dom": "^19.2.8"
61
+ },
62
+ "sideEffects": false,
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "scripts": {
67
+ "dev": "rslib build --watch",
68
+ "build": "rslib build",
69
+ "test": "rstest --passWithNoTests"
70
+ }
71
+ }
@@ -0,0 +1,4 @@
1
+ import { rslibConfig } from '@modern-js/rslib';
2
+ import { defineConfig } from '@rslib/core';
3
+
4
+ export default defineConfig(rslibConfig);
@@ -0,0 +1,36 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { ProjectConfig } from '@rstest/core';
4
+ import { withTestPreset } from '@scripts/rstest-config';
5
+
6
+ const packageDirectory = dirname(fileURLToPath(import.meta.url));
7
+ const commonConfig: ProjectConfig = {
8
+ setupFiles: [
9
+ resolve(packageDirectory, '../../../scripts/rstest-config/setup.ts'),
10
+ ],
11
+ globals: true,
12
+ };
13
+
14
+ export default {
15
+ projects: [
16
+ withTestPreset({
17
+ name: 'i18n-extensions-node',
18
+ testEnvironment: 'node',
19
+ include: ['tests/**/*.test.ts'],
20
+ extends: commonConfig,
21
+ }),
22
+ withTestPreset({
23
+ name: 'i18n-extensions-client',
24
+ testEnvironment: 'happy-dom',
25
+ include: ['tests/**/*.test.tsx'],
26
+ extends: {
27
+ ...commonConfig,
28
+ tools: {
29
+ swc: {
30
+ jsc: { transform: { react: { runtime: 'automatic' } } },
31
+ },
32
+ },
33
+ },
34
+ }),
35
+ ],
36
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './language-sync/controller';
2
+ export * from './language-sync/react';
3
+ export * from './localisedUrls/index';
@@ -0,0 +1,386 @@
1
+ export type LanguageSyncFailureReason = 'rejected' | 'timeout';
2
+
3
+ export interface LanguageSyncFailure {
4
+ attempts: number;
5
+ error: unknown;
6
+ language: string;
7
+ reason: LanguageSyncFailureReason;
8
+ }
9
+
10
+ export interface LanguageSyncPolicy {
11
+ attemptTimeoutMs?: number;
12
+ maxAttempts?: number;
13
+ retryDelayMs?: (failedAttempts: number) => number;
14
+ }
15
+
16
+ export interface LanguageSyncCallbacks<TTarget extends object> {
17
+ changeLanguage: (
18
+ target: TTarget,
19
+ language: string,
20
+ ) => PromiseLike<unknown> | unknown;
21
+ commitLanguage: (target: TTarget, language: string) => void;
22
+ readLanguage?: (target: TTarget) => string | undefined;
23
+ reportFailure?: (failure: LanguageSyncFailure) => void;
24
+ }
25
+
26
+ export interface LatestLanguageSyncBinding<TTarget extends object> {
27
+ activate(target: TTarget): void;
28
+ clearRequest(): void;
29
+ deactivate(): void;
30
+ request(language: string): void;
31
+ updateCallbacks(callbacks: LanguageSyncCallbacks<TTarget>): void;
32
+ }
33
+
34
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 1_000;
35
+ const DEFAULT_MAX_ATTEMPTS = 4;
36
+
37
+ const defaultRetryDelay = (failedAttempts: number) =>
38
+ Math.min(50 * 2 ** Math.max(failedAttempts - 1, 0), 1_000);
39
+
40
+ const normalizePolicy = (policy: LanguageSyncPolicy) => ({
41
+ attemptTimeoutMs: Math.max(
42
+ 1,
43
+ policy.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS,
44
+ ),
45
+ maxAttempts: Math.max(1, policy.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
46
+ retryDelayMs: policy.retryDelayMs ?? defaultRetryDelay,
47
+ });
48
+
49
+ class LanguageSyncTimeoutError extends Error {
50
+ constructor(language: string, timeoutMs: number) {
51
+ super(
52
+ `Language synchronization for "${language}" did not settle within ${timeoutMs}ms.`,
53
+ );
54
+ this.name = 'LanguageSyncTimeoutError';
55
+ }
56
+ }
57
+
58
+ interface ActiveIntent {
59
+ attempts: number;
60
+ generation: number;
61
+ language: string;
62
+ status: 'attempting' | 'failed' | 'retry-wait' | 'satisfied';
63
+ }
64
+
65
+ interface Attempt {
66
+ generation: number;
67
+ language: string;
68
+ settled: boolean;
69
+ timedOut: boolean;
70
+ timer: ReturnType<typeof setTimeout>;
71
+ }
72
+
73
+ let requestOrder = 0;
74
+
75
+ class Binding<TTarget extends object> {
76
+ callbacks: LanguageSyncCallbacks<TTarget> | undefined;
77
+ coordinator: Coordinator<TTarget> | undefined;
78
+ desiredLanguage: string | undefined;
79
+ order = 0;
80
+ target: TTarget | undefined;
81
+
82
+ constructor(readonly policy: ReturnType<typeof normalizePolicy>) {}
83
+
84
+ activate(target: TTarget) {
85
+ if (this.target === target && this.coordinator) {
86
+ return;
87
+ }
88
+ const desiredLanguage = this.desiredLanguage;
89
+ const order = this.order;
90
+ this.deactivate();
91
+ this.desiredLanguage = desiredLanguage;
92
+ this.order = order;
93
+ this.target = target;
94
+ this.coordinator = getCoordinator(target, this.policy);
95
+ this.coordinator.add(this);
96
+ this.coordinator.refresh();
97
+ }
98
+
99
+ clearRequest() {
100
+ if (!this.desiredLanguage) {
101
+ return;
102
+ }
103
+ this.desiredLanguage = undefined;
104
+ this.order = ++requestOrder;
105
+ this.coordinator?.refresh();
106
+ }
107
+
108
+ deactivate() {
109
+ const coordinator = this.coordinator;
110
+ this.coordinator = undefined;
111
+ this.target = undefined;
112
+ this.desiredLanguage = undefined;
113
+ coordinator?.remove(this);
114
+ }
115
+
116
+ request(language: string) {
117
+ const normalizedLanguage = language.trim();
118
+ if (!normalizedLanguage) {
119
+ return;
120
+ }
121
+ const repeatedFailedRequest =
122
+ this.desiredLanguage === normalizedLanguage &&
123
+ this.coordinator?.isFailed(normalizedLanguage);
124
+ this.desiredLanguage = normalizedLanguage;
125
+ this.order = ++requestOrder;
126
+ this.coordinator?.refresh(repeatedFailedRequest);
127
+ }
128
+
129
+ updateCallbacks(callbacks: LanguageSyncCallbacks<TTarget>) {
130
+ this.callbacks = callbacks;
131
+ }
132
+ }
133
+
134
+ class Coordinator<TTarget extends object> {
135
+ private readonly bindings = new Set<Binding<TTarget>>();
136
+ private generation = 0;
137
+ private intent: ActiveIntent | undefined;
138
+ private retryTimer: ReturnType<typeof setTimeout> | undefined;
139
+
140
+ constructor(
141
+ private readonly target: TTarget,
142
+ private readonly policy: ReturnType<typeof normalizePolicy>,
143
+ ) {}
144
+
145
+ add(binding: Binding<TTarget>) {
146
+ this.bindings.add(binding);
147
+ }
148
+
149
+ isFailed(language: string) {
150
+ return (
151
+ this.intent?.language === language && this.intent.status === 'failed'
152
+ );
153
+ }
154
+
155
+ refresh(forceRetry = false) {
156
+ const selected = this.selectBinding();
157
+ if (!selected?.desiredLanguage) {
158
+ this.invalidateIntent();
159
+ return;
160
+ }
161
+
162
+ const language = selected.desiredLanguage;
163
+ if (this.intent?.language === language && !forceRetry) {
164
+ if (this.intent.status === 'satisfied') {
165
+ this.commit(language);
166
+ }
167
+ return;
168
+ }
169
+
170
+ this.beginIntent(language, false);
171
+ }
172
+
173
+ remove(binding: Binding<TTarget>) {
174
+ this.bindings.delete(binding);
175
+ this.refresh();
176
+ }
177
+
178
+ private beginIntent(language: string, forceChange: boolean) {
179
+ this.clearRetry();
180
+ const intent: ActiveIntent = {
181
+ attempts: 0,
182
+ generation: ++this.generation,
183
+ language,
184
+ status: 'attempting',
185
+ };
186
+ this.intent = intent;
187
+
188
+ const activeBinding = this.selectBinding();
189
+ if (
190
+ !forceChange &&
191
+ activeBinding?.callbacks?.readLanguage?.(this.target) === language
192
+ ) {
193
+ intent.status = 'satisfied';
194
+ this.commit(language);
195
+ return;
196
+ }
197
+
198
+ this.startAttempt(intent);
199
+ }
200
+
201
+ private clearRetry() {
202
+ if (this.retryTimer !== undefined) {
203
+ clearTimeout(this.retryTimer);
204
+ this.retryTimer = undefined;
205
+ }
206
+ }
207
+
208
+ private commit(language: string) {
209
+ for (const binding of this.bindings) {
210
+ if (binding.callbacks) {
211
+ binding.callbacks.commitLanguage(this.target, language);
212
+ }
213
+ }
214
+ }
215
+
216
+ private failAttempt(
217
+ intent: ActiveIntent,
218
+ reason: LanguageSyncFailureReason,
219
+ error: unknown,
220
+ ) {
221
+ if (this.intent !== intent || intent.status === 'satisfied') {
222
+ return;
223
+ }
224
+
225
+ if (intent.attempts >= this.policy.maxAttempts) {
226
+ intent.status = 'failed';
227
+ for (const binding of this.bindings) {
228
+ if (binding.desiredLanguage === intent.language) {
229
+ binding.callbacks?.reportFailure?.({
230
+ attempts: intent.attempts,
231
+ error,
232
+ language: intent.language,
233
+ reason,
234
+ });
235
+ }
236
+ }
237
+ return;
238
+ }
239
+
240
+ intent.status = 'retry-wait';
241
+ const retryDelay = Math.max(0, this.policy.retryDelayMs(intent.attempts));
242
+ this.retryTimer = setTimeout(() => {
243
+ this.retryTimer = undefined;
244
+ if (this.intent === intent && this.selectBinding()?.desiredLanguage) {
245
+ intent.status = 'attempting';
246
+ this.startAttempt(intent);
247
+ }
248
+ }, retryDelay);
249
+ }
250
+
251
+ private invalidateIntent() {
252
+ this.clearRetry();
253
+ this.generation += 1;
254
+ this.intent = undefined;
255
+ }
256
+
257
+ private onFulfilled(attempt: Attempt) {
258
+ if (attempt.settled) {
259
+ return;
260
+ }
261
+ attempt.settled = true;
262
+ clearTimeout(attempt.timer);
263
+
264
+ const intent = this.intent;
265
+ if (!intent) {
266
+ return;
267
+ }
268
+
269
+ if (attempt.language === intent.language) {
270
+ this.clearRetry();
271
+ if (intent.status !== 'satisfied') {
272
+ intent.status = 'satisfied';
273
+ this.commit(intent.language);
274
+ }
275
+ return;
276
+ }
277
+
278
+ // A superseded operation may mutate a shared i18n singleton when it
279
+ // eventually settles. Reassert the committed intent through the newest
280
+ // mounted binding, even when the binding that started the stale work left.
281
+ this.beginIntent(intent.language, true);
282
+ }
283
+
284
+ private onRejected(attempt: Attempt, error: unknown) {
285
+ if (attempt.settled) {
286
+ return;
287
+ }
288
+ attempt.settled = true;
289
+ clearTimeout(attempt.timer);
290
+ if (attempt.timedOut) {
291
+ return;
292
+ }
293
+
294
+ const intent = this.intent;
295
+ if (
296
+ intent?.generation === attempt.generation &&
297
+ intent.language === attempt.language
298
+ ) {
299
+ this.failAttempt(intent, 'rejected', error);
300
+ }
301
+ }
302
+
303
+ private selectBinding() {
304
+ let selected: Binding<TTarget> | undefined;
305
+ for (const binding of this.bindings) {
306
+ if (
307
+ binding.desiredLanguage &&
308
+ binding.callbacks &&
309
+ (!selected || binding.order > selected.order)
310
+ ) {
311
+ selected = binding;
312
+ }
313
+ }
314
+ return selected;
315
+ }
316
+
317
+ private startAttempt(intent: ActiveIntent) {
318
+ const binding = this.selectBinding();
319
+ const callbacks = binding?.callbacks;
320
+ if (!callbacks || binding?.desiredLanguage !== intent.language) {
321
+ this.refresh();
322
+ return;
323
+ }
324
+
325
+ intent.attempts += 1;
326
+ const attempt: Attempt = {
327
+ generation: intent.generation,
328
+ language: intent.language,
329
+ settled: false,
330
+ timedOut: false,
331
+ timer: undefined as unknown as ReturnType<typeof setTimeout>,
332
+ };
333
+ attempt.timer = setTimeout(() => {
334
+ if (attempt.settled || attempt.timedOut) {
335
+ return;
336
+ }
337
+ attempt.timedOut = true;
338
+ if (
339
+ this.intent === intent &&
340
+ intent.generation === attempt.generation &&
341
+ intent.language === attempt.language
342
+ ) {
343
+ this.failAttempt(
344
+ intent,
345
+ 'timeout',
346
+ new LanguageSyncTimeoutError(
347
+ attempt.language,
348
+ this.policy.attemptTimeoutMs,
349
+ ),
350
+ );
351
+ }
352
+ }, this.policy.attemptTimeoutMs);
353
+
354
+ let result: PromiseLike<unknown> | unknown;
355
+ try {
356
+ result = callbacks.changeLanguage(this.target, intent.language);
357
+ } catch (error) {
358
+ this.onRejected(attempt, error);
359
+ return;
360
+ }
361
+
362
+ Promise.resolve(result).then(
363
+ () => this.onFulfilled(attempt),
364
+ error => this.onRejected(attempt, error),
365
+ );
366
+ }
367
+ }
368
+
369
+ const coordinators = new WeakMap<object, Coordinator<any>>();
370
+
371
+ const getCoordinator = <TTarget extends object>(
372
+ target: TTarget,
373
+ policy: ReturnType<typeof normalizePolicy>,
374
+ ): Coordinator<TTarget> => {
375
+ const existing = coordinators.get(target);
376
+ if (existing) {
377
+ return existing;
378
+ }
379
+ const coordinator = new Coordinator(target, policy);
380
+ coordinators.set(target, coordinator);
381
+ return coordinator;
382
+ };
383
+
384
+ export const createLatestLanguageSyncBinding = <TTarget extends object>(
385
+ policy: LanguageSyncPolicy = {},
386
+ ): LatestLanguageSyncBinding<TTarget> => new Binding(normalizePolicy(policy));