@snowcone-app/ui 0.4.10 → 0.4.12
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 +14 -0
- package/dist/index.cjs +7 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/patterns/RealtimeProvider.tsx +13 -2
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +21 -0
- package/src/services/CanvasExportService.ts +10 -1
- package/src/services/__tests__/CanvasExportService.emptyState.test.ts +48 -0
package/package.json
CHANGED
|
@@ -773,8 +773,19 @@ export function RealtimeProvider({
|
|
|
773
773
|
if (firstSight && sessionLive) {
|
|
774
774
|
processedFrameRef.current.set(result.mockupId, result.imageUrl);
|
|
775
775
|
// Seed/refresh the frozen background — the freshest full frame is
|
|
776
|
-
// always the best composite base.
|
|
777
|
-
|
|
776
|
+
// always the best composite base. Decode it NOW, not at composite
|
|
777
|
+
// time: with ws binary delivery `imageUrl` is the SDK's live blob:
|
|
778
|
+
// URL for this mockup, and the SDK revokes it the moment the next
|
|
779
|
+
// frame for the same mockup (the first cropped one) arrives. A
|
|
780
|
+
// lazily-loaded background would then fail with
|
|
781
|
+
// E_OVERLAY_FRAME_LOAD and kill cropped delivery for the session
|
|
782
|
+
// (observed: every edit cost 3 extra full renders). A decoded
|
|
783
|
+
// HTMLImageElement keeps its pixels after the URL is revoked.
|
|
784
|
+
const imgPromise = loadImageElement(result.imageUrl);
|
|
785
|
+
imgPromise.catch(() => {
|
|
786
|
+
/* surfaced by runComposite when it awaits the promise */
|
|
787
|
+
});
|
|
788
|
+
backgroundsRef.current.set(result.mockupId, { url: result.imageUrl, imgPromise });
|
|
778
789
|
}
|
|
779
790
|
// Full frames re-deliver on every run (matches pre-crop behavior —
|
|
780
791
|
// consumers dedupe by URL; delivery also clears pending state).
|
|
@@ -158,6 +158,27 @@ describe("RealtimeProvider cropped delivery", () => {
|
|
|
158
158
|
await seedAndFlip(rerender);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it("decodes the frozen background at seed time, before the SDK can revoke its blob: URL", async () => {
|
|
162
|
+
// With ws binary delivery the full frame's imageUrl is the SDK's live
|
|
163
|
+
// blob: URL, revoked when the next frame for that mockup arrives. The
|
|
164
|
+
// background must be decoded while the URL is still alive.
|
|
165
|
+
const { rerender } = render(<Harness version={1} />);
|
|
166
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
167
|
+
h.state.results = [fullResult("m1", "blob:live-m1"), fullResult("m2", "blob:live-m2")];
|
|
168
|
+
await act(async () => rerender(<Harness version={3} />));
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(h.loadImageElement).toHaveBeenCalledWith("blob:live-m1");
|
|
171
|
+
expect(h.loadImageElement).toHaveBeenCalledWith("blob:live-m2");
|
|
172
|
+
});
|
|
173
|
+
// ...and not again at composite time — the decoded element is reused.
|
|
174
|
+
const loadsAfterSeed = h.loadImageElement.mock.calls.length;
|
|
175
|
+
await waitFor(() => expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(true));
|
|
176
|
+
h.state.results = [croppedResult("m1", "blob:live-m1-crop"), fullResult("m2", "blob:live-m2")];
|
|
177
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
178
|
+
await waitFor(() => expect(h.compositeCroppedFrame).toHaveBeenCalled());
|
|
179
|
+
expect(h.loadImageElement.mock.calls.length).toBe(loadsAfterSeed);
|
|
180
|
+
});
|
|
181
|
+
|
|
161
182
|
it("composites cropped frames — consumers only ever see full-frame URLs", async () => {
|
|
162
183
|
const { rerender } = render(<Harness version={1} />);
|
|
163
184
|
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
@@ -394,8 +394,13 @@ class CanvasExportService {
|
|
|
394
394
|
handleStateChange = (state: any): void => {
|
|
395
395
|
if (!state) return;
|
|
396
396
|
|
|
397
|
+
// An empty design is skipped only BEFORE anything has been sent this
|
|
398
|
+
// session (the initial mount of a blank editor — the PDP already shows the
|
|
399
|
+
// blank product). Once art has rendered, an empty state is a real edit:
|
|
400
|
+
// deleting/undoing the last element must re-render the blank product, or
|
|
401
|
+
// the mockup keeps showing art that is no longer in the design.
|
|
397
402
|
const hasElements = state.elements?.length > 0;
|
|
398
|
-
if (!hasElements) return;
|
|
403
|
+
if (!hasElements && !this.jsonModeSentSuccessfully) return;
|
|
399
404
|
|
|
400
405
|
// Don't send or cache state while any image is still loading —
|
|
401
406
|
// the serializer would skip it and the server would render without artwork
|
|
@@ -507,6 +512,10 @@ class CanvasExportService {
|
|
|
507
512
|
this.stateChangeThrottleTimeout = null;
|
|
508
513
|
}
|
|
509
514
|
this.stateChangeLastSendTime = 0;
|
|
515
|
+
// A new editor session starts with nothing sent: its first empty state
|
|
516
|
+
// is a blank mount again, not a deletion (see handleStateChange).
|
|
517
|
+
this.jsonModeSentSuccessfully = false;
|
|
518
|
+
this.pendingCanvasState = null;
|
|
510
519
|
// Also clear ImageBitmap cache on full reset
|
|
511
520
|
if (this.clearImageBitmapCache) {
|
|
512
521
|
this.clearImageBitmapCache();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { canvasExportService } from "../CanvasExportService";
|
|
4
|
+
|
|
5
|
+
const state = (elements: unknown[]) => ({
|
|
6
|
+
elements,
|
|
7
|
+
artboards: [{ name: "Front", width: 100, height: 100 }],
|
|
8
|
+
activeArtboard: "Front",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("CanvasExportService — empty designs", () => {
|
|
12
|
+
let sendCanvasState: ReturnType<typeof vi.fn>;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
17
|
+
canvasExportService.reset();
|
|
18
|
+
sendCanvasState = vi.fn(() => true);
|
|
19
|
+
canvasExportService.configure({
|
|
20
|
+
isRealtimeEnabled: true,
|
|
21
|
+
sendCanvasState: sendCanvasState as never,
|
|
22
|
+
serializeStateForServer: ((s: ReturnType<typeof state>) => s) as never,
|
|
23
|
+
mockupCount: 1,
|
|
24
|
+
placementName: "Front",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("ignores an empty design before anything was ever sent (blank editor mount)", () => {
|
|
33
|
+
canvasExportService.handleStateChange(state([]));
|
|
34
|
+
vi.runAllTimers();
|
|
35
|
+
expect(sendCanvasState).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("sends an empty design once art has rendered — deleting the last element re-renders the blank product", () => {
|
|
39
|
+
canvasExportService.handleStateChange(state([{ type: "shape" }]));
|
|
40
|
+
vi.runAllTimers();
|
|
41
|
+
expect(sendCanvasState).toHaveBeenCalledTimes(1);
|
|
42
|
+
|
|
43
|
+
canvasExportService.handleStateChange(state([]));
|
|
44
|
+
vi.runAllTimers();
|
|
45
|
+
expect(sendCanvasState).toHaveBeenCalledTimes(2);
|
|
46
|
+
expect((sendCanvasState.mock.calls[1][1] as { elements: unknown[] }).elements).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|