@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/CHANGELOG.md +12 -0
- package/dist/index.cjs +46 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +46 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/patterns/RealtimeProvider.tsx +67 -4
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +65 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
770
|
-
|
|
771
|
-
pendingRerenderRef.current = [];
|
|
807
|
+
sendRerenderPaced(failed);
|
|
808
|
+
return;
|
|
772
809
|
}
|
|
773
810
|
forceRerenderByConfig(failed);
|
|
774
811
|
}, 0);
|
|
775
812
|
},
|
|
776
|
-
[sendFullFrameConfig, forceRerenderByConfig,
|
|
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
|
|