@civitai/blocks-react 0.58.1 → 0.60.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 +96 -0
- package/dist/hooks/useEntitlements.d.ts +112 -0
- package/dist/hooks/useEntitlements.js +176 -0
- package/dist/hooks/useGoodPurchase.d.ts +147 -0
- package/dist/hooks/useGoodPurchase.js +253 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/internal/popoverShim.d.ts +94 -0
- package/dist/internal/popoverShim.js +181 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { generateIdempotencyKey } from '../transport/transport.js';
|
|
3
|
+
import { useBlockToken } from './useBlockToken.js';
|
|
4
|
+
import { useBuzzPurchase } from './useBuzzPurchase.js';
|
|
5
|
+
import { useHostOrigin } from './useHostOrigin.js';
|
|
6
|
+
/**
|
|
7
|
+
* Backstop timeout for the direct REST purchase POST. Like {@link useTip} (and
|
|
8
|
+
* unlike the postMessage hooks), this talks to the HTTP API directly, so it
|
|
9
|
+
* needs its OWN bound — otherwise a hung request never rejects.
|
|
10
|
+
*/
|
|
11
|
+
const GOOD_PURCHASE_TIMEOUT_MS = 30_000;
|
|
12
|
+
/**
|
|
13
|
+
* A refusal the server produced deliberately, as opposed to a transport
|
|
14
|
+
* failure.
|
|
15
|
+
*
|
|
16
|
+
* `reason` is the machine-readable discriminator where one exists — branch on it
|
|
17
|
+
* rather than on `message`, which is viewer-facing copy and will be reworded.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 `reason` IS OFTEN `undefined`, AND A CONSUMER MUST HANDLE THAT. Only the
|
|
20
|
+
* SERVICE-level refusals carry one (`insufficient_funds`, `ledger_conflict`,
|
|
21
|
+
* `charge_failed`, `charge_unknown`, the price-disagreement and
|
|
22
|
+
* already-owned cases). The ENDPOINT's own refusals return `{ error }` alone:
|
|
23
|
+
* the 404 for an unavailable good, the 429 rate limit, the daily-cap 400 and
|
|
24
|
+
* every idempotency refusal (409 / 422 / 503). So a `switch (reason)` with no
|
|
25
|
+
* default silently swallows a material fraction of real failures — always fall
|
|
26
|
+
* back to `status` plus `message`.
|
|
27
|
+
*/
|
|
28
|
+
export class GoodPurchaseRefusal extends Error {
|
|
29
|
+
status;
|
|
30
|
+
reason;
|
|
31
|
+
constructor(message, status, reason) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'GoodPurchaseRefusal';
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Buy a DIGITAL GOOD for the viewer through the block-token-gated
|
|
40
|
+
* `POST /api/v1/blocks/goods/purchase` REST endpoint (scope
|
|
41
|
+
* `goods:purchase:self`).
|
|
42
|
+
*
|
|
43
|
+
* Direct-fetch against the VALIDATED host origin (`useHostOrigin()`) with the
|
|
44
|
+
* block bearer token, the same security-reviewed pattern as {@link useTip}. The
|
|
45
|
+
* BUYER is always the token subject — the server self-binds it and the block
|
|
46
|
+
* never supplies a user id, so there is no client-supplied value on the money
|
|
47
|
+
* path.
|
|
48
|
+
*
|
|
49
|
+
* 🔴 BUYING A GOOD AND BUYING BUZZ ARE OPPOSITE DIRECTIONS, AND THIS HOOK
|
|
50
|
+
* TOUCHES BOTH. A good is Buzz flowing FROM the viewer TO the app owner. The
|
|
51
|
+
* top-up modal this hook can open ({@link useBuzzPurchase}) is fiat flowing INTO
|
|
52
|
+
* the viewer's balance. They are different rails with different ledgers; the
|
|
53
|
+
* only thing they share is that one can unblock the other.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ THE PLATFORM RENDERS NO CONFIRMATION FOR THE PURCHASE ITSELF. This is a
|
|
56
|
+
* plain authed POST, so whatever confirm UI the viewer sees is the APP's. Spend
|
|
57
|
+
* is bounded by the good's manifest-reviewed price and the viewer's daily cap,
|
|
58
|
+
* but a good can be priced near that cap where a tip cannot — so show the price
|
|
59
|
+
* and get an explicit action before calling this.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const { purchase, loading, error } = useGoodPurchase();
|
|
63
|
+
* const { entitlement } = await purchase(
|
|
64
|
+
* { goodId: 'extra-slots', expectedPriceBuzz: 250 },
|
|
65
|
+
* { topUpOnInsufficientFunds: true },
|
|
66
|
+
* );
|
|
67
|
+
*/
|
|
68
|
+
export function useGoodPurchase() {
|
|
69
|
+
const host = useHostOrigin();
|
|
70
|
+
const { raw } = useBlockToken();
|
|
71
|
+
const { openPurchaseModal } = useBuzzPurchase();
|
|
72
|
+
const [loading, setLoading] = useState(false);
|
|
73
|
+
const [error, setError] = useState(null);
|
|
74
|
+
const mountedRef = useRef(true);
|
|
75
|
+
const inFlight = useRef(new Set());
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
mountedRef.current = true;
|
|
78
|
+
const controllers = inFlight.current;
|
|
79
|
+
return () => {
|
|
80
|
+
mountedRef.current = false;
|
|
81
|
+
for (const c of controllers)
|
|
82
|
+
c.abort();
|
|
83
|
+
controllers.clear();
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
const postOnce = useCallback(async (params, idempotencyKey) => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
inFlight.current.add(controller);
|
|
89
|
+
// Which abort fired is not recoverable from the signal — both a timeout
|
|
90
|
+
// and the unmount cleanup set `aborted` — so record it at the source.
|
|
91
|
+
// The two need OPPOSITE handling below, and conflating them is how a
|
|
92
|
+
// money-path timeout gets swallowed by an unmount-ignoring caller.
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
const timeoutId = setTimeout(() => {
|
|
95
|
+
timedOut = true;
|
|
96
|
+
controller.abort();
|
|
97
|
+
}, GOOD_PURCHASE_TIMEOUT_MS);
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(`${host}/api/v1/blocks/goods/purchase`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { Authorization: `Bearer ${raw}`, 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
goodId: params.goodId,
|
|
104
|
+
...(params.expectedPriceBuzz != null
|
|
105
|
+
? { expectedPriceBuzz: params.expectedPriceBuzz }
|
|
106
|
+
: {}),
|
|
107
|
+
idempotencyKey,
|
|
108
|
+
}),
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
});
|
|
111
|
+
// 🔴 AN ABORT DURING THE BODY READ MUST NOT BE SWALLOWED AS "no body".
|
|
112
|
+
// With a real `fetch` the response headers can arrive and the BODY still
|
|
113
|
+
// be in flight; aborting then rejects `res.json()` with an `AbortError`.
|
|
114
|
+
// A blanket `.catch(() => null)` turned that into `bodyJson === null`,
|
|
115
|
+
// and the classification below — which reads the BODY rather than the
|
|
116
|
+
// abort state — then produced exactly the wrong answer twice:
|
|
117
|
+
//
|
|
118
|
+
// 200 + unmount mid-body → a `malformed_success` refusal saying "the
|
|
119
|
+
// charge may have landed", reported to a caller the docs told to
|
|
120
|
+
// IGNORE navigate-away aborts. A purchase that SUCCEEDED, surfaced as
|
|
121
|
+
// a possible-charge failure.
|
|
122
|
+
//
|
|
123
|
+
// 4xx + timeout mid-body → `purchase request failed (<status>)` with
|
|
124
|
+
// `reason` lost, so the documented timeout error and its same-key
|
|
125
|
+
// retry advice never reached the caller.
|
|
126
|
+
//
|
|
127
|
+
// So a parse failure is only "no body" when the request was NOT aborted;
|
|
128
|
+
// otherwise it is rethrown and the `catch` below classifies it by the
|
|
129
|
+
// abort state, which is the only thing that can tell a timeout from an
|
|
130
|
+
// unmount. A genuinely unparseable body on a live request still yields
|
|
131
|
+
// `null` and the malformed/refusal handling below is unchanged.
|
|
132
|
+
const bodyJson = (await res.json().catch((err) => {
|
|
133
|
+
if (controller.signal.aborted)
|
|
134
|
+
throw err;
|
|
135
|
+
return null;
|
|
136
|
+
}));
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
throw new GoodPurchaseRefusal(bodyJson?.error ?? `purchase request failed (${res.status})`, res.status, bodyJson?.reason);
|
|
139
|
+
}
|
|
140
|
+
// 🔴 VALIDATE THE 2xx, because the declared return type promises an
|
|
141
|
+
// entitlement. Without this an unparseable 200 resolved as `null` and a
|
|
142
|
+
// wrong-shaped one resolved with `entitlement === undefined` — and the
|
|
143
|
+
// documented usage destructures it, so both surfaced as a TypeError in
|
|
144
|
+
// the app rather than as a failed purchase. On this hook the body IS the
|
|
145
|
+
// granted entitlement, so a caller cannot recover from a silent absence.
|
|
146
|
+
// `useEntitlements` in this same package performs the equivalent check.
|
|
147
|
+
//
|
|
148
|
+
// 🔴 `purchase` IS CHECKED TOO, and for the same reason `entitlement` is:
|
|
149
|
+
// `GoodPurchaseResult` declares it REQUIRED, so `result.purchase.id` is
|
|
150
|
+
// typed as safe and a body of `{ ok: true, entitlement }` alone would
|
|
151
|
+
// resolve and then TypeError at the dereference. The live 200 path
|
|
152
|
+
// (`src/pages/api/v1/blocks/goods/purchase.ts`) always sends all three,
|
|
153
|
+
// so this is the type's promise being kept rather than a reachable
|
|
154
|
+
// server bug — but an `as`-cast past an unvalidated required field is
|
|
155
|
+
// exactly how the `entitlement` case reached an app in the first place.
|
|
156
|
+
if (bodyJson == null ||
|
|
157
|
+
bodyJson.ok !== true ||
|
|
158
|
+
bodyJson.entitlement == null ||
|
|
159
|
+
bodyJson.purchase == null) {
|
|
160
|
+
throw new GoodPurchaseRefusal(`purchase succeeded (${res.status}) but the response was not a purchase result — the charge may have landed; do not retry without the same idempotency key`, res.status, 'malformed_success');
|
|
161
|
+
}
|
|
162
|
+
return bodyJson;
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
// A timeout or an unmount surfaces as a bare `AbortError`, which on a
|
|
166
|
+
// money path is the least informative wording available: the caller
|
|
167
|
+
// cannot tell it from a refusal, and the charge may have landed. Named
|
|
168
|
+
// and bounded, as `useTip` and `useEntitlements` both do.
|
|
169
|
+
//
|
|
170
|
+
// 🔴 THE `!(err instanceof GoodPurchaseRefusal)` HALF IS NOT DEFENSIVE
|
|
171
|
+
// PADDING — but it does NOT cover a refusal cut off mid-parse, which an
|
|
172
|
+
// earlier version of this comment claimed. That case no longer reaches
|
|
173
|
+
// here at all: an abort during the body read is rethrown above and
|
|
174
|
+
// classified as an abort, because a body nobody could read carries no
|
|
175
|
+
// `reason` to preserve.
|
|
176
|
+
//
|
|
177
|
+
// What it does cover is the race the other way round: the body had
|
|
178
|
+
// ALREADY ARRIVED and parsed into an `insufficient_funds` 400 when the
|
|
179
|
+
// abort fired underneath it — the 30s bound elapsing in the same tick
|
|
180
|
+
// the refusal was thrown, or an unmount landing there. `signal.aborted`
|
|
181
|
+
// is then true while we hold a complete, deliberate server refusal.
|
|
182
|
+
// Rewriting it into the abort `Error` would strip `reason`, so the
|
|
183
|
+
// top-up branch in `purchase` below would stop recognising it and the
|
|
184
|
+
// viewer would be shown a raw failure instead of the Buzz modal. A
|
|
185
|
+
// refusal we actually read always wins over an abort that raced it.
|
|
186
|
+
if (controller.signal.aborted && !(err instanceof GoodPurchaseRefusal)) {
|
|
187
|
+
if (timedOut) {
|
|
188
|
+
// The BOUND fired. This is a real failure the caller must handle,
|
|
189
|
+
// so it deliberately does NOT get the `AbortError` name below:
|
|
190
|
+
// callers routinely ignore `AbortError` as "we navigated away", and
|
|
191
|
+
// a silently-ignored money-path timeout is the worst outcome here.
|
|
192
|
+
throw new Error(`useGoodPurchase: request aborted (timed out after ${GOOD_PURCHASE_TIMEOUT_MS}ms). The charge may or may not have landed — retry with the SAME idempotencyKey to find out safely.`);
|
|
193
|
+
}
|
|
194
|
+
// 🔴 UNMOUNT. `name` stays `AbortError` on purpose: that is the
|
|
195
|
+
// discriminator a caller uses to IGNORE a rejection its component no
|
|
196
|
+
// longer cares about, and flattening it to a plain `Error` turns every
|
|
197
|
+
// navigate-away into a reported failure. The MESSAGE still says what
|
|
198
|
+
// happened — informative wording and a usable `name` are not a
|
|
199
|
+
// trade-off. `useBuzzWorkflow`'s `watch()` does the same.
|
|
200
|
+
const aborted = new Error('useGoodPurchase: request aborted (the hook unmounted before the response arrived). The charge may or may not have landed — retry with the SAME idempotencyKey to find out safely.');
|
|
201
|
+
aborted.name = 'AbortError';
|
|
202
|
+
throw aborted;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
clearTimeout(timeoutId);
|
|
208
|
+
inFlight.current.delete(controller);
|
|
209
|
+
}
|
|
210
|
+
}, [host, raw]);
|
|
211
|
+
const purchase = useCallback(async (params, options) => {
|
|
212
|
+
if (!host) {
|
|
213
|
+
throw new Error('useGoodPurchase: host origin not established yet (wait for BLOCK_INIT).');
|
|
214
|
+
}
|
|
215
|
+
if (mountedRef.current) {
|
|
216
|
+
setLoading(true);
|
|
217
|
+
setError(null);
|
|
218
|
+
}
|
|
219
|
+
const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
|
|
220
|
+
try {
|
|
221
|
+
try {
|
|
222
|
+
return await postOnce(params, idempotencyKey);
|
|
223
|
+
}
|
|
224
|
+
catch (first) {
|
|
225
|
+
const canTopUp = options?.topUpOnInsufficientFunds === true &&
|
|
226
|
+
first instanceof GoodPurchaseRefusal &&
|
|
227
|
+
first.reason === 'insufficient_funds';
|
|
228
|
+
if (!canTopUp)
|
|
229
|
+
throw first;
|
|
230
|
+
// Only retry if the viewer ACTUALLY bought Buzz. `purchased: false`
|
|
231
|
+
// is the ordinary "they closed the modal" case, and retrying it would
|
|
232
|
+
// just reproduce the same refusal — so the original refusal is what
|
|
233
|
+
// the app should see.
|
|
234
|
+
const { purchased } = await openPurchaseModal(params.expectedPriceBuzz);
|
|
235
|
+
if (!purchased)
|
|
236
|
+
throw first;
|
|
237
|
+
return await postOnce(params, idempotencyKey);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
242
|
+
if (mountedRef.current)
|
|
243
|
+
setError(e);
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
if (mountedRef.current)
|
|
248
|
+
setLoading(false);
|
|
249
|
+
}
|
|
250
|
+
}, [host, openPurchaseModal, postOnce]);
|
|
251
|
+
return { purchase, loading, error };
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=useGoodPurchase.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export { useHostOrigin } from './hooks/useHostOrigin.js';
|
|
|
26
26
|
export type { UseHostOrigin } from './hooks/useHostOrigin.js';
|
|
27
27
|
export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
|
|
28
28
|
export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
|
|
29
|
+
export { useEntitlements } from './hooks/useEntitlements.js';
|
|
30
|
+
export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
|
|
31
|
+
export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
|
|
32
|
+
export type { UseGoodPurchase, GoodPurchaseParams, GoodPurchaseOptions, GoodPurchaseResult, } from './hooks/useGoodPurchase.js';
|
|
29
33
|
export { useTip } from './hooks/useTip.js';
|
|
30
34
|
export type { TipParams, TipOptions, TipResult, UseTip } from './hooks/useTip.js';
|
|
31
35
|
export { useTipAllowance } from './hooks/useTipAllowance.js';
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,8 @@ export { useBlockSettings } from './hooks/useBlockSettings.js';
|
|
|
24
24
|
export { useBlockToken } from './hooks/useBlockToken.js';
|
|
25
25
|
export { useHostOrigin } from './hooks/useHostOrigin.js';
|
|
26
26
|
export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
|
|
27
|
+
export { useEntitlements } from './hooks/useEntitlements.js';
|
|
28
|
+
export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
|
|
27
29
|
export { useTip } from './hooks/useTip.js';
|
|
28
30
|
export { useTipAllowance } from './hooks/useTipAllowance.js';
|
|
29
31
|
export { useBlockResize } from './hooks/useBlockResize.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal stand-in for the HTML popover API, for NON-BROWSER DOMs.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS (#485). `jsdom` and `happy-dom` do not implement the popover
|
|
5
|
+
* API at ANY version we could find: `showPopover`, `hidePopover` and
|
|
6
|
+
* `togglePopover` are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike.
|
|
7
|
+
* `:popover-open` is worse than absent — it is unreliable in a way that is NOT a
|
|
8
|
+
* property of the runner's version: under jsdom it resolves through `nwsapi`, so
|
|
9
|
+
* the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
|
|
10
|
+
* and returns `false` depending on which `nwsapi` a lockfile pulled in (measured
|
|
11
|
+
* `false` on nwsapi 2.2.28). Anything that calls into that API therefore explodes,
|
|
12
|
+
* or lies, in the one environment an App Block's own test suite runs in.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 WHAT THIS DOES **NOT** DO, and you must read this before using it.
|
|
15
|
+
*
|
|
16
|
+
* It does not make a shadow-DOM component fully driveable under happy-dom.
|
|
17
|
+
* Measured on happy-dom 20.9.0: a click on a light-DOM child assigned to a
|
|
18
|
+
* `<slot>` bubbles to the HOST (a listener there fires 1x) but a listener on the
|
|
19
|
+
* `<slot>` ELEMENT ITSELF fires **0x**. Lit binds `@click` to the `<slot>`, so
|
|
20
|
+
* `<civitai-menu>`'s trigger click is SILENT there: no throw, no open, nothing.
|
|
21
|
+
* That is a property of happy-dom's event path through the flattened tree and
|
|
22
|
+
* this shim cannot fix it — installing it changes the count from 0 to 0. (jsdom
|
|
23
|
+
* 25 and 30 both DO deliver it, which is why this is probed rather than asserted.)
|
|
24
|
+
*
|
|
25
|
+
* So under a shimmed DOM you must drive overlay elements through their METHODS
|
|
26
|
+
* (`menu.show()` / `menu.hide()`), never by clicking the trigger. {@link
|
|
27
|
+
* installPopoverShim} PROBES for this on install and returns the result as
|
|
28
|
+
* {@link PopoverShimHandle.slottedClicksReachSlots}, warning loudly when it is
|
|
29
|
+
* false, because a shim that quietly made `show()` work while `click()` no-ops
|
|
30
|
+
* would read as "this element is testable now" while delivering half of it.
|
|
31
|
+
*
|
|
32
|
+
* Other deliberate deviations from the platform, all of them narrow:
|
|
33
|
+
* - `toggle` is dispatched in a MICROTASK, where the platform queues a task.
|
|
34
|
+
* A microtask flushes before the next `await`, which is what makes it
|
|
35
|
+
* observable after `await el.updateComplete` in a test; a real task would
|
|
36
|
+
* need a `setTimeout` round-trip. `beforetoggle` is not dispatched at all.
|
|
37
|
+
* - There is no top layer, no anchor positioning, and no LIGHT DISMISS: a
|
|
38
|
+
* click outside a shown popover does not close it. Those need layout and a
|
|
39
|
+
* hit-testing event path, neither of which a non-browser DOM has. Light
|
|
40
|
+
* dismiss is one of the two reasons `<civitai-menu>` uses popover at all, so
|
|
41
|
+
* if that is what you are testing, use a real browser.
|
|
42
|
+
* - `popover="manual"` vs `"auto"` is not distinguished (there being no light
|
|
43
|
+
* dismiss to distinguish them by).
|
|
44
|
+
*/
|
|
45
|
+
/** What {@link installPopoverShim} hands back. */
|
|
46
|
+
export interface PopoverShimHandle {
|
|
47
|
+
/**
|
|
48
|
+
* `true` when this shim was needed — i.e. the DOM did not already have a
|
|
49
|
+
* popover API. `false` means nothing was patched, which is the correct result
|
|
50
|
+
* in a real browser, and makes the call safe to make unconditionally in a
|
|
51
|
+
* setup file shared between a happy-dom project and a browser-mode project.
|
|
52
|
+
*/
|
|
53
|
+
installed: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* 🔴 Whether a click on a slotted light-DOM element reaches a listener on the
|
|
56
|
+
* `<slot>` it is assigned to — measured, on install, with a throwaway element.
|
|
57
|
+
*
|
|
58
|
+
* `true` in a real browser. `false` on happy-dom 20.9.0, and while it is
|
|
59
|
+
* `false` an element that binds its handlers to a `<slot>` (every Lit
|
|
60
|
+
* component that does, `<civitai-menu>`'s trigger included) cannot be driven
|
|
61
|
+
* by clicking. Drive it through its methods instead.
|
|
62
|
+
*/
|
|
63
|
+
slottedClicksReachSlots: boolean;
|
|
64
|
+
/** Restores whatever was on the prototypes before. Idempotent. */
|
|
65
|
+
uninstall(): void;
|
|
66
|
+
}
|
|
67
|
+
/** Options for {@link installPopoverShim}. */
|
|
68
|
+
export interface PopoverShimOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Suppress the `console.warn` fired when slotted clicks do not reach slot
|
|
71
|
+
* listeners. The measurement is still returned on the handle. Default `false`
|
|
72
|
+
* — the warning is the point, so silence it only once you have read it.
|
|
73
|
+
*/
|
|
74
|
+
quiet?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Install the popover shim on the current global DOM. Call it once, in a vitest
|
|
78
|
+
* `setupFiles` entry or at the top of a test file, BEFORE the elements render.
|
|
79
|
+
*
|
|
80
|
+
* Safe and inert in a real browser: it detects a working popover API and patches
|
|
81
|
+
* nothing (`installed: false`).
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* import { installPopoverShim } from '@civitai/blocks-react/testing';
|
|
86
|
+
*
|
|
87
|
+
* const shim = installPopoverShim();
|
|
88
|
+
* // Drive overlay elements through their methods — NOT by clicking the trigger.
|
|
89
|
+
* menu.show();
|
|
90
|
+
* await menu.updateComplete;
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare function installPopoverShim(options?: PopoverShimOptions): PopoverShimHandle;
|
|
94
|
+
//# sourceMappingURL=popoverShim.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal stand-in for the HTML popover API, for NON-BROWSER DOMs.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS (#485). `jsdom` and `happy-dom` do not implement the popover
|
|
5
|
+
* API at ANY version we could find: `showPopover`, `hidePopover` and
|
|
6
|
+
* `togglePopover` are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike.
|
|
7
|
+
* `:popover-open` is worse than absent — it is unreliable in a way that is NOT a
|
|
8
|
+
* property of the runner's version: under jsdom it resolves through `nwsapi`, so
|
|
9
|
+
* the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
|
|
10
|
+
* and returns `false` depending on which `nwsapi` a lockfile pulled in (measured
|
|
11
|
+
* `false` on nwsapi 2.2.28). Anything that calls into that API therefore explodes,
|
|
12
|
+
* or lies, in the one environment an App Block's own test suite runs in.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 WHAT THIS DOES **NOT** DO, and you must read this before using it.
|
|
15
|
+
*
|
|
16
|
+
* It does not make a shadow-DOM component fully driveable under happy-dom.
|
|
17
|
+
* Measured on happy-dom 20.9.0: a click on a light-DOM child assigned to a
|
|
18
|
+
* `<slot>` bubbles to the HOST (a listener there fires 1x) but a listener on the
|
|
19
|
+
* `<slot>` ELEMENT ITSELF fires **0x**. Lit binds `@click` to the `<slot>`, so
|
|
20
|
+
* `<civitai-menu>`'s trigger click is SILENT there: no throw, no open, nothing.
|
|
21
|
+
* That is a property of happy-dom's event path through the flattened tree and
|
|
22
|
+
* this shim cannot fix it — installing it changes the count from 0 to 0. (jsdom
|
|
23
|
+
* 25 and 30 both DO deliver it, which is why this is probed rather than asserted.)
|
|
24
|
+
*
|
|
25
|
+
* So under a shimmed DOM you must drive overlay elements through their METHODS
|
|
26
|
+
* (`menu.show()` / `menu.hide()`), never by clicking the trigger. {@link
|
|
27
|
+
* installPopoverShim} PROBES for this on install and returns the result as
|
|
28
|
+
* {@link PopoverShimHandle.slottedClicksReachSlots}, warning loudly when it is
|
|
29
|
+
* false, because a shim that quietly made `show()` work while `click()` no-ops
|
|
30
|
+
* would read as "this element is testable now" while delivering half of it.
|
|
31
|
+
*
|
|
32
|
+
* Other deliberate deviations from the platform, all of them narrow:
|
|
33
|
+
* - `toggle` is dispatched in a MICROTASK, where the platform queues a task.
|
|
34
|
+
* A microtask flushes before the next `await`, which is what makes it
|
|
35
|
+
* observable after `await el.updateComplete` in a test; a real task would
|
|
36
|
+
* need a `setTimeout` round-trip. `beforetoggle` is not dispatched at all.
|
|
37
|
+
* - There is no top layer, no anchor positioning, and no LIGHT DISMISS: a
|
|
38
|
+
* click outside a shown popover does not close it. Those need layout and a
|
|
39
|
+
* hit-testing event path, neither of which a non-browser DOM has. Light
|
|
40
|
+
* dismiss is one of the two reasons `<civitai-menu>` uses popover at all, so
|
|
41
|
+
* if that is what you are testing, use a real browser.
|
|
42
|
+
* - `popover="manual"` vs `"auto"` is not distinguished (there being no light
|
|
43
|
+
* dismiss to distinguish them by).
|
|
44
|
+
*/
|
|
45
|
+
/** The marker attribute a shown popover carries. Internal to the shim. */
|
|
46
|
+
const SHOWN_ATTR = 'data-civitai-popover-open';
|
|
47
|
+
/**
|
|
48
|
+
* Does a click on a slotted child reach a listener bound to the `<slot>`?
|
|
49
|
+
*
|
|
50
|
+
* Built as its own throwaway tree rather than asked of the component under test,
|
|
51
|
+
* so the answer is about the DOM implementation and not about one element's
|
|
52
|
+
* wiring. Returns `false` if anything in the probe is unsupported — an
|
|
53
|
+
* environment that cannot even run the probe certainly cannot deliver the event.
|
|
54
|
+
*/
|
|
55
|
+
function probeSlottedClickReachesSlot(doc) {
|
|
56
|
+
let host;
|
|
57
|
+
try {
|
|
58
|
+
host = doc.createElement('div');
|
|
59
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
60
|
+
const slot = doc.createElement('slot');
|
|
61
|
+
root.append(slot);
|
|
62
|
+
const child = doc.createElement('button');
|
|
63
|
+
host.append(child);
|
|
64
|
+
doc.body.append(host);
|
|
65
|
+
let slotSaw = 0;
|
|
66
|
+
slot.addEventListener('click', () => {
|
|
67
|
+
slotSaw += 1;
|
|
68
|
+
});
|
|
69
|
+
child.click();
|
|
70
|
+
return slotSaw > 0;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
host?.remove();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Install the popover shim on the current global DOM. Call it once, in a vitest
|
|
81
|
+
* `setupFiles` entry or at the top of a test file, BEFORE the elements render.
|
|
82
|
+
*
|
|
83
|
+
* Safe and inert in a real browser: it detects a working popover API and patches
|
|
84
|
+
* nothing (`installed: false`).
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { installPopoverShim } from '@civitai/blocks-react/testing';
|
|
89
|
+
*
|
|
90
|
+
* const shim = installPopoverShim();
|
|
91
|
+
* // Drive overlay elements through their methods — NOT by clicking the trigger.
|
|
92
|
+
* menu.show();
|
|
93
|
+
* await menu.updateComplete;
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function installPopoverShim(options = {}) {
|
|
97
|
+
const doc = globalThis.document;
|
|
98
|
+
const win = globalThis;
|
|
99
|
+
if (!doc || !win.Element || !win.HTMLElement) {
|
|
100
|
+
throw new Error('installPopoverShim() needs a DOM. Run it under a vitest `environment` of ' +
|
|
101
|
+
'`happy-dom` or `jsdom`, not `node`.');
|
|
102
|
+
}
|
|
103
|
+
const slottedClicksReachSlots = probeSlottedClickReachesSlot(doc);
|
|
104
|
+
if (!slottedClicksReachSlots && !options.quiet) {
|
|
105
|
+
// Loud on purpose. The popover half being fixed is what makes this the
|
|
106
|
+
// remaining reason a test "does nothing", and a silent no-op click is a far
|
|
107
|
+
// worse diagnostic than a throw.
|
|
108
|
+
console.warn('[civitai] popover shim installed, but THIS DOM DOES NOT DELIVER CLICKS ON SLOTTED ' +
|
|
109
|
+
'CONTENT TO LISTENERS ON THE <slot> (measured on install; happy-dom 20.x behaves this ' +
|
|
110
|
+
'way). Clicking a component\'s trigger will do nothing at all — no throw, no state ' +
|
|
111
|
+
'change. Drive overlay elements through their methods instead: `menu.show()` / ' +
|
|
112
|
+
'`menu.hide()`. See @civitai/blocks-react README § "Testing overlay elements".');
|
|
113
|
+
}
|
|
114
|
+
const proto = win.HTMLElement.prototype;
|
|
115
|
+
const already = typeof proto.showPopover === 'function';
|
|
116
|
+
if (already) {
|
|
117
|
+
return { installed: false, slottedClicksReachSlots, uninstall: () => { } };
|
|
118
|
+
}
|
|
119
|
+
const shown = new WeakSet();
|
|
120
|
+
const fireToggle = (el, from, to) => {
|
|
121
|
+
queueMicrotask(() => {
|
|
122
|
+
// `ToggleEvent` is undefined in both jsdom and happy-dom, so the two state
|
|
123
|
+
// fields are attached to a plain Event. Consumers read `event.newState`,
|
|
124
|
+
// which is what `<civitai-menu>`'s own handler does.
|
|
125
|
+
const event = new Event('toggle', { bubbles: false, cancelable: false });
|
|
126
|
+
event.oldState = from;
|
|
127
|
+
event.newState = to;
|
|
128
|
+
el.dispatchEvent(event);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
function assertPopover(el) {
|
|
132
|
+
if (!el.hasAttribute('popover')) {
|
|
133
|
+
throw new Error('InvalidStateError: showPopover/hidePopover called on an element without a `popover` attribute');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
proto.showPopover = function showPopover() {
|
|
137
|
+
assertPopover(this);
|
|
138
|
+
if (shown.has(this))
|
|
139
|
+
throw new Error('InvalidStateError: popover is already showing');
|
|
140
|
+
shown.add(this);
|
|
141
|
+
this.setAttribute(SHOWN_ATTR, '');
|
|
142
|
+
fireToggle(this, 'closed', 'open');
|
|
143
|
+
};
|
|
144
|
+
proto.hidePopover = function hidePopover() {
|
|
145
|
+
assertPopover(this);
|
|
146
|
+
if (!shown.has(this))
|
|
147
|
+
throw new Error('InvalidStateError: popover is not showing');
|
|
148
|
+
shown.delete(this);
|
|
149
|
+
this.removeAttribute(SHOWN_ATTR);
|
|
150
|
+
fireToggle(this, 'open', 'closed');
|
|
151
|
+
};
|
|
152
|
+
proto.togglePopover = function togglePopover(force) {
|
|
153
|
+
const want = force ?? !shown.has(this);
|
|
154
|
+
if (want && !shown.has(this))
|
|
155
|
+
this.showPopover();
|
|
156
|
+
else if (!want && shown.has(this))
|
|
157
|
+
this.hidePopover();
|
|
158
|
+
return shown.has(this);
|
|
159
|
+
};
|
|
160
|
+
// `:popover-open` is a SELECTOR, so it cannot be shimmed by adding a method —
|
|
161
|
+
// it has to be rewritten before the engine sees it. `matches` is the entry
|
|
162
|
+
// point the pseudo-class is reached through in practice; the marker attribute
|
|
163
|
+
// the two methods above maintain is what it rewrites to, which makes
|
|
164
|
+
// `:not(:popover-open)` work for free.
|
|
165
|
+
const nativeMatches = win.Element.prototype.matches;
|
|
166
|
+
const POPOVER_OPEN = /:popover-open\b/g;
|
|
167
|
+
win.Element.prototype.matches = function matches(selector) {
|
|
168
|
+
return nativeMatches.call(this, selector.replace(POPOVER_OPEN, `[${SHOWN_ATTR}]`));
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
installed: true,
|
|
172
|
+
slottedClicksReachSlots,
|
|
173
|
+
uninstall() {
|
|
174
|
+
delete proto.showPopover;
|
|
175
|
+
delete proto.hidePopover;
|
|
176
|
+
delete proto.togglePopover;
|
|
177
|
+
win.Element.prototype.matches = nativeMatches;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=popoverShim.js.map
|
package/dist/testing.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { type ReactNode } from 'react';
|
|
|
20
20
|
import { __resetTransport } from './transport/singleton.js';
|
|
21
21
|
import { type MockHostOptions } from './internal/mockHost.js';
|
|
22
22
|
export { __resetTransport as resetTransport };
|
|
23
|
+
export { installPopoverShim, type PopoverShimHandle, type PopoverShimOptions, } from './internal/popoverShim.js';
|
|
23
24
|
export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
|
|
24
25
|
/**
|
|
25
26
|
* Props for the dev {@link Harness}.
|
package/dist/testing.js
CHANGED
|
@@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
21
21
|
import { __resetTransport } from './transport/singleton.js';
|
|
22
22
|
import { createMockHost, readMockHostUrlOptions, } from './internal/mockHost.js';
|
|
23
23
|
export { __resetTransport as resetTransport };
|
|
24
|
+
export { installPopoverShim, } from './internal/popoverShim.js';
|
|
24
25
|
export { createMockHost, readMockHostUrlOptions, } from './internal/mockHost.js';
|
|
25
26
|
/**
|
|
26
27
|
* What the harness chrome's `consent=` readout says, from the TWO INDEPENDENT
|