@civitai/blocks-react 0.56.0 → 0.57.0

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 (262) hide show
  1. package/README.md +34 -2
  2. package/dist/api/generationResources.d.ts +4 -3
  3. package/dist/hooks/returnTypeLedger.d.ts +2 -0
  4. package/dist/hooks/returnTypeLedger.js +2 -0
  5. package/dist/hooks/useAppStorage.js +2 -2
  6. package/dist/hooks/useAppWorkflows.d.ts +5 -2
  7. package/dist/hooks/useAppWorkflows.js +18 -7
  8. package/dist/hooks/useBlockAnalytics.d.ts +5 -3
  9. package/dist/hooks/useBlockAnalytics.js +1 -1
  10. package/dist/hooks/useBlockBreakpoint.d.ts +7 -1
  11. package/dist/hooks/useBlockContext.d.ts +13 -2
  12. package/dist/hooks/useBlockContext.js +1 -1
  13. package/dist/hooks/useBlockResize.d.ts +7 -1
  14. package/dist/hooks/useBlockResize.js +1 -1
  15. package/dist/hooks/useBlockSettings.d.ts +6 -1
  16. package/dist/hooks/useBlockTheme.d.ts +6 -1
  17. package/dist/hooks/useBlockToken.d.ts +8 -3
  18. package/dist/hooks/useBlockToken.js +2 -2
  19. package/dist/hooks/useBuzzAccounts.d.ts +3 -2
  20. package/dist/hooks/useBuzzAccounts.js +16 -15
  21. package/dist/hooks/useBuzzBalance.d.ts +3 -2
  22. package/dist/hooks/useBuzzBalance.js +16 -18
  23. package/dist/hooks/useBuzzPurchase.d.ts +8 -6
  24. package/dist/hooks/useBuzzPurchase.js +3 -3
  25. package/dist/hooks/useBuzzTransactions.d.ts +4 -2
  26. package/dist/hooks/useBuzzTransactions.js +16 -15
  27. package/dist/hooks/useBuzzWorkflow.d.ts +71 -28
  28. package/dist/hooks/useBuzzWorkflow.js +31 -17
  29. package/dist/hooks/useCheckpointPicker.d.ts +18 -16
  30. package/dist/hooks/useCheckpointPicker.js +3 -3
  31. package/dist/hooks/useCivitaiNavigate.d.ts +5 -3
  32. package/dist/hooks/useCivitaiNavigate.js +1 -1
  33. package/dist/hooks/useCollectionFollow.js +4 -4
  34. package/dist/hooks/useConsentUnavailable.js +2 -2
  35. package/dist/hooks/useCreatePostFromApp.js +4 -4
  36. package/dist/hooks/useDailyCompensation.d.ts +4 -2
  37. package/dist/hooks/useDailyCompensation.js +17 -15
  38. package/dist/hooks/useDirectLoad.d.ts +7 -1
  39. package/dist/hooks/useDirectLoad.js +1 -1
  40. package/dist/hooks/useDomainMaturity.d.ts +6 -2
  41. package/dist/hooks/useGatedImages.d.ts +8 -0
  42. package/dist/hooks/useGatedImages.js +10 -2
  43. package/dist/hooks/useGenerationResources.d.ts +5 -3
  44. package/dist/hooks/useHostOrigin.d.ts +7 -1
  45. package/dist/hooks/useHostOrigin.js +1 -1
  46. package/dist/hooks/useImageUpload.d.ts +44 -17
  47. package/dist/hooks/useImageUpload.js +24 -4
  48. package/dist/hooks/usePublishGenerationOutputs.js +4 -4
  49. package/dist/hooks/useRequestConsent.d.ts +7 -5
  50. package/dist/hooks/useRequestConsent.js +1 -1
  51. package/dist/hooks/useRequestSequencer.d.ts +71 -0
  52. package/dist/hooks/useRequestSequencer.js +28 -0
  53. package/dist/hooks/useRequestSignIn.d.ts +7 -5
  54. package/dist/hooks/useRequestSignIn.js +1 -1
  55. package/dist/hooks/useResourcePicker.d.ts +16 -14
  56. package/dist/hooks/useResourcePicker.js +3 -3
  57. package/dist/hooks/useSaveImage.js +2 -2
  58. package/dist/hooks/useSharedStorage.js +2 -2
  59. package/dist/hooks/useTip.js +1 -1
  60. package/dist/hooks/useTipAllowance.d.ts +13 -1
  61. package/dist/hooks/useTipAllowance.js +49 -11
  62. package/dist/hooks/useViewer.d.ts +3 -2
  63. package/dist/hooks/useViewer.js +16 -18
  64. package/dist/hooks/useWildcardPack.d.ts +3 -1
  65. package/dist/hooks/useWildcardPack.js +19 -14
  66. package/dist/index.d.ts +30 -14
  67. package/dist/index.js +7 -7
  68. package/dist/internal/consentRefusalLatch.d.ts +1 -1
  69. package/dist/internal/consentRefusalLatch.js +1 -1
  70. package/dist/internal/liveHost.d.ts +63 -11
  71. package/dist/internal/liveHost.js +240 -37
  72. package/dist/internal/mockHost.js +16 -15
  73. package/dist/internal/pickerOverlay.d.ts +52 -4
  74. package/dist/internal/pickerOverlay.js +14 -3
  75. package/dist/testing.d.ts +1 -1
  76. package/dist/testing.js +1 -1
  77. package/dist/{internal → transport}/iframeTransport.d.ts +43 -5
  78. package/dist/{internal → transport}/iframeTransport.js +142 -12
  79. package/dist/transport/originMatcher.d.ts +92 -0
  80. package/dist/transport/originMatcher.js +222 -0
  81. package/dist/transport/requestId.d.ts +113 -0
  82. package/dist/transport/requestId.js +117 -0
  83. package/dist/{internal → transport}/validate.d.ts +34 -0
  84. package/dist/{internal → transport}/validate.js +189 -48
  85. package/dist/ui/BlockGate.js +1 -1
  86. package/dist/ui/SettingsForm.d.ts +8 -2
  87. package/dist/ui/SettingsForm.js +49 -3
  88. package/package.json +7 -148
  89. package/dist/api/generationResources.d.ts.map +0 -1
  90. package/dist/api/generationResources.js.map +0 -1
  91. package/dist/hooks/SfwGate.d.ts.map +0 -1
  92. package/dist/hooks/SfwGate.js.map +0 -1
  93. package/dist/hooks/useAppStorage.d.ts.map +0 -1
  94. package/dist/hooks/useAppStorage.js.map +0 -1
  95. package/dist/hooks/useAppWorkflows.d.ts.map +0 -1
  96. package/dist/hooks/useAppWorkflows.js.map +0 -1
  97. package/dist/hooks/useBlockAnalytics.d.ts.map +0 -1
  98. package/dist/hooks/useBlockAnalytics.js.map +0 -1
  99. package/dist/hooks/useBlockBreakpoint.d.ts.map +0 -1
  100. package/dist/hooks/useBlockBreakpoint.js.map +0 -1
  101. package/dist/hooks/useBlockContext.d.ts.map +0 -1
  102. package/dist/hooks/useBlockContext.js.map +0 -1
  103. package/dist/hooks/useBlockResize.d.ts.map +0 -1
  104. package/dist/hooks/useBlockResize.js.map +0 -1
  105. package/dist/hooks/useBlockSettings.d.ts.map +0 -1
  106. package/dist/hooks/useBlockSettings.js.map +0 -1
  107. package/dist/hooks/useBlockTheme.d.ts.map +0 -1
  108. package/dist/hooks/useBlockTheme.js.map +0 -1
  109. package/dist/hooks/useBlockToken.d.ts.map +0 -1
  110. package/dist/hooks/useBlockToken.js.map +0 -1
  111. package/dist/hooks/useBuzzAccounts.d.ts.map +0 -1
  112. package/dist/hooks/useBuzzAccounts.js.map +0 -1
  113. package/dist/hooks/useBuzzBalance.d.ts.map +0 -1
  114. package/dist/hooks/useBuzzBalance.js.map +0 -1
  115. package/dist/hooks/useBuzzPurchase.d.ts.map +0 -1
  116. package/dist/hooks/useBuzzPurchase.js.map +0 -1
  117. package/dist/hooks/useBuzzTransactions.d.ts.map +0 -1
  118. package/dist/hooks/useBuzzTransactions.js.map +0 -1
  119. package/dist/hooks/useBuzzWorkflow.d.ts.map +0 -1
  120. package/dist/hooks/useBuzzWorkflow.js.map +0 -1
  121. package/dist/hooks/useCheckpointPicker.d.ts.map +0 -1
  122. package/dist/hooks/useCheckpointPicker.js.map +0 -1
  123. package/dist/hooks/useCivitaiNavigate.d.ts.map +0 -1
  124. package/dist/hooks/useCivitaiNavigate.js.map +0 -1
  125. package/dist/hooks/useCollectionFollow.d.ts.map +0 -1
  126. package/dist/hooks/useCollectionFollow.js.map +0 -1
  127. package/dist/hooks/useConsentUnavailable.d.ts.map +0 -1
  128. package/dist/hooks/useConsentUnavailable.js.map +0 -1
  129. package/dist/hooks/useCreatePostFromApp.d.ts.map +0 -1
  130. package/dist/hooks/useCreatePostFromApp.js.map +0 -1
  131. package/dist/hooks/useDailyCompensation.d.ts.map +0 -1
  132. package/dist/hooks/useDailyCompensation.js.map +0 -1
  133. package/dist/hooks/useDirectLoad.d.ts.map +0 -1
  134. package/dist/hooks/useDirectLoad.js.map +0 -1
  135. package/dist/hooks/useDomainMaturity.d.ts.map +0 -1
  136. package/dist/hooks/useDomainMaturity.js.map +0 -1
  137. package/dist/hooks/useGatedImages.d.ts.map +0 -1
  138. package/dist/hooks/useGatedImages.js.map +0 -1
  139. package/dist/hooks/useGenerationResources.d.ts.map +0 -1
  140. package/dist/hooks/useGenerationResources.js.map +0 -1
  141. package/dist/hooks/useHostOrigin.d.ts.map +0 -1
  142. package/dist/hooks/useHostOrigin.js.map +0 -1
  143. package/dist/hooks/useImageUpload.d.ts.map +0 -1
  144. package/dist/hooks/useImageUpload.js.map +0 -1
  145. package/dist/hooks/usePublishGenerationOutputs.d.ts.map +0 -1
  146. package/dist/hooks/usePublishGenerationOutputs.js.map +0 -1
  147. package/dist/hooks/useRequestConsent.d.ts.map +0 -1
  148. package/dist/hooks/useRequestConsent.js.map +0 -1
  149. package/dist/hooks/useRequestSignIn.d.ts.map +0 -1
  150. package/dist/hooks/useRequestSignIn.js.map +0 -1
  151. package/dist/hooks/useResourcePicker.d.ts.map +0 -1
  152. package/dist/hooks/useResourcePicker.js.map +0 -1
  153. package/dist/hooks/useSaveImage.d.ts.map +0 -1
  154. package/dist/hooks/useSaveImage.js.map +0 -1
  155. package/dist/hooks/useSharedStorage.d.ts.map +0 -1
  156. package/dist/hooks/useSharedStorage.js.map +0 -1
  157. package/dist/hooks/useTip.d.ts.map +0 -1
  158. package/dist/hooks/useTip.js.map +0 -1
  159. package/dist/hooks/useTipAllowance.d.ts.map +0 -1
  160. package/dist/hooks/useTipAllowance.js.map +0 -1
  161. package/dist/hooks/useViewer.d.ts.map +0 -1
  162. package/dist/hooks/useViewer.js.map +0 -1
  163. package/dist/hooks/useWildcardPack.d.ts.map +0 -1
  164. package/dist/hooks/useWildcardPack.js.map +0 -1
  165. package/dist/index.d.ts.map +0 -1
  166. package/dist/index.js.map +0 -1
  167. package/dist/internal/catalog.d.ts.map +0 -1
  168. package/dist/internal/catalog.js.map +0 -1
  169. package/dist/internal/consent.d.ts.map +0 -1
  170. package/dist/internal/consent.js.map +0 -1
  171. package/dist/internal/consentRefusalLatch.d.ts.map +0 -1
  172. package/dist/internal/consentRefusalLatch.js.map +0 -1
  173. package/dist/internal/detector.d.ts.map +0 -1
  174. package/dist/internal/detector.js.map +0 -1
  175. package/dist/internal/directLoad.d.ts.map +0 -1
  176. package/dist/internal/directLoad.js.map +0 -1
  177. package/dist/internal/iframeTransport.d.ts.map +0 -1
  178. package/dist/internal/iframeTransport.js.map +0 -1
  179. package/dist/internal/inlineTransport.d.ts.map +0 -1
  180. package/dist/internal/inlineTransport.js.map +0 -1
  181. package/dist/internal/liveHost.d.ts.map +0 -1
  182. package/dist/internal/liveHost.js.map +0 -1
  183. package/dist/internal/mockHost.d.ts.map +0 -1
  184. package/dist/internal/mockHost.js.map +0 -1
  185. package/dist/internal/originMatcher.d.ts +0 -28
  186. package/dist/internal/originMatcher.d.ts.map +0 -1
  187. package/dist/internal/originMatcher.js +0 -89
  188. package/dist/internal/originMatcher.js.map +0 -1
  189. package/dist/internal/pickerOverlay.d.ts.map +0 -1
  190. package/dist/internal/pickerOverlay.js.map +0 -1
  191. package/dist/internal/replyError.d.ts.map +0 -1
  192. package/dist/internal/replyError.js.map +0 -1
  193. package/dist/internal/requestTimeouts.d.ts.map +0 -1
  194. package/dist/internal/requestTimeouts.js.map +0 -1
  195. package/dist/internal/singleton.d.ts.map +0 -1
  196. package/dist/internal/singleton.js.map +0 -1
  197. package/dist/internal/transport.d.ts.map +0 -1
  198. package/dist/internal/transport.js.map +0 -1
  199. package/dist/internal/validate.d.ts.map +0 -1
  200. package/dist/internal/validate.js.map +0 -1
  201. package/dist/live.d.ts.map +0 -1
  202. package/dist/live.js.map +0 -1
  203. package/dist/testing.d.ts.map +0 -1
  204. package/dist/testing.js.map +0 -1
  205. package/dist/ui/Alert.d.ts.map +0 -1
  206. package/dist/ui/Alert.js.map +0 -1
  207. package/dist/ui/Badge.d.ts.map +0 -1
  208. package/dist/ui/Badge.js.map +0 -1
  209. package/dist/ui/BlockGate.d.ts.map +0 -1
  210. package/dist/ui/BlockGate.js.map +0 -1
  211. package/dist/ui/Button.d.ts.map +0 -1
  212. package/dist/ui/Button.js.map +0 -1
  213. package/dist/ui/Card.d.ts.map +0 -1
  214. package/dist/ui/Card.js.map +0 -1
  215. package/dist/ui/Collapse.d.ts.map +0 -1
  216. package/dist/ui/Collapse.js.map +0 -1
  217. package/dist/ui/FollowButton.d.ts.map +0 -1
  218. package/dist/ui/FollowButton.js.map +0 -1
  219. package/dist/ui/Group.d.ts.map +0 -1
  220. package/dist/ui/Group.js.map +0 -1
  221. package/dist/ui/Loader.d.ts.map +0 -1
  222. package/dist/ui/Loader.js.map +0 -1
  223. package/dist/ui/Modal.d.ts.map +0 -1
  224. package/dist/ui/Modal.js.map +0 -1
  225. package/dist/ui/NumberInput.d.ts.map +0 -1
  226. package/dist/ui/NumberInput.js.map +0 -1
  227. package/dist/ui/ReportButton.d.ts.map +0 -1
  228. package/dist/ui/ReportButton.js.map +0 -1
  229. package/dist/ui/ResourceCard.d.ts.map +0 -1
  230. package/dist/ui/ResourceCard.js.map +0 -1
  231. package/dist/ui/SegmentedControl.d.ts.map +0 -1
  232. package/dist/ui/SegmentedControl.js.map +0 -1
  233. package/dist/ui/Select.d.ts.map +0 -1
  234. package/dist/ui/Select.js.map +0 -1
  235. package/dist/ui/SettingsForm.d.ts.map +0 -1
  236. package/dist/ui/SettingsForm.js.map +0 -1
  237. package/dist/ui/Slider.d.ts.map +0 -1
  238. package/dist/ui/Slider.js.map +0 -1
  239. package/dist/ui/Stack.d.ts.map +0 -1
  240. package/dist/ui/Stack.js.map +0 -1
  241. package/dist/ui/TextInput.d.ts.map +0 -1
  242. package/dist/ui/TextInput.js.map +0 -1
  243. package/dist/ui/Textarea.d.ts.map +0 -1
  244. package/dist/ui/Textarea.js.map +0 -1
  245. package/dist/ui/TipButton.d.ts.map +0 -1
  246. package/dist/ui/TipButton.js.map +0 -1
  247. package/dist/ui/index.d.ts.map +0 -1
  248. package/dist/ui/index.js.map +0 -1
  249. package/dist/ui/styles.d.ts.map +0 -1
  250. package/dist/ui/styles.js.map +0 -1
  251. /package/dist/{internal → transport}/detector.d.ts +0 -0
  252. /package/dist/{internal → transport}/detector.js +0 -0
  253. /package/dist/{internal → transport}/directLoad.d.ts +0 -0
  254. /package/dist/{internal → transport}/directLoad.js +0 -0
  255. /package/dist/{internal → transport}/inlineTransport.d.ts +0 -0
  256. /package/dist/{internal → transport}/inlineTransport.js +0 -0
  257. /package/dist/{internal → transport}/requestTimeouts.d.ts +0 -0
  258. /package/dist/{internal → transport}/requestTimeouts.js +0 -0
  259. /package/dist/{internal → transport}/singleton.d.ts +0 -0
  260. /package/dist/{internal → transport}/singleton.js +0 -0
  261. /package/dist/{internal → transport}/transport.d.ts +0 -0
  262. /package/dist/{internal → transport}/transport.js +0 -0
@@ -43,6 +43,14 @@ export interface UseGatedImages {
43
43
  * who can't see the image, and the block must render a placeholder for any
44
44
  * `hidden` entry.
45
45
  *
46
+ * 🔴 A `hidden` ENTRY IS NARROWED TO `{ imageId, status }` BEFORE IT REACHES
47
+ * HERE, and that is enforced in code rather than asserted in prose: the
48
+ * transport runs `projectInboundPayload` on every `IMAGES_RESULT` before
49
+ * delivery (`src/transport/validate.ts`), so a `previewUrl`, `src`, `imageUrl` or
50
+ * any other key a host attaches to a withheld image is DROPPED, not forwarded.
51
+ * Fields are dropped rather than the reply rejected so a future host-side field
52
+ * addition cannot hang this call — see `projectGatedImage`'s docblock.
53
+ *
46
54
  * 🔴 `nsfwLevel` AND `contentRating` ARE OPTIONAL, AND A MISSING ONE IS NOT "G".
47
55
  * They are absent exactly when `ratingPending` is present. Treating absent as a
48
56
  * safe default is the bug this state exists to stop: an image published seconds
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from 'react';
2
- import { getTransport } from '../internal/singleton.js';
3
- import { sendTypedRequest } from '../internal/transport.js';
2
+ import { getTransport } from '../transport/singleton.js';
3
+ import { sendTypedRequest } from '../transport/transport.js';
4
4
  /**
5
5
  * Read per-viewer gated display data for a list of image ids via the
6
6
  * host-mediated `GET_IMAGES_BY_IDS` → `IMAGES_RESULT` bridge — the read side of
@@ -14,6 +14,14 @@ import { sendTypedRequest } from '../internal/transport.js';
14
14
  * who can't see the image, and the block must render a placeholder for any
15
15
  * `hidden` entry.
16
16
  *
17
+ * 🔴 A `hidden` ENTRY IS NARROWED TO `{ imageId, status }` BEFORE IT REACHES
18
+ * HERE, and that is enforced in code rather than asserted in prose: the
19
+ * transport runs `projectInboundPayload` on every `IMAGES_RESULT` before
20
+ * delivery (`src/transport/validate.ts`), so a `previewUrl`, `src`, `imageUrl` or
21
+ * any other key a host attaches to a withheld image is DROPPED, not forwarded.
22
+ * Fields are dropped rather than the reply rejected so a future host-side field
23
+ * addition cannot hang this call — see `projectGatedImage`'s docblock.
24
+ *
17
25
  * 🔴 `nsfwLevel` AND `contentRating` ARE OPTIONAL, AND A MISSING ONE IS NOT "G".
18
26
  * They are absent exactly when `ratingPending` is present. Treating absent as a
19
27
  * safe default is the bug this state exists to stop: an image published seconds
@@ -1,4 +1,8 @@
1
1
  import type { BlockResourceInfo } from '@civitai/app-sdk/blocks';
2
+ /** What {@link useGenerationResources} returns. */
3
+ export interface UseGenerationResources {
4
+ fetch: (versionIds: number[]) => Promise<BlockResourceInfo[]>;
5
+ }
2
6
  /**
3
7
  * Rehydrate a saved set of generation resources by version id, WITHOUT
4
8
  * re-opening the resource picker. `fetch(versionIds)` GETs the block-token-gated
@@ -21,7 +25,5 @@ import type { BlockResourceInfo } from '@civitai/app-sdk/blocks';
21
25
  * const resources = await fetch([691639, 666002]); // by saved versionIds
22
26
  * // resources[0].strength / .minStrength / .maxStrength / .trainedWords / .clipSkip
23
27
  */
24
- export declare function useGenerationResources(): {
25
- fetch: (versionIds: number[]) => Promise<BlockResourceInfo[]>;
26
- };
28
+ export declare function useGenerationResources(): UseGenerationResources;
27
29
  //# sourceMappingURL=useGenerationResources.d.ts.map
@@ -1,3 +1,9 @@
1
+ /**
2
+ * What {@link useHostOrigin} returns: the validated host origin, or
3
+ * `undefined` before `BLOCK_INIT` lands. Named so a consumer can write it —
4
+ * see `./returnTypeLedger.js`.
5
+ */
6
+ export type UseHostOrigin = string | undefined;
1
7
  /**
2
8
  * Returns the validated host (parent) origin the block may safely direct-fetch
3
9
  * the civitai App Blocks HTTP API against — `undefined` until the transport
@@ -30,5 +36,5 @@
30
36
  * same tick it applies `BLOCK_INIT`, so the hook re-renders with the origin the
31
37
  * moment init lands.
32
38
  */
33
- export declare function useHostOrigin(): string | undefined;
39
+ export declare function useHostOrigin(): UseHostOrigin;
34
40
  //# sourceMappingURL=useHostOrigin.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { useSyncExternalStore } from 'react';
2
- import { getTransport } from '../internal/singleton.js';
2
+ import { getTransport } from '../transport/singleton.js';
3
3
  /**
4
4
  * Returns the validated host (parent) origin the block may safely direct-fetch
5
5
  * the civitai App Blocks HTTP API against — `undefined` until the transport
@@ -1,4 +1,45 @@
1
1
  import type { BlockGenerationSourceImageInfo, BlockImageScanResult, BlockPendingImageInfo, BlockUploadedImageInfo, BlockUploadPurpose } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * What {@link useImageUpload} returns for the DEFAULT (`purpose: 'display'`,
4
+ * blocking-scan) call — `useImageUpload()` with no arguments.
5
+ *
6
+ * 🔴 THIS HOOK IS OVERLOADED, SO ITS RETURN TYPE IS A FAMILY, NOT A SINGLETON
7
+ * (#380). Each overload names its own: {@link UseImageUploadGenerationSource},
8
+ * {@link UseImageUploadAsyncScan}, and this one. A single union would be worse
9
+ * than the anonymous literals it replaces — it would hand every caller a
10
+ * `scanStatus` that only one overload actually provides.
11
+ */
12
+ export interface UseImageUpload {
13
+ open: () => Promise<BlockUploadedImageInfo | null>;
14
+ }
15
+ /** What {@link useImageUpload} returns for `{ purpose: 'generationSource' }`. */
16
+ export interface UseImageUploadGenerationSource {
17
+ open: () => Promise<BlockGenerationSourceImageInfo | null>;
18
+ }
19
+ /** What {@link useImageUpload} returns for `{ asyncScan: true }`. */
20
+ export interface UseImageUploadAsyncScan {
21
+ /** Early-resolve handle (image persisted, NOT yet scanned) or `null` (dismissed). */
22
+ open: () => Promise<BlockPendingImageInfo | null>;
23
+ /**
24
+ * Resolve the async scan verdict for a handle returned by `open()`. Re-callable
25
+ * for retry: the host emits the verdict once and the hook buffers it, so a
26
+ * re-call after an `'error'`/timeout re-awaits (or immediately returns) the same
27
+ * verdict. An unknown/expired handle resolves to a retryable `'error'`, and so
28
+ * does a call that is still awaiting when the component UNMOUNTS — the promise
29
+ * always settles, never rejects, so no caller needs a `try`/`catch` (#393).
30
+ */
31
+ scanStatus: (handle: BlockPendingImageInfo) => Promise<BlockImageScanResult>;
32
+ }
33
+ /**
34
+ * The IMPLEMENTATION signature's return — not a call signature any consumer can
35
+ * reach, and deliberately not on the package entry. `scanStatus` is optional
36
+ * here so the two non-async overloads (which return `{ open }` only) stay
37
+ * compatible; the async overload types it required.
38
+ */
39
+ export interface UseImageUploadImplementation {
40
+ open: () => Promise<BlockUploadedImageInfo | BlockGenerationSourceImageInfo | BlockPendingImageInfo | null>;
41
+ scanStatus?: (handle: BlockPendingImageInfo) => Promise<BlockImageScanResult>;
42
+ }
2
43
  /** Options for {@link useImageUpload}. */
3
44
  export interface UseImageUploadOptions {
4
45
  /**
@@ -79,26 +120,12 @@ export interface UseImageUploadOptions {
79
120
  */
80
121
  export declare function useImageUpload(options: {
81
122
  purpose: 'generationSource';
82
- }): {
83
- open: () => Promise<BlockGenerationSourceImageInfo | null>;
84
- };
123
+ }): UseImageUploadGenerationSource;
85
124
  export declare function useImageUpload(options: {
86
125
  purpose?: 'display';
87
126
  asyncScan: true;
88
- }): {
89
- /** Early-resolve handle (image persisted, NOT yet scanned) or `null` (dismissed). */
90
- open: () => Promise<BlockPendingImageInfo | null>;
91
- /**
92
- * Resolve the async scan verdict for a handle returned by `open()`. Re-callable
93
- * for retry: the host emits the verdict once and the hook buffers it, so a
94
- * re-call after an `'error'`/timeout re-awaits (or immediately returns) the same
95
- * verdict. An unknown/expired handle resolves to a retryable `'error'`.
96
- */
97
- scanStatus: (handle: BlockPendingImageInfo) => Promise<BlockImageScanResult>;
98
- };
127
+ }): UseImageUploadAsyncScan;
99
128
  export declare function useImageUpload(options?: {
100
129
  purpose?: 'display';
101
- }): {
102
- open: () => Promise<BlockUploadedImageInfo | null>;
103
- };
130
+ }): UseImageUpload;
104
131
  //# sourceMappingURL=useImageUpload.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useRef } from 'react';
2
- import { HUMAN_INTERACTION_TIMEOUT_MS } from '../internal/requestTimeouts.js';
3
- import { getTransport } from '../internal/singleton.js';
4
- import { sendTypedRequest, subscribeTyped } from '../internal/transport.js';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { sendTypedRequest, subscribeTyped } from '../transport/transport.js';
5
5
  /**
6
6
  * Generous hook-side backstop for {@link BlockImageScanResult} delivery. The
7
7
  * host-side scan POLL budget is authoritative and the consuming app applies its
@@ -64,10 +64,30 @@ export function useImageUpload(options) {
64
64
  });
65
65
  return () => {
66
66
  unsubscribe();
67
+ // 🔴 #393 — SETTLE, DON'T JUST DISARM. This cleanup used to `clearTimeout`
68
+ // each waiter and stop there, which removed the ONLY remaining path to a
69
+ // settled promise: the listener is gone, so no verdict can ever arrive,
70
+ // and the backstop that would have resolved it has just been cancelled.
71
+ // An `await scanStatus(handle)` in flight at unmount therefore hung for
72
+ // the life of the page.
73
+ //
74
+ // RESOLVE, never reject: every other terminal path in this hook hands the
75
+ // caller a `BlockImageScanResult`, so rejecting here would be a new failure
76
+ // mode requiring a `try`/`catch` no existing caller has. `'error'` is the
77
+ // hook's RETRYABLE status, which is the honest verdict — the scan itself
78
+ // may well still be running server-side; this block just stopped watching.
67
79
  for (const entry of scans.values()) {
68
- for (const w of entry.waiters)
80
+ for (const w of entry.waiters) {
69
81
  clearTimeout(w.timeoutId);
82
+ w.resolve({ status: 'error', message: 'scan status unavailable (the upload hook unmounted)' });
83
+ }
84
+ entry.waiters = [];
70
85
  }
86
+ // Drop the tracking map too, so a `scanStatus()` call made AFTER unmount
87
+ // takes the immediate unknown-handle path rather than registering a waiter
88
+ // against a listener that no longer exists (which would sit for the full
89
+ // SCAN_STATUS_TIMEOUT_MS — settled, but ten minutes late).
90
+ scans.clear();
71
91
  };
72
92
  }, []);
73
93
  const open = useCallback(async () => {
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from 'react';
2
- import { HUMAN_INTERACTION_TIMEOUT_MS } from '../internal/requestTimeouts.js';
3
- import { getTransport } from '../internal/singleton.js';
4
- import { sendTypedRequest } from '../internal/transport.js';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { sendTypedRequest } from '../transport/transport.js';
5
5
  /**
6
6
  * Publish selected outputs of one of the calling app's OWN generations into bare,
7
7
  * real-scanned public `Image` rows via the host-mediated
@@ -37,7 +37,7 @@ export function usePublishGenerationOutputs() {
37
37
  // on screen. The generation was already billed and a dead publish
38
38
  // bridge has no refund path, so the charge stood and the outputs were
39
39
  // lost. Every human-gated request opts out the same way — the ledger is
40
- // `HUMAN_GATED_REQUEST_TYPES` in `internal/requestTimeouts.ts`.
40
+ // `HUMAN_GATED_REQUEST_TYPES` in `transport/requestTimeouts.ts`.
41
41
  { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
42
42
  if (reply.error || !reply.result) {
43
43
  // `||`, not `??`: `isValidPublishResult` gates `error` on SHAPE only, so a
@@ -1,3 +1,9 @@
1
+ /** What {@link useRequestConsent} returns. */
2
+ export interface UseRequestConsent {
3
+ requestConsent: (payload?: {
4
+ scopes?: string[];
5
+ }) => void;
6
+ }
1
7
  /**
2
8
  * Lazy consent. Asks the host to open civitai.com's consent UI when a
3
9
  * LOGGED-IN viewer clicks an action whose consent-gated scope the block token
@@ -39,9 +45,5 @@
39
45
  * // viewer is logged in but the token lacks the spend scopes:
40
46
  * requestConsent({ scopes: ['ai:write:budgeted', 'buzz:read:self'] });
41
47
  */
42
- export declare function useRequestConsent(): {
43
- requestConsent: (payload?: {
44
- scopes?: string[];
45
- }) => void;
46
- };
48
+ export declare function useRequestConsent(): UseRequestConsent;
47
49
  //# sourceMappingURL=useRequestConsent.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { armConsentRefusalLatch } from '../internal/consentRefusalLatch.js';
3
- import { getTransport } from '../internal/singleton.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
4
  /**
5
5
  * Lazy consent. Asks the host to open civitai.com's consent UI when a
6
6
  * LOGGED-IN viewer clicks an action whose consent-gated scope the block token
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ONE latest-wins guard for every hook that auto-issues a request and writes the
3
+ * reply into component state (#392).
4
+ *
5
+ * 🔴 WHY THIS IS A SHARED HELPER AND NOT A PATTERN TO COPY. Before it, eight
6
+ * hooks each open-coded a `mountedRef` and every one of them got the same thing
7
+ * wrong in the same direction: `mountedRef` answers "is this component still
8
+ * here?", which is not the question a reply has to pass. The question is "is
9
+ * this reply still the one I am waiting for?" — and a superseded request's reply
10
+ * passes the mount check every time, because nothing unmounted. A predicate
11
+ * duplicated across N call sites regenerates the same bug at N sites; this is
12
+ * the consolidation that makes it one site.
13
+ *
14
+ * THE FAILURE IT CLOSES. `refetch`'s identity changes (a `paramsKey` moves when
15
+ * the viewer clicks "next page"), the mount effect re-runs, and request B goes
16
+ * out while A is still in flight. B answers first and paints page 2. A answers
17
+ * second and — with only a mount guard — repaints page 1 *and* rewrites `cursor`
18
+ * to page 2's value, so "next" re-fetches the page already shown and the viewer
19
+ * is wedged. The transport correlates each reply to its own request correctly;
20
+ * the defect is purely in which reply the hook lets write state.
21
+ *
22
+ * WHAT IT SUPERSEDES. `isCurrent` is FALSE after unmount as well as for a
23
+ * superseded request, so it replaces `mountedRef` rather than sitting next to
24
+ * it — one predicate, one place, both hazards.
25
+ *
26
+ * ⚠️ HONEST LABEL ON THE MOUNT HALF: it is DEFENSIVE, not load-bearing, and no
27
+ * test in this package can kill it. Mutating `mountedRef.current && token ===
28
+ * latestRef.current` down to just the latest-wins half leaves 84 tests green,
29
+ * because on React 18+ a `setState` against an unmounted component is a silent
30
+ * no-op — there is no warning to catch and no render to observe. It is kept
31
+ * because it is what the hooks did before (it avoids the pointless work), NOT
32
+ * because anything measures it. The LATEST-WINS half is the one that carries
33
+ * weight: mutating THAT away reddens 24 cases.
34
+ *
35
+ * @example
36
+ * const seq = useRequestSequencer();
37
+ * const refetch = useCallback(() => {
38
+ * const token = seq.begin(); // supersedes any earlier request
39
+ * setLoading(true);
40
+ * sendTypedRequest(...)
41
+ * .then((reply) => {
42
+ * if (!seq.isCurrent(token)) return; // stale or unmounted — drop it
43
+ * setData(reply.result);
44
+ * setLoading(false);
45
+ * });
46
+ * }, [seq, ...]);
47
+ */
48
+ export interface RequestSequencer {
49
+ /**
50
+ * Open a new request and SUPERSEDE every earlier one. Returns the token that
51
+ * identifies it; hand that token to {@link RequestSequencer.isCurrent} before
52
+ * writing any state derived from the reply.
53
+ */
54
+ begin: () => number;
55
+ /**
56
+ * `true` only when `token` names the most recently begun request AND the
57
+ * component is still mounted. Every other case — a superseded request, a reply
58
+ * after unmount — is `false`.
59
+ */
60
+ isCurrent: (token: number) => boolean;
61
+ }
62
+ /**
63
+ * Create a {@link RequestSequencer} for one hook instance.
64
+ *
65
+ * The returned object is STABLE for the life of the component (both methods are
66
+ * `useCallback`-pinned with no dependencies, and the object itself is memoized),
67
+ * so a consumer can list it in a `useCallback` dependency array without
68
+ * retriggering the very effect it guards.
69
+ */
70
+ export declare function useRequestSequencer(): RequestSequencer;
71
+ //# sourceMappingURL=useRequestSequencer.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ /**
3
+ * Create a {@link RequestSequencer} for one hook instance.
4
+ *
5
+ * The returned object is STABLE for the life of the component (both methods are
6
+ * `useCallback`-pinned with no dependencies, and the object itself is memoized),
7
+ * so a consumer can list it in a `useCallback` dependency array without
8
+ * retriggering the very effect it guards.
9
+ */
10
+ export function useRequestSequencer() {
11
+ // Monotonic request counter. `0` is the "no request yet" value and is never
12
+ // handed out, so `isCurrent(0)` is false by construction.
13
+ const latestRef = useRef(0);
14
+ const mountedRef = useRef(true);
15
+ useEffect(() => {
16
+ mountedRef.current = true;
17
+ return () => {
18
+ mountedRef.current = false;
19
+ };
20
+ }, []);
21
+ const begin = useCallback(() => {
22
+ latestRef.current += 1;
23
+ return latestRef.current;
24
+ }, []);
25
+ const isCurrent = useCallback((token) => mountedRef.current && token === latestRef.current, []);
26
+ return useMemo(() => ({ begin, isCurrent }), [begin, isCurrent]);
27
+ }
28
+ //# sourceMappingURL=useRequestSequencer.js.map
@@ -1,3 +1,9 @@
1
+ /** What {@link useRequestSignIn} returns. */
2
+ export interface UseRequestSignIn {
3
+ requestSignIn: (payload?: {
4
+ returnUrl?: string;
5
+ }) => void;
6
+ }
1
7
  /**
2
8
  * Anonymous conversion. Asks the host to start civitai.com's login flow when a
3
9
  * logged-out viewer (`useBlockContext().viewer === null`) clicks an action that
@@ -16,9 +22,5 @@
16
22
  * const { requestSignIn } = useRequestSignIn();
17
23
  * if (viewer === null) return <button onClick={() => requestSignIn()}>Sign in</button>;
18
24
  */
19
- export declare function useRequestSignIn(): {
20
- requestSignIn: (payload?: {
21
- returnUrl?: string;
22
- }) => void;
23
- };
25
+ export declare function useRequestSignIn(): UseRequestSignIn;
24
26
  //# sourceMappingURL=useRequestSignIn.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { useCallback } from 'react';
2
- import { getTransport } from '../internal/singleton.js';
2
+ import { getTransport } from '../transport/singleton.js';
3
3
  /**
4
4
  * Anonymous conversion. Asks the host to start civitai.com's login flow when a
5
5
  * logged-out viewer (`useBlockContext().viewer === null`) clicks an action that
@@ -1,4 +1,19 @@
1
1
  import type { BlockResourceInfo, BlockResourcePickerType } from '@civitai/app-sdk/blocks';
2
+ /** What {@link useResourcePicker} returns. */
3
+ export interface UseResourcePicker {
4
+ open: (opts: {
5
+ /** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
6
+ * host rejects any other type (the modal never opens). */
7
+ resourceType: BlockResourcePickerType;
8
+ /**
9
+ * Optional base-model family hint — an ecosystem key (e.g. 'Flux1', 'SDXL')
10
+ * OR a baseModel name (e.g. 'Flux.1 D'); the host collapses it to the
11
+ * ecosystem family. Use the chosen checkpoint's `baseModel` to constrain a
12
+ * LoRA pick to the same family. Omit for an unconstrained pick of the type.
13
+ */
14
+ baseModelGroup?: string;
15
+ }) => Promise<BlockResourceInfo | null>;
16
+ }
2
17
  /**
3
18
  * Drives the platform-side resource picker for PAGE App Blocks (Design 1 —
4
19
  * host-chrome). Generalizes {@link useCheckpointPicker} from Checkpoint-only to
@@ -27,18 +42,5 @@ import type { BlockResourceInfo, BlockResourcePickerType } from '@civitai/app-sd
27
42
  * if (!picked) return; // user dismissed
28
43
  * // feed picked.versionId into body.additionalResources and submit
29
44
  */
30
- export declare function useResourcePicker(): {
31
- open: (opts: {
32
- /** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
33
- * host rejects any other type (the modal never opens). */
34
- resourceType: BlockResourcePickerType;
35
- /**
36
- * Optional base-model family hint — an ecosystem key (e.g. 'Flux1', 'SDXL')
37
- * OR a baseModel name (e.g. 'Flux.1 D'); the host collapses it to the
38
- * ecosystem family. Use the chosen checkpoint's `baseModel` to constrain a
39
- * LoRA pick to the same family. Omit for an unconstrained pick of the type.
40
- */
41
- baseModelGroup?: string;
42
- }) => Promise<BlockResourceInfo | null>;
43
- };
45
+ export declare function useResourcePicker(): UseResourcePicker;
44
46
  //# sourceMappingURL=useResourcePicker.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from 'react';
2
- import { HUMAN_INTERACTION_TIMEOUT_MS } from '../internal/requestTimeouts.js';
3
- import { getTransport } from '../internal/singleton.js';
4
- import { sendTypedRequest } from '../internal/transport.js';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { sendTypedRequest } from '../transport/transport.js';
5
5
  /**
6
6
  * Drives the platform-side resource picker for PAGE App Blocks (Design 1 —
7
7
  * host-chrome). Generalizes {@link useCheckpointPicker} from Checkpoint-only to
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from 'react';
2
- import { getTransport } from '../internal/singleton.js';
2
+ import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply } from '../internal/replyError.js';
4
- import { sendTypedRequest } from '../internal/transport.js';
4
+ import { sendTypedRequest } from '../transport/transport.js';
5
5
  /**
6
6
  * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
7
7
  * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'react';
2
- import { getTransport } from '../internal/singleton.js';
2
+ import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply, throwOnReplyError } from '../internal/replyError.js';
4
- import { sendTypedRequest } from '../internal/transport.js';
4
+ import { sendTypedRequest } from '../transport/transport.js';
5
5
  /**
6
6
  * App-scoped, append-only, community-votable SHARED datastore. Sibling of
7
7
  * {@link useAppStorage} (the per-viewer KV store) — same postMessage bridge,
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useHostOrigin } from './useHostOrigin.js';
3
3
  import { useBlockToken } from './useBlockToken.js';
4
- import { generateIdempotencyKey } from '../internal/transport.js';
4
+ import { generateIdempotencyKey } from '../transport/transport.js';
5
5
  /**
6
6
  * Backstop timeout for the direct REST tip POST. Like {@link useGenerationResources}
7
7
  * (and unlike the postMessage hooks), this talks to the HTTP API directly, so it
@@ -15,7 +15,14 @@ export interface UseTipAllowance {
15
15
  allowance: TipAllowance | null;
16
16
  /** `true` while a fetch (initial or `refetch`) is in flight. */
17
17
  loading: boolean;
18
- /** The last fetch's failure, or `null`. Cleared at the start of the next fetch. */
18
+ /**
19
+ * The last fetch's failure, or `null`. Cleared at the start of the next fetch.
20
+ *
21
+ * Also carries the NO-HOST-ORIGIN terminal state (#398): if `BLOCK_INIT` never
22
+ * lands — a direct/unembedded load, or `InlineTransport` before bootstrap —
23
+ * `loading` drops to `false` and this becomes a named `Error` after
24
+ * {@link HOST_ORIGIN_WAIT_MS}, instead of the hook spinning forever.
25
+ */
19
26
  error: Error | null;
20
27
  /** Re-read the allowance (e.g. after the viewer tips). */
21
28
  refetch: () => void;
@@ -32,6 +39,11 @@ export interface UseTipAllowance {
32
39
  * (`localStorage` is inert in the opaque-origin sandbox). Fetches once on mount
33
40
  * and exposes `refetch` (call it after a successful `useTip().tip(...)`).
34
41
  *
42
+ * Only the LATEST request may write state: a reply superseded by a newer
43
+ * `refetch` — or one that lands after unmount — is dropped (#392). If the host
44
+ * origin never arrives the hook reaches a TERMINAL state rather than spinning:
45
+ * see `error` above (#398).
46
+ *
35
47
  * @example
36
48
  * const { allowance, refetch } = useTipAllowance();
37
49
  * // allowance?.remaining — Buzz the viewer may still tip today
@@ -1,11 +1,28 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useHostOrigin } from './useHostOrigin.js';
3
3
  import { useBlockToken } from './useBlockToken.js';
4
+ import { useRequestSequencer } from './useRequestSequencer.js';
4
5
  /**
5
6
  * Backstop timeout for the direct REST allowance GET (see {@link useTip} /
6
7
  * {@link useGenerationResources} for why direct-fetch hooks need their own bound).
7
8
  */
8
9
  const TIP_ALLOWANCE_TIMEOUT_MS = 30_000;
10
+ /**
11
+ * How long the hook waits for `useHostOrigin()` before declaring the block
12
+ * un-embedded (#398).
13
+ *
14
+ * 🔴 A BOUND, NOT AN IMMEDIATE ERROR, AND THE DIFFERENCE MATTERS. The host
15
+ * origin is absent on EVERY boot — it lands a tick or two after mount, with
16
+ * `BLOCK_INIT` — so erroring on the first `!host` would flash a spurious error
17
+ * on every healthy embedded block. Only its CONTINUED absence is the fault, and
18
+ * that is what this measures.
19
+ *
20
+ * Equal to {@link TIP_ALLOWANCE_TIMEOUT_MS} by coincidence, not by derivation:
21
+ * this bounds a wait for the HOST to introduce itself, that one bounds a request
22
+ * already in flight. Two independent questions, so two constants — tuning either
23
+ * must not silently retune the other.
24
+ */
25
+ const HOST_ORIGIN_WAIT_MS = 30_000;
9
26
  /**
10
27
  * Read the viewer's REAL remaining daily tip allowance `{ cap, spent, remaining }`
11
28
  * through the block-token-gated `GET /api/v1/blocks/tip-allowance` REST endpoint
@@ -18,6 +35,11 @@ const TIP_ALLOWANCE_TIMEOUT_MS = 30_000;
18
35
  * (`localStorage` is inert in the opaque-origin sandbox). Fetches once on mount
19
36
  * and exposes `refetch` (call it after a successful `useTip().tip(...)`).
20
37
  *
38
+ * Only the LATEST request may write state: a reply superseded by a newer
39
+ * `refetch` — or one that lands after unmount — is dropped (#392). If the host
40
+ * origin never arrives the hook reaches a TERMINAL state rather than spinning:
41
+ * see `error` above (#398).
42
+ *
21
43
  * @example
22
44
  * const { allowance, refetch } = useTipAllowance();
23
45
  * // allowance?.remaining — Buzz the viewer may still tip today
@@ -28,26 +50,42 @@ export function useTipAllowance() {
28
50
  const [allowance, setAllowance] = useState(null);
29
51
  const [loading, setLoading] = useState(true);
30
52
  const [error, setError] = useState(null);
31
- const mountedRef = useRef(true);
53
+ // 🔴 `inFlight` IS NOT A SEQUENCING GUARD, and #392's triage note read it as
54
+ // one. It is drained only by the unmount cleanup below, so two overlapping
55
+ // `refetch()`es neither abort each other nor correlate their replies — the
56
+ // slower one simply wrote last. `useRequestSequencer` is what supplies the
57
+ // latest-wins predicate; this set exists to abort on unmount and to let a
58
+ // superseded request's socket be closed rather than left running.
32
59
  const inFlight = useRef(new Set());
60
+ const seq = useRequestSequencer();
33
61
  useEffect(() => {
34
- mountedRef.current = true;
35
62
  const controllers = inFlight.current;
36
63
  return () => {
37
- mountedRef.current = false;
38
64
  for (const c of controllers)
39
65
  c.abort();
40
66
  controllers.clear();
41
67
  };
42
68
  }, []);
69
+ // #398 — bounded wait for the host origin. Re-armed whenever `host` changes,
70
+ // and cleared the moment one arrives, so the terminal error is reachable ONLY
71
+ // when the origin is still absent a full HOST_ORIGIN_WAIT_MS after mount.
72
+ useEffect(() => {
73
+ if (host)
74
+ return;
75
+ const id = setTimeout(() => {
76
+ setLoading(false);
77
+ setError(new Error(`useTipAllowance: host origin not established after ${HOST_ORIGIN_WAIT_MS}ms (no BLOCK_INIT — the block is probably not embedded).`));
78
+ }, HOST_ORIGIN_WAIT_MS);
79
+ return () => clearTimeout(id);
80
+ }, [host, seq]);
43
81
  const refetch = useCallback(() => {
44
- // Wait for the host origin (established at BLOCK_INIT) before the first fetch.
82
+ // Wait for the host origin (established at BLOCK_INIT) before the first
83
+ // fetch. The effect above is what stops this from being an eternal spinner.
45
84
  if (!host)
46
85
  return;
47
- if (mountedRef.current) {
48
- setLoading(true);
49
- setError(null);
50
- }
86
+ const token = seq.begin();
87
+ setLoading(true);
88
+ setError(null);
51
89
  const controller = new AbortController();
52
90
  inFlight.current.add(controller);
53
91
  const timeoutId = setTimeout(() => controller.abort(), TIP_ALLOWANCE_TIMEOUT_MS);
@@ -57,7 +95,7 @@ export function useTipAllowance() {
57
95
  })
58
96
  .then(async (res) => {
59
97
  const body = (await res.json().catch(() => null));
60
- if (!mountedRef.current)
98
+ if (!seq.isCurrent(token))
61
99
  return;
62
100
  if (!res.ok || body == null || typeof body.remaining !== 'number') {
63
101
  setError(new Error(body?.error ?? `tip-allowance request failed (${res.status})`));
@@ -68,7 +106,7 @@ export function useTipAllowance() {
68
106
  setLoading(false);
69
107
  })
70
108
  .catch((err) => {
71
- if (!mountedRef.current)
109
+ if (!seq.isCurrent(token))
72
110
  return;
73
111
  setError(controller.signal.aborted
74
112
  ? new Error(`useTipAllowance: request aborted (timed out after ${TIP_ALLOWANCE_TIMEOUT_MS}ms or the hook unmounted).`)
@@ -81,7 +119,7 @@ export function useTipAllowance() {
81
119
  clearTimeout(timeoutId);
82
120
  inFlight.current.delete(controller);
83
121
  });
84
- }, [host, raw]);
122
+ }, [host, raw, seq]);
85
123
  useEffect(() => {
86
124
  refetch();
87
125
  }, [refetch]);
@@ -32,8 +32,9 @@ export interface UseViewer {
32
32
  *
33
33
  * Fetches once on mount and exposes `refetch` for on-demand refreshes. A host
34
34
  * that never answers surfaces as an `error` after the transport's request
35
- * timeout — the hook never hangs. Late responses that arrive after unmount are
36
- * ignored (no state update).
35
+ * timeout — the hook never hangs. Only the LATEST request may write state: a
36
+ * reply superseded by a newer `refetch` — or one that lands after unmount — is
37
+ * dropped (#392).
37
38
  *
38
39
  * @example
39
40
  * const { viewer, loading, error, refetch } = useViewer();