@civitai/blocks-react 0.59.0 → 0.61.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 (35) hide show
  1. package/README.md +296 -6
  2. package/dist/hooks/consentRetryOptions.d.ts +40 -0
  3. package/dist/hooks/consentRetryOptions.js +2 -0
  4. package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
  5. package/dist/hooks/useBuzzWorkflow.js +125 -44
  6. package/dist/hooks/useCheckpointPicker.d.ts +43 -10
  7. package/dist/hooks/useCheckpointPicker.js +37 -6
  8. package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
  9. package/dist/hooks/useCivitaiNavigate.js +50 -7
  10. package/dist/hooks/useCivitaiRoute.d.ts +63 -0
  11. package/dist/hooks/useCivitaiRoute.js +71 -0
  12. package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
  13. package/dist/hooks/useCreatePostFromApp.js +87 -34
  14. package/dist/hooks/useGoodPurchase.d.ts +2 -1
  15. package/dist/hooks/useGoodPurchase.js +64 -19
  16. package/dist/hooks/useRequestConsent.js +10 -12
  17. package/dist/hooks/useResourcePicker.d.ts +56 -7
  18. package/dist/hooks/useResourcePicker.js +27 -3
  19. package/dist/hooks/useTip.d.ts +2 -1
  20. package/dist/hooks/useTip.js +99 -20
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.js +3 -0
  23. package/dist/internal/liveHost.js +238 -7
  24. package/dist/internal/mockHost.js +67 -9
  25. package/dist/internal/popoverShim.d.ts +94 -0
  26. package/dist/internal/popoverShim.js +181 -0
  27. package/dist/internal/withConsentRetry.d.ts +239 -0
  28. package/dist/internal/withConsentRetry.js +457 -0
  29. package/dist/testing.d.ts +1 -0
  30. package/dist/testing.js +1 -0
  31. package/dist/transport/iframeTransport.d.ts +33 -0
  32. package/dist/transport/iframeTransport.js +56 -0
  33. package/dist/transport/validate.d.ts +25 -0
  34. package/dist/transport/validate.js +31 -0
  35. package/package.json +4 -4
@@ -0,0 +1,457 @@
1
+ import { subscribeTyped } from '../transport/transport.js';
2
+ import { armConsentRefusalLatch, readConsentRefusalLatch } from './consentRefusalLatch.js';
3
+ /**
4
+ * THE ONE PLACE consent prompt-and-retry lives.
5
+ *
6
+ * ## The problem it exists to remove
7
+ *
8
+ * A block calls a capability whose consent-gated scope its token was minted
9
+ * without. The call fails. Before this module every hook re-threw, and the app
10
+ * had to write the prompt-then-retry dance itself:
11
+ *
12
+ * ```ts
13
+ * try {
14
+ * await submit(body);
15
+ * } catch {
16
+ * requestConsent({ scopes: ['ai:write:budgeted'] });
17
+ * // …now watch useBlockToken().scopes, and retry — with the SAME key.
18
+ * }
19
+ * ```
20
+ *
21
+ * Almost nobody wrote it — so a working app looked broken. This module makes
22
+ * prompt-and-retry the DEFAULT, and every consent-gated hook routes through it
23
+ * rather than open-coding its own copy (a predicate duplicated at N call sites
24
+ * is typically wrong at N-1 of them, in the same direction).
25
+ *
26
+ * ## 🔴 THE MONEY RULE — READ THIS BEFORE CHANGING ANYTHING HERE
27
+ *
28
+ * `withConsentRetry` RE-INVOKES A CALLER-SUPPLIED CLOSURE. It does not build
29
+ * the request, and it deliberately cannot: the idempotency key is minted by the
30
+ * caller BEFORE the first attempt and captured in that closure, so both
31
+ * attempts carry the SAME value. `useBuzzWorkflow`'s own docs are explicit —
32
+ * *"A retry is a SECOND reservation unless you reuse the same idempotencyKey…
33
+ * `submit()` mints a fresh key per call by default, so an automatic retry
34
+ * double-reserves."* A version of this helper that took `(body, options)` and
35
+ * re-sent the message itself would mint a second key and double-charge a real
36
+ * person.
37
+ *
38
+ * So the contract for every call site is one line long: **mint the key outside
39
+ * the closure.** `test/withConsentRetry.test.tsx` asserts the literal key value
40
+ * on BOTH wire calls, and that assertion is mutation-checked.
41
+ *
42
+ * ## The predicate: STRUCTURAL, not a string match
43
+ *
44
+ * "Was this a consent failure?" cannot be answered from the error. Almost every
45
+ * bridge in this package reports host-side failure as a FREE-TEXT string the
46
+ * host forwards verbatim (`BUZZ_BALANCE_RESULT`, `PUBLISH_RESULT`,
47
+ * `APP_WORKFLOWS_RESULT`, … all say so in `messages.ts`), so a
48
+ * `/insufficient.scope/i` test would be a guess about server copy that can
49
+ * change without notice — the "spelled rather than structural" guard shape.
50
+ *
51
+ * The test used instead is a property of the TOKEN, read at the moment of
52
+ * failure: **does the token still lack a scope this operation requires?** That
53
+ * is true by definition for every genuine consent failure (a consent gate IS an
54
+ * absent scope) and false for the overwhelming majority of everything else — a
55
+ * rate limit, a 5xx, a malformed body all happen while the token HOLDS the
56
+ * scope, so those re-throw untouched with no prompt and no retry.
57
+ *
58
+ * ⚠️ It is a necessary condition, not a sufficient one. A NON-consent failure
59
+ * that happens while the token is ALSO missing the scope (a 500 on a submit
60
+ * from an un-granted token) will prompt and retry. That is the deliberate
61
+ * direction to be wrong in: the call needed that scope anyway, so the prompt is
62
+ * correct, and the retry is same-key. The inverse — string-matching, and so
63
+ * silently failing to prompt when a host reworded its error — is the failure
64
+ * this shape cannot have.
65
+ *
66
+ * ## The three hard rules
67
+ *
68
+ * 1. **EXACTLY ONE RETRY.** There is no loop, and adding one would be a defect
69
+ * rather than a tuning choice: a second consent failure means the grant did
70
+ * not fix the problem, so a third attempt is a third money reservation for
71
+ * nothing. The second failure propagates to the caller verbatim.
72
+ * 2. **NEVER RETRY THROUGH A `CONSENT_UNAVAILABLE` FOR THE SCOPES IT NAMED.**
73
+ * That push means the scope was clamped or withheld at mint and NO consent
74
+ * round-trip in this environment can ever add it, so a retry is a guaranteed
75
+ * second failure. Read via `readConsentRefusalLatch` — the existing buffer —
76
+ * rather than a second subscription, so there is one source of truth for
77
+ * "has the host refused". Checked BEFORE the prompt, and again as the wait's
78
+ * own losing arm (a refusal that arrives in answer to THIS prompt).
79
+ *
80
+ * 🔴 **THE LATCH READ IS SCOPE-AWARE, NOT TRANSPORT-GLOBAL — see
81
+ * {@link isRefusalFinalFor}.** The latch is one slot per transport, so
82
+ * treating any refusal as final disabled prompt-and-retry for EVERY hook and
83
+ * EVERY scope until the token rotated (~13 min). The justification does not
84
+ * reach that far: "clamped at mint" is a claim about the REFUSED scopes, and
85
+ * a retry for a scope the host never refused is not a guaranteed second
86
+ * failure.
87
+ *
88
+ * ⚠️ THE WAIT'S LOSING ARM IS NOT SCOPE-CHECKED, deliberately. A
89
+ * `CONSENT_UNAVAILABLE` arriving *during* the wait ends it whatever it names.
90
+ * The prompt that opened this wait asked for exactly `missing`, so in
91
+ * practice a refusal answering it is about those scopes; the only way to be
92
+ * wrong is a concurrent prompt for a DIFFERENT scope set being refused at the
93
+ * same moment, and being wrong there costs one needlessly-surfaced original
94
+ * error — never a spend, never a duplicate write. Scope-checking it would add
95
+ * a way to be wrong in the OTHER direction (waiting on past a real refusal),
96
+ * which is the expensive one.
97
+ * 3. **NEVER RETRY AN ABORT, OR A CALLER THAT WENT AWAY DURING THE WAIT.** An
98
+ * `AbortError` means the caller's component unmounted or its own bound
99
+ * elapsed — work that was cancelled on purpose must not be silently
100
+ * resurrected, least of all on a money path.
101
+ *
102
+ * 🔴 **AN UNMOUNT LANDING IN THE 60s WAIT IS THE SAME RULE IN THE TIME
103
+ * AXIS, and it needs its own check** (`isActive`, below): there is no
104
+ * in-flight request to abort at that point, so nothing produces an
105
+ * `AbortError` and the grant drove a second `attempt()` against a component
106
+ * that no longer exists. On `useTip` that meant a transfer left the viewer's
107
+ * balance with no UI left to report it — and with the hook's `inFlight` set
108
+ * already cleared, that second POST was not even abortable.
109
+ * 4. **NEVER RETRY A VIEWER REFUSAL, A SIGN-IN REQUIREMENT, OR A KEYLESS
110
+ * BRIDGE'S TIMEOUT.** `CreatePostError` and `CollectionFollowError` both
111
+ * carry `declined === true` when the person dismissed the host's own
112
+ * per-action confirm — an answer, not a failure; re-opening the dialog they
113
+ * just closed is nagging. `CreatePostError` also carries
114
+ * `signInRequired === true`, which is un-retryable for a stronger reason: no
115
+ * amount of scope granting gives a signed-OUT viewer a session, so the
116
+ * dialog is one the viewer cannot act on at all and `useRequestSignIn()` is
117
+ * what they need — 60s sooner. The same two classes carry
118
+ * `timedOut === true`, and `CreatePostError.timedOut`'s own docs say why it
119
+ * must not be retried:
120
+ * *"the write may have LANDED and only the reply failed to arrive — and here
121
+ * the write is a PUBLIC POST under the viewer's name… never retry
122
+ * automatically, which is how a duplicate post happens."* Both are keyed on
123
+ * the properties those classes already single-source, so a third such error
124
+ * joins the rule by declaring them.
125
+ *
126
+ * 🔴 **THE RULE `timedOut` ENCODES, IN ONE SENTENCE: a timeout is retryable
127
+ * IFF the call carries an idempotency key.** That is a mechanical property,
128
+ * not a preference. A timed-out request may have landed server-side; with a
129
+ * key the server collapses the re-send into the first result, so the retry
130
+ * is a REPLAY. Without one it is a genuine second write — a second public
131
+ * post, a second follow. So a hook stamps `timedOut` exactly when its wire
132
+ * message has no `idempotencyKey` field: `CREATE_POST_FROM_APP` and the
133
+ * collection-follow bridge do, and they stamp it.
134
+ *
135
+ * 🔴 **WHERE A HOOK STAMPS IT IS PART OF THE RULE, NOT AN IMPLEMENTATION
136
+ * DETAIL — THE STAMP MUST BE INSIDE THE `attempt` CLOSURE.** Until #500
137
+ * round 2 `useCreatePostFromApp` applied it in the catch WRAPPED AROUND this
138
+ * helper, so the raw `RequestTimeoutError` arrived here carrying neither
139
+ * flag, `isCallerMarkedFinal` returned false, and a `posts:write:self`-less
140
+ * token got a prompt and a SECOND POST. Rule 4 was unreachable for the one
141
+ * bridge it was written for, and `flags.timedOut === true` was dead code:
142
+ * deleting that arm left the whole suite green. A stamp applied outside the
143
+ * closure is invisible to every rule in this module.
144
+ *
145
+ * The three money paths — `useBuzzWorkflow.submit`, `useGoodPurchase` and
146
+ * `useTip` — all mint a key ABOVE the retry and hand the same value to both
147
+ * attempts, so none of them stamps it and all three retry a timeout. Their
148
+ * own docs prescribe that same-key retry as the recovery. (`useTip` stamped
149
+ * it until #500 round 1, which made it the only keyed hook that did not
150
+ * retry; that inconsistency is what this paragraph exists to have settled.)
151
+ *
152
+ * ⚠️ An UNMOUNT is a different thing and is covered by rule 3, not this one:
153
+ * `useTip` and `useGoodPurchase` both name their unmount abort `AbortError`
154
+ * and leave the bound-elapsed timeout a plain `Error`, so the two arms reach
155
+ * opposite outcomes here.
156
+ *
157
+ * ## What it CANNOT detect: a scope absent from the MANIFEST
158
+ *
159
+ * A scope the app never declared can never be granted either, and detecting
160
+ * that BEFORE the first attempt is not possible from inside a block today: the
161
+ * manifest is a build-time artifact, `BlockSnapshot` carries no `scopes`
162
+ * declaration (only the token's GRANTED set), and no bridge message exposes
163
+ * one. What covers it at runtime is rule 2 — the host computes its grantable
164
+ * set from the manifest, so an undeclared scope is un-grantable and comes back
165
+ * as `CONSENT_UNAVAILABLE`, which stops the retry. The cost is that this is
166
+ * paid ONE request/refusal round-trip late rather than pre-flight. Making it
167
+ * pre-flight needs the manifest on the wire, which is a host change.
168
+ */
169
+ /**
170
+ * How long to wait for the viewer to answer the consent dialog.
171
+ *
172
+ * 🔴 DELIBERATELY NOT `HUMAN_INTERACTION_TIMEOUT_MS` (10 min), and the
173
+ * difference is not a preference — it is a property of the message. Every OTHER
174
+ * human-gated request in this package is a REQUEST the host REPLIES to, so a
175
+ * dismissal arrives as an answer and the 10-minute ceiling only ever bounds a
176
+ * dialog nobody touched. `REQUEST_CONSENT` is FIRE-AND-FORGET: it carries no
177
+ * `requestId`, the host sends nothing on dismiss, and `CONSENT_UNAVAILABLE`
178
+ * covers only the can-NEVER-be-granted case. So "the viewer closed the dialog"
179
+ * and "the viewer has not clicked yet" are THE SAME OBSERVABLE — silence — and
180
+ * whatever this number is, a dismissal costs the caller exactly that long with a
181
+ * promise still pending. At 10 minutes an app that showed a spinner shows it for
182
+ * ten minutes, which is a second way to look broken.
183
+ *
184
+ * 60s is sized for the thing actually being waited on: a person noticing a modal
185
+ * the host just opened and pressing a button in it. Past that, the ORIGINAL
186
+ * error surfaces, the app is responsive again, and a viewer who grants late
187
+ * loses nothing — their next call sees the scope on the token and never enters
188
+ * this path at all.
189
+ *
190
+ * 🔴 NOT CONFIGURABLE, and that is a decision rather than an omission. A public
191
+ * `consentTimeoutMs` shipped on five signatures in the first draft of #500 with
192
+ * no consumer outside this package — its only demonstrated use was shortening
193
+ * this wait inside one test, which fake timers do without widening the API. If
194
+ * a real caller ever needs a different bound, that is the moment to add one.
195
+ */
196
+ export const CONSENT_GRANT_WAIT_MS = 60_000;
197
+ /**
198
+ * Post a `REQUEST_CONSENT` with a scopes hint.
199
+ *
200
+ * 🔴 Single-sourced with {@link useRequestConsent}, which calls straight into
201
+ * this function. Two spellings of "arm the latch, then send" is exactly the
202
+ * shape that lets one of them forget the arming — and a `CONSENT_UNAVAILABLE`
203
+ * with no listener at the instant it lands falls through the transport's no-op
204
+ * tail and is gone forever (see `consentRefusalLatch.ts`).
205
+ */
206
+ export function sendRequestConsent(transport, payload) {
207
+ // BEFORE the send, always. See the module header of `consentRefusalLatch.ts`.
208
+ armConsentRefusalLatch(transport);
209
+ transport.sendMessage({
210
+ type: 'REQUEST_CONSENT',
211
+ ...(payload ? { payload } : {}),
212
+ });
213
+ }
214
+ /**
215
+ * Which of `required` the transport's CURRENT token does not carry.
216
+ *
217
+ * Reads the live snapshot every call rather than closing over a value: a
218
+ * `TOKEN_REFRESH` can land at any moment, and the whole point of the wait below
219
+ * is that this answer CHANGES.
220
+ */
221
+ export function missingScopes(transport, required) {
222
+ const held = new Set(transport.getSnapshot().token.scopes);
223
+ return required.filter((scope) => !held.has(scope));
224
+ }
225
+ /** An error the caller cancelled on purpose — never resurrect one. */
226
+ function isAbort(err) {
227
+ return err instanceof Error && err.name === 'AbortError';
228
+ }
229
+ /**
230
+ * An error a hook has explicitly marked un-retryable — the viewer DISMISSED a
231
+ * host confirm (`declined`), there is no session to grant anything to
232
+ * (`signInRequired`), or the bridge timed out with no idempotency key to dedupe a
233
+ * second attempt (`timedOut`). See rule 4: `timedOut` means KEYLESS, not merely
234
+ * "timed out" — a keyed hook's timeout deliberately carries neither flag and IS
235
+ * retried, because the same key makes the re-send a replay.
236
+ *
237
+ * A duck-typed property test rather than `instanceof`, deliberately: the classes
238
+ * that carry these (`CreatePostError`, `CollectionFollowError`) live in
239
+ * `hooks/`, and importing them here to narrow would put an import cycle between
240
+ * the helper and the hooks that call it for no behavioural gain. `=== true`, not
241
+ * truthiness, so nothing accidental qualifies — and a `RequestTimeoutError`,
242
+ * which declares none of these fields, is deliberately NOT caught here: a hook
243
+ * whose timeout is final must say so on its OWN error class, inside the `attempt`
244
+ * closure (rule 4).
245
+ *
246
+ * ⚠️ NOT every un-actionable refusal is listed, and that is deliberate: the
247
+ * `'no images to post'` PAYLOAD refusal has no flag and so still prompts. It is
248
+ * the module header's accepted trade-off — a payload the host rejected is not a
249
+ * property of the token, and the structural predicate cannot tell the two apart
250
+ * without a string match. The three flags here are token/session properties the
251
+ * hook classes already single-source.
252
+ */
253
+ function isCallerMarkedFinal(err) {
254
+ if (typeof err !== 'object' || err === null)
255
+ return false;
256
+ const flags = err;
257
+ return (flags.declined === true || flags.timedOut === true || flags.signInRequired === true);
258
+ }
259
+ /**
260
+ * Does a latched `CONSENT_UNAVAILABLE` make THIS operation's retry pointless?
261
+ *
262
+ * Rule 2, narrowed from "any refusal" to "a refusal about a scope this call
263
+ * needs". Two arms, and the second is the fail-safe one:
264
+ *
265
+ * - The payload names a NON-EMPTY scope set DISJOINT from `requiredScopes` ⇒
266
+ * the host refused something else. Not final here. This is the arm that stops
267
+ * one refusal from switching the feature off across every hook and scope for
268
+ * the ~13 minutes until the token rotates.
269
+ * - Anything else — an EMPTY list, or one that intersects `requiredScopes` ⇒
270
+ * final, exactly as before.
271
+ *
272
+ * 🔴 THE EMPTY CASE MUST STAY FINAL, and `@civitai/app-sdk`'s own
273
+ * `ConsentUnavailablePayload` docs are why: *"`scopes` MAY BE EMPTY… Treat the
274
+ * message itself as the signal and `scopes` as an advisory detail for copy."*
275
+ * The host decides to refuse on the UNFILTERED requested set and then filters the
276
+ * echo to the known `BLOCK_SCOPES` vocabulary, so `[]` means "we are not telling
277
+ * you which", never "none". Reading it as "no scope was refused" would invert the
278
+ * guard on precisely the payload that carries the least information.
279
+ */
280
+ function isRefusalFinalFor(refusal, requiredScopes) {
281
+ const refused = refusal.scopes;
282
+ if (!Array.isArray(refused) || refused.length === 0)
283
+ return true;
284
+ return requiredScopes.some((scope) => refused.includes(scope));
285
+ }
286
+ /**
287
+ * Prompt for `missing`, then resolve `true` once the token carries all of them.
288
+ *
289
+ * Resolves `false` — meaning "give up, re-throw the caller's original error" —
290
+ * on either losing arm:
291
+ * - a `CONSENT_UNAVAILABLE` push (rule 2, answering THIS prompt);
292
+ * - `timeoutMs` elapsing with no answer (the viewer never engaged).
293
+ *
294
+ * Listeners are installed BEFORE the message goes out. The dev hosts reply on a
295
+ * `setTimeout(0)` and the real host is a full round-trip away, but a host that
296
+ * answered synchronously would otherwise have its answer dropped, and that is a
297
+ * race nobody would reproduce locally.
298
+ */
299
+ function awaitConsentGrant(transport, missing, timeoutMs) {
300
+ return new Promise((resolve) => {
301
+ const teardown = [];
302
+ let settled = false;
303
+ const settle = (granted) => {
304
+ if (settled)
305
+ return;
306
+ settled = true;
307
+ for (const fn of teardown)
308
+ fn();
309
+ resolve(granted);
310
+ };
311
+ teardown.push(transport.subscribe(() => {
312
+ if (missingScopes(transport, missing).length === 0)
313
+ settle(true);
314
+ }));
315
+ teardown.push(subscribeTyped(transport, 'CONSENT_UNAVAILABLE', () => settle(false)));
316
+ const timer = setTimeout(() => settle(false), timeoutMs);
317
+ teardown.push(() => clearTimeout(timer));
318
+ // 🔴 The hint MUST be non-empty and hold real scope names or the host sends
319
+ // no refusal at all (`resolveUngrantableConsentNotice` returns `notify:
320
+ // false` for `undefined`, a non-array, `[]`, `['']` and `[1, 2]` alike). A
321
+ // silent host here would mean rule 2's losing arm never fires and this wait
322
+ // could only ever end at the timeout. `missing` is non-empty by the caller's
323
+ // guard, and its members are the hook's own `BLOCK_SCOPES` constants.
324
+ sendRequestConsent(transport, { scopes: [...missing] });
325
+ // The grant could already have landed between the failure and this line
326
+ // (a concurrent call's prompt, say). `subscribe` only fires on CHANGE.
327
+ if (missingScopes(transport, missing).length === 0)
328
+ settle(true);
329
+ });
330
+ }
331
+ /**
332
+ * In-flight consent waits, keyed by transport instance and then by the exact
333
+ * scope set being waited on.
334
+ *
335
+ * 🔴 WHY: N CONCURRENT CALLERS ARE N HOOK INSTANCES, so a per-hook `pending` /
336
+ * `loading` gate defuses nothing. A feed of tip buttons tapped three times posted
337
+ * three `REQUEST_CONSENT`s — three host dialogs for one permission — and held
338
+ * three independent 60s waits. The scope set is the natural key: two callers
339
+ * waiting on the same missing scopes are waiting for literally the same event, so
340
+ * one prompt and one wait answer both.
341
+ *
342
+ * 🔴 WHAT THIS MUST NOT COLLAPSE IS THE ATTEMPTS. Only the WAIT is shared. Each
343
+ * caller still runs its own `attempt()` closure carrying its own idempotency key,
344
+ * because three tips are three transfers — sharing a key across them would
345
+ * collapse three payments into one. Pinned in `test/withConsentRetry.test.tsx`
346
+ * ("each keeps its OWN key").
347
+ *
348
+ * A `WeakMap` on the transport so a `resetTransport()` in tests strands nothing,
349
+ * and entries are deleted as they settle so a later call gets a fresh prompt
350
+ * rather than a resolved promise from a previous round.
351
+ */
352
+ const inFlightConsentWaits = new WeakMap();
353
+ /**
354
+ * {@link awaitConsentGrant}, de-duplicated across concurrent callers waiting on
355
+ * the SAME missing scope set. See {@link inFlightConsentWaits}.
356
+ */
357
+ function awaitConsentGrantShared(transport, missing, timeoutMs) {
358
+ // Sorted, so two callers listing the same scopes in a different order share.
359
+ const key = [...missing].sort().join('');
360
+ let byScopeSet = inFlightConsentWaits.get(transport);
361
+ if (!byScopeSet) {
362
+ byScopeSet = new Map();
363
+ inFlightConsentWaits.set(transport, byScopeSet);
364
+ }
365
+ const existing = byScopeSet.get(key);
366
+ if (existing)
367
+ return existing;
368
+ const wait = awaitConsentGrant(transport, missing, timeoutMs).finally(() => {
369
+ // Identity-checked: never evict a wait some later call already installed.
370
+ if (byScopeSet.get(key) === wait)
371
+ byScopeSet.delete(key);
372
+ });
373
+ byScopeSet.set(key, wait);
374
+ return wait;
375
+ }
376
+ /**
377
+ * Run `attempt`; on a consent-shaped failure, prompt the viewer and run it
378
+ * EXACTLY ONCE more.
379
+ *
380
+ * @param transport the singleton transport (snapshot + consent channel).
381
+ * @param requiredScopes the consent-gated scopes this operation needs. MUST be
382
+ * real {@link BLOCK_SCOPES} values — they are sent to the host as the
383
+ * `REQUEST_CONSENT` hint, which is silently ignored unless it holds at least
384
+ * one non-empty recognised name. An empty array disables the behaviour.
385
+ * @param attempt the operation, re-invoked verbatim on retry. 🔴 Mint any
386
+ * idempotency key OUTSIDE this closure — see the module header. 🔴 And stamp
387
+ * any final-error flag (`timedOut`, `declined`, `signInRequired`) INSIDE it,
388
+ * or rule 4 cannot see it.
389
+ * @param options caller opt-out. One field, `autoRequestConsent` — the 60s wait
390
+ * is NOT configurable, see {@link CONSENT_GRANT_WAIT_MS}.
391
+ * @param isActive rule 3 in the time axis — read IMMEDIATELY BEFORE the retry,
392
+ * never cached. A hook passes `() => mountedRef.current`; returning `false`
393
+ * re-throws the original error instead of re-invoking `attempt`. Optional so a
394
+ * caller with no component to outlive (a plain function, a test) needs nothing,
395
+ * and absent means "always active" — the pre-#500-round-2 behaviour.
396
+ */
397
+ export async function withConsentRetry(transport, requiredScopes, attempt, options, isActive) {
398
+ // `=== false`, not `!options?.autoRequestConsent`: the default is ON, so an
399
+ // absent option and an explicit `true` must behave identically.
400
+ if (options?.autoRequestConsent === false || requiredScopes.length === 0) {
401
+ return attempt();
402
+ }
403
+ try {
404
+ return await attempt();
405
+ }
406
+ catch (err) {
407
+ // Rules 3 and 4 — the outcomes a retry must not reopen.
408
+ if (isAbort(err) || isCallerMarkedFinal(err))
409
+ throw err;
410
+ // 🔴 THE PREDICATE IS ONLY SOUND ONCE `BLOCK_INIT` HAS LANDED. Before it the
411
+ // snapshot is all sentinel empties, so `token.scopes` is `[]` and
412
+ // `missingScopes` reports EVERY required scope as absent — which classifies
413
+ // any pre-init failure (typically the request's own bound elapsing while the
414
+ // message sits in the transport's outbound queue) as consent-shaped. Two
415
+ // costs, and the second is the one a viewer sees: the caller waits the full
416
+ // 60s ON TOP OF its own timeout, and the `REQUEST_CONSENT` is itself QUEUED,
417
+ // so it FLUSHES when init finally lands — a consent dialog opening for a call
418
+ // that failed minutes ago, which nobody asked for.
419
+ //
420
+ // Read at FAILURE time rather than at entry, deliberately: init may well have
421
+ // landed during the attempt, and in that case the token is real and the
422
+ // prompt is legitimate.
423
+ if (!transport.getSnapshot().ready)
424
+ throw err;
425
+ // The structural predicate. Nothing is missing ⇒ not a consent failure ⇒
426
+ // behaviour is exactly what it was before this module existed.
427
+ const missing = missingScopes(transport, requiredScopes);
428
+ if (missing.length === 0)
429
+ throw err;
430
+ // Rule 2, first half: a refusal ALREADY on record, for a scope THIS call
431
+ // needs (see `isRefusalFinalFor`). Arm first so the read is against an
432
+ // installed latch (arming is idempotent per transport instance, and only
433
+ // drops state belonging to a transport that is no longer current).
434
+ armConsentRefusalLatch(transport);
435
+ const refusal = readConsentRefusalLatch(transport);
436
+ if (refusal !== null && isRefusalFinalFor(refusal, requiredScopes))
437
+ throw err;
438
+ const granted = await awaitConsentGrantShared(transport, missing, CONSENT_GRANT_WAIT_MS);
439
+ // Refused or abandoned — the CALLER'S original error is what surfaces, not
440
+ // a synthetic one about consent. It is the accurate description of what
441
+ // went wrong with the thing they asked for.
442
+ if (!granted)
443
+ throw err;
444
+ // 🔴 RULE 3, IN THE TIME AXIS — CHECKED HERE, IMMEDIATELY BEFORE THE ONLY
445
+ // LINE THAT COSTS MONEY, NOT EARLIER. The 60s wait is long enough for the
446
+ // caller's component to be gone by now, and nothing else can see that: there
447
+ // is no in-flight request left to abort, so no `AbortError` is ever produced
448
+ // and `isAbort` above cannot fire. Work cancelled on purpose must not be
449
+ // silently resurrected — and a resurrected `tip()` moves Buzz with no UI left
450
+ // to report it.
451
+ if (isActive !== undefined && !isActive())
452
+ throw err;
453
+ // Rule 1: the one retry. A failure here propagates untouched.
454
+ return attempt();
455
+ }
456
+ }
457
+ //# sourceMappingURL=withConsentRetry.js.map
package/dist/testing.d.ts CHANGED
@@ -20,6 +20,7 @@ import { type ReactNode } from 'react';
20
20
  import { __resetTransport } from './transport/singleton.js';
21
21
  import { type MockHostOptions } from './internal/mockHost.js';
22
22
  export { __resetTransport as resetTransport };
23
+ export { installPopoverShim, type PopoverShimHandle, type PopoverShimOptions, } from './internal/popoverShim.js';
23
24
  export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
24
25
  /**
25
26
  * Props for the dev {@link Harness}.
package/dist/testing.js CHANGED
@@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react';
21
21
  import { __resetTransport } from './transport/singleton.js';
22
22
  import { createMockHost, readMockHostUrlOptions, } from './internal/mockHost.js';
23
23
  export { __resetTransport as resetTransport };
24
+ export { installPopoverShim, } from './internal/popoverShim.js';
24
25
  export { createMockHost, readMockHostUrlOptions, } from './internal/mockHost.js';
25
26
  /**
26
27
  * What the harness chrome's `consent=` readout says, from the TWO INDEPENDENT
@@ -264,6 +264,39 @@ export declare class IframeTransport implements BlockTransport {
264
264
  * every non-model slot).
265
265
  */
266
266
  private applyThemeChange;
267
+ /**
268
+ * Apply a host-pushed `ROUTE_CHANGED` to the snapshot.
269
+ *
270
+ * Emits only when the value actually MOVED, for the same reason
271
+ * {@link IframeTransport.applyThemeChange} does: `useSyncExternalStore`
272
+ * re-renders on identity change, so an unconditional spread would re-render
273
+ * every subscriber on a redundant push.
274
+ *
275
+ * 🔴 ONE WRITER, ONE READER — the sub-path lives in `context` and NOWHERE
276
+ * else. Unlike the theme, which the host forwards twice (top-level and inside
277
+ * `context`), `subPath` is a `PageSlotContext` field only, so there is no
278
+ * second copy to keep in step and no top-level `BlockSnapshot.subPath` is
279
+ * introduced here. Adding one would create exactly the divergence the theme
280
+ * handler exists to avoid, in a package where only one of the two could be the
281
+ * value `BLOCK_INIT` delivered.
282
+ *
283
+ * ONLY EVER UPDATES A CONTEXT THAT ALREADY CARRIES THE KEY; never INTRODUCES
284
+ * it — the same rule, and the same reason, as the theme handler. A model slot
285
+ * has no route and the host's own effect lives in `PageBlockHost`, so a
286
+ * `subPath` synthesised onto a model (or unknown) context would be this
287
+ * package asserting a page context the host never sent.
288
+ *
289
+ * 🔴 THAT MAKES IT INIT-GATED IN EFFECT, WHICH IS A DIFFERENCE FROM
290
+ * `THEME_CHANGE` AND IS CORRECT HERE. `EMPTY_SNAPSHOT.context` is
291
+ * `{ slotId: '' }`, so a push that lands BEFORE `BLOCK_INIT` has no key to
292
+ * update and is dropped. It costs nothing: the host sends this message only
293
+ * after init (`initSentRef`), and `snapshotFromInit` replaces the whole
294
+ * snapshot with a `context` whose `subPath` is the host's own current value —
295
+ * so a pre-init push could only ever be older than the init that follows it.
296
+ * The theme is not gated because it has a pre-init writer (the URL fragment)
297
+ * and a top-level field to hold the value; this has neither.
298
+ */
299
+ private applyRouteChange;
267
300
  private emit;
268
301
  }
269
302
  //# sourceMappingURL=iframeTransport.d.ts.map
@@ -623,6 +623,21 @@ export class IframeTransport {
623
623
  this.applyThemeChange(data.payload.theme);
624
624
  return;
625
625
  }
626
+ // Host-pushed ROUTE change (the page's sub-path moved under the app root; no
627
+ // requestId). Same shape of handling as THEME_CHANGE: apply to the snapshot
628
+ // and emit, never matches a pending request.
629
+ //
630
+ // 🔴 THIS BRANCH IS THE FEATURE. Nothing in the type system requires it: the
631
+ // `never` bind in `payloadValidatorFor` forces a VALIDATOR for every union
632
+ // member, and the union member plus the validator together are enough to
633
+ // make `ROUTE_CHANGED` compile, be accepted at the boundary, and then reach
634
+ // the no-op tail. A declared type is not a code path — pinned by the
635
+ // mutation case in `useCivitaiRoute.test.tsx`, which deletes this branch and
636
+ // watches the subscriber's own assertion fail.
637
+ if (isMessage(data, 'ROUTE_CHANGED')) {
638
+ this.applyRouteChange(data.payload.subPath);
639
+ return;
640
+ }
626
641
  // For request/response replies, look up the pending entry by `requestId`.
627
642
  //
628
643
  // 🔴 PROJECTED, NOT RAW. This is the last point before an inbound payload
@@ -732,6 +747,47 @@ export class IframeTransport {
732
747
  this.snapshot = next;
733
748
  this.emit();
734
749
  }
750
+ /**
751
+ * Apply a host-pushed `ROUTE_CHANGED` to the snapshot.
752
+ *
753
+ * Emits only when the value actually MOVED, for the same reason
754
+ * {@link IframeTransport.applyThemeChange} does: `useSyncExternalStore`
755
+ * re-renders on identity change, so an unconditional spread would re-render
756
+ * every subscriber on a redundant push.
757
+ *
758
+ * 🔴 ONE WRITER, ONE READER — the sub-path lives in `context` and NOWHERE
759
+ * else. Unlike the theme, which the host forwards twice (top-level and inside
760
+ * `context`), `subPath` is a `PageSlotContext` field only, so there is no
761
+ * second copy to keep in step and no top-level `BlockSnapshot.subPath` is
762
+ * introduced here. Adding one would create exactly the divergence the theme
763
+ * handler exists to avoid, in a package where only one of the two could be the
764
+ * value `BLOCK_INIT` delivered.
765
+ *
766
+ * ONLY EVER UPDATES A CONTEXT THAT ALREADY CARRIES THE KEY; never INTRODUCES
767
+ * it — the same rule, and the same reason, as the theme handler. A model slot
768
+ * has no route and the host's own effect lives in `PageBlockHost`, so a
769
+ * `subPath` synthesised onto a model (or unknown) context would be this
770
+ * package asserting a page context the host never sent.
771
+ *
772
+ * 🔴 THAT MAKES IT INIT-GATED IN EFFECT, WHICH IS A DIFFERENCE FROM
773
+ * `THEME_CHANGE` AND IS CORRECT HERE. `EMPTY_SNAPSHOT.context` is
774
+ * `{ slotId: '' }`, so a push that lands BEFORE `BLOCK_INIT` has no key to
775
+ * update and is dropped. It costs nothing: the host sends this message only
776
+ * after init (`initSentRef`), and `snapshotFromInit` replaces the whole
777
+ * snapshot with a `context` whose `subPath` is the host's own current value —
778
+ * so a pre-init push could only ever be older than the init that follows it.
779
+ * The theme is not gated because it has a pre-init writer (the URL fragment)
780
+ * and a top-level field to hold the value; this has neither.
781
+ */
782
+ applyRouteChange(subPath) {
783
+ const context = this.snapshot.context;
784
+ if (!('subPath' in context))
785
+ return;
786
+ if (context.subPath === subPath)
787
+ return;
788
+ this.snapshot = { ...this.snapshot, context: { ...context, subPath } };
789
+ this.emit();
790
+ }
735
791
  emit() {
736
792
  for (const listener of this.listeners)
737
793
  listener();
@@ -90,6 +90,31 @@ export declare function isValidTokenRefresh(p: unknown): p is {
90
90
  export declare function isValidThemeChange(p: unknown): p is {
91
91
  theme: Theme;
92
92
  };
93
+ /**
94
+ * Host-pushed ROUTE change — the sub-path below the app root that is now
95
+ * showing. No `requestId` field; the host is the initiator, exactly like
96
+ * `TOKEN_REFRESH`.
97
+ *
98
+ * STRICTNESS: a string, and NOT a non-empty one. `''` is the genuine value on an
99
+ * app's own index — the same decision `isPageSlotContext` records for the field
100
+ * this message updates ("`subPath` is checked as a string, not a NON-EMPTY one")
101
+ * — so requiring content here would drop every navigation back to the app root
102
+ * and freeze the block on whatever sub-path it was last told about. That is a
103
+ * worse failure than the one a stricter check would prevent.
104
+ *
105
+ * Nothing further is asserted about the shape of the string. A leading slash, a
106
+ * dot segment or an absolute URL would all be host-side bugs, and this package
107
+ * is not the authority on the host's own resolver: the value lands in
108
+ * `context.subPath`, which is data a block routes on, not a URL this package
109
+ * fetches or assigns. Dropping a push costs at most a stale route (the block
110
+ * keeps rendering the last good value) and never a hang — nothing awaits it.
111
+ *
112
+ * 🔴 A validator is only reachable once `payloadValidatorFor` maps the type to
113
+ * it — see the same note on {@link isValidThemeChange}.
114
+ */
115
+ export declare function isValidRouteChanged(p: unknown): p is {
116
+ subPath: string;
117
+ };
93
118
  /**
94
119
  * Host-pushed refusal of a `REQUEST_CONSENT` that can never be granted. No
95
120
  * `requestId` field; the host is the initiator, exactly like `TOKEN_REFRESH`.
@@ -294,6 +294,35 @@ export function isValidThemeChange(p) {
294
294
  return false;
295
295
  return true;
296
296
  }
297
+ /**
298
+ * Host-pushed ROUTE change — the sub-path below the app root that is now
299
+ * showing. No `requestId` field; the host is the initiator, exactly like
300
+ * `TOKEN_REFRESH`.
301
+ *
302
+ * STRICTNESS: a string, and NOT a non-empty one. `''` is the genuine value on an
303
+ * app's own index — the same decision `isPageSlotContext` records for the field
304
+ * this message updates ("`subPath` is checked as a string, not a NON-EMPTY one")
305
+ * — so requiring content here would drop every navigation back to the app root
306
+ * and freeze the block on whatever sub-path it was last told about. That is a
307
+ * worse failure than the one a stricter check would prevent.
308
+ *
309
+ * Nothing further is asserted about the shape of the string. A leading slash, a
310
+ * dot segment or an absolute URL would all be host-side bugs, and this package
311
+ * is not the authority on the host's own resolver: the value lands in
312
+ * `context.subPath`, which is data a block routes on, not a URL this package
313
+ * fetches or assigns. Dropping a push costs at most a stale route (the block
314
+ * keeps rendering the last good value) and never a hang — nothing awaits it.
315
+ *
316
+ * 🔴 A validator is only reachable once `payloadValidatorFor` maps the type to
317
+ * it — see the same note on {@link isValidThemeChange}.
318
+ */
319
+ export function isValidRouteChanged(p) {
320
+ if (!isObject(p))
321
+ return false;
322
+ if (typeof p.subPath !== 'string')
323
+ return false;
324
+ return true;
325
+ }
297
326
  /**
298
327
  * Host-pushed refusal of a `REQUEST_CONSENT` that can never be granted. No
299
328
  * `requestId` field; the host is the initiator, exactly like `TOKEN_REFRESH`.
@@ -1635,6 +1664,8 @@ export function payloadValidatorFor(type) {
1635
1664
  return isValidTokenRefreshResponse;
1636
1665
  case 'THEME_CHANGE':
1637
1666
  return isValidThemeChange;
1667
+ case 'ROUTE_CHANGED':
1668
+ return isValidRouteChanged;
1638
1669
  case 'CONSENT_UNAVAILABLE':
1639
1670
  return isValidConsentUnavailable;
1640
1671
  case 'ESTIMATE_RESULT':