@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/ui",
3
- "version": "0.9.3",
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.24.4"
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
- const behind = rendered < canvas;
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 > renderVersionRef.current) {
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
- const base = lastConfigBaseRef.current;
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 send = (mockupIds: string[]) =>
689
- sendConfigRaw({
690
- ...base,
691
- mockupIds,
692
- placementSettings: { ...placementSettingsOverridesRef.current },
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
- const step = fingerprint(failed) !== fingerprint(previous) ? failed : ids.slice(0, -1);
713
- if (step.length === 0) {
714
- // One mockup, already the whole session: no differing config exists.
715
- // The picture keeps its stale stamp (data-render-state="stale") and
716
- // the next edit re-renders it.
717
- console.error(
718
- `[RealtimeProvider] E_OVERLAY_RERENDER_UNAVAILABLE: cannot force a full-frame re-render of ${failed.join(", ")} (single-mockup session); it updates on the next edit.`
719
- );
720
- send(ids);
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
- send(step);
724
- send(ids);
776
+ forceRerenderByConfig(failed);
725
777
  }, 0);
726
778
  },
727
- [sendConfigRaw]
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
+