@owlmeans/client-i18n 0.1.18-rc.40 → 0.1.18-rc.41

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
@@ -12,7 +12,7 @@ React i18next adapter that loads translations registered via `@owlmeans/i18n` in
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/client-i18n@^0.1.18-rc.40
15
+ bun add @owlmeans/client-i18n@^0.1.18-rc.41
16
16
  ```
17
17
 
18
18
  ## Usage
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/client-i18n",
4
- "version": "0.1.18-rc.40",
5
- "generatedAt": "2026-09-24T14:00:35.045Z",
4
+ "version": "0.1.18-rc.41",
5
+ "generatedAt": "2026-09-25T15:32:39.584Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/client-i18n
9
9
 
10
10
  **Layer:** Client (React)
11
- **Install:** `"@owlmeans/client-i18n": "^0.1.18-rc.40"` in `dependencies`
11
+ **Install:** `"@owlmeans/client-i18n": "^0.1.18-rc.41"` in `dependencies`
12
12
 
13
13
  ## Purpose
14
14
 
@@ -37,13 +37,16 @@ The instance itself, for a host that has to configure i18next before the tree re
37
37
  |---|---|
38
38
  | `useI18nInstance(config)` | The i18next instance for this application, memoised for the mount |
39
39
  | `getI18nInstance(config)` | The same instance outside React |
40
- | `setLanguage(lng)` | Async: load `lng`'s pack (`loadI18nLanguage`), then persist and switch — what `useLanguage`'s setter calls |
40
+ | `setLanguage(lng)` | Async: load `lng`'s pack (`loadI18nLanguage`), then persist (if the persistence guard allows) and switch — what `useLanguage`'s setter calls |
41
+ | `setLanguagePersistence(guard \| null)` | Install the guard that decides whether an explicit choice may be written to storage; `null` removes it. Call BEFORE `prepareI18n` |
42
+ | `persistLanguage()` | Write the explicit choice storage refused earlier, now that the guard allows; `false` when nothing waits or storage is still refused |
43
+ | `getExplicitLanguage()` | The language a person chose explicitly since this page loaded (stored or not), else `null` — what a host asks before applying a language that arrived some other way |
41
44
  | `prepareI18n(config)` | Await the fallback language's loaders, then the initial language's; resolves to the language the instance will start in. Call once, before the first render |
42
45
  | `resolveInitialLanguage(config)` | The persisted `owlmeans-lng` when it is in `supportedLngs`, else the browser's language (`preferredLanguageOf`), else `fallbackLng ?? defaultLng ?? DEFAULT_LNG` |
43
46
  | `preferredLanguageOf(supportedLngs, preferred)` | Pure: the first preferred tag the config supports, exact or by base (`de-DE` → `de`), else `null` |
44
47
 
45
- All six live in `src/utils/instance.ts`; the package root re-exports `prepareI18n`,
46
- `resolveInitialLanguage` and `setLanguage`.
48
+ All of these live in `src/utils/instance.ts`; the package root re-exports `prepareI18n`,
49
+ `resolveInitialLanguage`, `setLanguage` and the three persistence functions above.
47
50
 
48
51
  **There is exactly one instance per document**, created on first request and reused by every later
49
52
  call whatever config is passed. So configuration is read once, at the first creation, and a plugin
@@ -101,7 +104,17 @@ the browser's language, then `fallbackLng`, instead of resolving nothing. Every
101
104
  `navigator` access is guarded, so a browser that refuses site data (or no browser) still renders.
102
105
 
103
106
  `tests/instance.spec.ts` pins `preferredLanguageOf` (base matching, browser order, exact tag first,
104
- `null` when nothing matches).
107
+ `null` when nothing matches) and the persistence guard.
108
+
109
+ **Persistence is guarded, not assumed.** With no guard installed every explicit choice is written, as
110
+ always. An application that asks its visitors before remembering anything installs one
111
+ (`setLanguagePersistence(() => allowed)`; `@owlmeans/web-panel/consent`'s `installConsentLanguage()` is
112
+ the ready-made one for the cookie dialog's `functional` category — see the `consent` skill). While it
113
+ says no: `setLanguage` still switches the UI but writes nothing and remembers the refused choice
114
+ (`persistLanguage()` writes it when storage becomes allowed), and a value already in storage counts
115
+ as ABSENT at start-up (`resolveInitialLanguage`), so a language nobody was allowed to remember is not
116
+ used. Installing a guard also resets what this page has chosen so far — install it before anything
117
+ calls `setLanguage`.
105
118
 
106
119
  `setLanguage(lng)` is async: it awaits `loadI18nLanguage(lng)`, then persists and calls i18next's
107
120
  `changeLanguage` — before the instance exists it records the language the instance will start in
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from './types.js';
2
2
  export { I18nContext, composePrefix, useI18n, useI18nLib, useI18nApp, useLanguage } from './context.js';
3
- export { prepareI18n, resolveInitialLanguage, setLanguage } from './utils/instance.js';
3
+ export { prepareI18n, resolveInitialLanguage, setLanguage, setLanguagePersistence, persistLanguage, getExplicitLanguage, } from './utils/instance.js';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AACvG,OAAO,EAAE,WAAW,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AACvG,OAAO,EACL,WAAW,EAAE,sBAAsB,EAAE,WAAW,EAAE,sBAAsB,EAAE,eAAe,EAAE,mBAAmB,GAC/G,MAAM,qBAAqB,CAAA"}
package/build/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from './types.js';
2
2
  export { I18nContext, composePrefix, useI18n, useI18nLib, useI18nApp, useLanguage } from './context.js';
3
- export { prepareI18n, resolveInitialLanguage, setLanguage } from './utils/instance.js';
3
+ export { prepareI18n, resolveInitialLanguage, setLanguage, setLanguagePersistence, persistLanguage, getExplicitLanguage, } from './utils/instance.js';
4
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AACvG,OAAO,EAAE,WAAW,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AACvG,OAAO,EACL,WAAW,EAAE,sBAAsB,EAAE,WAAW,EAAE,sBAAsB,EAAE,eAAe,EAAE,mBAAmB,GAC/G,MAAM,qBAAqB,CAAA"}
@@ -1,5 +1,23 @@
1
1
  import type { i18n } from 'i18next';
2
2
  import type { ClientConfig } from '@owlmeans/client-context';
3
+ /**
4
+ * The language a person chose explicitly since this page loaded (`setLanguage`), or `null`. What a
5
+ * host asks before it applies a language that arrived some other way — a link — which must never
6
+ * override what the person just picked.
7
+ */
8
+ export declare const getExplicitLanguage: () => string | null;
9
+ /**
10
+ * Install (or, with `null`, remove) the guard that decides whether a language choice may be
11
+ * remembered. Call it BEFORE `prepareI18n`: the initial language is resolved from storage, and a
12
+ * guard that says no makes a stored value count as absent.
13
+ */
14
+ export declare const setLanguagePersistence: (guard: (() => boolean) | null) => void;
15
+ /**
16
+ * Write the explicit choice storage refused earlier, now that it may be — call it when the guard's
17
+ * answer turns to yes (the visitor granted functional storage). `false` when there was nothing
18
+ * waiting, storage is still refused, or the write failed.
19
+ */
20
+ export declare const persistLanguage: () => boolean;
3
21
  export declare const useI18nInstance: (config: ClientConfig) => i18n;
4
22
  export declare const getI18nInstance: (config: ClientConfig) => i18n;
5
23
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAEnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAa5D,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAItD,CAAA;AAED,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAC3B,CAAA;AAE5B;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAS,MAAM,KAAG,OAAO,CAAC,IAAI,CAA8B,CAAA;AAwBpF;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,WAAkB,YAAY,KAAG,OAAO,CAAC,MAAM,CAatE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,WAAY,YAAY,KAAG,MAO7D,CAAA;AAaD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,kBACf,SAAS,MAAM,EAAE,aAAa,SAAS,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,KAClF,MAAM,GAAG,IAUX,CAAA"}
1
+ {"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAEnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AA4B5D;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,QAAO,MAAM,GAAG,IAAsB,CAAA;AAItE;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,UAAW,CAAC,MAAM,OAAO,CAAC,GAAG,IAAI,KAAG,IAKtE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAO,OAYlC,CAAA;AAED,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAItD,CAAA;AAED,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAC3B,CAAA;AAE5B;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAS,MAAM,KAAG,OAAO,CAAC,IAAI,CAA8B,CAAA;AA+BpF;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,WAAkB,YAAY,KAAG,OAAO,CAAC,MAAM,CAatE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,WAAY,YAAY,KAAG,MAO7D,CAAA;AAgBD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,kBACf,SAAS,MAAM,EAAE,aAAa,SAAS,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,KAClF,MAAM,GAAG,IAUX,CAAA"}
@@ -8,6 +8,54 @@ let i18nInstance = null;
8
8
  let preparedLng;
9
9
  /** The language of the latest `setLanguage` call — an earlier call that finishes later yields to it. */
10
10
  let requestedLng;
11
+ /**
12
+ * Whether an explicit language choice may be written to storage right now. Unset, storage is always
13
+ * allowed — an application with no notion of consent behaves exactly as before. A host that asks its
14
+ * visitors (`@owlmeans/web-panel/consent`'s `installConsentLanguage`) installs one that answers
15
+ * "has the visitor allowed functional storage?", so a language is not remembered on a device nobody
16
+ * asked about.
17
+ */
18
+ let persistenceGuard = null;
19
+ /** The latest explicit choice that storage refused; written the moment storage is allowed. */
20
+ let refusedChoice = null;
21
+ /** The latest language a person chose explicitly in this page's life, stored or not. */
22
+ let explicitChoice = null;
23
+ /**
24
+ * The language a person chose explicitly since this page loaded (`setLanguage`), or `null`. What a
25
+ * host asks before it applies a language that arrived some other way — a link — which must never
26
+ * override what the person just picked.
27
+ */
28
+ export const getExplicitLanguage = () => explicitChoice;
29
+ const persistenceAllowed = () => persistenceGuard == null || persistenceGuard();
30
+ /**
31
+ * Install (or, with `null`, remove) the guard that decides whether a language choice may be
32
+ * remembered. Call it BEFORE `prepareI18n`: the initial language is resolved from storage, and a
33
+ * guard that says no makes a stored value count as absent.
34
+ */
35
+ export const setLanguagePersistence = (guard) => {
36
+ persistenceGuard = guard;
37
+ // A new guard starts a new account of what this page's visitor has chosen and what storage refused.
38
+ refusedChoice = null;
39
+ explicitChoice = null;
40
+ };
41
+ /**
42
+ * Write the explicit choice storage refused earlier, now that it may be — call it when the guard's
43
+ * answer turns to yes (the visitor granted functional storage). `false` when there was nothing
44
+ * waiting, storage is still refused, or the write failed.
45
+ */
46
+ export const persistLanguage = () => {
47
+ if (refusedChoice == null || !persistenceAllowed()) {
48
+ return false;
49
+ }
50
+ try {
51
+ localStorage.setItem(LNG_STORAGE_KEY, refusedChoice);
52
+ refusedChoice = null;
53
+ return true;
54
+ }
55
+ catch (_) {
56
+ return false;
57
+ }
58
+ };
11
59
  export const useI18nInstance = (config) => {
12
60
  const instance = useMemo(() => createI18nInstance(config), []);
13
61
  return instance;
@@ -30,10 +78,18 @@ const switchLanguage = async (lng, persist) => {
30
78
  return;
31
79
  }
32
80
  if (persist) {
33
- try {
34
- localStorage.setItem(LNG_STORAGE_KEY, lng);
81
+ explicitChoice = lng;
82
+ if (persistenceAllowed()) {
83
+ refusedChoice = null;
84
+ try {
85
+ localStorage.setItem(LNG_STORAGE_KEY, lng);
86
+ }
87
+ catch (_) { /* noop in non-browser environments */ }
88
+ }
89
+ else {
90
+ // Not remembered — but not forgotten either: the visitor may allow it a moment later.
91
+ refusedChoice = lng;
35
92
  }
36
- catch (_) { /* noop in non-browser environments */ }
37
93
  }
38
94
  if (i18nInstance == null) {
39
95
  preparedLng = lng;
@@ -79,6 +135,9 @@ export const resolveInitialLanguage = (config) => {
79
135
  };
80
136
  const resolveFallbackLanguage = (config) => config.i18n?.fallbackLng ?? config.i18n?.defaultLng ?? DEFAULT_LNG;
81
137
  const getPersistedLanguage = () => {
138
+ if (!persistenceAllowed()) {
139
+ return null;
140
+ }
82
141
  try {
83
142
  return localStorage.getItem(LNG_STORAGE_KEY);
84
143
  }
@@ -1 +1 @@
1
- {"version":3,"file":"instance.js","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"instance.js","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/client-i18n",
3
- "version": "0.1.18-rc.40",
3
+ "version": "0.1.18-rc.41",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -37,7 +37,7 @@
37
37
  "typescript": "^7.0.2"
38
38
  },
39
39
  "dependencies": {
40
- "@owlmeans/client": "^0.1.18-rc.41",
40
+ "@owlmeans/client": "^0.1.18-rc.42",
41
41
  "@owlmeans/client-context": "^0.1.18-rc.39",
42
42
  "@owlmeans/i18n": "^0.1.18-rc.37",
43
43
  "i18next": "^26.2.0",
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
 
2
2
  export * from './types.js'
3
3
  export { I18nContext, composePrefix, useI18n, useI18nLib, useI18nApp, useLanguage } from './context.js'
4
- export { prepareI18n, resolveInitialLanguage, setLanguage } from './utils/instance.js'
4
+ export {
5
+ prepareI18n, resolveInitialLanguage, setLanguage, setLanguagePersistence, persistLanguage, getExplicitLanguage,
6
+ } from './utils/instance.js'
@@ -16,6 +16,61 @@ let preparedLng: string | undefined
16
16
  /** The language of the latest `setLanguage` call — an earlier call that finishes later yields to it. */
17
17
  let requestedLng: string | undefined
18
18
 
19
+ /**
20
+ * Whether an explicit language choice may be written to storage right now. Unset, storage is always
21
+ * allowed — an application with no notion of consent behaves exactly as before. A host that asks its
22
+ * visitors (`@owlmeans/web-panel/consent`'s `installConsentLanguage`) installs one that answers
23
+ * "has the visitor allowed functional storage?", so a language is not remembered on a device nobody
24
+ * asked about.
25
+ */
26
+ let persistenceGuard: (() => boolean) | null = null
27
+
28
+ /** The latest explicit choice that storage refused; written the moment storage is allowed. */
29
+ let refusedChoice: string | null = null
30
+
31
+ /** The latest language a person chose explicitly in this page's life, stored or not. */
32
+ let explicitChoice: string | null = null
33
+
34
+ /**
35
+ * The language a person chose explicitly since this page loaded (`setLanguage`), or `null`. What a
36
+ * host asks before it applies a language that arrived some other way — a link — which must never
37
+ * override what the person just picked.
38
+ */
39
+ export const getExplicitLanguage = (): string | null => explicitChoice
40
+
41
+ const persistenceAllowed = (): boolean => persistenceGuard == null || persistenceGuard()
42
+
43
+ /**
44
+ * Install (or, with `null`, remove) the guard that decides whether a language choice may be
45
+ * remembered. Call it BEFORE `prepareI18n`: the initial language is resolved from storage, and a
46
+ * guard that says no makes a stored value count as absent.
47
+ */
48
+ export const setLanguagePersistence = (guard: (() => boolean) | null): void => {
49
+ persistenceGuard = guard
50
+ // A new guard starts a new account of what this page's visitor has chosen and what storage refused.
51
+ refusedChoice = null
52
+ explicitChoice = null
53
+ }
54
+
55
+ /**
56
+ * Write the explicit choice storage refused earlier, now that it may be — call it when the guard's
57
+ * answer turns to yes (the visitor granted functional storage). `false` when there was nothing
58
+ * waiting, storage is still refused, or the write failed.
59
+ */
60
+ export const persistLanguage = (): boolean => {
61
+ if (refusedChoice == null || !persistenceAllowed()) {
62
+ return false
63
+ }
64
+ try {
65
+ localStorage.setItem(LNG_STORAGE_KEY, refusedChoice)
66
+ refusedChoice = null
67
+
68
+ return true
69
+ } catch (_) {
70
+ return false
71
+ }
72
+ }
73
+
19
74
  export const useI18nInstance = (config: ClientConfig): i18n => {
20
75
  const instance = useMemo(() => createI18nInstance(config), [])
21
76
 
@@ -43,9 +98,16 @@ const switchLanguage = async (lng: string, persist: boolean): Promise<void> => {
43
98
  return
44
99
  }
45
100
  if (persist) {
46
- try {
47
- localStorage.setItem(LNG_STORAGE_KEY, lng)
48
- } catch (_) { /* noop in non-browser environments */ }
101
+ explicitChoice = lng
102
+ if (persistenceAllowed()) {
103
+ refusedChoice = null
104
+ try {
105
+ localStorage.setItem(LNG_STORAGE_KEY, lng)
106
+ } catch (_) { /* noop in non-browser environments */ }
107
+ } else {
108
+ // Not remembered — but not forgotten either: the visitor may allow it a moment later.
109
+ refusedChoice = lng
110
+ }
49
111
  }
50
112
  if (i18nInstance == null) {
51
113
  preparedLng = lng
@@ -97,6 +159,9 @@ const resolveFallbackLanguage = (config: ClientConfig): string =>
97
159
  config.i18n?.fallbackLng ?? config.i18n?.defaultLng ?? DEFAULT_LNG
98
160
 
99
161
  const getPersistedLanguage = (): string | null => {
162
+ if (!persistenceAllowed()) {
163
+ return null
164
+ }
100
165
  try {
101
166
  return localStorage.getItem(LNG_STORAGE_KEY)
102
167
  } catch (_) {
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { preferredLanguageOf } from '../src/utils/instance.js'
2
+ import { afterEach, beforeEach } from 'bun:test'
3
+ import {
4
+ persistLanguage, preferredLanguageOf, resolveInitialLanguage, setLanguage, setLanguagePersistence,
5
+ } from '../src/utils/instance.js'
3
6
 
4
7
  const SUPPORTED = ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr']
5
8
 
@@ -25,3 +28,71 @@ describe('preferredLanguageOf — the first-visit language', () => {
25
28
  expect(preferredLanguageOf(SUPPORTED, [null, undefined, ''])).toBeNull()
26
29
  })
27
30
  })
31
+
32
+ describe('language persistence guard — a choice is remembered only while storage is allowed', () => {
33
+ const store = new Map<string, string>()
34
+ const config = { i18n: { supportedLngs: SUPPORTED, fallbackLng: 'en' } } as never
35
+ let allowed = false
36
+
37
+ beforeEach(() => {
38
+ store.clear()
39
+ allowed = false
40
+ ;(globalThis as any).localStorage = {
41
+ getItem: (k: string) => store.get(k) ?? null,
42
+ setItem: (k: string, v: string) => { store.set(k, v) },
43
+ removeItem: (k: string) => { store.delete(k) },
44
+ }
45
+ setLanguagePersistence(() => allowed)
46
+ })
47
+ afterEach(() => { setLanguagePersistence(null) })
48
+
49
+ test('with the guard saying no, an explicit choice is not written', async () => {
50
+ await setLanguage('pl')
51
+
52
+ expect(store.has('owlmeans-lng')).toBe(false)
53
+ })
54
+
55
+ test('the refused choice is written the moment storage is allowed', async () => {
56
+ await setLanguage('pl')
57
+ expect(persistLanguage()).toBe(false)
58
+
59
+ allowed = true
60
+
61
+ expect(persistLanguage()).toBe(true)
62
+ expect(store.get('owlmeans-lng')).toBe('pl')
63
+ expect(persistLanguage()).toBe(false)
64
+ })
65
+
66
+ test('the latest refused choice is the one that is written', async () => {
67
+ await setLanguage('pl')
68
+ await setLanguage('de')
69
+ allowed = true
70
+ persistLanguage()
71
+
72
+ expect(store.get('owlmeans-lng')).toBe('de')
73
+ })
74
+
75
+ test('with the guard saying yes, a choice is written at once, as before', async () => {
76
+ allowed = true
77
+ await setLanguage('fr')
78
+
79
+ expect(store.get('owlmeans-lng')).toBe('fr')
80
+ })
81
+
82
+ test('a stored language counts as absent while the guard says no, and is used once it says yes', () => {
83
+ store.set('owlmeans-lng', 'de')
84
+
85
+ expect(resolveInitialLanguage(config)).not.toBe('de')
86
+
87
+ allowed = true
88
+ expect(resolveInitialLanguage(config)).toBe('de')
89
+ })
90
+
91
+ test('with no guard installed nothing changes: choices are written and read', async () => {
92
+ setLanguagePersistence(null)
93
+ await setLanguage('es')
94
+
95
+ expect(store.get('owlmeans-lng')).toBe('es')
96
+ expect(resolveInitialLanguage(config)).toBe('es')
97
+ })
98
+ })