@snowcone-app/ui 0.9.3 → 0.9.5
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 +15 -0
- package/dist/index.cjs +90 -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 +90 -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 +127 -37
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +97 -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.5",
|
|
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
|
});
|
|
@@ -62,6 +62,9 @@ const DEFAULT_ARTBOARD_SIZE = 2000;
|
|
|
62
62
|
// Enforce a conservative cap to prevent memory crashes
|
|
63
63
|
const MAX_CONCURRENT_BLOBS = 5;
|
|
64
64
|
|
|
65
|
+
/** How long a frame whose blob: URL was revoked waits for its newer frame before counting as a real failure. */
|
|
66
|
+
const FRAME_SUPERSEDE_GRACE_MS = 2000;
|
|
67
|
+
|
|
65
68
|
/** `current` in order, then any of `extra` not already in it. */
|
|
66
69
|
function mergeMockupIds(current: readonly string[], extra: readonly string[]): string[] {
|
|
67
70
|
const out = [...current];
|
|
@@ -75,6 +78,8 @@ export interface MockupResult {
|
|
|
75
78
|
imageUrl: string;
|
|
76
79
|
/** The canvas version this frame was rendered from (see getCanvasVersion). */
|
|
77
80
|
requestVersion?: number;
|
|
81
|
+
/** Per-placement versions folded into this frame (servers with per-placement stamps; see getLatestSentVersions). */
|
|
82
|
+
versions?: Record<string, number>;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
// Type alias for backward compatibility with code that imported RealtimeState from Product
|
|
@@ -242,6 +247,13 @@ export interface RealtimeContextValue {
|
|
|
242
247
|
*/
|
|
243
248
|
getCanvasVersion: () => number;
|
|
244
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The latest canvas version sent per placement. With a server that stamps
|
|
252
|
+
* `versions` on every frame, a picture is current exactly when, for every
|
|
253
|
+
* placement here, its frame's `versions[placement]` is at least this.
|
|
254
|
+
*/
|
|
255
|
+
getLatestSentVersions: () => Record<string, number>;
|
|
256
|
+
|
|
245
257
|
// Flush pending blobs immediately (call on mouse up, action complete, etc.)
|
|
246
258
|
flushPendingBlobs: () => void;
|
|
247
259
|
hasPendingBlobs: () => boolean;
|
|
@@ -450,6 +462,14 @@ export function RealtimeProvider({
|
|
|
450
462
|
// Logged for traces; the actionable surface is `renderError` on the
|
|
451
463
|
// context (and the hook state), which consumers must render visibly.
|
|
452
464
|
console.error(`[RealtimeProvider] Render error [${detail.code}]:`, error);
|
|
465
|
+
// The box refused a `rerender` (rate limit, incomplete placements, …):
|
|
466
|
+
// force the re-render the older way so the failed pictures still update.
|
|
467
|
+
if (detail.code.startsWith("E_RERENDER_") && pendingRerenderRef.current.length > 0) {
|
|
468
|
+
const ids = pendingRerenderRef.current;
|
|
469
|
+
pendingRerenderRef.current = [];
|
|
470
|
+
forceRerenderByConfigRef.current?.(ids);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
453
473
|
// A per-mockup refusal names the mockup: remember it so that card shows
|
|
454
474
|
// "unavailable" while the others keep streaming.
|
|
455
475
|
if (detail.mockupId) {
|
|
@@ -480,6 +500,8 @@ export function RealtimeProvider({
|
|
|
480
500
|
flushPendingBlobs,
|
|
481
501
|
hasPendingBlobs,
|
|
482
502
|
getLastSentVersion,
|
|
503
|
+
getLatestSentVersions,
|
|
504
|
+
rerender: rerenderRaw,
|
|
483
505
|
} = realtimeMockup;
|
|
484
506
|
|
|
485
507
|
// PERFORMANCE: Store mockup results in ref only - NO STATE UPDATES
|
|
@@ -589,6 +611,12 @@ export function RealtimeProvider({
|
|
|
589
611
|
// config's set, or the last updateMockupIds set. A full-frame fallback
|
|
590
612
|
// re-sends this set (plus the failed mockups) — it must never shrink it.
|
|
591
613
|
const requestedMockupIdsRef = useRef<string[]>([]);
|
|
614
|
+
// True once a frame carries per-placement `versions`: the box then also
|
|
615
|
+
// understands `rerender` (rendercenter rc#839 + rc#840, one roll).
|
|
616
|
+
const serverStampsVersionsRef = useRef(false);
|
|
617
|
+
// Mockups a `rerender` was sent for, until it is answered or refused.
|
|
618
|
+
const pendingRerenderRef = useRef<string[]>([]);
|
|
619
|
+
const forceRerenderByConfigRef = useRef<((failed: string[]) => void) | null>(null);
|
|
592
620
|
// Cropped frames that could not be shown, waiting for one batched
|
|
593
621
|
// full-frame re-render (see rerenderFullFrame).
|
|
594
622
|
const fullFrameRerenderIdsRef = useRef<Set<string>>(new Set());
|
|
@@ -671,6 +699,50 @@ export function RealtimeProvider({
|
|
|
671
699
|
* single-mockup configs in a row, last wins). Failures are batched into ONE
|
|
672
700
|
* full-frame config: everything the session was rendering plus the failed.
|
|
673
701
|
*/
|
|
702
|
+
const sendFullFrameConfig = useCallback(
|
|
703
|
+
(mockupIds: string[]) => {
|
|
704
|
+
const base = lastConfigBaseRef.current;
|
|
705
|
+
if (!base) return;
|
|
706
|
+
sendConfigRaw({
|
|
707
|
+
...base,
|
|
708
|
+
mockupIds,
|
|
709
|
+
placementSettings: { ...placementSettingsOverridesRef.current },
|
|
710
|
+
} as Parameters<typeof sendConfigRaw>[0]);
|
|
711
|
+
},
|
|
712
|
+
[sendConfigRaw]
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Force a re-render of `failed` with configs alone (boxes without the
|
|
717
|
+
* `rerender` message, or one that refused it). The box re-renders on a
|
|
718
|
+
* config only when its render fingerprint changes, and that fingerprint
|
|
719
|
+
* ignores overlayCrop and SORTS mockupIds, so "same set, crop dropped" is no
|
|
720
|
+
* change: nothing re-renders and the failed picture stays old until the next
|
|
721
|
+
* edit (BC43BE swap, prod, 2026-11-11: hero stuck at v1, canvas v3). So: a
|
|
722
|
+
* config that differs from the one in force, then the set in force again,
|
|
723
|
+
* which differs from its predecessor and stays.
|
|
724
|
+
*/
|
|
725
|
+
const forceRerenderByConfig = useCallback(
|
|
726
|
+
(failed: string[]) => {
|
|
727
|
+
const inForce = requestedMockupIdsRef.current;
|
|
728
|
+
const fp = (list: readonly string[]) => [...list].sort().join(",");
|
|
729
|
+
const step = fp(failed) !== fp(inForce) ? failed : inForce.slice(0, -1);
|
|
730
|
+
if (step.length === 0) {
|
|
731
|
+
// One mockup, already the whole session: no differing config exists.
|
|
732
|
+
// The picture keeps its stale stamp and the next edit re-renders it.
|
|
733
|
+
console.error(
|
|
734
|
+
`[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.`
|
|
735
|
+
);
|
|
736
|
+
sendFullFrameConfig(inForce);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
sendFullFrameConfig(step);
|
|
740
|
+
sendFullFrameConfig(inForce);
|
|
741
|
+
},
|
|
742
|
+
[sendFullFrameConfig]
|
|
743
|
+
);
|
|
744
|
+
forceRerenderByConfigRef.current = forceRerenderByConfig;
|
|
745
|
+
|
|
674
746
|
const rerenderFullFrame = useCallback(
|
|
675
747
|
(mockupId: string) => {
|
|
676
748
|
fullFrameRerenderIdsRef.current.add(mockupId);
|
|
@@ -679,52 +751,32 @@ export function RealtimeProvider({
|
|
|
679
751
|
fullFrameRerenderTimerRef.current = null;
|
|
680
752
|
const failed = [...fullFrameRerenderIdsRef.current];
|
|
681
753
|
fullFrameRerenderIdsRef.current.clear();
|
|
682
|
-
|
|
683
|
-
if (!base || failed.length === 0) return;
|
|
754
|
+
if (!lastConfigBaseRef.current || failed.length === 0) return;
|
|
684
755
|
const previous = requestedMockupIdsRef.current;
|
|
685
756
|
const ids = mergeMockupIds(previous, failed);
|
|
686
757
|
croppedActiveRef.current = false;
|
|
687
758
|
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);
|
|
759
|
+
const fp = (list: readonly string[]) => [...list].sort().join(",");
|
|
760
|
+
if (fp(ids) !== fp(previous)) {
|
|
761
|
+
// A wider set: the config itself is a render-relevant change, and it
|
|
762
|
+
// re-renders every picture full-frame.
|
|
763
|
+
sendFullFrameConfig(ids);
|
|
710
764
|
return;
|
|
711
765
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// the
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
);
|
|
720
|
-
|
|
721
|
-
return;
|
|
766
|
+
if (serverStampsVersionsRef.current) {
|
|
767
|
+
// The box has `rerender` (rc#840, same roll as the versions stamp
|
|
768
|
+
// seen on its frames): drop the crop (no render by itself), then ask
|
|
769
|
+
// for exactly the failed pictures. A refusal (E_RERENDER_*) falls
|
|
770
|
+
// back to the config way (see onError).
|
|
771
|
+
sendFullFrameConfig(ids);
|
|
772
|
+
pendingRerenderRef.current = failed;
|
|
773
|
+
if (rerenderRaw(failed)) return;
|
|
774
|
+
pendingRerenderRef.current = [];
|
|
722
775
|
}
|
|
723
|
-
|
|
724
|
-
send(ids);
|
|
776
|
+
forceRerenderByConfig(failed);
|
|
725
777
|
}, 0);
|
|
726
778
|
},
|
|
727
|
-
[
|
|
779
|
+
[sendFullFrameConfig, forceRerenderByConfig, rerenderRaw]
|
|
728
780
|
);
|
|
729
781
|
|
|
730
782
|
/** Flip to cropped delivery once every configured mockup has a frozen
|
|
@@ -772,6 +824,10 @@ export function RealtimeProvider({
|
|
|
772
824
|
else merged.push(result);
|
|
773
825
|
});
|
|
774
826
|
mockupResultsRef.current = merged;
|
|
827
|
+
if (pendingRerenderRef.current.length > 0) {
|
|
828
|
+
const delivered = new Set(results.map((r) => r.mockupId));
|
|
829
|
+
pendingRerenderRef.current = pendingRerenderRef.current.filter((id) => !delivered.has(id));
|
|
830
|
+
}
|
|
775
831
|
|
|
776
832
|
const clearedPlacements = new Set<string>();
|
|
777
833
|
results.forEach((result) => {
|
|
@@ -890,6 +946,32 @@ export function RealtimeProvider({
|
|
|
890
946
|
) {
|
|
891
947
|
return;
|
|
892
948
|
}
|
|
949
|
+
// The FRAME (not the background) failed to load from a revoked blob:
|
|
950
|
+
// URL. The SDK revokes a mockup's frame only when that mockup's next
|
|
951
|
+
// frame arrives, so the newer frame exists even if React state hasn't
|
|
952
|
+
// delivered it here yet (prod 2026-11-11, phone: this race switched
|
|
953
|
+
// cropped delivery off for the whole session). Wait for it; if none
|
|
954
|
+
// shows up, it really failed.
|
|
955
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
956
|
+
if (
|
|
957
|
+
result.imageUrl.startsWith("blob:") &&
|
|
958
|
+
message.startsWith("E_OVERLAY_FRAME_LOAD") &&
|
|
959
|
+
message.includes(result.imageUrl.slice(0, 160))
|
|
960
|
+
) {
|
|
961
|
+
const failedUrl = result.imageUrl;
|
|
962
|
+
setTimeout(() => {
|
|
963
|
+
if (gen !== sceneGenRef.current) return;
|
|
964
|
+
if (compositeQueueRef.current.has(result.mockupId)) return;
|
|
965
|
+
if (processedFrameRef.current.get(result.mockupId) !== failedUrl) return;
|
|
966
|
+
console.error(
|
|
967
|
+
`[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: frame for ${result.mockupId} failed to load and no newer frame arrived within ${FRAME_SUPERSEDE_GRACE_MS} ms — full-frame delivery for the rest of this session.`,
|
|
968
|
+
err
|
|
969
|
+
);
|
|
970
|
+
cropCapableRef.current = false;
|
|
971
|
+
rerenderFullFrame(result.mockupId);
|
|
972
|
+
}, FRAME_SUPERSEDE_GRACE_MS);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
893
975
|
console.error(
|
|
894
976
|
"[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed — full-frame delivery for the rest of this session.",
|
|
895
977
|
err
|
|
@@ -934,6 +1016,9 @@ export function RealtimeProvider({
|
|
|
934
1016
|
console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}${r.cropRect ? ' [crop]' : ''}`));
|
|
935
1017
|
|
|
936
1018
|
const displayable: SdkMockupResult[] = [];
|
|
1019
|
+
if (!serverStampsVersionsRef.current && rawMockupResults.some((r) => r.versions && typeof r.versions === "object")) {
|
|
1020
|
+
serverStampsVersionsRef.current = true;
|
|
1021
|
+
}
|
|
937
1022
|
rawMockupResults.forEach((result) => {
|
|
938
1023
|
if (!result.imageUrl) {
|
|
939
1024
|
console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
|
|
@@ -1610,6 +1695,9 @@ export function RealtimeProvider({
|
|
|
1610
1695
|
const getLastSentVersionRef = useRef(getLastSentVersion);
|
|
1611
1696
|
getLastSentVersionRef.current = getLastSentVersion;
|
|
1612
1697
|
const getCanvasVersion = useCallback(() => getLastSentVersionRef.current?.() ?? 0, []);
|
|
1698
|
+
const getLatestSentVersionsRef = useRef(getLatestSentVersions);
|
|
1699
|
+
getLatestSentVersionsRef.current = getLatestSentVersions;
|
|
1700
|
+
const getLatestSentVersionsStable = useCallback(() => getLatestSentVersionsRef.current?.() ?? {}, []);
|
|
1613
1701
|
|
|
1614
1702
|
const stableSubscribePipelineSettled = useCallback(
|
|
1615
1703
|
(callback: () => void) => {
|
|
@@ -1664,6 +1752,7 @@ export function RealtimeProvider({
|
|
|
1664
1752
|
getMockupUrl,
|
|
1665
1753
|
getMockupUrls,
|
|
1666
1754
|
getCanvasVersion,
|
|
1755
|
+
getLatestSentVersions: getLatestSentVersionsStable,
|
|
1667
1756
|
isMockupLoading,
|
|
1668
1757
|
registerBlobUrl,
|
|
1669
1758
|
// WebRTC streaming
|
|
@@ -1696,6 +1785,7 @@ export function RealtimeProvider({
|
|
|
1696
1785
|
getMockupUrl,
|
|
1697
1786
|
getMockupUrls,
|
|
1698
1787
|
getCanvasVersion,
|
|
1788
|
+
getLatestSentVersionsStable,
|
|
1699
1789
|
isMockupLoading,
|
|
1700
1790
|
registerBlobUrl,
|
|
1701
1791
|
]
|
|
@@ -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,89 @@ 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
|
+
});
|
|
415
|
+
|
|
416
|
+
it("a frame whose blob: URL was revoked waits for its newer frame instead of switching the session to full frames (prod, phone, 2026-11-11)", async () => {
|
|
417
|
+
const { rerender } = render(<Harness version={1} />);
|
|
418
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
419
|
+
await seedAndFlip(rerender);
|
|
420
|
+
h.sendConfig.mockClear();
|
|
421
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
422
|
+
|
|
423
|
+
// v3's frame was revoked by the SDK (its v4 already arrived there) before React state delivered v4 here.
|
|
424
|
+
h.compositeCroppedFrame.mockImplementationOnce(() =>
|
|
425
|
+
Promise.reject(new Error("E_OVERLAY_FRAME_LOAD: image failed to load: blob:v3-m1"))
|
|
426
|
+
);
|
|
427
|
+
h.state.results = [croppedResult("m1", "blob:v3-m1"), fullResult("m2", "https://r/m2-full")];
|
|
428
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
429
|
+
// v4 lands within the grace window and composites.
|
|
430
|
+
h.state.results = [croppedResult("m1", "blob:v4-m1"), fullResult("m2", "https://r/m2-full")];
|
|
431
|
+
await act(async () => rerender(<Harness version={5} />));
|
|
432
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 2300)); });
|
|
433
|
+
expect(h.compositeCroppedFrame).toHaveBeenLastCalledWith(expect.objectContaining({ frameUrl: "blob:v4-m1" }));
|
|
434
|
+
expect(h.sendConfig).not.toHaveBeenCalled(); // cropped delivery stays on, nothing re-rendered
|
|
435
|
+
errSpy.mockRestore();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("a revoked frame with no newer frame within the grace window is a real failure: full-frame re-render", async () => {
|
|
439
|
+
const { rerender } = render(<Harness version={1} />);
|
|
440
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
441
|
+
await seedAndFlip(rerender);
|
|
442
|
+
h.sendConfig.mockClear();
|
|
443
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
444
|
+
h.compositeCroppedFrame.mockImplementationOnce(() =>
|
|
445
|
+
Promise.reject(new Error("E_OVERLAY_FRAME_LOAD: image failed to load: blob:v3-m1"))
|
|
446
|
+
);
|
|
447
|
+
h.state.results = [croppedResult("m1", "blob:v3-m1"), fullResult("m2", "https://r/m2-full")];
|
|
448
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
449
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 200)); });
|
|
450
|
+
expect(h.sendConfig).not.toHaveBeenCalled(); // still inside the grace window
|
|
451
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 2100)); });
|
|
452
|
+
await waitFor(() => expect(sentConfigs().at(-1)?.mockupIds).toEqual(["m1", "m2"]));
|
|
453
|
+
expect(sentConfigs().every((c) => c.overlayCrop === undefined)).toBe(true);
|
|
454
|
+
errSpy.mockRestore();
|
|
455
|
+
});
|
|
362
456
|
});
|
|
457
|
+
|