@snowcone-app/ui 0.9.5 → 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.5",
3
+ "version": "0.9.6",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -64,6 +64,8 @@ const MAX_CONCURRENT_BLOBS = 5;
64
64
 
65
65
  /** How long a frame whose blob: URL was revoked waits for its newer frame before counting as a real failure. */
66
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;
67
69
 
68
70
  /** `current` in order, then any of `extra` not already in it. */
69
71
  function mergeMockupIds(current: readonly string[], extra: readonly string[]): string[] {
@@ -617,6 +619,10 @@ export function RealtimeProvider({
617
619
  // Mockups a `rerender` was sent for, until it is answered or refused.
618
620
  const pendingRerenderRef = useRef<string[]>([]);
619
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);
620
626
  // Cropped frames that could not be shown, waiting for one batched
621
627
  // full-frame re-render (see rerenderFullFrame).
622
628
  const fullFrameRerenderIdsRef = useRef<Set<string>>(new Set());
@@ -743,6 +749,35 @@ export function RealtimeProvider({
743
749
  );
744
750
  forceRerenderByConfigRef.current = forceRerenderByConfig;
745
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
+
746
781
  const rerenderFullFrame = useCallback(
747
782
  (mockupId: string) => {
748
783
  fullFrameRerenderIdsRef.current.add(mockupId);
@@ -769,14 +804,13 @@ export function RealtimeProvider({
769
804
  // for exactly the failed pictures. A refusal (E_RERENDER_*) falls
770
805
  // back to the config way (see onError).
771
806
  sendFullFrameConfig(ids);
772
- pendingRerenderRef.current = failed;
773
- if (rerenderRaw(failed)) return;
774
- pendingRerenderRef.current = [];
807
+ sendRerenderPaced(failed);
808
+ return;
775
809
  }
776
810
  forceRerenderByConfig(failed);
777
811
  }, 0);
778
812
  },
779
- [sendFullFrameConfig, forceRerenderByConfig, rerenderRaw]
813
+ [sendFullFrameConfig, forceRerenderByConfig, sendRerenderPaced]
780
814
  );
781
815
 
782
816
  /** Flip to cropped delivery once every configured mockup has a frozen
@@ -453,5 +453,29 @@ describe("RealtimeProvider cropped delivery", () => {
453
453
  expect(sentConfigs().every((c) => c.overlayCrop === undefined)).toBe(true);
454
454
  errSpy.mockRestore();
455
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
+ });
456
480
  });
457
481