@civitai/blocks-react 0.60.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.
package/README.md CHANGED
@@ -123,7 +123,9 @@ const { ready, context, viewer, theme, settings, blockId, blockInstanceId, appId
123
123
  ```
124
124
 
125
125
  - `context` — `BlockContext` (`{ slotId, … }`); narrow to `ModelSlotContext` for
126
- model-page slots.
126
+ model-page slots. LIVE on a page slot: `context.subPath` starts at the
127
+ `BLOCK_INIT` value and then tracks the host's `ROUTE_CHANGED` push on every
128
+ navigation — see [`useCivitaiRoute()`](#usecivitairoute).
127
129
  - `viewer` — `ViewerInfo | null` (`null` = anonymous). **Gate sign-in with
128
130
  `isSignedIn(viewer)`** (from `@civitai/app-sdk/blocks`), never on
129
131
  `viewer.id`/`viewer.username` (both `@deprecated`). Don't open-code the gate:
@@ -961,10 +963,31 @@ await shared.withdraw(key); // remove my own entry
961
963
 
962
964
  Drive the platform Checkpoint picker + persist a viewer override.
963
965
 
966
+ 🔴 **`baseModelGroup` is a FILTER — derive it, never hardcode it.** The host hides
967
+ every checkpoint outside the family you pass, so a literal ecosystem pins every
968
+ viewer to whichever family the author happened to test with. Read it from the
969
+ checkpoint the block already holds. The parameter is currently **required** by
970
+ this hook's type, and `''` is **not** an escape hatch — it does not even mean the
971
+ same thing on both hosts. On a **model slot** the host normalises whatever string
972
+ you send, so `''` resolves to the real ecosystem key `Other` and NARROWS to that
973
+ one family. On a **page** the host drops a zero-length value, so `''` behaves
974
+ exactly like omitting it. Neither is what you meant on at least one surface:
975
+ pass a family derived from a real checkpoint, and never `''`.
976
+
964
977
  ```tsx
978
+ import { isModelSlotContext } from '@civitai/app-sdk/blocks';
979
+
980
+ const { context } = useBlockContext();
965
981
  const { open, persist } = useCheckpointPicker();
966
- const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
967
- if (selected) await persist(selected.versionId); // null clears the override
982
+
983
+ // Derive the family from the checkpoint the block already holds — never a literal.
984
+ if (isModelSlotContext(context) && context.checkpoint) {
985
+ const { selected } = await open({
986
+ baseModelGroup: context.checkpoint.baseModel,
987
+ currentVersionId: context.checkpoint.versionId,
988
+ });
989
+ if (selected) await persist(selected.versionId); // null clears the override
990
+ }
968
991
  ```
969
992
 
970
993
  ### `useResourcePicker()`
@@ -974,15 +997,39 @@ The viewer searches in host chrome; the block only ever sees the one resource it
974
997
  picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced
975
998
  server-side at estimate/submit.
976
999
 
1000
+ 🔴 **Pass NO `baseModelGroup` by default.** It is an optional FILTER, and the host
1001
+ hides every resource outside the family you pass — so a hardcoded ecosystem makes
1002
+ the viewer's own valid LoRAs invisible and the picker look empty or broken. Omit
1003
+ it and the viewer sees everything of that type.
1004
+
977
1005
  ```tsx
978
1006
  const { open } = useResourcePicker();
979
- const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
1007
+ const picked = await open({ resourceType: 'LORA' }); // unconstrained — the default
980
1008
  if (picked) {
981
1009
  const versionId = picked.versionId; // feed into body.additionalResources
982
1010
  const weight = picked.strength; // recommended default weight (may be undefined)
983
1011
  }
984
1012
  ```
985
1013
 
1014
+ Constrain it **only** when the block already holds a chosen checkpoint the pick has
1015
+ to match — and then derive the family from that checkpoint, never from a literal.
1016
+ 🔴 **This hook is PAGE-ONLY, and a page slot has no `context.checkpoint`** — that
1017
+ field lives on `ModelSlotContext` alone, so the family comes from
1018
+ `BlockResourceInfo.baseModel`, the `baseModel` of a Checkpoint this same picker
1019
+ returned earlier:
1020
+
1021
+ ```tsx
1022
+ const { open } = useResourcePicker();
1023
+
1024
+ const checkpoint = await open({ resourceType: 'Checkpoint' });
1025
+ if (checkpoint) {
1026
+ const matching = await open({
1027
+ resourceType: 'LORA',
1028
+ baseModelGroup: checkpoint.baseModel, // from the pick above — BlockResourceInfo.baseModel
1029
+ });
1030
+ }
1031
+ ```
1032
+
986
1033
  ### `useImageUpload()`
987
1034
 
988
1035
  Host-mediated image upload — the host opens its native upload modal and the
@@ -1018,13 +1065,113 @@ const first = resources[0]; // .versionId / .strength / .trainedWord
1018
1065
 
1019
1066
  ### `useCivitaiNavigate()`
1020
1067
 
1021
- Request a navigation within civitai.com (host-mediated; fire-and-forget).
1068
+ Request a navigation from the host. The hook sends a `NAVIGATE` message and
1069
+ returns — fire-and-forget, so the block never learns what the host did,
1070
+ including when the host **refuses** the request.
1071
+
1072
+ `scope` selects the **space** `path` is resolved in, and it **defaults to
1073
+ `'app'`**:
1074
+
1075
+ | `scope` | `path` resolves | The viewer |
1076
+ |---|---|---|
1077
+ | `'app'` *(default)* | under **this app's own route**, as a sub-path of it | stays in your app; the page stays mounted |
1078
+ | `'site'` | at the **civitai.com root** | leaves your app for a site page |
1079
+
1080
+ > 🔴 **A leading slash carries no meaning.** The host normalises it away in
1081
+ > **both** scopes, so `'/settings'` and `'settings'` are one request within
1082
+ > whichever scope you chose. That means `navigate('/models/12345')` asks for
1083
+ > **your app's** `/models/12345` — *not* civitai's model page. To reach the
1084
+ > civitai.com page, say so: `navigate('models/12345', { scope: 'site' })`.
1085
+ >
1086
+ > Both spellings were app-scoped before `scope` existed, so no call you have
1087
+ > already written changed meaning — that is the point of the default.
1088
+
1089
+ `'site'` is granted **per-surface**: the public run page and the dev tunnel allow
1090
+ it, and a private run or a moderator's review preview refuse it. A refusal is
1091
+ silent, so do not build a flow that needs to know it happened.
1092
+
1093
+ `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
1094
+ `'new_tab'` is host-side behaviour and the host is the authority on it; this
1095
+ package sends the message and makes no promise about the outcome.
1096
+
1097
+ > 🔴 **Nothing in your manifest enables `'new_tab'`.** In particular, do **not**
1098
+ > declare `allow-popups-to-escape-sandbox`: the host intersects a manifest's
1099
+ > `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
1100
+ > is dropped for every block at every trust tier and declaring it has no effect.
1101
+ > Earlier versions of this page said `'new_tab'` required it — that was wrong.
1022
1102
 
1023
1103
  ```tsx
1024
1104
  const { navigate } = useCivitaiNavigate();
1025
- navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
1105
+
1106
+ // Your app's own pages — the default.
1107
+ navigate('settings'); // this app's /settings
1108
+ navigate('/settings'); // identical; the slash means nothing
1109
+
1110
+ // A civitai.com page — needs an explicit scope.
1111
+ navigate('models/12345', { scope: 'site' });
1112
+ navigate('models/12345', { scope: 'site', target: 'new_tab' });
1113
+
1114
+ // The pre-`scope` two-argument shape still works, and is still app-scoped.
1115
+ navigate('detail/7', 'new_tab');
1116
+ ```
1117
+
1118
+ An app-scoped `navigate()` is **half** of a round trip. The host owns the
1119
+ history, so the way your block learns where it ended up is
1120
+ [`useCivitaiRoute()`](#usecivitairoute) — read that next if you are routing.
1121
+
1122
+ ### `useCivitaiRoute()`
1123
+
1124
+ The sub-path below your app's root that is **currently showing**. This is the
1125
+ other half of an app-scoped [`useCivitaiNavigate()`](#usecivitainavigate): you
1126
+ ask the host to move, the host pushes it shallowly so your frame stays mounted,
1127
+ and this is how you find out where you went.
1128
+
1129
+ ```tsx
1130
+ function Router() {
1131
+ const subPath = useCivitaiRoute(); // '' on your app's index
1132
+ const [view, id] = subPath.split('/');
1133
+ return view === 'compare' ? <Compare id={id} /> : <Index />;
1134
+ }
1026
1135
  ```
1027
1136
 
1137
+ It also reports the moves you **did not** ask for: the viewer's own
1138
+ back/forward, and a deep link the host resolved after init.
1139
+
1140
+ Two things set the value — `BLOCK_INIT`'s `context.subPath` at mount, and the
1141
+ host's `ROUTE_CHANGED` push on every later change. **The first value is never a
1142
+ message**, which is why this is a value hook rather than an `onRouteChanged`
1143
+ callback: a callback alone cannot see where the block started, and a change that
1144
+ lands before its subscription effect runs is lost. It is the same value as
1145
+ `useBlockContext().context.subPath` on a page slot — reach for this when the
1146
+ route is all you need, and because its return type is a plain `string` instead of
1147
+ a field on a union you have to narrow.
1148
+
1149
+ > 🔴 **No leading slash.** The host sends the segment below your app root, so
1150
+ > `subPath === 'compare/42'` is the comparison that works and
1151
+ > `subPath === '/compare/42'` is the one that silently never matches.
1152
+
1153
+ > 🔴 **`''` is a real route — your app's index — and it is also the pre-init
1154
+ > value.** The two are indistinguishable from this hook alone, exactly as
1155
+ > `'light'` is both a real theme and [`useBlockTheme()`](#useblocktheme)'s
1156
+ > pre-init value. Gate on `useBlockContext().ready` if your first paint must tell
1157
+ > them apart.
1158
+
1159
+ > 🔴 **Read it on every render.** A block that copies the value into state once
1160
+ > at mount, or routes imperatively in a mount-only effect, stays on the route it
1161
+ > started with — the URL moves and nothing renders, which is the exact symptom
1162
+ > this message exists to end.
1163
+
1164
+ **Page slot only.** A model-page slot has no route of its own, so this returns
1165
+ `''` there and never moves. Against a host that predates `ROUTE_CHANGED` the
1166
+ value simply stays at the init sub-path (the old behaviour); nothing awaits the
1167
+ message, so there is no hang either way.
1168
+
1169
+ Exercise it locally with `pnpm dev:live`, where `navigate()` drives the real
1170
+ message end-to-end. `createMockHost` has **no** route control, deliberately: it
1171
+ does not handle `NAVIGATE` at all and has no URL to move, so a synthetic setter
1172
+ there would be a second, weaker way to produce a message the live host already
1173
+ produces from the call a block actually makes.
1174
+
1028
1175
  ### `useBlockAnalytics()`
1029
1176
 
1030
1177
  Fire-and-forget event tracking into the host's analytics pipeline.
@@ -1045,8 +1192,97 @@ const { requestSignIn } = useRequestSignIn();
1045
1192
  requestSignIn();
1046
1193
  ```
1047
1194
 
1195
+ ### Automatic consent prompt-and-retry (on by default)
1196
+
1197
+ **You usually do not need to write any of this.** Since `1.0.0` the
1198
+ consent-gated calls below handle a missing scope themselves: the call fails, the
1199
+ SDK opens the host's consent dialog naming the scope the call needs, waits for
1200
+ the grant, and then **retries the original call once** so it resolves as if it
1201
+ had just worked.
1202
+
1203
+ | Hook | Call | Scope it prompts for |
1204
+ |---|---|---|
1205
+ | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
1206
+ | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
1207
+ | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
1208
+ | `useTip()` | `tip()` | `social:tip:self` |
1209
+
1210
+ 🔴 **`estimate()` is deliberately NOT in that table.** It is a price READ, and
1211
+ blocks call it from an effect keyed on the generation form — on mount, and again
1212
+ on every parameter change. Prompting there would open a consent dialog with no
1213
+ user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
1214
+ it always has; show no price and let `submit()` do the asking.
1215
+
1216
+ ```tsx
1217
+ // This is the whole thing. No try/catch around a consent prompt, no watching
1218
+ // useBlockToken().scopes, no manual retry.
1219
+ const { submit } = useBuzzWorkflow();
1220
+ const snapshot = await submit(body);
1221
+ ```
1222
+
1223
+ 🔴 **The retry re-sends the FIRST attempt's `idempotencyKey`.** That is what
1224
+ makes it safe on the money paths: `submit()`, `purchase()` and `tip()` mint the
1225
+ key once, before the first attempt, so however many attempts one call makes the
1226
+ server sees **one** logical operation and charges once. A retry with a fresh key
1227
+ would be a second reservation against the viewer's Buzz.
1228
+
1229
+ It **never** retries when:
1230
+
1231
+ - the token already holds every scope the call needs (so the failure was not
1232
+ about consent — a rate limit, a 5xx, a bad body all behave exactly as before);
1233
+ - the call failed **before `BLOCK_INIT`** landed. There is no real token yet, so
1234
+ "the token is missing this scope" is not a fact about consent;
1235
+ - the host has pushed `CONSENT_UNAVAILABLE` **naming a scope this call needs** —
1236
+ that scope can never be granted here, so a retry is a guaranteed second
1237
+ failure. A refusal that names *other* scopes leaves this call alone, and one
1238
+ that names **none** (the payload's `scopes` is documented as advisory and may
1239
+ be empty) is treated as covering everything, which is the safe reading;
1240
+ - the viewer dismissed a host confirm (`declined`), or there is no session to
1241
+ grant anything to (`signInRequired` — route that into `useRequestSignIn()`);
1242
+ - the component **unmounted** — including *during* the 60 s wait, so a grant that
1243
+ arrives after your component is gone does not spend anything;
1244
+ - the request timed out on a bridge with **no idempotency key** (`createPost()`,
1245
+ collection follow) — the retry would be a genuine second write, i.e. a second
1246
+ public post. A timeout on a call that HAS a key — `submit()`, `purchase()`,
1247
+ `tip()` — *is* retried, because the retry re-sends that key and the server
1248
+ collapses the two into one operation;
1249
+ - **a second time.** One retry, never a loop. A second consent failure surfaces
1250
+ to you unchanged.
1251
+
1252
+ If the viewer never answers the dialog, the **original** error is re-thrown after
1253
+ 60 s and your `catch` sees exactly what it would have seen before.
1254
+
1255
+ ⚠️ **One failure is deliberately NOT excluded:** a host refusal about the
1256
+ *payload* rather than the token — `createPost()`'s `'no images to post'` — still
1257
+ prompts, because nothing distinguishes it structurally from a genuine consent
1258
+ failure without string-matching server copy. The cost is one needless dialog; the
1259
+ retry re-opens the host's own confirm and cannot publish anything new.
1260
+
1261
+ **Concurrent callers share one dialog.** `N` calls that are in flight together and
1262
+ need the same scope post **one** `REQUEST_CONSENT` and wait on it together — a
1263
+ feed of tip buttons does not open a dialog per button. Each call still retries its
1264
+ **own** request with its **own** idempotency key, so *N* tips remain *N* transfers.
1265
+ ⚠️ This is de-duplication of *concurrent* waits only: calls made one after another
1266
+ each get their own prompt, which is the shape that keeps `estimate()` out of the
1267
+ table above.
1268
+
1269
+ Opt out per call — the same single option on every hook above:
1270
+
1271
+ ```tsx
1272
+ await submit(body, { autoRequestConsent: false }); // pre-1.0 behaviour
1273
+ ```
1274
+
1275
+ ⚠️ **Not covered:** a scope your **manifest** never declared can never be granted
1276
+ either, and a block cannot see its own manifest at runtime — so that case is
1277
+ caught one round-trip late, by the host's `CONSENT_UNAVAILABLE`, rather than
1278
+ before the first attempt. Declare the scopes your app uses.
1279
+
1048
1280
  ### `useRequestConsent()`
1049
1281
 
1282
+ The manual version of the above — still exported, still the right tool when you
1283
+ want to prompt *before* a call (e.g. on an onboarding screen) rather than after
1284
+ one fails.
1285
+
1050
1286
  Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes
1051
1287
  an action whose consent-gated scope the block token is missing (e.g. Generate
1052
1288
  needs `ai:write:budgeted` but the viewer hasn't granted it). Fire-and-forget —
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared, PUBLIC options for the SDK's automatic consent prompt-and-retry.
3
+ *
4
+ * Declared here rather than beside the mechanics in
5
+ * `internal/withConsentRetry.ts` for one structural reason: `src/index.ts` may
6
+ * not reach a module under `internal/` (#378, enforced by
7
+ * `tests/guards/blocks-react-entry-directory-names.test.mjs`, which follows
8
+ * re-export edges transitively). A caller has to be able to SPELL this type in
9
+ * its own signatures, so the type is public and the mechanics stay private.
10
+ *
11
+ * Every consent-gated call reaches this field through the ONE declaration here
12
+ * — `SubmitWorkflowOptions`, `GoodPurchaseOptions` and `TipOptions` EXTEND it
13
+ * (they have money fields of their own); `createPost()` takes it directly. A
14
+ * predicate — or an option name — open-coded at N sites is typically wrong at
15
+ * N-1 of them.
16
+ *
17
+ * 🔴 ONE FIELD, DELIBERATELY. A second, `consentTimeoutMs`, was cut in #500
18
+ * round 1: it had no consumer outside this package and its only demonstrated
19
+ * use was shortening the 60s wait inside a TEST. A test seam does not belong on
20
+ * five public signatures — the test now uses fake timers instead, and the wait
21
+ * bound is the non-public `CONSENT_GRANT_WAIT_MS`. Adding a knob here commits
22
+ * the package to it forever; do not add one without a caller that needs it.
23
+ */
24
+ export interface ConsentRetryOptions {
25
+ /**
26
+ * Whether a consent-gated failure should automatically open the host's
27
+ * consent dialog and, on grant, retry the call ONCE.
28
+ *
29
+ * **Defaults to `true`** — the good behaviour is the default one. Set `false`
30
+ * to get the pre-1.0 behaviour: the original error is re-thrown unchanged
31
+ * and nothing is prompted.
32
+ *
33
+ * 🔴 THE RETRY REUSES THE FIRST ATTEMPT'S IDEMPOTENCY KEY on every money path
34
+ * that has one (`submit`, `purchase`, `tip`). That is what makes it safe: a
35
+ * retry with a FRESH key is a SECOND reservation against the viewer's Buzz.
36
+ * See `internal/withConsentRetry.ts`.
37
+ */
38
+ autoRequestConsent?: boolean;
39
+ }
40
+ //# sourceMappingURL=consentRetryOptions.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=consentRetryOptions.js.map
@@ -1,4 +1,5 @@
1
1
  import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
2
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
2
3
  /**
3
4
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
4
5
  * in SECONDS.
@@ -393,7 +394,7 @@ export declare class WorkflowSubmitError extends Error {
393
394
  constructor(snapshot: BlockWorkflowSnapshot, code: WorkflowSubmitErrorCode);
394
395
  }
395
396
  /** Optional per-submit controls. */
396
- export interface SubmitWorkflowOptions {
397
+ export interface SubmitWorkflowOptions extends ConsentRetryOptions {
397
398
  /**
398
399
  * A STABLE idempotency key for this logical submit. Reuse the SAME value when
399
400
  * RETRYING a submit whose response was lost (timeout / network drop) so the
@@ -405,6 +406,11 @@ export interface SubmitWorkflowOptions {
405
406
  * SAFE. That code means a workflow probably exists and its spend may already
406
407
  * be committed server-side; retrying WITHOUT reusing the key mints a fresh one
407
408
  * and therefore a SECOND reservation. See {@link WorkflowSubmitError.code}.
409
+ *
410
+ * The SDK's own automatic consent retry obeys this: whichever value ends up
411
+ * here — yours, or the one `submit()` mints — is the value BOTH of its
412
+ * attempts carry. So an error you receive may already be a second attempt's;
413
+ * if you then retry a third time by hand, reuse this key for that too.
408
414
  */
409
415
  idempotencyKey?: string;
410
416
  }
@@ -430,6 +436,13 @@ export interface UseBuzzWorkflow {
430
436
  * `result` is updated to the returned snapshot BEFORE any rejection, so a
431
437
  * failed estimate can never leave a previous, differently-configured
432
438
  * estimate's price sitting in `result` for a Confirm gate to read.
439
+ *
440
+ * 🔴 NO AUTOMATIC CONSENT PROMPT HERE, unlike {@link UseBuzzWorkflow.submit}.
441
+ * Blocks call `estimate()` from an effect keyed on the generation form, so it
442
+ * fires on mount and on every parameter change — prompting there would open a
443
+ * consent dialog with no user gesture behind it, once per edit. A missing
444
+ * scope surfaces as an ordinary rejection; show no price and let `submit()`
445
+ * do the asking.
433
446
  */
434
447
  estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
435
448
  /**
@@ -468,6 +481,15 @@ export interface UseBuzzWorkflow {
468
481
  *
469
482
  * `result` is updated to the returned snapshot BEFORE any rejection, so a
470
483
  * failed submit can never leave a previous submit's workflow in `result`.
484
+ *
485
+ * 🔴 CONSENT IS HANDLED FOR YOU. When the token lacks `ai:write:budgeted`,
486
+ * this opens the host's consent dialog, waits for the grant, and re-sends the
487
+ * submit ONCE — with the SAME {@link SubmitWorkflowOptions.idempotencyKey}, so
488
+ * the two attempts are one reservation, not two. Nothing else changes: a
489
+ * failure while the token DOES hold the scope is untouched, a
490
+ * `CONSENT_UNAVAILABLE` environment is never retried, and a second consent
491
+ * failure reaches you unchanged. Opt out with
492
+ * {@link ConsentRetryOptions.autoRequestConsent}`: false`.
471
493
  */
472
494
  submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
473
495
  /**
@@ -1,6 +1,21 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
+ import { withConsentRetry } from '../internal/withConsentRetry.js';
2
4
  import { getTransport } from '../transport/singleton.js';
3
5
  import { generateIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
6
+ /**
7
+ * The consent-gated scope {@link UseBuzzWorkflow.submit} requires.
8
+ *
9
+ * 🔴 `submit()` ONLY. `estimate()` needs the same scope but is deliberately not
10
+ * routed through the automatic consent retry — see the comment on `estimate`
11
+ * below; it is an on-mount read with no gesture behind it.
12
+ *
13
+ * Named from {@link BLOCK_SCOPES}, never a string literal: this array is sent to
14
+ * the host as the `REQUEST_CONSENT` hint, and the host ignores a hint with no
15
+ * RECOGNISED non-empty name — so a typo here would not error, it would make the
16
+ * automatic prompt silently do nothing.
17
+ */
18
+ const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
4
19
  /**
5
20
  * Snapshot statuses that mean "no further polling is needed."
6
21
  * Used by both `submit` (a host can return an instant-fail / cached result)
@@ -556,6 +571,50 @@ export function useBuzzWorkflow() {
556
571
  const [status, setStatus] = useState('idle');
557
572
  const [result, setResult] = useState(null);
558
573
  const [error, setError] = useState(null);
574
+ /**
575
+ * Whether this hook's component is still mounted.
576
+ *
577
+ * 🔴 ITS ONLY JOB IS THE CONSENT RETRY, and that is a MONEY gate rather than a
578
+ * setState-after-unmount tidy-up. `submit()` can sit in `withConsentRetry`'s
579
+ * 60s grant wait long after the component is gone, and nothing else can see
580
+ * that: this hook's calls go through the postMessage bridge, so there is no
581
+ * `AbortController` to fire and rule 3's `AbortError` path never triggers. A
582
+ * grant arriving after unmount would then RESERVE BUZZ for a generation nobody
583
+ * is left to watch. Read immediately before the retry, never cached.
584
+ */
585
+ const mountedRef = useRef(true);
586
+ useEffect(() => {
587
+ mountedRef.current = true;
588
+ return () => {
589
+ mountedRef.current = false;
590
+ };
591
+ }, []);
592
+ /**
593
+ * 🔴 `estimate()` IS DELIBERATELY NOT ROUTED THROUGH `withConsentRetry`, and
594
+ * that exclusion is load-bearing rather than an oversight.
595
+ *
596
+ * The automatic prompt is for calls a PERSON just made. `estimate()` is not
597
+ * one: `starters/examples/buzz-workflow/src/App.tsx` calls it from a
598
+ * `useEffect` keyed on the form inputs, so it fires on mount and again on
599
+ * every parameter edit. Routing it would open a consent dialog with no gesture
600
+ * behind it and hold each call pending for the full 60s grant wait. That is the
601
+ * same reason the Buzz READS are excluded; `estimate()` just happens to live on
602
+ * a hook whose OTHER call moves money.
603
+ *
604
+ * ⚠️ The in-flight DE-DUPLICATION `withConsentRetry` gained in #500 round 2
605
+ * does not change this verdict, and reading it as a reason to route
606
+ * `estimate()` would be a mistake. It collapses CONCURRENT waits on the same
607
+ * scope set into one dialog; a form edited over several seconds produces
608
+ * SEQUENTIAL calls, each after the previous wait settled, so the N-dialogs
609
+ * problem survives for exactly this shape. The no-gesture objection is
610
+ * independent of it either way.
611
+ *
612
+ * A failed estimate keeps the behaviour that starter's own `catch` is written
613
+ * against: reject with the server's reason, and show no price, because a
614
+ * missing quote is not viewer-actionable copy. `submit()` IS routed, and that
615
+ * is where both the gesture and the charge are. Pinned by
616
+ * `test/withConsentRetry.test.tsx`.
617
+ */
559
618
  const estimate = useCallback(async (body) => {
560
619
  setError(null);
561
620
  setStatus('estimating');
@@ -606,62 +665,84 @@ export function useBuzzWorkflow() {
606
665
  throw err;
607
666
  }
608
667
  }, []);
668
+ /**
669
+ * ONE submit round-trip AND its result contract.
670
+ *
671
+ * 🔴 `idempotencyKey` IS A PARAMETER, NOT MINTED HERE, AND THAT IS THE MONEY
672
+ * SAFETY PROPERTY OF THIS WHOLE FILE. `submit` mints it ONCE, above the
673
+ * consent retry, and passes the same value into both invocations of this
674
+ * function. Minting it here instead would give the automatic retry a FRESH
675
+ * key — a SECOND Buzz reservation for one logical submit, which is exactly
676
+ * what {@link SubmitWorkflowOptions.idempotencyKey}'s docs forbid.
677
+ */
678
+ const submitOnce = useCallback(async (body, idempotencyKey) => {
679
+ const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
680
+ // 🔴 PUBLISH THE SNAPSHOT BEFORE THE REJECTION BELOW, for the same reason
681
+ // `estimate` does: a block may render from `result` rather than from the
682
+ // returned value, and jumping over this line would leave the PREVIOUS
683
+ // submit's snapshot in place — a live control pointing at a workflow THIS
684
+ // submit did not queue.
685
+ setResult(snapshot);
686
+ // 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
687
+ // `submit` half of civitai/civitai#4159). Two producers report
688
+ // `status:'failed'` and `status` separates neither:
689
+ //
690
+ // - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
691
+ // (open a top-up flow). The server quotes the price it refused to
692
+ // charge, so `cost.total` is present. It RESOLVES — turning this arm
693
+ // into a throw is the one change that would break the recovery path.
694
+ // - a failure-shaped reply with NO `cost` is not a usable outcome. It
695
+ // REJECTS — and the `code` says which kind, because they differ on
696
+ // whether money moved (see WorkflowSubmitError.code).
697
+ //
698
+ // BOTH clauses are load-bearing. Dropping `status === 'failed'` would
699
+ // reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
700
+ // too); dropping the cost test would reject the budget rejection. And the
701
+ // test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
702
+ // real price and falsy.
703
+ //
704
+ // 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
705
+ // would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
706
+ // legitimate outcomes the server really does emit without a price
707
+ // (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
708
+ // WIDENING is invisible to a mutation sweep that only deletes clauses, so
709
+ // all three statuses are pinned by their own fixtures in the test file.
710
+ if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
711
+ // 🔴 WHICH ARM: the host stamps its own synthesised failures with the
712
+ // `'failed'` sentinel, while a server-built reply carries `workflow.id`
713
+ // (or the `'whatif'` sentinel).
714
+ // Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
715
+ // money MAY be committed — an unknown id must never buy the reassuring
716
+ // "nothing was charged" reading.
717
+ throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
718
+ }
719
+ setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
720
+ return snapshot;
721
+ }, []);
609
722
  const submit = useCallback(async (body, options) => {
610
723
  setError(null);
611
724
  setStatus('submitting');
612
725
  // Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
613
726
  // charge), or mint a fresh one per call (each call is a new logical submit).
727
+ //
728
+ // 🔴 MINTED HERE, OUTSIDE THE CLOSURE `withConsentRetry` RE-INVOKES. Both
729
+ // attempts therefore carry the SAME key and the host+orchestrator collapse
730
+ // them to ONE reservation. Move this line inside `submitOnce` and an
731
+ // automatic retry double-reserves a real person's Buzz — the single
732
+ // regression this feature exists to not have.
614
733
  const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
615
734
  try {
616
- const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
617
- // 🔴 PUBLISH THE SNAPSHOT BEFORE THE REJECTION BELOW, for the same reason
618
- // `estimate` does: a block may render from `result` rather than from the
619
- // returned value, and jumping over this line would leave the PREVIOUS
620
- // submit's snapshot in place — a live control pointing at a workflow THIS
621
- // submit did not queue.
622
- setResult(snapshot);
623
- // 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
624
- // `submit` half of civitai/civitai#4159). Two producers report
625
- // `status:'failed'` and `status` separates neither:
626
- //
627
- // - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
628
- // (open a top-up flow). The server quotes the price it refused to
629
- // charge, so `cost.total` is present. It RESOLVES — turning this arm
630
- // into a throw is the one change that would break the recovery path.
631
- // - a failure-shaped reply with NO `cost` is not a usable outcome. It
632
- // REJECTS — and the `code` says which kind, because they differ on
633
- // whether money moved (see WorkflowSubmitError.code).
634
- //
635
- // BOTH clauses are load-bearing. Dropping `status === 'failed'` would
636
- // reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
637
- // too); dropping the cost test would reject the budget rejection. And the
638
- // test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
639
- // real price and falsy.
640
- //
641
- // 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
642
- // would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
643
- // legitimate outcomes the server really does emit without a price
644
- // (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
645
- // WIDENING is invisible to a mutation sweep that only deletes clauses, so
646
- // all three statuses are pinned by their own fixtures in the test file.
647
- if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
648
- // 🔴 WHICH ARM: the host stamps its own synthesised failures with the
649
- // `'failed'` sentinel, while a server-built reply carries `workflow.id`
650
- // (or the `'whatif'` sentinel).
651
- // Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
652
- // money MAY be committed — an unknown id must never buy the reassuring
653
- // "nothing was charged" reading.
654
- throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
655
- }
656
- setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
657
- return snapshot;
735
+ return await withConsentRetry(getTransport(), WORKFLOW_SCOPES, () => submitOnce(body, idempotencyKey), options,
736
+ // Rule 3 in the time axis — see `mountedRef` above for why this hook
737
+ // needs it even though it has no `AbortController`.
738
+ () => mountedRef.current);
658
739
  }
659
740
  catch (err) {
660
741
  setError(err);
661
742
  setStatus('error');
662
743
  throw err;
663
744
  }
664
- }, []);
745
+ }, [submitOnce]);
665
746
  /**
666
747
  * ONE poll round-trip, with an optional long-poll hint. The single place that
667
748
  * builds a `POLL_WORKFLOW` message, so `poll` and `watch` cannot drift on the