@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.
- package/dist/hooks/useBuzzWorkflow.d.ts +22 -0
- package/dist/hooks/useBuzzWorkflow.js +10 -2
- package/dist/hooks/useGoodPurchase.d.ts +13 -0
- package/dist/hooks/useGoodPurchase.js +5 -2
- package/dist/hooks/useTip.d.ts +42 -1
- package/dist/hooks/useTip.js +36 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/internal/mockHost.js +37 -0
- package/dist/internal/mockHostIdempotency.d.ts +100 -0
- package/dist/internal/mockHostIdempotency.js +113 -0
- package/dist/transport/transport.d.ts +57 -0
- package/dist/transport/transport.js +76 -0
- package/dist/ui/BlockGate.d.ts +3 -2
- package/dist/ui/BlockGate.js +37 -43
- package/dist/ui/TipButton.d.ts +38 -0
- package/dist/ui/TipButton.js +73 -2
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.js +17 -2
- package/package.json +5 -5
|
@@ -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
|
*
|
package/dist/ui/BlockGate.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
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
|
/**
|
package/dist/ui/BlockGate.js
CHANGED
|
@@ -1,52 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect
|
|
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
|
-
*
|
|
9
|
+
* The theme the PAGE is already painted in — never the OS preference.
|
|
10
10
|
*
|
|
11
|
-
* A directly-loaded block has
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
91
|
-
*
|
|
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 =
|
|
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(() => {
|
package/dist/ui/TipButton.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/ui/TipButton.js
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
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
|