@snowcone-app/ui 0.9.4 → 0.9.6

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.4",
3
+ "version": "0.9.6",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -62,6 +62,11 @@ 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
+ /** The box accepts one `rerender` per this many ms (rc#840; more are refused with E_RERENDER_RATE_LIMITED). */
68
+ const RERENDER_MIN_INTERVAL_MS = 550;
69
+
65
70
  /** `current` in order, then any of `extra` not already in it. */
66
71
  function mergeMockupIds(current: readonly string[], extra: readonly string[]): string[] {
67
72
  const out = [...current];
@@ -614,6 +619,10 @@ export function RealtimeProvider({
614
619
  // Mockups a `rerender` was sent for, until it is answered or refused.
615
620
  const pendingRerenderRef = useRef<string[]>([]);
616
621
  const forceRerenderByConfigRef = useRef<((failed: string[]) => void) | null>(null);
622
+ // Pacing of `rerender` requests (the box allows one per 500 ms).
623
+ const lastRerenderAtRef = useRef(0);
624
+ const rerenderQueueRef = useRef<string[]>([]);
625
+ const rerenderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
617
626
  // Cropped frames that could not be shown, waiting for one batched
618
627
  // full-frame re-render (see rerenderFullFrame).
619
628
  const fullFrameRerenderIdsRef = useRef<Set<string>>(new Set());
@@ -740,6 +749,35 @@ export function RealtimeProvider({
740
749
  );
741
750
  forceRerenderByConfigRef.current = forceRerenderByConfig;
742
751
 
752
+ /**
753
+ * Ask the box to re-render `ids`, never faster than it allows (one request
754
+ * per RERENDER_MIN_INTERVAL_MS; it refuses more with E_RERENDER_RATE_LIMITED,
755
+ * prod 2026-11-11). Requests inside the interval wait for the remainder and
756
+ * go out as ONE request with every picture that failed meanwhile.
757
+ */
758
+ const sendRerenderPaced = useCallback(
759
+ (ids: string[]) => {
760
+ rerenderQueueRef.current = mergeMockupIds(rerenderQueueRef.current, ids);
761
+ if (rerenderTimerRef.current) return;
762
+ const wait = Math.max(0, lastRerenderAtRef.current + RERENDER_MIN_INTERVAL_MS - Date.now());
763
+ const flush = () => {
764
+ rerenderTimerRef.current = null;
765
+ const batch = rerenderQueueRef.current;
766
+ rerenderQueueRef.current = [];
767
+ if (batch.length === 0) return;
768
+ lastRerenderAtRef.current = Date.now();
769
+ pendingRerenderRef.current = mergeMockupIds(pendingRerenderRef.current, batch);
770
+ if (!rerenderRaw(batch)) {
771
+ pendingRerenderRef.current = [];
772
+ forceRerenderByConfigRef.current?.(batch);
773
+ }
774
+ };
775
+ if (wait === 0) flush();
776
+ else rerenderTimerRef.current = setTimeout(flush, wait);
777
+ },
778
+ [rerenderRaw]
779
+ );
780
+
743
781
  const rerenderFullFrame = useCallback(
744
782
  (mockupId: string) => {
745
783
  fullFrameRerenderIdsRef.current.add(mockupId);
@@ -766,14 +804,13 @@ export function RealtimeProvider({
766
804
  // for exactly the failed pictures. A refusal (E_RERENDER_*) falls
767
805
  // back to the config way (see onError).
768
806
  sendFullFrameConfig(ids);
769
- pendingRerenderRef.current = failed;
770
- if (rerenderRaw(failed)) return;
771
- pendingRerenderRef.current = [];
807
+ sendRerenderPaced(failed);
808
+ return;
772
809
  }
773
810
  forceRerenderByConfig(failed);
774
811
  }, 0);
775
812
  },
776
- [sendFullFrameConfig, forceRerenderByConfig, rerenderRaw]
813
+ [sendFullFrameConfig, forceRerenderByConfig, sendRerenderPaced]
777
814
  );
778
815
 
779
816
  /** Flip to cropped delivery once every configured mockup has a frozen
@@ -943,6 +980,32 @@ export function RealtimeProvider({
943
980
  ) {
944
981
  return;
945
982
  }
983
+ // The FRAME (not the background) failed to load from a revoked blob:
984
+ // URL. The SDK revokes a mockup's frame only when that mockup's next
985
+ // frame arrives, so the newer frame exists even if React state hasn't
986
+ // delivered it here yet (prod 2026-11-11, phone: this race switched
987
+ // cropped delivery off for the whole session). Wait for it; if none
988
+ // shows up, it really failed.
989
+ const message = err instanceof Error ? err.message : String(err);
990
+ if (
991
+ result.imageUrl.startsWith("blob:") &&
992
+ message.startsWith("E_OVERLAY_FRAME_LOAD") &&
993
+ message.includes(result.imageUrl.slice(0, 160))
994
+ ) {
995
+ const failedUrl = result.imageUrl;
996
+ setTimeout(() => {
997
+ if (gen !== sceneGenRef.current) return;
998
+ if (compositeQueueRef.current.has(result.mockupId)) return;
999
+ if (processedFrameRef.current.get(result.mockupId) !== failedUrl) return;
1000
+ console.error(
1001
+ `[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.`,
1002
+ err
1003
+ );
1004
+ cropCapableRef.current = false;
1005
+ rerenderFullFrame(result.mockupId);
1006
+ }, FRAME_SUPERSEDE_GRACE_MS);
1007
+ return;
1008
+ }
946
1009
  console.error(
947
1010
  "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed — full-frame delivery for the rest of this session.",
948
1011
  err
@@ -412,5 +412,70 @@ describe("RealtimeProvider cropped delivery", () => {
412
412
  expect(h.rerender).not.toHaveBeenCalled();
413
413
  errSpy.mockRestore();
414
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
+ });
456
+
457
+ it("never asks the box to rerender faster than it allows: failures 100 ms apart become 2 requests >= 500 ms apart", async () => {
458
+ const { rerender } = render(<Harness version={1} />);
459
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
460
+ const v = { versions: { Front: 1 } };
461
+ h.state.results = [{ ...fullResult("m1", "https://r/m1-full"), ...v }, { ...fullResult("m2", "https://r/m2-full"), ...v }];
462
+ await act(async () => rerender(<Harness version={2} />));
463
+ await waitFor(() => expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(true));
464
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
465
+ h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
466
+ const calls: number[] = [];
467
+ h.rerender.mockImplementation(() => { calls.push(Date.now()); return true; });
468
+
469
+ h.state.results = [croppedResult("m1", "https://r/m1-crop-a")];
470
+ await act(async () => rerender(<Harness version={3} />));
471
+ await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
472
+ h.state.results = [croppedResult("m1", "https://r/m1-crop-a"), croppedResult("m2", "https://r/m2-crop-a")];
473
+ await act(async () => rerender(<Harness version={4} />));
474
+ await act(async () => { await new Promise((r) => setTimeout(r, 900)); });
475
+
476
+ expect(h.rerender.mock.calls.map((c) => c[0])).toEqual([["m1"], ["m2"]]);
477
+ expect(calls[1] - calls[0]).toBeGreaterThanOrEqual(500);
478
+ errSpy.mockRestore();
479
+ });
415
480
  });
416
481