@snowcone-app/ui 0.9.3 → 0.9.4
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/CHANGELOG.md +9 -0
- package/dist/index.cjs +73 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +73 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composed/HeroProductImage.tsx +14 -3
- package/src/patterns/RealtimeProvider.tsx +98 -37
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +56 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "React components for merchandise visualization and customization",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"react-instantsearch": "^7.15.5",
|
|
104
104
|
"react-zoom-pan-pinch": "^3.6.4",
|
|
105
105
|
"tailwind-merge": "^3.0.0",
|
|
106
|
-
"@snowcone-app/sdk": "0.
|
|
106
|
+
"@snowcone-app/sdk": "0.25.0"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -555,6 +555,9 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
555
555
|
// picture stuck on `stale` is the "editor updates, pictures don't" defect
|
|
556
556
|
// (snowcone.app phone case, 2026-11-11). Absent until the first edit.
|
|
557
557
|
const renderVersionRef = useRef(0);
|
|
558
|
+
// Per-placement versions of the shown frame (servers that stamp them).
|
|
559
|
+
const renderVersionsRef = useRef<Record<string, number> | null>(null);
|
|
560
|
+
const getLatestSentVersions = realtimeContext?.getLatestSentVersions;
|
|
558
561
|
const refusalCodeRef = useRef<string | null>(null);
|
|
559
562
|
refusalCodeRef.current = unavailable?.code ?? null;
|
|
560
563
|
const sessionErrorCodeRef = useRef<string | null>(null);
|
|
@@ -571,13 +574,20 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
571
574
|
return;
|
|
572
575
|
}
|
|
573
576
|
const rendered = renderVersionRef.current;
|
|
574
|
-
|
|
577
|
+
// With per-placement stamps, current = every placement at (or past) the
|
|
578
|
+
// latest version SENT for it. A single max can't say that: a frame can
|
|
579
|
+
// hold one group's newest state and another group's older one.
|
|
580
|
+
const versions = renderVersionsRef.current;
|
|
581
|
+
const sent = versions ? getLatestSentVersions?.() : undefined;
|
|
582
|
+
const behind = versions && sent
|
|
583
|
+
? Object.entries(sent).some(([placement, v]) => (versions[placement] ?? -1) < v)
|
|
584
|
+
: rendered < canvas;
|
|
575
585
|
node.dataset.canvasVersion = String(canvas);
|
|
576
586
|
node.dataset.renderVersion = String(rendered);
|
|
577
587
|
node.dataset.renderState = refusalCodeRef.current || (behind && sessionErrorCodeRef.current)
|
|
578
588
|
? "refused"
|
|
579
589
|
: behind ? "stale" : "current";
|
|
580
|
-
}, [getCanvasVersion]);
|
|
590
|
+
}, [getCanvasVersion, getLatestSentVersions]);
|
|
581
591
|
// Re-apply after every render (the root node can change; refusals are state).
|
|
582
592
|
useEffect(() => {
|
|
583
593
|
applyRenderStamp();
|
|
@@ -588,8 +598,9 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
588
598
|
if (!actualMockupId || !subscribeSent || !subscribeResult) return;
|
|
589
599
|
const offSent = subscribeSent(() => applyRenderStamp());
|
|
590
600
|
const offResult = subscribeResult(actualMockupId, (result) => {
|
|
591
|
-
if (typeof result?.requestVersion === "number" && result.requestVersion
|
|
601
|
+
if (typeof result?.requestVersion === "number" && result.requestVersion >= renderVersionRef.current) {
|
|
592
602
|
renderVersionRef.current = result.requestVersion;
|
|
603
|
+
renderVersionsRef.current = result.versions ?? null;
|
|
593
604
|
}
|
|
594
605
|
applyRenderStamp();
|
|
595
606
|
});
|
|
@@ -75,6 +75,8 @@ export interface MockupResult {
|
|
|
75
75
|
imageUrl: string;
|
|
76
76
|
/** The canvas version this frame was rendered from (see getCanvasVersion). */
|
|
77
77
|
requestVersion?: number;
|
|
78
|
+
/** Per-placement versions folded into this frame (servers with per-placement stamps; see getLatestSentVersions). */
|
|
79
|
+
versions?: Record<string, number>;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
// Type alias for backward compatibility with code that imported RealtimeState from Product
|
|
@@ -242,6 +244,13 @@ export interface RealtimeContextValue {
|
|
|
242
244
|
*/
|
|
243
245
|
getCanvasVersion: () => number;
|
|
244
246
|
|
|
247
|
+
/**
|
|
248
|
+
* The latest canvas version sent per placement. With a server that stamps
|
|
249
|
+
* `versions` on every frame, a picture is current exactly when, for every
|
|
250
|
+
* placement here, its frame's `versions[placement]` is at least this.
|
|
251
|
+
*/
|
|
252
|
+
getLatestSentVersions: () => Record<string, number>;
|
|
253
|
+
|
|
245
254
|
// Flush pending blobs immediately (call on mouse up, action complete, etc.)
|
|
246
255
|
flushPendingBlobs: () => void;
|
|
247
256
|
hasPendingBlobs: () => boolean;
|
|
@@ -450,6 +459,14 @@ export function RealtimeProvider({
|
|
|
450
459
|
// Logged for traces; the actionable surface is `renderError` on the
|
|
451
460
|
// context (and the hook state), which consumers must render visibly.
|
|
452
461
|
console.error(`[RealtimeProvider] Render error [${detail.code}]:`, error);
|
|
462
|
+
// The box refused a `rerender` (rate limit, incomplete placements, …):
|
|
463
|
+
// force the re-render the older way so the failed pictures still update.
|
|
464
|
+
if (detail.code.startsWith("E_RERENDER_") && pendingRerenderRef.current.length > 0) {
|
|
465
|
+
const ids = pendingRerenderRef.current;
|
|
466
|
+
pendingRerenderRef.current = [];
|
|
467
|
+
forceRerenderByConfigRef.current?.(ids);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
453
470
|
// A per-mockup refusal names the mockup: remember it so that card shows
|
|
454
471
|
// "unavailable" while the others keep streaming.
|
|
455
472
|
if (detail.mockupId) {
|
|
@@ -480,6 +497,8 @@ export function RealtimeProvider({
|
|
|
480
497
|
flushPendingBlobs,
|
|
481
498
|
hasPendingBlobs,
|
|
482
499
|
getLastSentVersion,
|
|
500
|
+
getLatestSentVersions,
|
|
501
|
+
rerender: rerenderRaw,
|
|
483
502
|
} = realtimeMockup;
|
|
484
503
|
|
|
485
504
|
// PERFORMANCE: Store mockup results in ref only - NO STATE UPDATES
|
|
@@ -589,6 +608,12 @@ export function RealtimeProvider({
|
|
|
589
608
|
// config's set, or the last updateMockupIds set. A full-frame fallback
|
|
590
609
|
// re-sends this set (plus the failed mockups) — it must never shrink it.
|
|
591
610
|
const requestedMockupIdsRef = useRef<string[]>([]);
|
|
611
|
+
// True once a frame carries per-placement `versions`: the box then also
|
|
612
|
+
// understands `rerender` (rendercenter rc#839 + rc#840, one roll).
|
|
613
|
+
const serverStampsVersionsRef = useRef(false);
|
|
614
|
+
// Mockups a `rerender` was sent for, until it is answered or refused.
|
|
615
|
+
const pendingRerenderRef = useRef<string[]>([]);
|
|
616
|
+
const forceRerenderByConfigRef = useRef<((failed: string[]) => void) | null>(null);
|
|
592
617
|
// Cropped frames that could not be shown, waiting for one batched
|
|
593
618
|
// full-frame re-render (see rerenderFullFrame).
|
|
594
619
|
const fullFrameRerenderIdsRef = useRef<Set<string>>(new Set());
|
|
@@ -671,6 +696,50 @@ export function RealtimeProvider({
|
|
|
671
696
|
* single-mockup configs in a row, last wins). Failures are batched into ONE
|
|
672
697
|
* full-frame config: everything the session was rendering plus the failed.
|
|
673
698
|
*/
|
|
699
|
+
const sendFullFrameConfig = useCallback(
|
|
700
|
+
(mockupIds: string[]) => {
|
|
701
|
+
const base = lastConfigBaseRef.current;
|
|
702
|
+
if (!base) return;
|
|
703
|
+
sendConfigRaw({
|
|
704
|
+
...base,
|
|
705
|
+
mockupIds,
|
|
706
|
+
placementSettings: { ...placementSettingsOverridesRef.current },
|
|
707
|
+
} as Parameters<typeof sendConfigRaw>[0]);
|
|
708
|
+
},
|
|
709
|
+
[sendConfigRaw]
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Force a re-render of `failed` with configs alone (boxes without the
|
|
714
|
+
* `rerender` message, or one that refused it). The box re-renders on a
|
|
715
|
+
* config only when its render fingerprint changes, and that fingerprint
|
|
716
|
+
* ignores overlayCrop and SORTS mockupIds, so "same set, crop dropped" is no
|
|
717
|
+
* change: nothing re-renders and the failed picture stays old until the next
|
|
718
|
+
* edit (BC43BE swap, prod, 2026-11-11: hero stuck at v1, canvas v3). So: a
|
|
719
|
+
* config that differs from the one in force, then the set in force again,
|
|
720
|
+
* which differs from its predecessor and stays.
|
|
721
|
+
*/
|
|
722
|
+
const forceRerenderByConfig = useCallback(
|
|
723
|
+
(failed: string[]) => {
|
|
724
|
+
const inForce = requestedMockupIdsRef.current;
|
|
725
|
+
const fp = (list: readonly string[]) => [...list].sort().join(",");
|
|
726
|
+
const step = fp(failed) !== fp(inForce) ? failed : inForce.slice(0, -1);
|
|
727
|
+
if (step.length === 0) {
|
|
728
|
+
// One mockup, already the whole session: no differing config exists.
|
|
729
|
+
// The picture keeps its stale stamp and the next edit re-renders it.
|
|
730
|
+
console.error(
|
|
731
|
+
`[RealtimeProvider] E_OVERLAY_RERENDER_UNAVAILABLE: cannot force a full-frame re-render of ${failed.join(", ")} (single-mockup session on a box without \`rerender\`); it updates on the next edit.`
|
|
732
|
+
);
|
|
733
|
+
sendFullFrameConfig(inForce);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
sendFullFrameConfig(step);
|
|
737
|
+
sendFullFrameConfig(inForce);
|
|
738
|
+
},
|
|
739
|
+
[sendFullFrameConfig]
|
|
740
|
+
);
|
|
741
|
+
forceRerenderByConfigRef.current = forceRerenderByConfig;
|
|
742
|
+
|
|
674
743
|
const rerenderFullFrame = useCallback(
|
|
675
744
|
(mockupId: string) => {
|
|
676
745
|
fullFrameRerenderIdsRef.current.add(mockupId);
|
|
@@ -679,52 +748,32 @@ export function RealtimeProvider({
|
|
|
679
748
|
fullFrameRerenderTimerRef.current = null;
|
|
680
749
|
const failed = [...fullFrameRerenderIdsRef.current];
|
|
681
750
|
fullFrameRerenderIdsRef.current.clear();
|
|
682
|
-
|
|
683
|
-
if (!base || failed.length === 0) return;
|
|
751
|
+
if (!lastConfigBaseRef.current || failed.length === 0) return;
|
|
684
752
|
const previous = requestedMockupIdsRef.current;
|
|
685
753
|
const ids = mergeMockupIds(previous, failed);
|
|
686
754
|
croppedActiveRef.current = false;
|
|
687
755
|
requestedMockupIdsRef.current = ids;
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
} as Parameters<typeof sendConfigRaw>[0]);
|
|
694
|
-
// The box re-renders on a config only when its render fingerprint
|
|
695
|
-
// changes, and that fingerprint deliberately ignores overlayCrop and
|
|
696
|
-
// SORTS mockupIds. Dropping the crop with the same set is "no change":
|
|
697
|
-
// nothing re-renders and the failed picture stays on its old render
|
|
698
|
-
// until the next edit (BC43BE swap, prod, 2026-11-11: hero stuck at
|
|
699
|
-
// v1 while the canvas reached v3). So the re-render is forced with a
|
|
700
|
-
// config that differs from the one before it, then the full set,
|
|
701
|
-
// which also differs from its predecessor and is the one that stays
|
|
702
|
-
// in force.
|
|
703
|
-
// TODO(lane-editor): this is a trick on the fingerprint. Replace it
|
|
704
|
-
// with the box's explicit "re-render these mockups now" request
|
|
705
|
-
// (asked of lane-rc-dof with the realtime-versions change) as soon as
|
|
706
|
-
// the fleet has it.
|
|
707
|
-
const fingerprint = (list: readonly string[]) => [...list].sort().join(",");
|
|
708
|
-
if (fingerprint(ids) !== fingerprint(previous)) {
|
|
709
|
-
send(ids);
|
|
756
|
+
const fp = (list: readonly string[]) => [...list].sort().join(",");
|
|
757
|
+
if (fp(ids) !== fp(previous)) {
|
|
758
|
+
// A wider set: the config itself is a render-relevant change, and it
|
|
759
|
+
// re-renders every picture full-frame.
|
|
760
|
+
sendFullFrameConfig(ids);
|
|
710
761
|
return;
|
|
711
762
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// the
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
);
|
|
720
|
-
|
|
721
|
-
return;
|
|
763
|
+
if (serverStampsVersionsRef.current) {
|
|
764
|
+
// The box has `rerender` (rc#840, same roll as the versions stamp
|
|
765
|
+
// seen on its frames): drop the crop (no render by itself), then ask
|
|
766
|
+
// for exactly the failed pictures. A refusal (E_RERENDER_*) falls
|
|
767
|
+
// back to the config way (see onError).
|
|
768
|
+
sendFullFrameConfig(ids);
|
|
769
|
+
pendingRerenderRef.current = failed;
|
|
770
|
+
if (rerenderRaw(failed)) return;
|
|
771
|
+
pendingRerenderRef.current = [];
|
|
722
772
|
}
|
|
723
|
-
|
|
724
|
-
send(ids);
|
|
773
|
+
forceRerenderByConfig(failed);
|
|
725
774
|
}, 0);
|
|
726
775
|
},
|
|
727
|
-
[
|
|
776
|
+
[sendFullFrameConfig, forceRerenderByConfig, rerenderRaw]
|
|
728
777
|
);
|
|
729
778
|
|
|
730
779
|
/** Flip to cropped delivery once every configured mockup has a frozen
|
|
@@ -772,6 +821,10 @@ export function RealtimeProvider({
|
|
|
772
821
|
else merged.push(result);
|
|
773
822
|
});
|
|
774
823
|
mockupResultsRef.current = merged;
|
|
824
|
+
if (pendingRerenderRef.current.length > 0) {
|
|
825
|
+
const delivered = new Set(results.map((r) => r.mockupId));
|
|
826
|
+
pendingRerenderRef.current = pendingRerenderRef.current.filter((id) => !delivered.has(id));
|
|
827
|
+
}
|
|
775
828
|
|
|
776
829
|
const clearedPlacements = new Set<string>();
|
|
777
830
|
results.forEach((result) => {
|
|
@@ -934,6 +987,9 @@ export function RealtimeProvider({
|
|
|
934
987
|
console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}${r.cropRect ? ' [crop]' : ''}`));
|
|
935
988
|
|
|
936
989
|
const displayable: SdkMockupResult[] = [];
|
|
990
|
+
if (!serverStampsVersionsRef.current && rawMockupResults.some((r) => r.versions && typeof r.versions === "object")) {
|
|
991
|
+
serverStampsVersionsRef.current = true;
|
|
992
|
+
}
|
|
937
993
|
rawMockupResults.forEach((result) => {
|
|
938
994
|
if (!result.imageUrl) {
|
|
939
995
|
console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
|
|
@@ -1610,6 +1666,9 @@ export function RealtimeProvider({
|
|
|
1610
1666
|
const getLastSentVersionRef = useRef(getLastSentVersion);
|
|
1611
1667
|
getLastSentVersionRef.current = getLastSentVersion;
|
|
1612
1668
|
const getCanvasVersion = useCallback(() => getLastSentVersionRef.current?.() ?? 0, []);
|
|
1669
|
+
const getLatestSentVersionsRef = useRef(getLatestSentVersions);
|
|
1670
|
+
getLatestSentVersionsRef.current = getLatestSentVersions;
|
|
1671
|
+
const getLatestSentVersionsStable = useCallback(() => getLatestSentVersionsRef.current?.() ?? {}, []);
|
|
1613
1672
|
|
|
1614
1673
|
const stableSubscribePipelineSettled = useCallback(
|
|
1615
1674
|
(callback: () => void) => {
|
|
@@ -1664,6 +1723,7 @@ export function RealtimeProvider({
|
|
|
1664
1723
|
getMockupUrl,
|
|
1665
1724
|
getMockupUrls,
|
|
1666
1725
|
getCanvasVersion,
|
|
1726
|
+
getLatestSentVersions: getLatestSentVersionsStable,
|
|
1667
1727
|
isMockupLoading,
|
|
1668
1728
|
registerBlobUrl,
|
|
1669
1729
|
// WebRTC streaming
|
|
@@ -1696,6 +1756,7 @@ export function RealtimeProvider({
|
|
|
1696
1756
|
getMockupUrl,
|
|
1697
1757
|
getMockupUrls,
|
|
1698
1758
|
getCanvasVersion,
|
|
1759
|
+
getLatestSentVersionsStable,
|
|
1699
1760
|
isMockupLoading,
|
|
1700
1761
|
registerBlobUrl,
|
|
1701
1762
|
]
|
|
@@ -40,11 +40,17 @@ const h = vi.hoisted(() => {
|
|
|
40
40
|
disconnect: vi.fn(),
|
|
41
41
|
flushPendingBlobs: vi.fn(),
|
|
42
42
|
hasPendingBlobs: vi.fn(() => false),
|
|
43
|
+
getLastSentVersion: vi.fn(() => 0),
|
|
44
|
+
getLatestSentVersions: vi.fn(() => ({})),
|
|
45
|
+
rerender: vi.fn((_ids: string[]) => true),
|
|
46
|
+
opts: null as null | { onError?: (msg: string, detail: Record<string, unknown>) => void },
|
|
43
47
|
};
|
|
44
48
|
});
|
|
45
49
|
|
|
46
50
|
vi.mock("@snowcone-app/sdk/react", () => ({
|
|
47
|
-
useRealtimeMockup: () =>
|
|
51
|
+
useRealtimeMockup: (opts: unknown) => {
|
|
52
|
+
h.opts = opts as typeof h.opts;
|
|
53
|
+
return {
|
|
48
54
|
isConnected: true,
|
|
49
55
|
isConfigured: true,
|
|
50
56
|
renderError: null,
|
|
@@ -59,7 +65,11 @@ vi.mock("@snowcone-app/sdk/react", () => ({
|
|
|
59
65
|
disconnect: h.disconnect,
|
|
60
66
|
flushPendingBlobs: h.flushPendingBlobs,
|
|
61
67
|
hasPendingBlobs: h.hasPendingBlobs,
|
|
62
|
-
|
|
68
|
+
getLastSentVersion: h.getLastSentVersion,
|
|
69
|
+
getLatestSentVersions: h.getLatestSentVersions,
|
|
70
|
+
rerender: h.rerender,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
63
73
|
}));
|
|
64
74
|
|
|
65
75
|
vi.mock("@snowcone-app/sdk", () => ({
|
|
@@ -359,4 +369,48 @@ describe("RealtimeProvider cropped delivery", () => {
|
|
|
359
369
|
});
|
|
360
370
|
expect(h.sendConfig).not.toHaveBeenCalled();
|
|
361
371
|
});
|
|
372
|
+
|
|
373
|
+
it("with a box that stamps per-placement versions, a failed composite asks `rerender` for exactly that picture (no config trick)", async () => {
|
|
374
|
+
const { rerender } = render(<Harness version={1} />);
|
|
375
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
376
|
+
// Seed with frames that carry versions: the box has rc#839/#840.
|
|
377
|
+
const v = { versions: { Front: 1 } };
|
|
378
|
+
h.state.results = [{ ...fullResult("m1", "https://r/m1-full"), ...v }];
|
|
379
|
+
await act(async () => rerender(<Harness version={2} />));
|
|
380
|
+
h.state.results = [{ ...fullResult("m1", "https://r/m1-full"), ...v }, { ...fullResult("m2", "https://r/m2-full"), ...v }];
|
|
381
|
+
await act(async () => rerender(<Harness version={3} />));
|
|
382
|
+
await waitFor(() => expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(true));
|
|
383
|
+
h.sendConfig.mockClear();
|
|
384
|
+
|
|
385
|
+
h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
|
|
386
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
387
|
+
h.state.results = [croppedResult("m1", "https://r/m1-crop")];
|
|
388
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
389
|
+
await waitFor(() => expect(h.rerender).toHaveBeenCalledWith(["m1"]));
|
|
390
|
+
// One config (crop dropped, same set), never an intermediate narrower one.
|
|
391
|
+
expect(sentConfigs().map((c) => c.mockupIds)).toEqual([["m1", "m2"]]);
|
|
392
|
+
expect(sentConfigs()[0].overlayCrop).toBeUndefined();
|
|
393
|
+
|
|
394
|
+
// The box refuses (rate limit): fall back to the config way for the same picture.
|
|
395
|
+
h.sendConfig.mockClear();
|
|
396
|
+
await act(async () => {
|
|
397
|
+
h.opts!.onError!("rate limited", { code: "E_RERENDER_RATE_LIMITED", message: "rate limited" });
|
|
398
|
+
});
|
|
399
|
+
expect(sentConfigs().map((c) => c.mockupIds)).toEqual([["m1"], ["m1", "m2"]]);
|
|
400
|
+
errSpy.mockRestore();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("with a box that has no versions stamp, `rerender` is never sent (it would not understand it)", async () => {
|
|
404
|
+
const { rerender } = render(<Harness version={1} />);
|
|
405
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
406
|
+
await seedAndFlip(rerender);
|
|
407
|
+
h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
|
|
408
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
409
|
+
h.state.results = [croppedResult("m1", "https://r/m1-crop")];
|
|
410
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
411
|
+
await waitFor(() => expect(sentConfigs().at(-1)?.mockupIds).toEqual(["m1", "m2"]));
|
|
412
|
+
expect(h.rerender).not.toHaveBeenCalled();
|
|
413
|
+
errSpy.mockRestore();
|
|
414
|
+
});
|
|
362
415
|
});
|
|
416
|
+
|