@civitai/blocks-react 0.62.0 → 0.63.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.
@@ -1,3 +1,4 @@
1
+ import { BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH, BLOCK_IDEMPOTENCY_KEY_REGEX, blockIdempotencyKeyRejection, } from '@civitai/app-sdk/blocks';
1
2
  /**
2
3
  * Type-safe wrapper around `transport.sendRequest`. Hooks always go through
3
4
  * this so the response payload narrows based on `responseType`.
@@ -188,6 +189,81 @@ export function generateIdempotencyKey() {
188
189
  .toString(36)
189
190
  .slice(2, 12)}`;
190
191
  }
192
+ /**
193
+ * A CALLER-SUPPLIED `idempotencyKey` that the host would reject. Thrown by
194
+ * `useBuzzWorkflow().submit`, `useTip().tip` and `useGoodPurchase().purchase`
195
+ * **before anything is sent**.
196
+ *
197
+ * 🔴 NOTHING WAS SENT AND NOTHING WAS SPENT — that is the whole reason this is a
198
+ * DISTINCT class rather than one of the existing money-path errors. Every other
199
+ * rejection on these hooks is money-AMBIGUOUS by design:
200
+ * `WorkflowSubmitError`'s own docs say its `'exception'` arm covers "a lost
201
+ * response or an in-progress idempotency conflict", and the abort/timeout
202
+ * rejections say in as many words that "the charge may or may not have landed".
203
+ * Reusing any of those for a key refused at the boundary would hand the caller
204
+ * an error whose documented contract is strictly weaker than the truth, and the
205
+ * repo's money rule runs the other way: never tell a caller money did not move
206
+ * unless you know it. Here we do know, structurally — the request never left the
207
+ * iframe — so the caller gets a type that says so.
208
+ *
209
+ * Two further reasons it is not a `WorkflowSubmitError`:
210
+ * - that class REQUIRES a `snapshot`, which is the host's reply. There is no
211
+ * reply. Synthesising one would be inventing a host message the host never
212
+ * sent, and `workflowId: 'failed'` specifically means "the host had no
213
+ * workflow to report" — a claim about the host we are not entitled to make.
214
+ * - it is not a runtime OUTCOME at all. It is a defect in the calling block's
215
+ * own code, in the same family as the existing
216
+ * `'host origin not established yet'` throw: no retry fixes it, and the fix
217
+ * is a source change.
218
+ *
219
+ * 🔴 THE KEY IS REFUSED, NEVER REWRITTEN. See `@civitai/app-sdk/blocks`'
220
+ * `idempotency.ts`: sanitising a caller's key would break the identity the key
221
+ * exists to carry — two distinct logical submits could collapse onto one slot,
222
+ * or a retry could be normalised differently from its first attempt and mint a
223
+ * SECOND reservation. A loud refusal is the only money-safe answer.
224
+ *
225
+ * Branch on `instanceof` or `name === 'InvalidIdempotencyKeyError'`; the
226
+ * `message` wording is developer-facing and is not a contract. Do NOT render it
227
+ * to a viewer — they cannot act on it.
228
+ */
229
+ export class InvalidIdempotencyKeyError extends Error {
230
+ /** The rejected value, verbatim, for logging. The block's own construction. */
231
+ idempotencyKey;
232
+ /** Which hook refused it, e.g. `'useBuzzWorkflow.submit'`. */
233
+ source;
234
+ constructor(source, idempotencyKey, message) {
235
+ super(message);
236
+ this.name = 'InvalidIdempotencyKeyError';
237
+ this.source = source;
238
+ this.idempotencyKey = idempotencyKey;
239
+ }
240
+ }
241
+ /**
242
+ * Refuse a caller-supplied key, or mint one when the caller supplied none.
243
+ *
244
+ * 🔴 THE `undefined` CASE IS NOT VALIDATED, AND MUST NOT BE. `idempotencyKey` is
245
+ * optional on all three hooks; absent means "mint one for me", and the generated
246
+ * key conforms by construction. Routing `undefined` into the predicate would
247
+ * turn every ordinary keyless call into a refusal.
248
+ *
249
+ * Ordering is load-bearing: validation happens BEFORE the generator is consulted
250
+ * and before any transport/fetch work, so a bad key costs nothing and cannot
251
+ * race a send.
252
+ */
253
+ export function resolveIdempotencyKey(source, supplied) {
254
+ if (supplied === undefined)
255
+ return generateIdempotencyKey();
256
+ const why = blockIdempotencyKeyRejection(supplied);
257
+ if (why !== null) {
258
+ throw new InvalidIdempotencyKeyError(source, supplied, `${source}: refusing to send a malformed idempotencyKey — ${why}. ` +
259
+ `Nothing was sent and nothing was spent. The host requires ` +
260
+ `${String(BLOCK_IDEMPOTENCY_KEY_REGEX)} and rejects anything else with a 400; ` +
261
+ `the key is NOT sanitised for you, because rewriting an idempotency key would ` +
262
+ `break the identity it exists to carry. Compose the key from letters, digits, ` +
263
+ `underscore and hyphen only, at most ${BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH} characters.`);
264
+ }
265
+ return supplied;
266
+ }
191
267
  /**
192
268
  * A request that got NO REPLY before its timeout elapsed.
193
269
  *
@@ -31,8 +31,9 @@ export interface DirectLoadFallbackProps {
31
31
  * "waiting for the Civitai host" card with a dev hint — NEVER a broken
32
32
  * `apps/run/localhost` link.
33
33
  *
34
- * Theme-aware via `prefers-color-scheme` (there is no host `theme` on a direct
35
- * load) and styled with the `/ui` pack's tokens.
34
+ * Themed from the PAGE — `data-theme` on `<html>`, dark when absent — never
35
+ * from the OS preference (see {@link readDocumentTheme}), and styled with the
36
+ * `/ui` pack's tokens.
36
37
  */
37
38
  export declare function DirectLoadFallback({ hostname, autoRedirectMs, }: DirectLoadFallbackProps): React.JSX.Element;
38
39
  /**
@@ -1,52 +1,45 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect } from 'react';
3
3
  import { hostToRunUrl } from '../transport/directLoad.js';
4
4
  import { useDirectLoad } from '../hooks/useDirectLoad.js';
5
5
  import { Card } from './Card.js';
6
6
  import { Stack } from './Stack.js';
7
7
  import { useBlocksStyles } from './styles.js';
8
8
  /**
9
- * Read the OS/browser color-scheme preference, live.
9
+ * The theme the PAGE is already painted in — never the OS preference.
10
10
  *
11
- * A directly-loaded block has NO `BLOCK_INIT`, so there is no host `theme` to
12
- * set `data-theme` from (the usual gotcha-#60 path). We fall back to
13
- * `prefers-color-scheme` so the fallback card still themes correctly in light
14
- * AND dark. Guarded for SSR / engines without `matchMedia`.
11
+ * A directly-loaded block has no host: no `BLOCK_INIT`, no `THEME_CHANGE`. The
12
+ * only theme signal that can reach this card is the one the document itself
13
+ * booted with — `data-theme` on `<html>`, which the scaffolded `index.html`
14
+ * sets pre-paint from the host fragment (`#civitai-block=v1&theme=…`). So the
15
+ * card follows the page instead of second-guessing it, and a block with no
16
+ * fragment boots dark like every other Civitai surface.
17
+ *
18
+ * `'light'` is the ONLY value that buys light — exactly the rule the pre-paint
19
+ * script applies. Absent, empty, `'auto'`, a typo, or no DOM at all (SSR) are
20
+ * all dark.
21
+ *
22
+ * Reading `prefers-color-scheme` here was the defect: on a light-OS machine it
23
+ * painted a LIGHT card on a deliberately DARK page.
24
+ *
25
+ * Setting the attribute explicitly, rather than inheriting it, keeps this card's
26
+ * theme a decision of this component rather than of whatever is above it.
27
+ *
28
+ * ⚠️ It used to be load-bearing for a stronger reason that no longer holds:
29
+ * `@civitai/theme` shipped
30
+ * `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { … } }`, so a
31
+ * document carrying no `data-theme` handed its tokens back to the OS and only an
32
+ * explicit attribute could stop it. Since `@civitai/theme@0.5.0` the base is
33
+ * dark and that at-rule is gone, so inheriting would reach the same answer on a
34
+ * direct-load page. One real difference survives and is why this stays: the read
35
+ * below is `document.documentElement`, whereas inheritance takes the NEAREST
36
+ * `[data-theme]` ancestor. Those coincide on a direct load — no host, no wrapper
37
+ * — but that is a property of the deployment, not of this code.
15
38
  */
16
- function usePrefersColorScheme() {
17
- const [dark, setDark] = useState(() => {
18
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
19
- return false;
20
- try {
21
- return window.matchMedia('(prefers-color-scheme: dark)').matches;
22
- }
23
- catch {
24
- return false;
25
- }
26
- });
27
- useEffect(() => {
28
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
29
- return;
30
- let mql;
31
- try {
32
- mql = window.matchMedia('(prefers-color-scheme: dark)');
33
- }
34
- catch {
35
- return;
36
- }
37
- const onChange = (e) => setDark(e.matches);
38
- // Modern browsers: addEventListener. Older Safari: addListener.
39
- if (typeof mql.addEventListener === 'function') {
40
- mql.addEventListener('change', onChange);
41
- return () => mql.removeEventListener('change', onChange);
42
- }
43
- if (typeof mql.addListener === 'function') {
44
- mql.addListener(onChange);
45
- return () => mql.removeListener(onChange);
46
- }
47
- return;
48
- }, []);
49
- return dark ? 'dark' : 'light';
39
+ function readDocumentTheme() {
40
+ if (typeof document === 'undefined')
41
+ return 'dark';
42
+ return document.documentElement?.dataset?.theme === 'light' ? 'light' : 'dark';
50
43
  }
51
44
  const wrapperStyle = {
52
45
  minHeight: '100%',
@@ -87,12 +80,13 @@ const bodyStyle = {
87
80
  * "waiting for the Civitai host" card with a dev hint — NEVER a broken
88
81
  * `apps/run/localhost` link.
89
82
  *
90
- * Theme-aware via `prefers-color-scheme` (there is no host `theme` on a direct
91
- * load) and styled with the `/ui` pack's tokens.
83
+ * Themed from the PAGE — `data-theme` on `<html>`, dark when absent — never
84
+ * from the OS preference (see {@link readDocumentTheme}), and styled with the
85
+ * `/ui` pack's tokens.
92
86
  */
93
87
  export function DirectLoadFallback({ hostname, autoRedirectMs, }) {
94
88
  useBlocksStyles();
95
- const theme = usePrefersColorScheme();
89
+ const theme = readDocumentTheme();
96
90
  const resolvedHost = hostname ?? (typeof window !== 'undefined' ? window.location?.hostname : undefined);
97
91
  const runUrl = hostToRunUrl(resolvedHost);
98
92
  useEffect(() => {
@@ -73,6 +73,44 @@ export interface TipButtonProps {
73
73
  */
74
74
  'data-testid'?: string;
75
75
  }
76
+ /**
77
+ * Compose this control's idempotency key from the tip's full identity.
78
+ *
79
+ * 🔴 EXTRACTED SO THE REACT-VERSION DIMENSION IS TESTABLE AT ALL, and that is
80
+ * the whole reason it is not inline. `useId()`'s FORMAT differs by React
81
+ * version, and this package's peer range admits `^18.0.0 || ^19.0.0`:
82
+ * React 18.3.1 returns `":R0:"`, early React 19 a guillemet-wrapped id, and
83
+ * React 19.2.6 (resolved here today) `"_r_0_"`. Only the last of those clears
84
+ * the host charset on its own.
85
+ *
86
+ * While the composition was inline the suite was STRUCTURALLY BLIND to that
87
+ * dimension: the installed React happens to produce a conforming seed, so
88
+ * deleting the normalisation below changed nothing any test could see — it was
89
+ * run as a mutation and SURVIVED a fully green file. A pure function can be fed
90
+ * the OTHER versions' shapes without installing them, which is what turns
91
+ * "passes here" into "passes across the range we declare".
92
+ *
93
+ * 🔴 NORMALISING THIS SEED IS NOT SANITISING A CALLER'S KEY. The repo-wide rule
94
+ * is REFUSE-never-rewrite for a key a BLOCK supplies, because that key is an
95
+ * identity its author chose and rewriting it breaks idempotency. This seed is
96
+ * the opposite: an opaque uniqueness token this component mints itself, whose
97
+ * only contract is "distinct per mount". Normalising characters React may change
98
+ * between versions is correct handling for a value we own — and the result is
99
+ * still validated by `useTip`, which refuses it rather than repairing it if this
100
+ * composition is ever wrong again.
101
+ *
102
+ * 🔴 THE DELIMITER IS `_`, AND A COLON HERE WAS A LIVE PRODUCTION DEFECT. This
103
+ * used to join with ':'. The host enforces `^[A-Za-z0-9_-]{1,64}$` on
104
+ * `idempotencyKey` at `/api/v1/blocks/tip`, so EVERY tip this button posted was
105
+ * rejected with `invalid_format` / 400 — and it always supplies a key, so there
106
+ * was no unkeyed path that happened to work.
107
+ *
108
+ * INJECTIVITY is preserved, which is what the colon was chosen for: after
109
+ * normalisation the seed contains only `[A-Za-z0-9_-]`, `toUserId`/`amount`/
110
+ * `entityId` are numbers, and `entityType` is one of three `_`-free literals —
111
+ * so distinct tip identities still yield distinct keys. Pinned by a test.
112
+ */
113
+ export declare function composeTipIdempotencyKey(seed: string, toUserId: number, amount: number, entityType: 'Image' | 'Collection' | 'User' | undefined, entityId: number | undefined): string;
76
114
  /**
77
115
  * Send a Buzz tip, behind an in-block two-step confirm.
78
116
  *
@@ -9,6 +9,47 @@ const NOTE_STYLE = {
9
9
  lineHeight: 1.45,
10
10
  color: 'var(--civitai-color-text-dimmed)',
11
11
  };
12
+ /**
13
+ * Compose this control's idempotency key from the tip's full identity.
14
+ *
15
+ * 🔴 EXTRACTED SO THE REACT-VERSION DIMENSION IS TESTABLE AT ALL, and that is
16
+ * the whole reason it is not inline. `useId()`'s FORMAT differs by React
17
+ * version, and this package's peer range admits `^18.0.0 || ^19.0.0`:
18
+ * React 18.3.1 returns `":R0:"`, early React 19 a guillemet-wrapped id, and
19
+ * React 19.2.6 (resolved here today) `"_r_0_"`. Only the last of those clears
20
+ * the host charset on its own.
21
+ *
22
+ * While the composition was inline the suite was STRUCTURALLY BLIND to that
23
+ * dimension: the installed React happens to produce a conforming seed, so
24
+ * deleting the normalisation below changed nothing any test could see — it was
25
+ * run as a mutation and SURVIVED a fully green file. A pure function can be fed
26
+ * the OTHER versions' shapes without installing them, which is what turns
27
+ * "passes here" into "passes across the range we declare".
28
+ *
29
+ * 🔴 NORMALISING THIS SEED IS NOT SANITISING A CALLER'S KEY. The repo-wide rule
30
+ * is REFUSE-never-rewrite for a key a BLOCK supplies, because that key is an
31
+ * identity its author chose and rewriting it breaks idempotency. This seed is
32
+ * the opposite: an opaque uniqueness token this component mints itself, whose
33
+ * only contract is "distinct per mount". Normalising characters React may change
34
+ * between versions is correct handling for a value we own — and the result is
35
+ * still validated by `useTip`, which refuses it rather than repairing it if this
36
+ * composition is ever wrong again.
37
+ *
38
+ * 🔴 THE DELIMITER IS `_`, AND A COLON HERE WAS A LIVE PRODUCTION DEFECT. This
39
+ * used to join with ':'. The host enforces `^[A-Za-z0-9_-]{1,64}$` on
40
+ * `idempotencyKey` at `/api/v1/blocks/tip`, so EVERY tip this button posted was
41
+ * rejected with `invalid_format` / 400 — and it always supplies a key, so there
42
+ * was no unkeyed path that happened to work.
43
+ *
44
+ * INJECTIVITY is preserved, which is what the colon was chosen for: after
45
+ * normalisation the seed contains only `[A-Za-z0-9_-]`, `toUserId`/`amount`/
46
+ * `entityId` are numbers, and `entityType` is one of three `_`-free literals —
47
+ * so distinct tip identities still yield distinct keys. Pinned by a test.
48
+ */
49
+ export function composeTipIdempotencyKey(seed, toUserId, amount, entityType, entityId) {
50
+ const safeSeed = seed.replace(/[^A-Za-z0-9_-]/g, '_');
51
+ return `${safeSeed}_${toUserId}_${amount}_${entityType ?? '-'}_${entityId ?? '-'}`;
52
+ }
12
53
  /**
13
54
  * Send a Buzz tip, behind an in-block two-step confirm.
14
55
  *
@@ -107,8 +148,38 @@ export function TipButton({ toUserId, amount, entityType, entityId, noun, tipped
107
148
  * on the first landed transfer for a given key, and a repeat under the SAME
108
149
  * key is that same transfer. Change either and rotate.
109
150
  */
110
- const keySeed = useId();
111
- const idempotencyKey = `${keySeed}:${toUserId}:${amount}:${entityType ?? '-'}:${entityId ?? '-'}`;
151
+ /**
152
+ * 🔴 THE DELIMITER IS `_`, AND A COLON HERE WAS A LIVE PRODUCTION DEFECT.
153
+ * This component used to compose the key with ':' separators. The host
154
+ * enforces `^[A-Za-z0-9_-]{1,64}$` on `idempotencyKey` at
155
+ * `/api/v1/blocks/tip`, so EVERY tip this button posted was rejected with
156
+ * `invalid_format` / 400 — and `TipButton` always supplies a key, so there was
157
+ * no unkeyed path that happened to work. Measured 2026-10-02 under React 19:
158
+ * `useId()` returns `_r_0_` (which clears the charset on its own), while the
159
+ * composed `_r_0_:123:50:Image:99` does not — so under the React resolved here
160
+ * the DELIMITERS were the whole fault. Reported separately and not measured by
161
+ * this change: React 18.3.1 returns `":R0:"` and early React 19 a
162
+ * guillemet-wrapped id, both of which fail the charset on their own. The peer
163
+ * range is `^18.0.0 || ^19.0.0`, so the seed must be normalised regardless of
164
+ * which arm a consumer is on.
165
+ *
166
+ * `_` is a safe delimiter and the composition stays INJECTIVE, which is the
167
+ * property the colon was chosen for: after the sanitisation below the seed
168
+ * cannot contain `_`-ambiguity it did not already have, `toUserId`/`amount`/
169
+ * `entityId` are numbers (digits only), and `entityType` is one of the three
170
+ * literals `'Image' | 'Collection' | 'User'` — none contains `_`. So distinct
171
+ * tip identities still produce distinct keys.
172
+ *
173
+ * 🔴 SANITISING THE SEED IS NOT SANITISING A CALLER'S KEY. The repo-wide rule
174
+ * is REFUSE-never-rewrite for a key the BLOCK supplies, because that key is an
175
+ * identity its author chose. This seed is the opposite: an opaque uniqueness
176
+ * token this component mints itself, whose only contract is "distinct per
177
+ * mount". Normalising characters React's own format may change between major
178
+ * versions is the correct handling for a value we own — and the result is
179
+ * still validated by `useTip`, which refuses it rather than fixing it if this
180
+ * composition is ever wrong again.
181
+ */
182
+ const idempotencyKey = composeTipIdempotencyKey(useId(), toUserId, amount, entityType, entityId);
112
183
  const settled = done || tipped;
113
184
  // Both guards are about a NUMBER reaching a money path, and both were once
114
185
  // missing: `amount={0}` rendered "Tip 0" and posted it, and an unusable