@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.
- package/README.md +296 -6
- package/dist/hooks/consentRetryOptions.d.ts +40 -0
- package/dist/hooks/consentRetryOptions.js +2 -0
- package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
- package/dist/hooks/useBuzzWorkflow.js +125 -44
- package/dist/hooks/useCheckpointPicker.d.ts +43 -10
- package/dist/hooks/useCheckpointPicker.js +37 -6
- package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
- package/dist/hooks/useCivitaiNavigate.js +50 -7
- package/dist/hooks/useCivitaiRoute.d.ts +63 -0
- package/dist/hooks/useCivitaiRoute.js +71 -0
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +87 -34
- package/dist/hooks/useGoodPurchase.d.ts +2 -1
- package/dist/hooks/useGoodPurchase.js +64 -19
- package/dist/hooks/useRequestConsent.js +10 -12
- package/dist/hooks/useResourcePicker.d.ts +56 -7
- package/dist/hooks/useResourcePicker.js +27 -3
- package/dist/hooks/useTip.d.ts +2 -1
- package/dist/hooks/useTip.js +99 -20
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/internal/liveHost.js +238 -7
- package/dist/internal/mockHost.js +67 -9
- package/dist/internal/popoverShim.d.ts +94 -0
- package/dist/internal/popoverShim.js +181 -0
- package/dist/internal/withConsentRetry.d.ts +239 -0
- package/dist/internal/withConsentRetry.js +457 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/transport/iframeTransport.d.ts +33 -0
- package/dist/transport/iframeTransport.js +56 -0
- package/dist/transport/validate.d.ts +25 -0
- package/dist/transport/validate.js +31 -0
- package/package.json +4 -4
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
|
-
|
|
967
|
-
|
|
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'
|
|
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
|
|
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
|
-
|
|
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 —
|
|
@@ -1445,6 +1681,7 @@ what went stale in [#334](https://github.com/civitai/civitai-app-starters/issues
|
|
|
1445
1681
|
| `createMockHost` | A framework-agnostic fake of the embedding host — answers every `*_RESULT` message, with knobs for generation cost/latency/failure, Buzz balance, app + shared storage, consent, maturity. Returns a `MockHost`; call `.install()` and keep the returned teardown. **No network, no Buzz.** |
|
|
1446
1682
|
| `readMockHostUrlOptions` | Reads the harness URL toggles (`?viewer` `?consent` `?fail` `?theme` `?pick` `?balance` `?latency` `?seed` …) into a `Partial<MockHostOptions>`. `Harness` applies it for you; call it directly only in a hand-rolled harness. |
|
|
1447
1683
|
| `Harness` | The React wrapper: installs a `createMockHost` on mount, tears it down on unmount, and renders an optional on-screen outbound-message log. Takes every `MockHostOptions` field plus `applyUrlToggles` and `showLog`. |
|
|
1684
|
+
| `installPopoverShim` | Stands in for the HTML popover API, which neither `jsdom` nor `happy-dom` implements at any version. Needed only if your own code calls `showPopover`/`hidePopover`/`togglePopover` or queries `:popover-open` — `@civitai/components`' own elements do not. Returns a `PopoverShimHandle`; inert (`installed: false`) in a real browser. **Read [Testing overlay elements](#testing-overlay-elements) first: it does not make trigger clicks work.** |
|
|
1448
1685
|
|
|
1449
1686
|
<!-- TESTING-SURFACE:VALUES:END -->
|
|
1450
1687
|
|
|
@@ -1472,6 +1709,8 @@ MockHostScenarioPatch
|
|
|
1472
1709
|
MockSharedScenario
|
|
1473
1710
|
MockSharedSeed
|
|
1474
1711
|
MockStorageScenario
|
|
1712
|
+
PopoverShimHandle
|
|
1713
|
+
PopoverShimOptions
|
|
1475
1714
|
```
|
|
1476
1715
|
|
|
1477
1716
|
<!-- TESTING-SURFACE:TYPES:END -->
|
|
@@ -1501,6 +1740,57 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
|
|
|
1501
1740
|
uninstall();
|
|
1502
1741
|
```
|
|
1503
1742
|
|
|
1743
|
+
### Testing overlay elements
|
|
1744
|
+
|
|
1745
|
+
`<civitai-menu>`, and anything else that opens a panel, live in an environment
|
|
1746
|
+
that is **incomplete** rather than merely different. Two facts, both measured on
|
|
1747
|
+
happy-dom 20.9.0; neither is a bug in the components.
|
|
1748
|
+
|
|
1749
|
+
**1. There is no popover API.** `showPopover`, `hidePopover` and `togglePopover`
|
|
1750
|
+
are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike. `:popover-open`
|
|
1751
|
+
is worse than absent: it is **unreliable**, and the unreliability is not a
|
|
1752
|
+
property of your runner's version. It resolves through `nwsapi` under jsdom, so
|
|
1753
|
+
the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
|
|
1754
|
+
and returns `false` depending on which `nwsapi` your lockfile pulled in
|
|
1755
|
+
(measured: `false` on nwsapi 2.2.28). `@civitai/components`' own elements no
|
|
1756
|
+
longer read it, which is what takes that variable off the table for them. Install
|
|
1757
|
+
the shim if **your** code touches the API:
|
|
1758
|
+
|
|
1759
|
+
```ts
|
|
1760
|
+
import { installPopoverShim } from '@civitai/blocks-react/testing';
|
|
1761
|
+
|
|
1762
|
+
const shim = installPopoverShim();
|
|
1763
|
+
// …
|
|
1764
|
+
shim.uninstall();
|
|
1765
|
+
```
|
|
1766
|
+
|
|
1767
|
+
It is inert in a real browser (`installed: false`), so it is safe to call from a
|
|
1768
|
+
setup file shared between a happy-dom project and a browser-mode project.
|
|
1769
|
+
|
|
1770
|
+
**2. 🔴 UNDER happy-dom A TRIGGER CLICK DOES NOTHING, and the shim does not change
|
|
1771
|
+
that.** A click on light-DOM content assigned to a `<slot>` reaches the **host** (a
|
|
1772
|
+
listener there fires once) but **not** a listener on the `<slot>` element — and
|
|
1773
|
+
that is the node Lit binds `@click` to. So the click dispatches, bubbles, and
|
|
1774
|
+
then the handler is never called: no throw, no state change, a test that quietly
|
|
1775
|
+
does nothing. jsdom (25 and 30) *does* deliver it, so this one is happy-dom's
|
|
1776
|
+
alone — which is exactly why the shim **measures** it rather than asserting it:
|
|
1777
|
+
`installPopoverShim` probes on install, reports the answer as
|
|
1778
|
+
`handle.slottedClicksReachSlots`, and `console.warn`s when it is `false`.
|
|
1779
|
+
|
|
1780
|
+
**Drive overlay elements through their methods:**
|
|
1781
|
+
|
|
1782
|
+
```ts
|
|
1783
|
+
menu.show(); // ✅ works in every DOM
|
|
1784
|
+
await menu.updateComplete;
|
|
1785
|
+
|
|
1786
|
+
triggerButton.click(); // ❌ silently does nothing under happy-dom
|
|
1787
|
+
```
|
|
1788
|
+
|
|
1789
|
+
Also absent, because they need layout and a hit-testing event path: the top
|
|
1790
|
+
layer, anchor positioning, and **light dismiss** (a click outside a shown panel
|
|
1791
|
+
does not close it). If what you are testing is one of those, use a real browser —
|
|
1792
|
+
this repo's own `browser` vitest project is the worked example.
|
|
1793
|
+
|
|
1504
1794
|
### In a dev harness
|
|
1505
1795
|
|
|
1506
1796
|
```tsx
|
|
@@ -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
|
|
@@ -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
|
/**
|