@civitai/blocks-react 0.60.0 → 0.61.1

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,49 @@ await shared.withdraw(key); // remove my own entry
961
963
 
962
964
  Drive the platform Checkpoint picker + persist a viewer override.
963
965
 
966
+ 🔴 **OMIT `baseModelGroup` BY DEFAULT.** It is an ecosystem-family FILTER, not a
967
+ label: the host HIDES every checkpoint outside the family you pass, so passing the
968
+ family you are already in is a trap — the picker then offers only the ecosystem the
969
+ user is trying to leave. Omit it for an unconstrained pick and the host applies no
970
+ narrowing at all, offering every checkpoint the viewer can generate with. Pass it
971
+ ONLY when the block must stay inside a family it already holds — a
972
+ regenerate/variation flow, say — and then DERIVE it from that checkpoint, never a
973
+ hardcoded ecosystem string: a literal pins every viewer to whichever family the
974
+ author happened to test with. `''` is **not** an escape hatch — it does not even
975
+ mean the same thing on both hosts. On a **model slot** the host normalises whatever
976
+ string you send, so `''` resolves to the real ecosystem key `Other` and NARROWS to
977
+ that one family. On a **page** the host drops a zero-length value, so `''` behaves
978
+ exactly like omitting it. Neither is what you meant on at least one surface: omit
979
+ the key, or pass a family derived from a real checkpoint, and never `''`.
980
+
981
+ ```tsx
982
+ import { isModelSlotContext } from '@civitai/app-sdk/blocks';
983
+
984
+ const { context } = useBlockContext();
985
+ const { open, persist } = useCheckpointPicker();
986
+
987
+ // DEFAULT — pass no baseModelGroup. The viewer can reach every family.
988
+ if (isModelSlotContext(context) && context.checkpoint) {
989
+ const { selected } = await open({ currentVersionId: context.checkpoint.versionId });
990
+ if (selected) await persist(selected.versionId); // null clears the override
991
+ }
992
+ ```
993
+
994
+ Pass `baseModelGroup` **only** when the block must stay inside a family it already
995
+ holds — a regenerate or variation flow pinned to one checkpoint's ecosystem — and
996
+ then derive it from that checkpoint, never from a literal:
997
+
964
998
  ```tsx
999
+ import { isModelSlotContext } from '@civitai/app-sdk/blocks';
1000
+
1001
+ const { context } = useBlockContext();
965
1002
  const { open, persist } = useCheckpointPicker();
966
- const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
967
- if (selected) await persist(selected.versionId); // null clears the override
1003
+
1004
+ // ONLY to stay inside the family the block already holds — derived, never a literal.
1005
+ if (isModelSlotContext(context) && context.checkpoint) {
1006
+ const { selected } = await open({ baseModelGroup: context.checkpoint.baseModel });
1007
+ if (selected) await persist(selected.versionId);
1008
+ }
968
1009
  ```
969
1010
 
970
1011
  ### `useResourcePicker()`
@@ -974,15 +1015,39 @@ The viewer searches in host chrome; the block only ever sees the one resource it
974
1015
  picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced
975
1016
  server-side at estimate/submit.
976
1017
 
1018
+ 🔴 **Pass NO `baseModelGroup` by default.** It is an optional FILTER, and the host
1019
+ hides every resource outside the family you pass — so a hardcoded ecosystem makes
1020
+ the viewer's own valid LoRAs invisible and the picker look empty or broken. Omit
1021
+ it and the viewer sees everything of that type.
1022
+
977
1023
  ```tsx
978
1024
  const { open } = useResourcePicker();
979
- const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
1025
+ const picked = await open({ resourceType: 'LORA' }); // unconstrained — the default
980
1026
  if (picked) {
981
1027
  const versionId = picked.versionId; // feed into body.additionalResources
982
1028
  const weight = picked.strength; // recommended default weight (may be undefined)
983
1029
  }
984
1030
  ```
985
1031
 
1032
+ Constrain it **only** when the block already holds a chosen checkpoint the pick has
1033
+ to match — and then derive the family from that checkpoint, never from a literal.
1034
+ 🔴 **This hook is PAGE-ONLY, and a page slot has no `context.checkpoint`** — that
1035
+ field lives on `ModelSlotContext` alone, so the family comes from
1036
+ `BlockResourceInfo.baseModel`, the `baseModel` of a Checkpoint this same picker
1037
+ returned earlier:
1038
+
1039
+ ```tsx
1040
+ const { open } = useResourcePicker();
1041
+
1042
+ const checkpoint = await open({ resourceType: 'Checkpoint' });
1043
+ if (checkpoint) {
1044
+ const matching = await open({
1045
+ resourceType: 'LORA',
1046
+ baseModelGroup: checkpoint.baseModel, // from the pick above — BlockResourceInfo.baseModel
1047
+ });
1048
+ }
1049
+ ```
1050
+
986
1051
  ### `useImageUpload()`
987
1052
 
988
1053
  Host-mediated image upload — the host opens its native upload modal and the
@@ -1018,13 +1083,113 @@ const first = resources[0]; // .versionId / .strength / .trainedWord
1018
1083
 
1019
1084
  ### `useCivitaiNavigate()`
1020
1085
 
1021
- Request a navigation within civitai.com (host-mediated; fire-and-forget).
1086
+ Request a navigation from the host. The hook sends a `NAVIGATE` message and
1087
+ returns — fire-and-forget, so the block never learns what the host did,
1088
+ including when the host **refuses** the request.
1089
+
1090
+ `scope` selects the **space** `path` is resolved in, and it **defaults to
1091
+ `'app'`**:
1092
+
1093
+ | `scope` | `path` resolves | The viewer |
1094
+ |---|---|---|
1095
+ | `'app'` *(default)* | under **this app's own route**, as a sub-path of it | stays in your app; the page stays mounted |
1096
+ | `'site'` | at the **civitai.com root** | leaves your app for a site page |
1097
+
1098
+ > 🔴 **A leading slash carries no meaning.** The host normalises it away in
1099
+ > **both** scopes, so `'/settings'` and `'settings'` are one request within
1100
+ > whichever scope you chose. That means `navigate('/models/12345')` asks for
1101
+ > **your app's** `/models/12345` — *not* civitai's model page. To reach the
1102
+ > civitai.com page, say so: `navigate('models/12345', { scope: 'site' })`.
1103
+ >
1104
+ > Both spellings were app-scoped before `scope` existed, so no call you have
1105
+ > already written changed meaning — that is the point of the default.
1106
+
1107
+ `'site'` is granted **per-surface**: the public run page and the dev tunnel allow
1108
+ it, and a private run or a moderator's review preview refuse it. A refusal is
1109
+ silent, so do not build a flow that needs to know it happened.
1110
+
1111
+ `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
1112
+ `'new_tab'` is host-side behaviour and the host is the authority on it; this
1113
+ package sends the message and makes no promise about the outcome.
1114
+
1115
+ > 🔴 **Nothing in your manifest enables `'new_tab'`.** In particular, do **not**
1116
+ > declare `allow-popups-to-escape-sandbox`: the host intersects a manifest's
1117
+ > `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
1118
+ > is dropped for every block at every trust tier and declaring it has no effect.
1119
+ > Earlier versions of this page said `'new_tab'` required it — that was wrong.
1022
1120
 
1023
1121
  ```tsx
1024
1122
  const { navigate } = useCivitaiNavigate();
1025
- navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
1123
+
1124
+ // Your app's own pages — the default.
1125
+ navigate('settings'); // this app's /settings
1126
+ navigate('/settings'); // identical; the slash means nothing
1127
+
1128
+ // A civitai.com page — needs an explicit scope.
1129
+ navigate('models/12345', { scope: 'site' });
1130
+ navigate('models/12345', { scope: 'site', target: 'new_tab' });
1131
+
1132
+ // The pre-`scope` two-argument shape still works, and is still app-scoped.
1133
+ navigate('detail/7', 'new_tab');
1026
1134
  ```
1027
1135
 
1136
+ An app-scoped `navigate()` is **half** of a round trip. The host owns the
1137
+ history, so the way your block learns where it ended up is
1138
+ [`useCivitaiRoute()`](#usecivitairoute) — read that next if you are routing.
1139
+
1140
+ ### `useCivitaiRoute()`
1141
+
1142
+ The sub-path below your app's root that is **currently showing**. This is the
1143
+ other half of an app-scoped [`useCivitaiNavigate()`](#usecivitainavigate): you
1144
+ ask the host to move, the host pushes it shallowly so your frame stays mounted,
1145
+ and this is how you find out where you went.
1146
+
1147
+ ```tsx
1148
+ function Router() {
1149
+ const subPath = useCivitaiRoute(); // '' on your app's index
1150
+ const [view, id] = subPath.split('/');
1151
+ return view === 'compare' ? <Compare id={id} /> : <Index />;
1152
+ }
1153
+ ```
1154
+
1155
+ It also reports the moves you **did not** ask for: the viewer's own
1156
+ back/forward, and a deep link the host resolved after init.
1157
+
1158
+ Two things set the value — `BLOCK_INIT`'s `context.subPath` at mount, and the
1159
+ host's `ROUTE_CHANGED` push on every later change. **The first value is never a
1160
+ message**, which is why this is a value hook rather than an `onRouteChanged`
1161
+ callback: a callback alone cannot see where the block started, and a change that
1162
+ lands before its subscription effect runs is lost. It is the same value as
1163
+ `useBlockContext().context.subPath` on a page slot — reach for this when the
1164
+ route is all you need, and because its return type is a plain `string` instead of
1165
+ a field on a union you have to narrow.
1166
+
1167
+ > 🔴 **No leading slash.** The host sends the segment below your app root, so
1168
+ > `subPath === 'compare/42'` is the comparison that works and
1169
+ > `subPath === '/compare/42'` is the one that silently never matches.
1170
+
1171
+ > 🔴 **`''` is a real route — your app's index — and it is also the pre-init
1172
+ > value.** The two are indistinguishable from this hook alone, exactly as
1173
+ > `'light'` is both a real theme and [`useBlockTheme()`](#useblocktheme)'s
1174
+ > pre-init value. Gate on `useBlockContext().ready` if your first paint must tell
1175
+ > them apart.
1176
+
1177
+ > 🔴 **Read it on every render.** A block that copies the value into state once
1178
+ > at mount, or routes imperatively in a mount-only effect, stays on the route it
1179
+ > started with — the URL moves and nothing renders, which is the exact symptom
1180
+ > this message exists to end.
1181
+
1182
+ **Page slot only.** A model-page slot has no route of its own, so this returns
1183
+ `''` there and never moves. Against a host that predates `ROUTE_CHANGED` the
1184
+ value simply stays at the init sub-path (the old behaviour); nothing awaits the
1185
+ message, so there is no hang either way.
1186
+
1187
+ Exercise it locally with `pnpm dev:live`, where `navigate()` drives the real
1188
+ message end-to-end. `createMockHost` has **no** route control, deliberately: it
1189
+ does not handle `NAVIGATE` at all and has no URL to move, so a synthetic setter
1190
+ there would be a second, weaker way to produce a message the live host already
1191
+ produces from the call a block actually makes.
1192
+
1028
1193
  ### `useBlockAnalytics()`
1029
1194
 
1030
1195
  Fire-and-forget event tracking into the host's analytics pipeline.
@@ -1045,8 +1210,97 @@ const { requestSignIn } = useRequestSignIn();
1045
1210
  requestSignIn();
1046
1211
  ```
1047
1212
 
1213
+ ### Automatic consent prompt-and-retry (on by default)
1214
+
1215
+ **You usually do not need to write any of this.** Since `1.0.0` the
1216
+ consent-gated calls below handle a missing scope themselves: the call fails, the
1217
+ SDK opens the host's consent dialog naming the scope the call needs, waits for
1218
+ the grant, and then **retries the original call once** so it resolves as if it
1219
+ had just worked.
1220
+
1221
+ | Hook | Call | Scope it prompts for |
1222
+ |---|---|---|
1223
+ | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
1224
+ | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
1225
+ | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
1226
+ | `useTip()` | `tip()` | `social:tip:self` |
1227
+
1228
+ 🔴 **`estimate()` is deliberately NOT in that table.** It is a price READ, and
1229
+ blocks call it from an effect keyed on the generation form — on mount, and again
1230
+ on every parameter change. Prompting there would open a consent dialog with no
1231
+ user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
1232
+ it always has; show no price and let `submit()` do the asking.
1233
+
1234
+ ```tsx
1235
+ // This is the whole thing. No try/catch around a consent prompt, no watching
1236
+ // useBlockToken().scopes, no manual retry.
1237
+ const { submit } = useBuzzWorkflow();
1238
+ const snapshot = await submit(body);
1239
+ ```
1240
+
1241
+ 🔴 **The retry re-sends the FIRST attempt's `idempotencyKey`.** That is what
1242
+ makes it safe on the money paths: `submit()`, `purchase()` and `tip()` mint the
1243
+ key once, before the first attempt, so however many attempts one call makes the
1244
+ server sees **one** logical operation and charges once. A retry with a fresh key
1245
+ would be a second reservation against the viewer's Buzz.
1246
+
1247
+ It **never** retries when:
1248
+
1249
+ - the token already holds every scope the call needs (so the failure was not
1250
+ about consent — a rate limit, a 5xx, a bad body all behave exactly as before);
1251
+ - the call failed **before `BLOCK_INIT`** landed. There is no real token yet, so
1252
+ "the token is missing this scope" is not a fact about consent;
1253
+ - the host has pushed `CONSENT_UNAVAILABLE` **naming a scope this call needs** —
1254
+ that scope can never be granted here, so a retry is a guaranteed second
1255
+ failure. A refusal that names *other* scopes leaves this call alone, and one
1256
+ that names **none** (the payload's `scopes` is documented as advisory and may
1257
+ be empty) is treated as covering everything, which is the safe reading;
1258
+ - the viewer dismissed a host confirm (`declined`), or there is no session to
1259
+ grant anything to (`signInRequired` — route that into `useRequestSignIn()`);
1260
+ - the component **unmounted** — including *during* the 60 s wait, so a grant that
1261
+ arrives after your component is gone does not spend anything;
1262
+ - the request timed out on a bridge with **no idempotency key** (`createPost()`,
1263
+ collection follow) — the retry would be a genuine second write, i.e. a second
1264
+ public post. A timeout on a call that HAS a key — `submit()`, `purchase()`,
1265
+ `tip()` — *is* retried, because the retry re-sends that key and the server
1266
+ collapses the two into one operation;
1267
+ - **a second time.** One retry, never a loop. A second consent failure surfaces
1268
+ to you unchanged.
1269
+
1270
+ If the viewer never answers the dialog, the **original** error is re-thrown after
1271
+ 60 s and your `catch` sees exactly what it would have seen before.
1272
+
1273
+ ⚠️ **One failure is deliberately NOT excluded:** a host refusal about the
1274
+ *payload* rather than the token — `createPost()`'s `'no images to post'` — still
1275
+ prompts, because nothing distinguishes it structurally from a genuine consent
1276
+ failure without string-matching server copy. The cost is one needless dialog; the
1277
+ retry re-opens the host's own confirm and cannot publish anything new.
1278
+
1279
+ **Concurrent callers share one dialog.** `N` calls that are in flight together and
1280
+ need the same scope post **one** `REQUEST_CONSENT` and wait on it together — a
1281
+ feed of tip buttons does not open a dialog per button. Each call still retries its
1282
+ **own** request with its **own** idempotency key, so *N* tips remain *N* transfers.
1283
+ ⚠️ This is de-duplication of *concurrent* waits only: calls made one after another
1284
+ each get their own prompt, which is the shape that keeps `estimate()` out of the
1285
+ table above.
1286
+
1287
+ Opt out per call — the same single option on every hook above:
1288
+
1289
+ ```tsx
1290
+ await submit(body, { autoRequestConsent: false }); // pre-1.0 behaviour
1291
+ ```
1292
+
1293
+ ⚠️ **Not covered:** a scope your **manifest** never declared can never be granted
1294
+ either, and a block cannot see its own manifest at runtime — so that case is
1295
+ caught one round-trip late, by the host's `CONSENT_UNAVAILABLE`, rather than
1296
+ before the first attempt. Declare the scopes your app uses.
1297
+
1048
1298
  ### `useRequestConsent()`
1049
1299
 
1300
+ The manual version of the above — still exported, still the right tool when you
1301
+ want to prompt *before* a call (e.g. on an onboarding screen) rather than after
1302
+ one fails.
1303
+
1050
1304
  Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes
1051
1305
  an action whose consent-gated scope the block token is missing (e.g. Generate
1052
1306
  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