@gusnips/react 0.4.4 → 0.4.6
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 +23 -7
- package/dist/auth-store.d.ts +31 -0
- package/dist/auth-store.d.ts.map +1 -1
- package/dist/auth-store.js +27 -4
- package/dist/auth-store.js.map +1 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +7 -5
- package/dist/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/auth-store.test.ts +60 -0
- package/src/auth-store.ts +48 -4
- package/src/i18n.test.ts +29 -11
- package/src/i18n.ts +7 -5
package/README.md
CHANGED
|
@@ -136,6 +136,22 @@ where there is no window. A session bootstrap can only be in flight in a browser
|
|
|
136
136
|
during a build is a wait that never ends: it once shipped a spinner as the indexable body of a
|
|
137
137
|
page whose whole job was to be found.
|
|
138
138
|
|
|
139
|
+
When your store needs more than those flags, `authSlice` in the same place gives you them as a
|
|
140
|
+
plain object to spread, so `create` stays yours — and with it `persist`, your own actions, and
|
|
141
|
+
fields of your own:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
|
|
145
|
+
...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({
|
|
146
|
+
isAnonymous: !!user?.isAnonymous,
|
|
147
|
+
})),
|
|
148
|
+
}));
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The second argument is what makes this more than spreading extra keys in yourself: a field read
|
|
152
|
+
off the user has to be rewritten whenever the user changes, and `setUser` and `clear` are the two
|
|
153
|
+
writes you no longer own.
|
|
154
|
+
|
|
139
155
|
`hydrateOrMount` is at `@gusnips/react/hydrate`, and the guards are at `@gusnips/react/guards`.
|
|
140
156
|
|
|
141
157
|
## On a phone
|
|
@@ -169,13 +185,13 @@ something.
|
|
|
169
185
|
`@gusnips/react` itself needs `react`, `react-dom` and nothing else. Anything that needs more
|
|
170
186
|
lives behind a subpath, so you install a dependency only if you import the thing that uses it:
|
|
171
187
|
|
|
172
|
-
| Import from | What is in it
|
|
173
|
-
| ------------------------- |
|
|
174
|
-
| `@gusnips/react` | the client and rest
|
|
175
|
-
| `@gusnips/react/store` | `createAuthStore`
|
|
176
|
-
| `@gusnips/react/guards` | the route guards
|
|
177
|
-
| `@gusnips/react/ui` | the seven wrappers
|
|
178
|
-
| `@gusnips/react/contract` | two prerender names
|
|
188
|
+
| Import from | What is in it | What you must have |
|
|
189
|
+
| ------------------------- | ------------------------------ | ------------------ |
|
|
190
|
+
| `@gusnips/react` | the client and rest | react, react-dom |
|
|
191
|
+
| `@gusnips/react/store` | `createAuthStore`, `authSlice` | zustand |
|
|
192
|
+
| `@gusnips/react/guards` | the route guards | react-router-dom |
|
|
193
|
+
| `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
|
|
194
|
+
| `@gusnips/react/contract` | two prerender names | nothing |
|
|
179
195
|
|
|
180
196
|
The rule behind that table: **a peer marked optional must not be reachable from the main entry
|
|
181
197
|
point.** An optional peer the barrel imports anyway is not optional — it is a required one whose
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -46,4 +46,35 @@ export interface AuthState<TUser> {
|
|
|
46
46
|
* Only one of the three donors knew this. It is invariant 7.
|
|
47
47
|
*/
|
|
48
48
|
export declare function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>>;
|
|
49
|
+
/**
|
|
50
|
+
* zustand's `set`, narrowed to the two writes this slice makes.
|
|
51
|
+
*
|
|
52
|
+
* Not `Partial<AuthState<TUser> & TExtra>`, which is what it means and what a caller passes:
|
|
53
|
+
* inside a generic function TypeScript cannot check `{ isLoading }` against a partial of an
|
|
54
|
+
* unresolved type parameter, because `TExtra` might yet declare an `isLoading` of its own. The
|
|
55
|
+
* union says the same thing in terms it can check at both ends. Any real store's setter
|
|
56
|
+
* satisfies it, where `TExtra` is a concrete type.
|
|
57
|
+
*/
|
|
58
|
+
type AuthSet<TUser, TExtra> = (partial: (TExtra & Partial<AuthState<TUser>>) | Pick<AuthState<TUser>, "isLoading">) => void;
|
|
59
|
+
/**
|
|
60
|
+
* The session flags as a plain object, for a product whose store needs more than them.
|
|
61
|
+
*
|
|
62
|
+
* `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
|
|
63
|
+
* cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
|
|
64
|
+
* met it had a superset and neither could use it. One holds a remember-me choice, a profile row
|
|
65
|
+
* and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
|
|
66
|
+
* one is the reason this takes a `derive` function rather than just letting the caller spread
|
|
67
|
+
* extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
|
|
68
|
+
* `clear`, which are exactly the two writes the product does not own.
|
|
69
|
+
*
|
|
70
|
+
* The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
|
|
74
|
+
* ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({ isAnonymous: !!user?.isAnonymous })),
|
|
75
|
+
* }));
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export declare function authSlice<TUser, TExtra extends object>(set: AuthSet<TUser, TExtra>, derive: (user: TUser | null) => TExtra): AuthState<TUser> & TExtra;
|
|
79
|
+
export {};
|
|
49
80
|
//# sourceMappingURL=auth-store.d.ts.map
|
package/dist/auth-store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,SAAS,CAAC,KAAK;IAC9B,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI,CAAC;IACtC,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,KAAK,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"auth-store.d.ts","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,SAAS,CAAC,KAAK;IAC9B,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI,CAAC;IACtC,UAAU,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,KAAK,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAElF;AAKD;;;;;;;;GAQG;AACH,KAAK,OAAO,CAAC,KAAK,EAAE,MAAM,IAAI,CAC5B,OAAO,EAAE,CAAC,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC,KAChF,IAAI,CAAC;AAEV;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,SAAS,MAAM,EACpD,GAAG,EAAE,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,EAC3B,MAAM,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,KAAK,MAAM,GACrC,SAAS,CAAC,KAAK,CAAC,GAAG,MAAM,CAW3B"}
|
package/dist/auth-store.js
CHANGED
|
@@ -15,13 +15,36 @@ import { create } from "zustand";
|
|
|
15
15
|
* Only one of the three donors knew this. It is invariant 7.
|
|
16
16
|
*/
|
|
17
17
|
export function createAuthStore() {
|
|
18
|
-
return create((set) => ({
|
|
18
|
+
return create((set) => authSlice(set, () => ({})));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The session flags as a plain object, for a product whose store needs more than them.
|
|
22
|
+
*
|
|
23
|
+
* `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
|
|
24
|
+
* cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
|
|
25
|
+
* met it had a superset and neither could use it. One holds a remember-me choice, a profile row
|
|
26
|
+
* and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
|
|
27
|
+
* one is the reason this takes a `derive` function rather than just letting the caller spread
|
|
28
|
+
* extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
|
|
29
|
+
* `clear`, which are exactly the two writes the product does not own.
|
|
30
|
+
*
|
|
31
|
+
* The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
|
|
35
|
+
* ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({ isAnonymous: !!user?.isAnonymous })),
|
|
36
|
+
* }));
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function authSlice(set, derive) {
|
|
40
|
+
return {
|
|
41
|
+
...derive(null),
|
|
19
42
|
user: null,
|
|
20
43
|
isAuthenticated: false,
|
|
21
44
|
isLoading: typeof window !== "undefined",
|
|
22
|
-
setUser: (user) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
|
|
45
|
+
setUser: (user) => set({ ...derive(user), user, isAuthenticated: Boolean(user), isLoading: false }),
|
|
23
46
|
setLoading: (isLoading) => set({ isLoading }),
|
|
24
|
-
clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
|
|
25
|
-
}
|
|
47
|
+
clear: () => set({ ...derive(null), user: null, isAuthenticated: false, isLoading: false }),
|
|
48
|
+
};
|
|
26
49
|
}
|
|
27
50
|
//# sourceMappingURL=auth-store.js.map
|
package/dist/auth-store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqC,MAAM,SAAS,CAAC;AAkCpE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,MAAM,CAAmB,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqC,MAAM,SAAS,CAAC;AAkCpE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,MAAM,CAAmB,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,CAAoB,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAC1F,CAAC;AAkBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,SAAS,CACvB,GAA2B,EAC3B,MAAsC;IAEtC,OAAO;QACL,GAAG,MAAM,CAAC,IAAI,CAAC;QACf,IAAI,EAAE,IAAI;QACV,eAAe,EAAE,KAAK;QACtB,SAAS,EAAE,OAAO,MAAM,KAAK,WAAW;QACxC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAChB,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;QAClF,UAAU,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC;QAC7C,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;KAC5F,CAAC;AACJ,CAAC"}
|
package/dist/i18n.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACT,EAAE,eAAe;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACT,EAAE,eAAe;;;;;;;;;;;;;;;;EAuBjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAmB/E"}
|
package/dist/i18n.js
CHANGED
|
@@ -26,11 +26,13 @@ export function i18nInitOptions({ fallbackLng, supportedLngs, storageKey, queryK
|
|
|
26
26
|
lookupLocalStorage: storageKey,
|
|
27
27
|
},
|
|
28
28
|
react: { useSuspense: false },
|
|
29
|
-
// `nonExplicitSupportedLngs` is omitted deliberately
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
29
|
+
// `nonExplicitSupportedLngs` is omitted deliberately. It breaks both shapes a catalog comes in.
|
|
30
|
+
// Region-coded: it validates the BASE subtag against `supportedLngs`, "pt" is not in a list
|
|
31
|
+
// that says "pt-BR", and the whole app silently renders English — both donors say so in the
|
|
32
|
+
// same words. Base codes: an "en-US" browser keeps "en-US" as `i18n.language`. Beside
|
|
33
|
+
// `currentOnly` no catalog matches it, so the reader gets the fallback language; beside
|
|
34
|
+
// `languageOnly` the copy is right and every map an app keys by the tag misses — the third
|
|
35
|
+
// migration shipped English copy as `lang="pt-BR"`. Without the flag "en-US" settles on "en".
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
/**
|
package/dist/i18n.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA4BH,MAAM,UAAU,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACQ;IAChB,OAAO;QACL,WAAW;QACX,aAAa,EAAE,CAAC,GAAG,aAAa,CAAC;QACjC,IAAI,EAAE,aAAsB;QAC5B,aAAa,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE,yBAAyB;QAChE,SAAS,EAAE;YACT,KAAK,EAAE,QAAQ;gBACb,CAAC,CAAC,CAAC,aAAa,EAAE,cAAc,EAAE,WAAW,CAAC;gBAC9C,CAAC,CAAC,CAAC,cAAc,EAAE,WAAW,CAAC;YACjC,MAAM,EAAE,CAAC,cAAc,CAAC;YACxB,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACpD,kBAAkB,EAAE,UAAU;SAC/B;QACD,KAAK,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE;QAC7B,
|
|
1
|
+
{"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA4BH,MAAM,UAAU,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACQ;IAChB,OAAO;QACL,WAAW;QACX,aAAa,EAAE,CAAC,GAAG,aAAa,CAAC;QACjC,IAAI,EAAE,aAAsB;QAC5B,aAAa,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE,yBAAyB;QAChE,SAAS,EAAE;YACT,KAAK,EAAE,QAAQ;gBACb,CAAC,CAAC,CAAC,aAAa,EAAE,cAAc,EAAE,WAAW,CAAC;gBAC9C,CAAC,CAAC,CAAC,cAAc,EAAE,WAAW,CAAC;YACjC,MAAM,EAAE,CAAC,cAAc,CAAC;YACxB,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACpD,kBAAkB,EAAE,UAAU;SAC/B;QACD,KAAK,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE;QAC7B,gGAAgG;QAChG,4FAA4F;QAC5F,4FAA4F;QAC5F,sFAAsF;QACtF,wFAAwF;QACxF,2FAA2F;QAC3F,8FAA8F;KAC/F,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAAI,SAAY,EAAE,IAA4B;IAC1E,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,OAAO,GAAG,IAAI,MAAM,CACxB,UAAU,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EACvF,GAAG,CACJ,CAAC;IAEF,MAAM,IAAI,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAE9F,MAAM,IAAI,GAAG,CAAC,KAAc,EAAW,EAAE;QACvC,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;QAClD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;YACpC,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACjF,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;IAEF,OAAO,IAAI,CAAC,SAAS,CAAM,CAAC;AAC9B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, chunk-reload recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { create } from "zustand";
|
|
3
|
+
import { authSlice, createAuthStore, type AuthState } from "./auth-store.ts";
|
|
4
|
+
|
|
5
|
+
interface User {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
isAnonymous?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("createAuthStore", () => {
|
|
12
|
+
it("ends the loading state when it learns who the user is", () => {
|
|
13
|
+
const store = createAuthStore<User>();
|
|
14
|
+
|
|
15
|
+
store.getState().setUser({ id: "1", email: "a@b.c" });
|
|
16
|
+
|
|
17
|
+
expect(store.getState().isAuthenticated).toBe(true);
|
|
18
|
+
expect(store.getState().isLoading).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The shape an adopter actually has: an anonymous flag read off the user, plus state and an
|
|
24
|
+
* action of its own. Both halves have to survive `setUser` and `clear`, which is the whole
|
|
25
|
+
* reason `authSlice` exists — a store built on `createAuthStore` could hold neither.
|
|
26
|
+
*/
|
|
27
|
+
type Extra = { isAnonymous: boolean };
|
|
28
|
+
|
|
29
|
+
const useAuthStore = create<AuthState<User> & Extra & { restored: boolean; restore: () => void }>(
|
|
30
|
+
(set) => ({
|
|
31
|
+
...authSlice<User, Extra>(set, (user) => ({ isAnonymous: Boolean(user?.isAnonymous) })),
|
|
32
|
+
restored: false,
|
|
33
|
+
restore: () => set({ restored: true }),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
describe("authSlice", () => {
|
|
38
|
+
it("keeps a derived field in step with the user it is read from", () => {
|
|
39
|
+
useAuthStore.getState().setUser({ id: "1", email: "a@b.c", isAnonymous: true });
|
|
40
|
+
expect(useAuthStore.getState().isAnonymous).toBe(true);
|
|
41
|
+
|
|
42
|
+
useAuthStore.getState().setUser({ id: "2", email: "d@e.f" });
|
|
43
|
+
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
|
44
|
+
|
|
45
|
+
useAuthStore.getState().setUser({ id: "3", email: "g@h.i", isAnonymous: true });
|
|
46
|
+
// Signing out must not leave the flag behind: the next visitor is not anonymous, they are
|
|
47
|
+
// nobody, and a stale `true` here reads as "signed in anonymously" to every guard.
|
|
48
|
+
useAuthStore.getState().clear();
|
|
49
|
+
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
|
50
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves the product's own state alone", () => {
|
|
54
|
+
useAuthStore.getState().restore();
|
|
55
|
+
useAuthStore.getState().setUser({ id: "1", email: "a@b.c" });
|
|
56
|
+
useAuthStore.getState().clear();
|
|
57
|
+
|
|
58
|
+
expect(useAuthStore.getState().restored).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/auth-store.ts
CHANGED
|
@@ -48,12 +48,56 @@ export interface AuthState<TUser> {
|
|
|
48
48
|
* Only one of the three donors knew this. It is invariant 7.
|
|
49
49
|
*/
|
|
50
50
|
export function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>> {
|
|
51
|
-
return create<AuthState<TUser>>((set) => ({
|
|
51
|
+
return create<AuthState<TUser>>((set) => authSlice<TUser, EmptyExtra>(set, () => ({})));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What `authSlice` derives when a product adds nothing of its own. */
|
|
55
|
+
type EmptyExtra = Record<never, never>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* zustand's `set`, narrowed to the two writes this slice makes.
|
|
59
|
+
*
|
|
60
|
+
* Not `Partial<AuthState<TUser> & TExtra>`, which is what it means and what a caller passes:
|
|
61
|
+
* inside a generic function TypeScript cannot check `{ isLoading }` against a partial of an
|
|
62
|
+
* unresolved type parameter, because `TExtra` might yet declare an `isLoading` of its own. The
|
|
63
|
+
* union says the same thing in terms it can check at both ends. Any real store's setter
|
|
64
|
+
* satisfies it, where `TExtra` is a concrete type.
|
|
65
|
+
*/
|
|
66
|
+
type AuthSet<TUser, TExtra> = (
|
|
67
|
+
partial: (TExtra & Partial<AuthState<TUser>>) | Pick<AuthState<TUser>, "isLoading">,
|
|
68
|
+
) => void;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The session flags as a plain object, for a product whose store needs more than them.
|
|
72
|
+
*
|
|
73
|
+
* `createAuthStore` owns its `create()` call, which means it owns the whole store: a product
|
|
74
|
+
* cannot wrap it in `persist`, cannot add an action, and cannot add a field. Both adopters that
|
|
75
|
+
* met it had a superset and neither could use it. One holds a remember-me choice, a profile row
|
|
76
|
+
* and the sign-in methods themselves, under `persist`. The other adds `isAnonymous` — and that
|
|
77
|
+
* one is the reason this takes a `derive` function rather than just letting the caller spread
|
|
78
|
+
* extra keys in: `isAnonymous` is read off the user, so it has to be rewritten by `setUser` and
|
|
79
|
+
* `clear`, which are exactly the two writes the product does not own.
|
|
80
|
+
*
|
|
81
|
+
* The caller keeps `create`, so middleware, extra actions and the store's own name stay theirs:
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* const useAuthStore = create<AuthState<User> & { isAnonymous: boolean }>((set) => ({
|
|
85
|
+
* ...authSlice<User, { isAnonymous: boolean }>(set, (user) => ({ isAnonymous: !!user?.isAnonymous })),
|
|
86
|
+
* }));
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function authSlice<TUser, TExtra extends object>(
|
|
90
|
+
set: AuthSet<TUser, TExtra>,
|
|
91
|
+
derive: (user: TUser | null) => TExtra,
|
|
92
|
+
): AuthState<TUser> & TExtra {
|
|
93
|
+
return {
|
|
94
|
+
...derive(null),
|
|
52
95
|
user: null,
|
|
53
96
|
isAuthenticated: false,
|
|
54
97
|
isLoading: typeof window !== "undefined",
|
|
55
|
-
setUser: (user) =>
|
|
98
|
+
setUser: (user) =>
|
|
99
|
+
set({ ...derive(user), user, isAuthenticated: Boolean(user), isLoading: false }),
|
|
56
100
|
setLoading: (isLoading) => set({ isLoading }),
|
|
57
|
-
clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
|
|
58
|
-
}
|
|
101
|
+
clear: () => set({ ...derive(null), user: null, isAuthenticated: false, isLoading: false }),
|
|
102
|
+
};
|
|
59
103
|
}
|
package/src/i18n.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { createInstance } from "i18next";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { applyBrandVars, i18nInitOptions } from "./i18n.ts";
|
|
3
|
+
import { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
|
|
3
4
|
|
|
4
5
|
describe("applyBrandVars", () => {
|
|
5
6
|
it("fills placeholders anywhere in the tree and leaves the input alone", () => {
|
|
@@ -27,17 +28,34 @@ describe("applyBrandVars", () => {
|
|
|
27
28
|
});
|
|
28
29
|
});
|
|
29
30
|
|
|
31
|
+
/** For a browser saying `lng`: the app's `i18n.language`, and the catalog that answers. */
|
|
32
|
+
async function settle(
|
|
33
|
+
options: Omit<I18nInitOptions, "storageKey">,
|
|
34
|
+
lng: string,
|
|
35
|
+
): Promise<[language: string, catalog: string]> {
|
|
36
|
+
const i18n = createInstance();
|
|
37
|
+
const resources = Object.fromEntries(
|
|
38
|
+
options.supportedLngs.map((tag) => [tag, { translation: { tag } }]),
|
|
39
|
+
);
|
|
40
|
+
await i18n.init({ ...i18nInitOptions({ ...options, storageKey: "app.locale" }), resources, lng });
|
|
41
|
+
return [i18n.language, i18n.t("tag")];
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
describe("i18nInitOptions", () => {
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
expect(
|
|
45
|
+
// `nonExplicitSupportedLngs` breaks both catalog shapes, and this pins both. Region-coded, the
|
|
46
|
+
// app renders English for a pt-BR reader — the bug both donors' comments name. Base codes, an
|
|
47
|
+
// "en-US" browser keeps "en-US" as `i18n.language`, which is what an app keys `<html lang>` and
|
|
48
|
+
// `og:locale` by: the third migration's site missed its lookup and shipped English copy as
|
|
49
|
+
// `lang="pt-BR"`.
|
|
50
|
+
it("settles a browser's regional tag on a language the catalogs have", async () => {
|
|
51
|
+
const baseCodes = { fallbackLng: "pt", supportedLngs: ["pt", "es", "en"] };
|
|
52
|
+
expect(await settle(baseCodes, "en-US")).toEqual(["en", "en"]);
|
|
53
|
+
expect(await settle(baseCodes, "es-419")).toEqual(["es", "es"]);
|
|
54
|
+
expect(await settle(baseCodes, "fr-FR")).toEqual(["pt", "pt"]);
|
|
55
|
+
|
|
56
|
+
const regionCoded = { fallbackLng: "en", supportedLngs: ["en", "pt-BR"] };
|
|
57
|
+
expect(await settle(regionCoded, "pt-BR")).toEqual(["pt-BR", "pt-BR"]);
|
|
58
|
+
expect(await settle(regionCoded, "en-GB")).toEqual(["en", "en"]);
|
|
41
59
|
});
|
|
42
60
|
|
|
43
61
|
// Only one donor had the query parameter, and it is the only detection source somebody chose
|
package/src/i18n.ts
CHANGED
|
@@ -58,11 +58,13 @@ export function i18nInitOptions({
|
|
|
58
58
|
lookupLocalStorage: storageKey,
|
|
59
59
|
},
|
|
60
60
|
react: { useSuspense: false },
|
|
61
|
-
// `nonExplicitSupportedLngs` is omitted deliberately
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
61
|
+
// `nonExplicitSupportedLngs` is omitted deliberately. It breaks both shapes a catalog comes in.
|
|
62
|
+
// Region-coded: it validates the BASE subtag against `supportedLngs`, "pt" is not in a list
|
|
63
|
+
// that says "pt-BR", and the whole app silently renders English — both donors say so in the
|
|
64
|
+
// same words. Base codes: an "en-US" browser keeps "en-US" as `i18n.language`. Beside
|
|
65
|
+
// `currentOnly` no catalog matches it, so the reader gets the fallback language; beside
|
|
66
|
+
// `languageOnly` the copy is right and every map an app keys by the tag misses — the third
|
|
67
|
+
// migration shipped English copy as `lang="pt-BR"`. Without the flag "en-US" settles on "en".
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
|