@snowcone-app/ui 0.9.2 → 0.9.3

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.2",
3
+ "version": "0.9.3",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -545,6 +545,65 @@ export const HeroProductImage = memo(function HeroProductImage({
545
545
  };
546
546
  }, [mode, realtimeContext?.subscribePendingBlob, placement]);
547
547
 
548
+ // RENDER-VERSION STAMP — machine-readable truth about this picture, written
549
+ // straight to the DOM (no re-renders mid-drag):
550
+ // data-canvas-version the canvas version the session last sent
551
+ // data-render-version the version the shown picture was rendered from
552
+ // (0 = no live render yet: the pre-edit image)
553
+ // data-render-state current | stale | refused
554
+ // A check asserts every visible picture reaches `current` after an edit; a
555
+ // picture stuck on `stale` is the "editor updates, pictures don't" defect
556
+ // (snowcone.app phone case, 2026-11-11). Absent until the first edit.
557
+ const renderVersionRef = useRef(0);
558
+ const refusalCodeRef = useRef<string | null>(null);
559
+ refusalCodeRef.current = unavailable?.code ?? null;
560
+ const sessionErrorCodeRef = useRef<string | null>(null);
561
+ sessionErrorCodeRef.current = realtimeContext?.renderError?.code ?? null;
562
+ const getCanvasVersion = realtimeContext?.getCanvasVersion;
563
+ const applyRenderStamp = useCallback(() => {
564
+ const node = containerRef.current;
565
+ if (!node) return;
566
+ const canvas = getCanvasVersion?.() ?? 0;
567
+ if (canvas <= 0) {
568
+ delete node.dataset.canvasVersion;
569
+ delete node.dataset.renderVersion;
570
+ delete node.dataset.renderState;
571
+ return;
572
+ }
573
+ const rendered = renderVersionRef.current;
574
+ const behind = rendered < canvas;
575
+ node.dataset.canvasVersion = String(canvas);
576
+ node.dataset.renderVersion = String(rendered);
577
+ node.dataset.renderState = refusalCodeRef.current || (behind && sessionErrorCodeRef.current)
578
+ ? "refused"
579
+ : behind ? "stale" : "current";
580
+ }, [getCanvasVersion]);
581
+ // Re-apply after every render (the root node can change; refusals are state).
582
+ useEffect(() => {
583
+ applyRenderStamp();
584
+ });
585
+ useEffect(() => {
586
+ const subscribeSent = realtimeContext?.subscribeBlobSent;
587
+ const subscribeResult = realtimeContext?.subscribeMockupResultById;
588
+ if (!actualMockupId || !subscribeSent || !subscribeResult) return;
589
+ const offSent = subscribeSent(() => applyRenderStamp());
590
+ const offResult = subscribeResult(actualMockupId, (result) => {
591
+ if (typeof result?.requestVersion === "number" && result.requestVersion > renderVersionRef.current) {
592
+ renderVersionRef.current = result.requestVersion;
593
+ }
594
+ applyRenderStamp();
595
+ });
596
+ return () => {
597
+ offSent?.();
598
+ offResult?.();
599
+ };
600
+ }, [
601
+ actualMockupId,
602
+ realtimeContext?.subscribeBlobSent,
603
+ realtimeContext?.subscribeMockupResultById,
604
+ applyRenderStamp,
605
+ ]);
606
+
548
607
  // Register with priority context
549
608
  useEffect(() => {
550
609
  if (!priorityContext || !actualMockupId || !placement) return;
@@ -4,9 +4,10 @@
4
4
  * MockupPriorityProvider - Priority-based mockup rendering coordinator
5
5
  *
6
6
  * Manages a 3-level priority system for mockup rendering:
7
- * - Level 1: Single most visible mockup (highest priority, pauses others)
8
- * - Level 2: Other visible/partially visible mockups
9
- * - Level 3: Off-screen mockups (max 5 prefetch, sorted by likelihood)
7
+ * - Level 1: Every mockup in view, the most visible first (one request)
8
+ * - Level 2: The rest of the near-view set (preload zone / carousel neighbours)
9
+ * - Level 3: Every other registered mockup — after an edit stops, none is
10
+ * left on an older canvas version
10
11
  *
11
12
  * Architecture:
12
13
  * - Uses refs for visibility state to avoid re-renders during scroll
@@ -200,17 +201,44 @@ export function MockupPriorityProvider({ children }: MockupPriorityProviderProps
200
201
  return;
201
202
  }
202
203
 
204
+ // Each level is ONE request for a SET: the server renders exactly the
205
+ // last request's mockupIds, so a level is never split into several
206
+ // requests racing where the last wins.
207
+ // 1 — every picture actually in view (the most visible first). On a wide
208
+ // gallery that is several pictures; level 1 alone was the single
209
+ // largest, so the others in view stayed on the old render mid-drag.
210
+ // 2 — the rest of the near-view set (preload zone / carousel neighbours).
211
+ // 3 — EVERY other registered picture, not just the scroll prefetch: after
212
+ // an edit stops, no picture may be left on an older canvas version.
213
+ const mobile = mobileCarouselRef.current;
214
+ const universe = mobile && mobile.mockupIds.length > 0
215
+ ? mobile.mockupIds
216
+ : registrationOrderRef.current;
217
+ const inView: string[] = [];
218
+ if (level1MockupRef.current) {
219
+ inView.push(level1MockupRef.current);
220
+ if (!mobile || mobile.mockupIds.length === 0) {
221
+ visibilityMapRef.current.forEach((info, id) => {
222
+ if (info.visibleArea > 0 && !inView.includes(id)) inView.push(id);
223
+ });
224
+ }
225
+ }
203
226
  let mockupIds: string[];
204
227
  switch (level) {
205
228
  case 1:
206
- mockupIds = level1MockupRef.current ? [level1MockupRef.current] : [];
229
+ mockupIds = inView;
207
230
  break;
208
231
  case 2:
209
- mockupIds = [...level2MockupsRef.current];
232
+ mockupIds = level2MockupsRef.current.filter((id) => !inView.includes(id));
210
233
  break;
211
- case 3:
212
- mockupIds = [...level3QueueRef.current];
234
+ case 3: {
235
+ const earlier = new Set([...inView, ...level2MockupsRef.current]);
236
+ mockupIds = [
237
+ ...level3QueueRef.current.filter((id) => !earlier.has(id)),
238
+ ...universe.filter((id) => !earlier.has(id) && !level3QueueRef.current.includes(id)),
239
+ ];
213
240
  break;
241
+ }
214
242
  default:
215
243
  mockupIds = [];
216
244
  }
@@ -253,6 +281,20 @@ export function MockupPriorityProvider({ children }: MockupPriorityProviderProps
253
281
  // Still dragging - stay on Level 1, don't advance to Level 2
254
282
  // The next pending blob will re-request Level 1
255
283
  currentRenderingLevelRef.current = null; // Ready for next Level 1 request
284
+ // ...but the edit may already be over. A fast render (the CUDA lane answers in ~10 ms) lands the LAST
285
+ // level-1 result inside the debounce window, and no later blob comes to re-trigger: Levels 2/3 — every
286
+ // other picture, the hero included when level 1 isn't it — were never re-rendered after an edit
287
+ // (snowcone.app, phone case, 2026-11-11). Re-check once the window closes; a new pending blob cancels it.
288
+ if (levelAdvanceTimeoutRef.current) {
289
+ clearTimeout(levelAdvanceTimeoutRef.current);
290
+ }
291
+ levelAdvanceTimeoutRef.current = setTimeout(() => {
292
+ levelAdvanceTimeoutRef.current = null;
293
+ const quiet = Date.now() - lastPendingBlobTimeRef.current >= DRAG_STOP_DEBOUNCE_MS;
294
+ if (quiet && currentRenderingLevelRef.current === null && pendingMockupsRef.current.size === 0) {
295
+ requestMockupsForLevel(2);
296
+ }
297
+ }, DRAG_STOP_DEBOUNCE_MS - timeSinceLastPendingBlob + 10);
256
298
  return;
257
299
  }
258
300
 
@@ -62,10 +62,19 @@ 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
+ /** `current` in order, then any of `extra` not already in it. */
66
+ function mergeMockupIds(current: readonly string[], extra: readonly string[]): string[] {
67
+ const out = [...current];
68
+ for (const id of extra) if (!out.includes(id)) out.push(id);
69
+ return out;
70
+ }
71
+
65
72
  // Realtime mockup types
66
73
  export interface MockupResult {
67
74
  mockupId: string;
68
75
  imageUrl: string;
76
+ /** The canvas version this frame was rendered from (see getCanvasVersion). */
77
+ requestVersion?: number;
69
78
  }
70
79
 
71
80
  // Type alias for backward compatibility with code that imported RealtimeState from Product
@@ -225,6 +234,14 @@ export interface RealtimeContextValue {
225
234
  callback: (placement: string) => void
226
235
  ) => () => void;
227
236
 
237
+ /**
238
+ * The canvas version the session last sent (0 before the first edit
239
+ * reaches the server). Every rendered result carries the `requestVersion`
240
+ * it was rendered from: a picture whose version is below this one shows an
241
+ * older design. Read it after a `subscribeBlobSent` notification.
242
+ */
243
+ getCanvasVersion: () => number;
244
+
228
245
  // Flush pending blobs immediately (call on mouse up, action complete, etc.)
229
246
  flushPendingBlobs: () => void;
230
247
  hasPendingBlobs: () => boolean;
@@ -568,6 +585,20 @@ export function RealtimeProvider({
568
585
  const compositeQueueRef = useRef<Map<string, SdkMockupResult>>(new Map());
569
586
  const compositeBusyRef = useRef<Set<string>>(new Set());
570
587
  const compositeCanvasRef = useRef<HTMLCanvasElement | null>(null);
588
+ // The mockupIds the session renders on every edit right now: the last
589
+ // config's set, or the last updateMockupIds set. A full-frame fallback
590
+ // re-sends this set (plus the failed mockups) — it must never shrink it.
591
+ const requestedMockupIdsRef = useRef<string[]>([]);
592
+ // Cropped frames that could not be shown, waiting for one batched
593
+ // full-frame re-render (see rerenderFullFrame).
594
+ const fullFrameRerenderIdsRef = useRef<Set<string>>(new Set());
595
+ const fullFrameRerenderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
596
+ useEffect(
597
+ () => () => {
598
+ if (fullFrameRerenderTimerRef.current) clearTimeout(fullFrameRerenderTimerRef.current);
599
+ },
600
+ []
601
+ );
571
602
 
572
603
  /**
573
604
  * Register a blob URL with lifecycle management
@@ -614,6 +645,7 @@ export function RealtimeProvider({
614
645
  const base = lastConfigBaseRef.current;
615
646
  if (!base) return;
616
647
  croppedActiveRef.current = withCrop;
648
+ requestedMockupIdsRef.current = (base.mockupIds as string[] | undefined) ?? [];
617
649
  sendConfigRaw({
618
650
  ...base,
619
651
  placementSettings: { ...placementSettingsOverridesRef.current },
@@ -629,6 +661,72 @@ export function RealtimeProvider({
629
661
  sendCropConfig(false);
630
662
  }, [sendCropConfig]);
631
663
 
664
+ /**
665
+ * A cropped frame could not be shown (no frozen background, or the
666
+ * composite failed): drop to full-frame and re-render it WITHOUT narrowing
667
+ * the session. The server renders exactly the last config's mockupIds, so
668
+ * one config per failed mockup left the session rendering only the last
669
+ * one — every later edit re-rendered one off-screen picture and the hero
670
+ * never updated (snowcone.app phone case BC43BE, 2026-11-11: five
671
+ * single-mockup configs in a row, last wins). Failures are batched into ONE
672
+ * full-frame config: everything the session was rendering plus the failed.
673
+ */
674
+ const rerenderFullFrame = useCallback(
675
+ (mockupId: string) => {
676
+ fullFrameRerenderIdsRef.current.add(mockupId);
677
+ if (fullFrameRerenderTimerRef.current) return;
678
+ fullFrameRerenderTimerRef.current = setTimeout(() => {
679
+ fullFrameRerenderTimerRef.current = null;
680
+ const failed = [...fullFrameRerenderIdsRef.current];
681
+ fullFrameRerenderIdsRef.current.clear();
682
+ const base = lastConfigBaseRef.current;
683
+ if (!base || failed.length === 0) return;
684
+ const previous = requestedMockupIdsRef.current;
685
+ const ids = mergeMockupIds(previous, failed);
686
+ croppedActiveRef.current = false;
687
+ 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);
710
+ return;
711
+ }
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;
722
+ }
723
+ send(step);
724
+ send(ids);
725
+ }, 0);
726
+ },
727
+ [sendConfigRaw]
728
+ );
729
+
632
730
  /** Flip to cropped delivery once every configured mockup has a frozen
633
731
  * background (a cropped frame can't seed one). */
634
732
  const maybeFlipCrop = useCallback(() => {
@@ -759,8 +857,7 @@ export function RealtimeProvider({
759
857
  console.error(
760
858
  `[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background — falling back to full-frame and re-rendering it.`
761
859
  );
762
- unflipCrop();
763
- updateMockupIdsRaw([result.mockupId]);
860
+ rerenderFullFrame(result.mockupId);
764
861
  return;
765
862
  }
766
863
  try {
@@ -779,17 +876,30 @@ export function RealtimeProvider({
779
876
  if (gen !== sceneGenRef.current) return;
780
877
  deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
781
878
  } catch (err) {
879
+ // A newer frame for this mockup already arrived: the SDK revokes a
880
+ // mockup's previous blob: frame the moment the next one lands, so a
881
+ // composite still loading the old frame fails mid-drag. That is
882
+ // supersession, not a broken pipeline — the newer frame composites
883
+ // next (latest wins). Treating it as a failure killed cropped delivery
884
+ // and re-requested the stale mockup (BC43BE trace, 2026-11-11: five v3
885
+ // composites failed as v4 arrived 0.6 s later).
886
+ if (
887
+ gen !== sceneGenRef.current ||
888
+ compositeQueueRef.current.has(result.mockupId) ||
889
+ processedFrameRef.current.get(result.mockupId) !== result.imageUrl
890
+ ) {
891
+ return;
892
+ }
782
893
  console.error(
783
894
  "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed — full-frame delivery for the rest of this session.",
784
895
  err
785
896
  );
786
897
  cropCapableRef.current = false;
787
- unflipCrop();
788
898
  // Re-render the affected mockup full-frame so this edit isn't lost.
789
- updateMockupIdsRaw([result.mockupId]);
899
+ rerenderFullFrame(result.mockupId);
790
900
  }
791
901
  },
792
- [deliverResults, registerBlobUrl, unflipCrop, updateMockupIdsRaw]
902
+ [deliverResults, registerBlobUrl, rerenderFullFrame]
793
903
  );
794
904
 
795
905
  /** Serial per-mockup composite pump (latest-wins). */
@@ -1068,6 +1178,7 @@ export function RealtimeProvider({
1068
1178
  // send — preserving cropped delivery across re-sends that kept the scene
1069
1179
  // (e.g. spread-crop changes).
1070
1180
  lastConfigBaseRef.current = configPayload as unknown as Record<string, unknown>;
1181
+ requestedMockupIdsRef.current = mockupIds;
1071
1182
  sendConfigRaw(
1072
1183
  (croppedActiveRef.current
1073
1184
  ? { ...configPayload, overlayCrop: "auto" as const }
@@ -1147,6 +1258,7 @@ export function RealtimeProvider({
1147
1258
  ) {
1148
1259
  unflipCrop();
1149
1260
  }
1261
+ requestedMockupIdsRef.current = [...mockupIds];
1150
1262
  return updateMockupIdsRaw(mockupIds);
1151
1263
  },
1152
1264
  [updateMockupIdsRaw, unflipCrop]
@@ -1495,6 +1607,10 @@ export function RealtimeProvider({
1495
1607
  []
1496
1608
  );
1497
1609
 
1610
+ const getLastSentVersionRef = useRef(getLastSentVersion);
1611
+ getLastSentVersionRef.current = getLastSentVersion;
1612
+ const getCanvasVersion = useCallback(() => getLastSentVersionRef.current?.() ?? 0, []);
1613
+
1498
1614
  const stableSubscribePipelineSettled = useCallback(
1499
1615
  (callback: () => void) => {
1500
1616
  return subscribePipelineSettledRef.current(callback);
@@ -1547,6 +1663,7 @@ export function RealtimeProvider({
1547
1663
  // Centralized URL management
1548
1664
  getMockupUrl,
1549
1665
  getMockupUrls,
1666
+ getCanvasVersion,
1550
1667
  isMockupLoading,
1551
1668
  registerBlobUrl,
1552
1669
  // WebRTC streaming
@@ -1578,6 +1695,7 @@ export function RealtimeProvider({
1578
1695
  hasPendingBlobs,
1579
1696
  getMockupUrl,
1580
1697
  getMockupUrls,
1698
+ getCanvasVersion,
1581
1699
  isMockupLoading,
1582
1700
  registerBlobUrl,
1583
1701
  ]
@@ -0,0 +1,112 @@
1
+ /**
2
+ * After an edit, every picture re-renders — not only level 1 (snowcone.app phone case, 2026-11-11).
3
+ *
4
+ * The CUDA lane answers a level-1 render in ~10 ms, so the LAST level-1 result of an edit lands inside the 250 ms
5
+ * drag-stop window. The provider used to treat that as "still dragging" and wait for the next blob to re-trigger —
6
+ * but after the last edit none comes, so levels 2/3 (the other pictures; the hero, whenever level 1 wasn't it) were
7
+ * never requested again: the editor "updated" and the pictures stayed old.
8
+ */
9
+ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
10
+ import React, { useEffect } from "react";
11
+ import { render, act, cleanup } from "@testing-library/react";
12
+
13
+ const rt = vi.hoisted(() => ({
14
+ updateMockupIds: vi.fn((_ids: string[]) => true),
15
+ results: new Set<(r: Array<{ mockupId: string }>) => void>(),
16
+ pending: new Set<(placement: string) => void>(),
17
+ sent: new Set<(placement: string) => void>(),
18
+ }));
19
+
20
+ vi.mock("../RealtimeProvider", () => ({
21
+ useRealtimeOptional: () => ({
22
+ isConfigured: true,
23
+ updateMockupIds: rt.updateMockupIds,
24
+ subscribeMockupResults: (cb: (r: Array<{ mockupId: string }>) => void) => { rt.results.add(cb); return () => rt.results.delete(cb); },
25
+ subscribePendingBlob: (cb: (p: string) => void) => { rt.pending.add(cb); return () => rt.pending.delete(cb); },
26
+ subscribeBlobSent: (cb: (p: string) => void) => { rt.sent.add(cb); return () => rt.sent.delete(cb); },
27
+ }),
28
+ }));
29
+
30
+ const { MockupPriorityProvider, useMockupPriority } = await import("../MockupPriorityProvider");
31
+
32
+ const IDS = ["HERO", "SECOND", "THIRD", "FOURTH"];
33
+ function Carousel() {
34
+ const p = useMockupPriority();
35
+ useEffect(() => { p.reportMobileCarouselIndex(0, IDS); }, [p]);
36
+ return null;
37
+ }
38
+ const edit = () => { rt.pending.forEach((cb) => cb("Front")); rt.sent.forEach((cb) => cb("Front")); };
39
+ const rendered = (ids: string[]) => rt.results.forEach((cb) => cb(ids.map((mockupId) => ({ mockupId }))));
40
+ const lastRequest = () => rt.updateMockupIds.mock.calls.at(-1)?.[0];
41
+
42
+ describe("MockupPriorityProvider: the pictures catch up after the last edit", () => {
43
+ beforeEach(() => { vi.useFakeTimers(); rt.updateMockupIds.mockClear(); });
44
+ afterEach(() => { cleanup(); vi.useRealTimers(); });
45
+
46
+ it("a level-1 result inside the drag window still leads to levels 2 and 3 once the edit stops", async () => {
47
+ render(<MockupPriorityProvider><Carousel /></MockupPriorityProvider>);
48
+ await act(async () => { vi.advanceTimersByTime(100); });
49
+ rt.updateMockupIds.mockClear();
50
+
51
+ // the last edit: blob sent → level 1 requested → its render lands 10 ms later (inside the 250 ms window)
52
+ await act(async () => { edit(); });
53
+ expect(lastRequest()).toEqual(["HERO"]);
54
+ await act(async () => { vi.advanceTimersByTime(10); rendered(["HERO"]); });
55
+ expect(lastRequest()).toEqual(["HERO"]); // still in the window: nothing more yet
56
+
57
+ // the window closes with no further edit → level 2 (the neighbour), then level 3 (the rest)
58
+ await act(async () => { vi.advanceTimersByTime(260); });
59
+ expect(lastRequest()).toEqual(["SECOND"]);
60
+ await act(async () => { rendered(["SECOND"]); });
61
+ expect(lastRequest()).toEqual(["THIRD", "FOURTH"]);
62
+ });
63
+
64
+ it("a further edit inside the window cancels the catch-up (level 1 is re-requested instead)", async () => {
65
+ render(<MockupPriorityProvider><Carousel /></MockupPriorityProvider>);
66
+ await act(async () => { vi.advanceTimersByTime(100); });
67
+ await act(async () => { edit(); vi.advanceTimersByTime(10); rendered(["HERO"]); });
68
+ rt.updateMockupIds.mockClear();
69
+ await act(async () => { vi.advanceTimersByTime(100); edit(); }); // the drag goes on
70
+ expect(lastRequest()).toEqual(["HERO"]);
71
+ await act(async () => { vi.advanceTimersByTime(200); });
72
+ expect(rt.updateMockupIds.mock.calls.every((c) => (c[0] as string[]).includes("HERO"))).toBe(true); // no level 2 mid-drag
73
+ });
74
+
75
+ it("desktop: the drag renders every picture in view in ONE request, and the catch-up reaches every registered picture", async () => {
76
+ // A wide gallery: HERO and SECOND in view, THIRD in the preload zone,
77
+ // seven more far below. Level 3 used to stop at 5 prefetches.
78
+ const ids = ["HERO", "SECOND", "THIRD", ...Array.from({ length: 7 }, (_, i) => `FAR${i}`)];
79
+ const rect = (top: number) => ({ top, bottom: top + 400, left: 0, right: 500, width: 500, height: 400, x: 0, y: top, toJSON: () => ({}) }) as DOMRect;
80
+ const tops: Record<string, number> = { HERO: 0, SECOND: 300, THIRD: 900 };
81
+ ids.forEach((id, i) => { if (!(id in tops)) tops[id] = 3000 + i * 500; });
82
+ function Gallery() {
83
+ const p = useMockupPriority();
84
+ useEffect(() => {
85
+ ids.forEach((id) => {
86
+ const el = document.createElement("div");
87
+ el.getBoundingClientRect = () => rect(tops[id]);
88
+ p.registerMockup(id, "Front", undefined, el);
89
+ p.updateElementRef(id, el);
90
+ });
91
+ }, [p]);
92
+ return null;
93
+ }
94
+ render(<MockupPriorityProvider><Gallery /></MockupPriorityProvider>);
95
+ await act(async () => { vi.advanceTimersByTime(200); });
96
+ rt.updateMockupIds.mockClear();
97
+
98
+ await act(async () => { edit(); });
99
+ expect(rt.updateMockupIds).toHaveBeenCalledTimes(1); // one request, not one per picture
100
+ expect(lastRequest()).toEqual(["HERO", "SECOND"]);
101
+
102
+ await act(async () => { vi.advanceTimersByTime(10); rendered(["HERO", "SECOND"]); });
103
+ await act(async () => { vi.advanceTimersByTime(260); });
104
+ expect(lastRequest()).toEqual(["THIRD"]);
105
+ await act(async () => { rendered(["THIRD"]); });
106
+ expect([...(lastRequest() ?? [])].sort()).toEqual(ids.filter((id) => id.startsWith("FAR")).sort());
107
+
108
+ const requested = new Set(rt.updateMockupIds.mock.calls.flatMap((c) => c[0] as string[]));
109
+ expect([...requested].sort()).toEqual([...ids].sort());
110
+ });
111
+ });
112
+
@@ -124,6 +124,23 @@ function sentConfigs(): Array<Record<string, unknown>> {
124
124
 
125
125
  let blobCounter = 0;
126
126
 
127
+ /**
128
+ * The box's rule (rendercenter realtime.ts renderFingerprint): a config
129
+ * re-renders only when its render fingerprint changes — overlayCrop is NOT in
130
+ * it and mockupIds are SORTED. Returns the mockup sets the box re-renders for
131
+ * `configs`, given the set in force before them.
132
+ */
133
+ function boxRerenders(before: string[], configs: Array<Record<string, unknown>>): string[][] {
134
+ const fp = (ids: unknown) => [...(ids as string[])].sort().join(",");
135
+ let prev = fp(before);
136
+ const out: string[][] = [];
137
+ for (const c of configs) {
138
+ if (fp(c.mockupIds) !== prev) out.push(c.mockupIds as string[]);
139
+ prev = fp(c.mockupIds);
140
+ }
141
+ return out;
142
+ }
143
+
127
144
  beforeEach(() => {
128
145
  vi.clearAllMocks();
129
146
  h.state.results = [];
@@ -220,12 +237,20 @@ describe("RealtimeProvider cropped delivery", () => {
220
237
  await act(async () => rerender(<Harness version={4} />));
221
238
 
222
239
  await waitFor(() => {
223
- // Unflipped: config re-sent WITHOUT overlayCrop
240
+ // Unflipped: configs WITHOUT overlayCrop, ending on the whole session
241
+ // set (m1 + m2)...
224
242
  expect(sentConfigs().length).toBeGreaterThan(0);
225
243
  expect(sentConfigs().every((c) => !c.overlayCrop)).toBe(true);
226
- // The lost edit is re-rendered full-frame
227
- expect(h.updateMockupIds).toHaveBeenCalledWith(["m1"]);
244
+ expect(sentConfigs().at(-1)?.mockupIds).toEqual(["m1", "m2"]);
228
245
  });
246
+ // ...and the box really re-renders the lost edit. Dropping the crop with
247
+ // the same set is "no change" to the box (BC43BE swap, prod 2026-11-11:
248
+ // the hero stayed on v1 while the canvas reached v3).
249
+ const rerenders = boxRerenders(["m1", "m2"], sentConfigs());
250
+ expect(rerenders.some((ids) => ids.includes("m1"))).toBe(true);
251
+ expect(rerenders.at(-1)).toEqual(["m1", "m2"]);
252
+ // Never a single-mockup request: the server renders the last config's set.
253
+ expect(h.updateMockupIds).not.toHaveBeenCalled();
229
254
 
230
255
  // Kill-switch: even after everything re-seeds, no flip this session
231
256
  h.sendConfig.mockClear();
@@ -238,6 +263,81 @@ describe("RealtimeProvider cropped delivery", () => {
238
263
  errSpy.mockRestore();
239
264
  });
240
265
 
266
+ it("several failed composites in one edit never narrow the session to the last one (BC43BE, 2026-11-11)", async () => {
267
+ // Prod trace: five cropped frames failed together and the fallback sent
268
+ // one config per mockup — [V7X7MN] … [YDNHY6] — so the server rendered
269
+ // only YDNHY6 on every later edit and the hero never updated.
270
+ const { rerender } = render(<Harness version={1} />);
271
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
272
+ await seedAndFlip(rerender);
273
+ h.sendConfig.mockClear();
274
+
275
+ h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
276
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
277
+ h.state.results = [
278
+ croppedResult("m1", "https://r/m1-crop-2"),
279
+ croppedResult("m2", "https://r/m2-crop-2"),
280
+ ];
281
+ await act(async () => rerender(<Harness version={4} />));
282
+
283
+ await waitFor(() => expect(sentConfigs().length).toBeGreaterThan(0));
284
+ await act(async () => {
285
+ await new Promise((r) => setTimeout(r, 20));
286
+ });
287
+ // The box re-renders both failed pictures, and the config left in force
288
+ // (the last one: the server renders the last config's set on every later
289
+ // edit) covers every picture.
290
+ const configs = sentConfigs();
291
+ expect(configs.every((c) => c.overlayCrop === undefined)).toBe(true);
292
+ expect(configs.at(-1)?.mockupIds).toEqual(["m1", "m2"]);
293
+ const rerenders = boxRerenders(["m1", "m2"], configs);
294
+ expect(rerenders.at(-1)).toEqual(["m1", "m2"]);
295
+ expect(h.updateMockupIds).not.toHaveBeenCalled();
296
+ errSpy.mockRestore();
297
+ });
298
+
299
+ it("a composite whose frame was superseded mid-drag is dropped, not treated as a failure", async () => {
300
+ // The SDK revokes a mockup's previous blob: frame when the next one lands,
301
+ // so the old composite fails to load it. The newer frame must win and
302
+ // cropped delivery must stay on.
303
+ const { rerender } = render(<Harness version={1} />);
304
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
305
+ await seedAndFlip(rerender);
306
+ h.sendConfig.mockClear();
307
+
308
+ let rejectOld!: (e: Error) => void;
309
+ h.compositeCroppedFrame.mockImplementationOnce(
310
+ () => new Promise((_, reject) => { rejectOld = reject; })
311
+ );
312
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
313
+ const seen: string[] = [];
314
+ capturedCtx.subscribeMockupResultById("m1", (r) => seen.push(r.imageUrl));
315
+
316
+ h.state.results = [croppedResult("m1", "blob:v3-m1"), fullResult("m2", "https://r/m2-full")];
317
+ await act(async () => rerender(<Harness version={4} />));
318
+ await waitFor(() => expect(h.compositeCroppedFrame).toHaveBeenCalledTimes(1));
319
+
320
+ // v4 lands while v3 is still compositing; the SDK revoked v3's URL.
321
+ h.state.results = [croppedResult("m1", "blob:v4-m1"), fullResult("m2", "https://r/m2-full")];
322
+ await act(async () => rerender(<Harness version={5} />));
323
+ await act(async () => { rejectOld(new Error("E_OVERLAY_FRAME_LOAD")); });
324
+
325
+ await waitFor(() => {
326
+ expect(h.compositeCroppedFrame).toHaveBeenLastCalledWith(
327
+ expect.objectContaining({ frameUrl: "blob:v4-m1" })
328
+ );
329
+ expect(seen.some((u) => u.startsWith("blob:composite-"))).toBe(true);
330
+ });
331
+ await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
332
+ expect(h.sendConfig).not.toHaveBeenCalled(); // no unflip, no re-request
333
+ expect(h.updateMockupIds).not.toHaveBeenCalled();
334
+ expect(errSpy).not.toHaveBeenCalledWith(
335
+ expect.stringContaining("E_OVERLAY_COMPOSITE_FAILED"),
336
+ expect.anything()
337
+ );
338
+ errSpy.mockRestore();
339
+ });
340
+
241
341
  it("drops to full-frame before rendering a never-seeded mockup via updateMockupIds", async () => {
242
342
  const { rerender } = render(<Harness version={1} />);
243
343
  await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());