@gusnips/react 0.9.24 → 0.9.26

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 (116) hide show
  1. package/README.md +173 -36
  2. package/dist/api-client.d.ts +51 -51
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +3 -3
  5. package/dist/api-error.d.ts +11 -11
  6. package/dist/api-error.js +11 -11
  7. package/dist/auth-email.d.ts +40 -0
  8. package/dist/auth-email.d.ts.map +1 -0
  9. package/dist/auth-email.js +106 -0
  10. package/dist/auth-email.js.map +1 -0
  11. package/dist/auth-store.d.ts +23 -23
  12. package/dist/auth-store.js +13 -13
  13. package/dist/commands.d.ts +80 -0
  14. package/dist/commands.d.ts.map +1 -0
  15. package/dist/commands.js +192 -0
  16. package/dist/commands.js.map +1 -0
  17. package/dist/copy.d.ts +3 -3
  18. package/dist/copy.js +3 -3
  19. package/dist/deploy-recovery.d.ts +40 -40
  20. package/dist/deploy-recovery.js +40 -40
  21. package/dist/describe-error.d.ts +68 -68
  22. package/dist/describe-error.d.ts.map +1 -1
  23. package/dist/describe-error.js +5 -5
  24. package/dist/error-boundary.d.ts +12 -12
  25. package/dist/guards.d.ts +15 -15
  26. package/dist/guards.js +3 -3
  27. package/dist/hydrate.d.ts +15 -16
  28. package/dist/hydrate.d.ts.map +1 -1
  29. package/dist/hydrate.js +9 -9
  30. package/dist/hydrate.js.map +1 -1
  31. package/dist/i18n.d.ts +15 -16
  32. package/dist/i18n.d.ts.map +1 -1
  33. package/dist/i18n.js +5 -6
  34. package/dist/i18n.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal-path.d.ts +8 -7
  40. package/dist/internal-path.d.ts.map +1 -1
  41. package/dist/internal-path.js +8 -7
  42. package/dist/internal-path.js.map +1 -1
  43. package/dist/prerender-contract.d.ts +7 -7
  44. package/dist/prerender-contract.js +7 -7
  45. package/dist/query.d.ts +16 -17
  46. package/dist/query.d.ts.map +1 -1
  47. package/dist/query.js +2 -2
  48. package/dist/query.js.map +1 -1
  49. package/dist/sse.d.ts +19 -20
  50. package/dist/sse.d.ts.map +1 -1
  51. package/dist/sse.js +18 -19
  52. package/dist/sse.js.map +1 -1
  53. package/dist/states.d.ts +12 -12
  54. package/dist/states.d.ts.map +1 -1
  55. package/dist/supabase-session.d.ts +47 -48
  56. package/dist/supabase-session.d.ts.map +1 -1
  57. package/dist/supabase-session.js +35 -35
  58. package/dist/supabase-session.js.map +1 -1
  59. package/dist/theme.d.ts +4 -4
  60. package/dist/theme.js +4 -4
  61. package/dist/ui/class-name.d.ts +3 -4
  62. package/dist/ui/class-name.d.ts.map +1 -1
  63. package/dist/ui/class-name.js +3 -4
  64. package/dist/ui/class-name.js.map +1 -1
  65. package/dist/ui/combobox.d.ts +11 -11
  66. package/dist/ui/combobox.d.ts.map +1 -1
  67. package/dist/ui/dialog.d.ts +5 -5
  68. package/dist/ui/dialog.js +2 -2
  69. package/dist/ui/drawer.d.ts +10 -10
  70. package/dist/ui/drawer.d.ts.map +1 -1
  71. package/dist/ui/drawer.js +2 -2
  72. package/dist/ui/input.d.ts +2 -2
  73. package/dist/ui/input.js +2 -2
  74. package/dist/ui/menu.d.ts +9 -9
  75. package/dist/ui/menu.d.ts.map +1 -1
  76. package/dist/ui/menu.js +4 -4
  77. package/dist/ui/menu.js.map +1 -1
  78. package/dist/ui/select.d.ts +12 -12
  79. package/dist/ui/select.d.ts.map +1 -1
  80. package/dist/ui/select.js +3 -3
  81. package/dist/ui/tabs.d.ts +14 -15
  82. package/dist/ui/tabs.d.ts.map +1 -1
  83. package/dist/ui/tabs.js +13 -14
  84. package/dist/ui/tabs.js.map +1 -1
  85. package/package.json +5 -1
  86. package/src/api-client.ts +51 -51
  87. package/src/api-error.ts +11 -11
  88. package/src/auth-email.test.ts +145 -0
  89. package/src/auth-email.ts +112 -0
  90. package/src/auth-store.ts +23 -23
  91. package/src/commands-hook.test.ts +444 -0
  92. package/src/commands.test.ts +245 -0
  93. package/src/commands.ts +268 -0
  94. package/src/copy.ts +3 -3
  95. package/src/deploy-recovery.ts +40 -40
  96. package/src/describe-error.ts +68 -68
  97. package/src/error-boundary.tsx +12 -12
  98. package/src/guards.tsx +15 -15
  99. package/src/hydrate.ts +15 -16
  100. package/src/i18n.ts +15 -16
  101. package/src/index.ts +2 -0
  102. package/src/internal-path.ts +8 -7
  103. package/src/prerender-contract.ts +7 -7
  104. package/src/query.ts +16 -17
  105. package/src/sse.ts +19 -20
  106. package/src/states.ts +12 -12
  107. package/src/supabase-session.ts +46 -47
  108. package/src/theme.ts +4 -4
  109. package/src/ui/class-name.ts +3 -4
  110. package/src/ui/combobox.tsx +11 -11
  111. package/src/ui/dialog.tsx +5 -5
  112. package/src/ui/drawer.tsx +10 -10
  113. package/src/ui/input.tsx +2 -2
  114. package/src/ui/menu.tsx +8 -8
  115. package/src/ui/select.tsx +12 -12
  116. package/src/ui/tabs.tsx +14 -15
package/dist/ui/tabs.d.ts CHANGED
@@ -9,19 +9,18 @@ type TabListLabel = {
9
9
  "aria-labelledby": string;
10
10
  };
11
11
  export type TabListProps = Omit<Primitive.List.Props, "aria-labelledby"> & TabListLabel & {
12
- /** Classes for the scroll wrapper — the element that owns the sideways scroll. */
12
+ /** Classes for the wrapper that owns the sideways scroll. */
13
13
  wrapperClassName?: string;
14
14
  };
15
15
  /**
16
16
  * The rail, and the reason this file exists.
17
17
  *
18
- * It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel
19
- * below the fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto`
20
- * makes the element a scroll container, a scroll container clips everything its children paint
21
- * outside the padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from
22
- * `visible` to `auto` as well. A self-scrolling rail therefore crops the focus ring on all
23
- * FOUR sides of whichever tab the keyboard is on — top and bottom always, the left of the
24
- * first tab and the right of the last.
18
+ * It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel below the
19
+ * fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto` makes the
20
+ * element a scroll container, a scroll container clips everything its children paint outside the
21
+ * padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from `visible` to `auto`
22
+ * as well. A self-scrolling rail therefore crops the focus ring on all FOUR sides of whichever tab
23
+ * the keyboard is on: top and bottom always, the left of the first tab and the right of the last.
25
24
  *
26
25
  * So the scroll goes on a wrapper, and the wrapper's padding is the room the ring needs. The
27
26
  * number is derived, not inherited: `@gusnips/tokens` draws `outline: 2px solid` at
@@ -35,17 +34,17 @@ export type TabListProps = Omit<Primitive.List.Props, "aria-labelledby"> & TabLi
35
34
  export declare function TabList({ label, className, wrapperClassName, children, ...rest }: TabListProps): import("react").JSX.Element;
36
35
  export declare function Tab({ className, ...rest }: Primitive.Tab.Props): import("react").JSX.Element;
37
36
  /**
38
- * The travelling marker under the active tab — a SEPARATE part, because the two donors
39
- * disagreed about whether to have one at all and both were right about their own app. Render
40
- * it or do not; the rail works either way.
37
+ * The travelling marker under the active tab. A SEPARATE part, because the two donors disagreed
38
+ * about whether to have one at all and both were right about their own app. Render it or do not;
39
+ * the rail works either way.
41
40
  *
42
41
  * It must be a child of `TabList`: Base UI measures the active tab against the enclosing
43
42
  * `[role="tablist"]` and writes `--active-tab-left` / `--active-tab-width` onto this element.
44
43
  *
45
- * `renderBeforeHydration` is on by default because this package's apps are prerendered — with
46
- * it off, every prerendered page shows the rail with no marker until React arrives. It writes
47
- * one inline script to do that; under a strict CSP without Base UI's `CspProvider` the script
48
- * is blocked and the behaviour falls back to exactly what `false` would have done.
44
+ * `renderBeforeHydration` is on by default because this package's apps are prerendered. With it
45
+ * off, every prerendered page shows the rail with no marker until React arrives. It writes one
46
+ * inline script to do that; under a strict CSP without Base UI's `CspProvider` the script is
47
+ * blocked and the behaviour falls back to exactly what `false` would have done.
49
48
  */
50
49
  export declare function TabIndicator({ className, ...rest }: Primitive.Indicator.Props): import("react").JSX.Element;
51
50
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAOxD,eAAO,MAAM,IAAI,gJAAiB,CAAC;AACnC,eAAO,MAAM,QAAQ,iJAAkB,CAAC;AAExC,KAAK,YAAY,GACb;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,SAAS,CAAA;CAAE,GAChD;IAAE,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,iBAAiB,CAAC,GACtE,YAAY,GAAG;IACb,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEJ;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,+BAa9F;AAYD,wBAAgB,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,GAAG,CAAC,KAAK,+BAE9D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,+BAY7E"}
1
+ {"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAOxD,eAAO,MAAM,IAAI,gJAAiB,CAAC;AACnC,eAAO,MAAM,QAAQ,iJAAkB,CAAC;AAExC,KAAK,YAAY,GACb;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,SAAS,CAAA;CAAE,GAChD;IAAE,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,iBAAiB,CAAC,GACtE,YAAY,GAAG;IACb,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEJ;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,+BAa9F;AAYD,wBAAgB,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,GAAG,CAAC,KAAK,+BAE9D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,+BAY7E"}
package/dist/ui/tabs.js CHANGED
@@ -10,13 +10,12 @@ export const TabPanel = Primitive.Panel;
10
10
  /**
11
11
  * The rail, and the reason this file exists.
12
12
  *
13
- * It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel
14
- * below the fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto`
15
- * makes the element a scroll container, a scroll container clips everything its children paint
16
- * outside the padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from
17
- * `visible` to `auto` as well. A self-scrolling rail therefore crops the focus ring on all
18
- * FOUR sides of whichever tab the keyboard is on — top and bottom always, the left of the
19
- * first tab and the right of the last.
13
+ * It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel below the
14
+ * fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto` makes the
15
+ * element a scroll container, a scroll container clips everything its children paint outside the
16
+ * padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from `visible` to `auto`
17
+ * as well. A self-scrolling rail therefore crops the focus ring on all FOUR sides of whichever tab
18
+ * the keyboard is on: top and bottom always, the left of the first tab and the right of the last.
20
19
  *
21
20
  * So the scroll goes on a wrapper, and the wrapper's padding is the room the ring needs. The
22
21
  * number is derived, not inherited: `@gusnips/tokens` draws `outline: 2px solid` at
@@ -44,17 +43,17 @@ export function Tab({ className, ...rest }) {
44
43
  return _jsx(Primitive.Tab, { ...rest, className: mergeClassName(tabClasses, className) });
45
44
  }
46
45
  /**
47
- * The travelling marker under the active tab — a SEPARATE part, because the two donors
48
- * disagreed about whether to have one at all and both were right about their own app. Render
49
- * it or do not; the rail works either way.
46
+ * The travelling marker under the active tab. A SEPARATE part, because the two donors disagreed
47
+ * about whether to have one at all and both were right about their own app. Render it or do not;
48
+ * the rail works either way.
50
49
  *
51
50
  * It must be a child of `TabList`: Base UI measures the active tab against the enclosing
52
51
  * `[role="tablist"]` and writes `--active-tab-left` / `--active-tab-width` onto this element.
53
52
  *
54
- * `renderBeforeHydration` is on by default because this package's apps are prerendered — with
55
- * it off, every prerendered page shows the rail with no marker until React arrives. It writes
56
- * one inline script to do that; under a strict CSP without Base UI's `CspProvider` the script
57
- * is blocked and the behaviour falls back to exactly what `false` would have done.
53
+ * `renderBeforeHydration` is on by default because this package's apps are prerendered. With it
54
+ * off, every prerendered page shows the rail with no marker until React arrives. It writes one
55
+ * inline script to do that; under a strict CSP without Base UI's `CspProvider` the script is
56
+ * blocked and the behaviour falls back to exactly what `false` would have done.
58
57
  */
59
58
  export function TabIndicator({ className, ...rest }) {
60
59
  return (_jsx(Primitive.Indicator, { "aria-hidden": "true", renderBeforeHydration: true, ...rest, className: mergeClassName("absolute bottom-0 left-0 h-0.5 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] bg-foreground transition-[translate,width]", className) }));
@@ -1 +1 @@
1
- {"version":3,"file":"tabs.js","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,6FAA6F;AAC7F,uFAAuF;AACvF,oCAAoC;AACpC,MAAM,CAAC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC;AACnC,MAAM,CAAC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC;AAYxC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAgB;IAC7F,OAAO,CACL,cAAK,SAAS,EAAE,EAAE,CAAC,sCAAsC,EAAE,gBAAgB,CAAC,YAC1E,KAAC,SAAS,CAAC,IAAI,kBACD,KAAK,KACb,IAAI;YACR,sEAAsE;YACtE,SAAS,EAAE,cAAc,CAAC,qBAAqB,EAAE,SAAS,CAAC,YAE1D,QAAQ,GACM,GACb,CACP,CAAC;AACJ,CAAC;AAED,6FAA6F;AAC7F,uFAAuF;AACvF,2DAA2D;AAC3D,EAAE;AACF,4FAA4F;AAC5F,6FAA6F;AAC7F,uBAAuB;AACvB,MAAM,UAAU,GACd,yJAAyJ,CAAC;AAE5J,MAAM,UAAU,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAuB;IAC7D,OAAO,KAAC,SAAS,CAAC,GAAG,OAAK,IAAI,EAAE,SAAS,EAAE,cAAc,CAAC,UAAU,EAAE,SAAS,CAAC,GAAI,CAAC;AACvF,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAA6B;IAC5E,OAAO,CACL,KAAC,SAAS,CAAC,SAAS,mBACN,MAAM,EAClB,qBAAqB,WACjB,IAAI,EACR,SAAS,EAAE,cAAc,CACvB,4IAA4I,EAC5I,SAAS,CACV,GACD,CACH,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"tabs.js","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,6FAA6F;AAC7F,uFAAuF;AACvF,oCAAoC;AACpC,MAAM,CAAC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC;AACnC,MAAM,CAAC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC;AAYxC;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAgB;IAC7F,OAAO,CACL,cAAK,SAAS,EAAE,EAAE,CAAC,sCAAsC,EAAE,gBAAgB,CAAC,YAC1E,KAAC,SAAS,CAAC,IAAI,kBACD,KAAK,KACb,IAAI;YACR,sEAAsE;YACtE,SAAS,EAAE,cAAc,CAAC,qBAAqB,EAAE,SAAS,CAAC,YAE1D,QAAQ,GACM,GACb,CACP,CAAC;AACJ,CAAC;AAED,6FAA6F;AAC7F,uFAAuF;AACvF,2DAA2D;AAC3D,EAAE;AACF,4FAA4F;AAC5F,6FAA6F;AAC7F,uBAAuB;AACvB,MAAM,UAAU,GACd,yJAAyJ,CAAC;AAE5J,MAAM,UAAU,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAuB;IAC7D,OAAO,KAAC,SAAS,CAAC,GAAG,OAAK,IAAI,EAAE,SAAS,EAAE,cAAc,CAAC,UAAU,EAAE,SAAS,CAAC,GAAI,CAAC;AACvF,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAA6B;IAC5E,OAAO,CACL,KAAC,SAAS,CAAC,SAAS,mBACN,MAAM,EAClB,qBAAqB,WACjB,IAAI,EACR,SAAS,EAAE,cAAc,CACvB,4IAA4I,EAC5I,SAAS,CACV,GACD,CACH,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.24",
3
+ "version": "0.9.26",
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, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,10 @@
41
41
  "./theme": {
42
42
  "types": "./dist/theme.d.ts",
43
43
  "default": "./dist/theme.js"
44
+ },
45
+ "./commands": {
46
+ "types": "./dist/commands.d.ts",
47
+ "default": "./dist/commands.js"
44
48
  }
45
49
  },
46
50
  "types": "./dist/index.d.ts",
package/src/api-client.ts CHANGED
@@ -5,10 +5,10 @@ import { ApiError } from "./api-error.ts";
5
5
  /**
6
6
  * The app's one door to the API. Nothing else should call `fetch`.
7
7
  *
8
- * Every route answers the same envelope, so unwrapping and error-shaping belong here once
9
- * rather than in every hook. Three donors built this independently and all three arrived at
10
- * single-flight token refresh — see {@link SessionAdapter.refresh} for the reason, which is
11
- * the sharpest thing in this file.
8
+ * Every route answers the same envelope, so unwrapping and error-shaping belong here once rather
9
+ * than in every hook. Three donors built this independently and all three arrived at single-flight
10
+ * token refresh. See {@link SessionAdapter.refresh} for the reason, which is the sharpest thing in
11
+ * this file.
12
12
  *
13
13
  * Four things deliberately did NOT come across from the donors, because they belong to a
14
14
  * product and not to a client:
@@ -16,7 +16,7 @@ import { ApiError } from "./api-error.ts";
16
16
  * - **Toasts.** Whether a failure is spoken aloud is a design decision that differs per surface.
17
17
  * Throw, and let the caller decide.
18
18
  * - **In-flight GET deduplication.** react-query already dedups by key, so the client's copy is
19
- * redundant — and one donor's version returned a `clone()` of a body already being consumed,
19
+ * redundant. One donor's version also returned a `clone()` of a body already being consumed,
20
20
  * which is a race with no symptom until it has one.
21
21
  * - **Service-status tracking and impersonation.** One donor's, and firmly its own.
22
22
  * - **Analytics correlation headers.** Injected through {@link ApiClientOptions.headers}.
@@ -26,8 +26,8 @@ import { ApiError } from "./api-error.ts";
26
26
  export interface RefreshResult {
27
27
  token: string | null;
28
28
  /**
29
- * False when the refresh never got an ANSWER out of the auth server — a dropped connection,
30
- * a 5xx, a captive portal.
29
+ * False when the refresh never got an ANSWER out of the auth server because of a dropped
30
+ * connection, a 5xx or a captive portal.
31
31
  *
32
32
  * This flag is invariant 3, and only one of three donors had it. Losing a packet says nothing
33
33
  * about whether a session is still good, so a client that collapses "auth said no" into
@@ -39,8 +39,8 @@ export interface RefreshResult {
39
39
  }
40
40
 
41
41
  /**
42
- * The seam onto whatever holds the session. Supabase in every donor so far, but nothing here
43
- * knows that — an adapter is four lines and keeps the auth vendor out of this package.
42
+ * The seam onto whatever holds the session. Supabase in every donor so far, but nothing here knows
43
+ * that. An adapter is four lines and keeps the auth vendor out of this package.
44
44
  */
45
45
  export interface SessionAdapter {
46
46
  getToken(): Promise<string | null>;
@@ -49,9 +49,9 @@ export interface SessionAdapter {
49
49
  }
50
50
 
51
51
  /**
52
- * How long anything here waits on a sign-out: the default for
53
- * {@link ApiClientOptions.signOutTimeoutMs}, and the deadline of `signOutEvenOffline` in
54
- * `@gusnips/react/supabase`. Shared so the two give up together — a redirect that gave up sooner
52
+ * How long anything here waits on a sign-out: the default for {@link
53
+ * ApiClientOptions.signOutTimeoutMs}, and the deadline of `signOutEvenOffline` in
54
+ * `@gusnips/react/supabase`. Shared so the two give up together. A redirect that gave up sooner
55
55
  * would navigate away while the session was still in storage.
56
56
  */
57
57
  export const SIGN_OUT_TIMEOUT_MS = 3_000;
@@ -70,54 +70,54 @@ export interface ApiClientOptions {
70
70
  */
71
71
  headers?: () => Record<string, string>;
72
72
  /**
73
- * The session is gone and cannot be renewed — send them to sign in.
73
+ * The session is gone and cannot be renewed. Send them to sign in.
74
74
  *
75
75
  * The client does not know the route, and it deliberately still THROWS after calling this:
76
76
  * `location.replace()` does not stop the current frame, so a caller's own `onError` must
77
77
  * still run or a half-finished screen keeps rendering against data that will never arrive.
78
78
  * The error it throws carries `expected: true`.
79
79
  *
80
- * Ending the session IN PLACE is equally supported — routing to the sign-in screen without a
81
- * reload — and it is the case that keeps this client alive across the next sign-in. The
82
- * sign-out latch is keyed on the refused token for exactly that reason; see `refusedToken`.
80
+ * Routing to the sign-in screen without a reload is equally supported. It is the case that keeps
81
+ * this client alive across the next sign-in. The sign-out latch is keyed on the refused token for
82
+ * exactly that reason; see `refusedToken`.
83
83
  */
84
84
  onSessionDead: () => void;
85
85
  /**
86
86
  * Every failed response, seen once, just before it is thrown.
87
87
  *
88
- * For a reaction that belongs to the whole app rather than to one call site. The donor's case
89
- * is the sharp one: a mid-session account suspension 403s every authed route except
90
- * `GET /auth/me`, so the moment one arrives the app has to refresh `me` and route to the
91
- * screen that explains it — otherwise every query on the page fails at once and the shell
92
- * half-renders behind an error storm until `me` goes stale on its own.
88
+ * For a reaction that belongs to the whole app rather than to one call site. The donor's case is
89
+ * the sharp one: a mid-session account suspension 403s every authed route except `GET /auth/me`,
90
+ * so the moment one arrives the app has to refresh `me` and route to the screen that explains it.
91
+ * Otherwise every query on the page fails at once and the shell half-renders behind an error
92
+ * storm until `me` goes stale on its own.
93
93
  *
94
- * It cannot live at a call site, because the point is that it fires from whichever call
95
- * happened to be first. It went in the donor's client directly, which made the client import
96
- * its query cache and its query keys — a cycle that this hook removes.
94
+ * It cannot live at a call site, because the point is that it fires from whichever call happened
95
+ * to be first. It went in the donor's client directly, which made the client import its query
96
+ * cache and its query keys, creating a cycle that this hook removes.
97
97
  *
98
98
  * Observation only: the error is thrown either way, and throwing from here would replace a
99
99
  * real API failure with whatever the listener hit.
100
100
  *
101
101
  * **The response comes with it, because a refusal's headers are otherwise unreachable.**
102
- * `request` throws before the `Response` escapes, so an adopter whose API states something on
103
- * the error path had no way to read it at all — one reports what every call cost, refusals
104
- * included, which is how "a blocked page costs nothing" becomes a thing a customer can check
105
- * rather than a thing we assert. That is the distinction worth keeping: field-error flattening
106
- * and a per-call meter callback both stayed in their products, because a product can write
107
- * those itself. This one it cannot, at any price, and a capability the kit makes unreachable
108
- * is a regression the kit has to undo.
102
+ * `request` throws before the `Response` escapes, so an adopter whose API states something on the
103
+ * error path had no way to read it at all. One reports what every call cost, refusals included,
104
+ * which is how "a blocked page costs nothing" becomes a thing a customer can check rather than a
105
+ * thing we assert. That is the distinction worth keeping: field-error flattening and a per-call
106
+ * meter callback both stayed in their products, because a product can write those itself. This
107
+ * one it cannot, at any price, and a capability the kit makes unreachable is a regression the kit
108
+ * has to undo.
109
109
  *
110
- * The BODY has already been read by the time this runs — the envelope was needed to build the
111
- * error — so `response.json()` here throws. The headers are what is left, and what this is for.
110
+ * The BODY has already been read by the time this runs to build the error from the envelope, so
111
+ * `response.json()` here throws. The headers are what is left, and what this is for.
112
112
  */
113
113
  onError?: (error: ApiError, response: Response) => void;
114
114
  /**
115
115
  * Abort a request that has not answered. Default 30s.
116
116
  *
117
- * Neither donor bounded its authenticated requests at all — both bounded only their keyless
118
- * clients — so a stalled connection was a spinner with no end. providerkit learned the same
119
- * lesson about streams: a request with no deadline is a bug that only shows up on a bad
120
- * network, which is exactly when nobody can reproduce it.
117
+ * Neither donor bounded its authenticated requests. Both bounded only their keyless clients, so a
118
+ * stalled connection was a spinner with no end. providerkit learned the same lesson about
119
+ * streams: a request with no deadline is a bug that only shows up on a bad network, which is
120
+ * exactly when nobody can reproduce it.
121
121
  */
122
122
  timeoutMs?: number;
123
123
  /** How many times to refresh-and-retry a 401. Default 2. */
@@ -127,9 +127,9 @@ export interface ApiClientOptions {
127
127
  /**
128
128
  * How long to wait for `signOut()` before redirecting anyway. Default 3s.
129
129
  *
130
- * A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()`
131
- * covers a rejection but not a HANG, and a hang leaves the tab signed out in name only —
132
- * still on the page, every request 401ing, nothing left that could redirect it.
130
+ * A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()` covers
131
+ * a rejection but not a HANG, and a hang leaves the tab signed out in name only: still on the
132
+ * page, every request 401ing, nothing left that could redirect it.
133
133
  */
134
134
  signOutTimeoutMs?: number;
135
135
  /** Header carrying the server's request id, echoed onto {@link ApiError.requestId}. */
@@ -151,9 +151,9 @@ export interface ApiClient {
151
151
  put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
152
152
  patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
153
153
  /**
154
- * DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and
155
- * a 204 has NO BODY — `res.json()` on one throws `Unexpected end of JSON input`, which reads
156
- * like a malformed response and is really just a success nobody was allowed to parse.
154
+ * DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and a
155
+ * 204 has NO BODY. `res.json()` on one throws `Unexpected end of JSON input`, which reads like a
156
+ * malformed response and is really just a success nobody was allowed to parse.
157
157
  *
158
158
  * Split from {@link ApiClient.delJson} rather than guessing per response, because the guess is
159
159
  * the part that hides a bug: a route that was supposed to answer with data and returned
@@ -161,21 +161,21 @@ export interface ApiClient {
161
161
  * layers away. The donor that hit this had written exactly these two functions.
162
162
  */
163
163
  del(path: string, options?: RequestOptions): Promise<void>;
164
- /** The DELETE that answers with something worth reading — a queue entry it handed back. */
164
+ /** The DELETE that answers with something worth reading, such as a queue entry it handed back. */
165
165
  delJson<T>(path: string, options?: RequestOptions): Promise<T>;
166
166
  /**
167
- * The whole envelope rather than just `data`, for a route whose `meta` is half the answer —
168
- * a list's `total`, or a metered call's receipt.
167
+ * The whole envelope rather than just `data`, for a route whose `meta` is half the answer, such
168
+ * as a list's `total` or a metered call's receipt.
169
169
  */
170
170
  page<T, M>(path: string, options?: RequestOptions): Promise<ApiSuccess<T, M>>;
171
171
  /**
172
172
  * The same, off a POST.
173
173
  *
174
- * Two adopters needed this and both wrote the same workaround: `client.request(…)` followed by
175
- * a hand-rolled `res.json()`, stepping around the client's own envelope reader and its one
176
- * assertion about the success shape. One of them left the reason sitting in a comment — "the
177
- * whole envelope is reachable off a GET and not off a POST, so this one reads the body itself"
178
- * — which is this package describing its own gap in somebody else's file.
174
+ * Two adopters needed this and both wrote the same workaround: `client.request(…)` followed by a
175
+ * hand-rolled `res.json()`, stepping around the client's own envelope reader and its one
176
+ * assertion about the success shape. One of them left the reason sitting in a comment: "the whole
177
+ * envelope is reachable off a GET and not off a POST, so this one reads the body itself". This
178
+ * package's gap was described in somebody else's file.
179
179
  *
180
180
  * A metered route takes a body whenever its input is too long or too punctuated to be a path
181
181
  * segment (a URL, a search query), and then the receipt has to survive the POST too. When the
package/src/api-error.ts CHANGED
@@ -3,9 +3,9 @@ import type { ApiErrorBody } from "@gusnips/http";
3
3
  /**
4
4
  * A refusal, with the whole envelope intact.
5
5
  *
6
- * `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries
7
- * the parts that make a refusal actionable — the `resetAt` on a 429, the plan that lifts a 402.
8
- * Keeping all of it means a caller never has to re-parse a response body that has already been
6
+ * `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries the
7
+ * parts that make a refusal actionable, such as the `resetAt` on a 429 or the plan that lifts a
8
+ * 402. Keeping all of it means a caller never has to re-parse a response body that has already been
9
9
  * read once.
10
10
  *
11
11
  * A real `class` and not an interface with a factory: one donor used the interface shape, which
@@ -23,17 +23,17 @@ export class ApiError extends Error {
23
23
  *
24
24
  * Read from the `Retry-After` HEADER, which is where HTTP puts it and where a rate limiter
25
25
  * written by anyone puts it. The second migration is why this exists: that API sends
26
- * `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
27
- * which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
28
- * both the copy and the retry rule were flying blind on the one refusal that states its own
29
- * expiry. Two conventions, same fact; `retryAfterSecs` from `@gusnips/http/retry` reads either.
26
+ * `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader looked in
27
+ * `details.retryAfterSecs`, the first donor's convention, and found nothing, and both the copy
28
+ * and the retry rule were flying blind on the one refusal that states its own expiry. Two
29
+ * conventions, same fact; `retryAfterSecs` from `@gusnips/http/retry` reads either.
30
30
  */
31
31
  readonly retryAfterSecs: number | undefined;
32
32
 
33
33
  /**
34
- * The client raised this itself to stop a caller while it was already handling the
35
- * situation — today, the 401 thrown after a dead session, where the person is already on
36
- * their way to the sign-in page.
34
+ * The client raised this itself to stop a caller while it was already handling the situation.
35
+ * Today this is the 401 thrown after a dead session, where the person is already on their way to
36
+ * the sign-in page.
37
37
  *
38
38
  * It is control flow, not a fault. Exception reporting should skip it, and an error surface
39
39
  * should not ask somebody to retry something that is not broken. One donor had this and it is
@@ -65,7 +65,7 @@ export class ApiError extends Error {
65
65
  }
66
66
 
67
67
  /**
68
- * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
68
+ * A request aborted by the caller's own `AbortController` or a navigation that unmounted the
69
69
  * component waiting on it.
70
70
  *
71
71
  * Worth its own predicate because it is the one failure that must NOT be reported, retried or
@@ -0,0 +1,145 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
3
+
4
+ const KEY = "frontkit.auth-email";
5
+ const MINUTE = 60 * 1000;
6
+
7
+ /**
8
+ * `localStorage` stubbed rather than run under a DOM, the same call as `theme.test.ts`. What
9
+ * matters is what a browser does at the edges: site data blocked (every method throws), a quota
10
+ * that refuses new writes while old ones stay readable, and a page with no storage at all.
11
+ */
12
+ function storage({ blocked = false }: { blocked?: boolean } = {}) {
13
+ const store = new Map<string, string>();
14
+ const state = { blocked, full: false };
15
+ vi.stubGlobal("localStorage", {
16
+ getItem: (key: string) => {
17
+ if (state.blocked) throw new Error("site data blocked");
18
+ return store.get(key) ?? null;
19
+ },
20
+ setItem: (key: string, value: string) => {
21
+ if (state.blocked || state.full) throw new Error("site data blocked");
22
+ store.set(key, value);
23
+ },
24
+ removeItem: (key: string) => {
25
+ if (state.blocked) throw new Error("site data blocked");
26
+ store.delete(key);
27
+ },
28
+ });
29
+ return { store, state };
30
+ }
31
+
32
+ beforeEach(() => {
33
+ vi.useFakeTimers();
34
+ vi.setSystemTime(new Date("2026-10-08T10:00:00Z"));
35
+ });
36
+
37
+ afterEach(() => {
38
+ forgetAuthEmail();
39
+ vi.unstubAllGlobals();
40
+ vi.useRealTimers();
41
+ });
42
+
43
+ describe("remember and recall", () => {
44
+ it("starts empty", () => {
45
+ storage();
46
+ expect(recallAuthEmail()).toBe("");
47
+ });
48
+
49
+ it("hands back what was typed, as typed", () => {
50
+ storage();
51
+ rememberAuthEmail(" Ana@Example.com ");
52
+ expect(recallAuthEmail()).toBe(" Ana@Example.com ");
53
+ });
54
+
55
+ it("writes to localStorage, so a new tab opened from a mail finds it", () => {
56
+ const { store } = storage();
57
+ rememberAuthEmail("ana@example.com");
58
+ expect(JSON.parse(store.get(KEY) ?? "null")).toMatchObject({ email: "ana@example.com" });
59
+ });
60
+
61
+ it("empties when the field is cleared", () => {
62
+ const { store } = storage();
63
+ rememberAuthEmail("ana@example.com");
64
+ rememberAuthEmail("");
65
+ expect(recallAuthEmail()).toBe("");
66
+ expect(store.has(KEY)).toBe(false);
67
+ });
68
+
69
+ it("keeps the last good value when handed something longer than an address can be", () => {
70
+ storage();
71
+ rememberAuthEmail("ana@example.com");
72
+ rememberAuthEmail("a".repeat(255));
73
+ expect(recallAuthEmail()).toBe("ana@example.com");
74
+ });
75
+
76
+ it("forgets on request", () => {
77
+ const { store } = storage();
78
+ rememberAuthEmail("ana@example.com");
79
+ forgetAuthEmail();
80
+ expect(recallAuthEmail()).toBe("");
81
+ expect(store.has(KEY)).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("the thirty minutes", () => {
86
+ it("lets go after thirty minutes of nothing, and deletes what it found", () => {
87
+ const { store } = storage();
88
+ rememberAuthEmail("ana@example.com");
89
+ vi.advanceTimersByTime(30 * MINUTE);
90
+ expect(recallAuthEmail()).toBe("ana@example.com");
91
+ vi.advanceTimersByTime(1);
92
+ expect(recallAuthEmail()).toBe("");
93
+ expect(store.has(KEY)).toBe(false);
94
+ });
95
+
96
+ it("renews on every keystroke", () => {
97
+ storage();
98
+ rememberAuthEmail("ana@example.com");
99
+ vi.advanceTimersByTime(25 * MINUTE);
100
+ rememberAuthEmail("ana@example.com.br");
101
+ vi.advanceTimersByTime(25 * MINUTE);
102
+ expect(recallAuthEmail()).toBe("ana@example.com.br");
103
+ });
104
+ });
105
+
106
+ describe("when the browser will not store it", () => {
107
+ it("falls back to memory when site data is blocked", () => {
108
+ storage({ blocked: true });
109
+ rememberAuthEmail("ana@example.com");
110
+ expect(recallAuthEmail()).toBe("ana@example.com");
111
+ forgetAuthEmail();
112
+ expect(recallAuthEmail()).toBe("");
113
+ });
114
+
115
+ it("falls back to memory when there is no localStorage at all", () => {
116
+ vi.stubGlobal("localStorage", undefined);
117
+ rememberAuthEmail("ana@example.com");
118
+ expect(recallAuthEmail()).toBe("ana@example.com");
119
+ });
120
+
121
+ it("prefers the newer address when a full quota left an older one in storage", () => {
122
+ const { state } = storage();
123
+ rememberAuthEmail("old@example.com");
124
+ state.full = true;
125
+ vi.advanceTimersByTime(MINUTE);
126
+ rememberAuthEmail("new@example.com");
127
+ expect(recallAuthEmail()).toBe("new@example.com");
128
+ });
129
+
130
+ it("prefers what another tab wrote after this tab's memory went stale", () => {
131
+ const { store } = storage();
132
+ rememberAuthEmail("old@example.com");
133
+ vi.advanceTimersByTime(MINUTE);
134
+ store.set(KEY, JSON.stringify({ email: "new@example.com", at: Date.now() }));
135
+ expect(recallAuthEmail()).toBe("new@example.com");
136
+ });
137
+
138
+ it("ignores a stored value that is not ours", () => {
139
+ const { store } = storage();
140
+ store.set(KEY, "{not json");
141
+ expect(recallAuthEmail()).toBe("");
142
+ store.set(KEY, JSON.stringify({ email: 42, at: "now" }));
143
+ expect(recallAuthEmail()).toBe("");
144
+ });
145
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The email a person has typed during sign-in, kept so the next auth screen opens with it filled.
3
+ *
4
+ * Sign-in, "use a code instead", "forgot password" and "create account" are separate screens, and
5
+ * each one used to start with an empty email field. Someone who typed their address, then chose
6
+ * another way in, typed it again. The fix is the same everywhere, so it lives here once.
7
+ *
8
+ * ```tsx
9
+ * <input name="email" type="email" defaultValue={recallAuthEmail()}
10
+ * onChange={(event) => rememberAuthEmail(event.currentTarget.value)} />
11
+ * ```
12
+ *
13
+ * - **Not in the URL.** A query string rides in `Referer`, in access logs, in history and in every
14
+ * analytics tool that records page addresses. An email is personal data and has no business there.
15
+ * - **`localStorage`, not `sessionStorage`.** An emailed link opens in a new tab, and a new tab
16
+ * does not inherit `sessionStorage`. The person who confirms their account from the mail lands
17
+ * on sign-in in that new tab, which is the screen that most needs the address.
18
+ * - **Thirty minutes, sliding.** Long enough to read a code in a mail client and come back; short
19
+ * enough that a shared computer forgets. Every keystroke renews it, and a value found expired is
20
+ * deleted when it is read, so it does not sit on disk afterwards.
21
+ * - **Memory behind it.** A browser that blocks site data throws on `localStorage` itself, and
22
+ * React Native has none. The value then lasts until the page (or the app) is left, which still
23
+ * covers moving between screens.
24
+ *
25
+ * Read it once when a screen opens, as a default value. It is not a live store: nothing re-renders
26
+ * when it changes, and a prerendered page must read it after hydration, never while rendering.
27
+ * Never put a password, a code or a token here. Call `forgetAuthEmail()` once a session exists, so
28
+ * the next person at this browser starts empty.
29
+ */
30
+
31
+ const KEY = "frontkit.auth-email";
32
+ const TTL_MS = 30 * 60 * 1000;
33
+ /** The longest an address can be (RFC 5321). A longer value is not one, and is not kept. */
34
+ const MAX_LENGTH = 254;
35
+
36
+ interface Saved {
37
+ email: string;
38
+ at: number;
39
+ }
40
+
41
+ let memory: Saved | null = null;
42
+
43
+ function isSaved(value: unknown): value is Saved {
44
+ return (
45
+ typeof value === "object" &&
46
+ value !== null &&
47
+ "email" in value &&
48
+ typeof value.email === "string" &&
49
+ "at" in value &&
50
+ typeof value.at === "number"
51
+ );
52
+ }
53
+
54
+ function readStored(): Saved | null {
55
+ try {
56
+ const raw = globalThis.localStorage?.getItem(KEY);
57
+ if (!raw) return null;
58
+ const parsed: unknown = JSON.parse(raw);
59
+ return isSaved(parsed) ? parsed : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Storage and memory are written together, so they only differ when one of them missed a write:
67
+ * storage that is full keeps an older address while memory holds the new one, and a second tab
68
+ * writes storage while this tab's memory goes stale. The newer of the two is the one the person
69
+ * typed last.
70
+ */
71
+ function newest(): Saved | null {
72
+ const stored = readStored();
73
+ if (!stored || !memory) return stored ?? memory;
74
+ return stored.at >= memory.at ? stored : memory;
75
+ }
76
+
77
+ /** The address typed earlier in this sign-in, or an empty string when there is none. */
78
+ export function recallAuthEmail(): string {
79
+ const saved = newest();
80
+ if (!saved) return "";
81
+ if (Date.now() - saved.at > TTL_MS) {
82
+ forgetAuthEmail();
83
+ return "";
84
+ }
85
+ return saved.email;
86
+ }
87
+
88
+ /**
89
+ * Keep what the person has typed. Call it from the email field's change handler, so every screen
90
+ * they reach next can open with it. The value is stored as typed: trim it where you submit it.
91
+ * An empty value forgets.
92
+ */
93
+ export function rememberAuthEmail(value: string): void {
94
+ if (value === "") return forgetAuthEmail();
95
+ if (value.length > MAX_LENGTH) return;
96
+ memory = { email: value, at: Date.now() };
97
+ try {
98
+ globalThis.localStorage?.setItem(KEY, JSON.stringify(memory));
99
+ } catch {
100
+ // Blocked or full: the memory copy above carries it until the page is left.
101
+ }
102
+ }
103
+
104
+ /** Drop it. Call this once a session exists, so the next person at this browser starts empty. */
105
+ export function forgetAuthEmail(): void {
106
+ memory = null;
107
+ try {
108
+ globalThis.localStorage?.removeItem(KEY);
109
+ } catch {
110
+ // Blocked: nothing was stored, so there is nothing to drop.
111
+ }
112
+ }