@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 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 | What you must have |
173
- | ------------------------- | ------------------- | ------------------ |
174
- | `@gusnips/react` | the client and rest | react, react-dom |
175
- | `@gusnips/react/store` | `createAuthStore` | zustand |
176
- | `@gusnips/react/guards` | the route guards | react-router-dom |
177
- | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
178
- | `@gusnips/react/contract` | two prerender names | nothing |
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
@@ -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
@@ -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,CASlF"}
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"}
@@ -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
@@ -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;QACxC,IAAI,EAAE,IAAI;QACV,eAAe,EAAE,KAAK;QACtB,SAAS,EAAE,OAAO,MAAM,KAAK,WAAW;QACxC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,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,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;KAC3E,CAAC,CAAC,CAAC;AACN,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"}
@@ -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;;;;;;;;;;;;;;;;EAqBjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAmB/E"}
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, and both donors say so in the same
30
- // words. Enabling it validates the BASE subtag against `supportedLngs`, which rejects a
31
- // region-coded "pt-BR" (its base "pt" is not in the list) and silently falls the whole app
32
- // back to English. Detected sub-locales like "en-US" already resolve to their base through
33
- // i18next's ordinary fallback hierarchy, so the flag buys nothing and breaks pt-BR.
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,yFAAyF;QACzF,wFAAwF;QACxF,2FAA2F;QAC3F,2FAA2F;QAC3F,oFAAoF;KACrF,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"}
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.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) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
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
- // The comment in both donors names the bug: `nonExplicitSupportedLngs` validates the BASE
32
- // subtag, which rejects "pt-BR" (its base "pt" is not listed) and silently falls the whole
33
- // app back to English. Its absence is load-bearing.
34
- it("does not set nonExplicitSupportedLngs", () => {
35
- const options = i18nInitOptions({
36
- fallbackLng: "en",
37
- supportedLngs: ["en", "pt-BR"],
38
- storageKey: "app.locale",
39
- });
40
- expect(options).not.toHaveProperty("nonExplicitSupportedLngs");
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, and both donors say so in the same
62
- // words. Enabling it validates the BASE subtag against `supportedLngs`, which rejects a
63
- // region-coded "pt-BR" (its base "pt" is not in the list) and silently falls the whole app
64
- // back to English. Detected sub-locales like "en-US" already resolve to their base through
65
- // i18next's ordinary fallback hierarchy, so the flag buys nothing and breaks pt-BR.
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