@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/ui",
3
- "version": "0.9.3",
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.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
  });
@@ -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
- const base = lastConfigBaseRef.current;
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 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);
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
- 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;
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
- send(step);
724
- send(ids);
773
+ forceRerenderByConfig(failed);
725
774
  }, 0);
726
775
  },
727
- [sendConfigRaw]
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
+