@snowcone-app/ui 0.9.2 → 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/CHANGELOG.md +17 -0
- package/dist/index.cjs +192 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +192 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composed/HeroProductImage.tsx +70 -0
- package/src/patterns/MockupPriorityProvider.tsx +49 -7
- package/src/patterns/RealtimeProvider.tsx +184 -5
- package/src/patterns/__tests__/MockupPriorityProvider.dragStop.test.tsx +112 -0
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +159 -5
|
@@ -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", () => ({
|
|
@@ -124,6 +134,23 @@ function sentConfigs(): Array<Record<string, unknown>> {
|
|
|
124
134
|
|
|
125
135
|
let blobCounter = 0;
|
|
126
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The box's rule (rendercenter realtime.ts renderFingerprint): a config
|
|
139
|
+
* re-renders only when its render fingerprint changes — overlayCrop is NOT in
|
|
140
|
+
* it and mockupIds are SORTED. Returns the mockup sets the box re-renders for
|
|
141
|
+
* `configs`, given the set in force before them.
|
|
142
|
+
*/
|
|
143
|
+
function boxRerenders(before: string[], configs: Array<Record<string, unknown>>): string[][] {
|
|
144
|
+
const fp = (ids: unknown) => [...(ids as string[])].sort().join(",");
|
|
145
|
+
let prev = fp(before);
|
|
146
|
+
const out: string[][] = [];
|
|
147
|
+
for (const c of configs) {
|
|
148
|
+
if (fp(c.mockupIds) !== prev) out.push(c.mockupIds as string[]);
|
|
149
|
+
prev = fp(c.mockupIds);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
127
154
|
beforeEach(() => {
|
|
128
155
|
vi.clearAllMocks();
|
|
129
156
|
h.state.results = [];
|
|
@@ -220,12 +247,20 @@ describe("RealtimeProvider cropped delivery", () => {
|
|
|
220
247
|
await act(async () => rerender(<Harness version={4} />));
|
|
221
248
|
|
|
222
249
|
await waitFor(() => {
|
|
223
|
-
// Unflipped:
|
|
250
|
+
// Unflipped: configs WITHOUT overlayCrop, ending on the whole session
|
|
251
|
+
// set (m1 + m2)...
|
|
224
252
|
expect(sentConfigs().length).toBeGreaterThan(0);
|
|
225
253
|
expect(sentConfigs().every((c) => !c.overlayCrop)).toBe(true);
|
|
226
|
-
|
|
227
|
-
expect(h.updateMockupIds).toHaveBeenCalledWith(["m1"]);
|
|
254
|
+
expect(sentConfigs().at(-1)?.mockupIds).toEqual(["m1", "m2"]);
|
|
228
255
|
});
|
|
256
|
+
// ...and the box really re-renders the lost edit. Dropping the crop with
|
|
257
|
+
// the same set is "no change" to the box (BC43BE swap, prod 2026-11-11:
|
|
258
|
+
// the hero stayed on v1 while the canvas reached v3).
|
|
259
|
+
const rerenders = boxRerenders(["m1", "m2"], sentConfigs());
|
|
260
|
+
expect(rerenders.some((ids) => ids.includes("m1"))).toBe(true);
|
|
261
|
+
expect(rerenders.at(-1)).toEqual(["m1", "m2"]);
|
|
262
|
+
// Never a single-mockup request: the server renders the last config's set.
|
|
263
|
+
expect(h.updateMockupIds).not.toHaveBeenCalled();
|
|
229
264
|
|
|
230
265
|
// Kill-switch: even after everything re-seeds, no flip this session
|
|
231
266
|
h.sendConfig.mockClear();
|
|
@@ -238,6 +273,81 @@ describe("RealtimeProvider cropped delivery", () => {
|
|
|
238
273
|
errSpy.mockRestore();
|
|
239
274
|
});
|
|
240
275
|
|
|
276
|
+
it("several failed composites in one edit never narrow the session to the last one (BC43BE, 2026-11-11)", async () => {
|
|
277
|
+
// Prod trace: five cropped frames failed together and the fallback sent
|
|
278
|
+
// one config per mockup — [V7X7MN] … [YDNHY6] — so the server rendered
|
|
279
|
+
// only YDNHY6 on every later edit and the hero never updated.
|
|
280
|
+
const { rerender } = render(<Harness version={1} />);
|
|
281
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
282
|
+
await seedAndFlip(rerender);
|
|
283
|
+
h.sendConfig.mockClear();
|
|
284
|
+
|
|
285
|
+
h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
|
|
286
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
287
|
+
h.state.results = [
|
|
288
|
+
croppedResult("m1", "https://r/m1-crop-2"),
|
|
289
|
+
croppedResult("m2", "https://r/m2-crop-2"),
|
|
290
|
+
];
|
|
291
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
292
|
+
|
|
293
|
+
await waitFor(() => expect(sentConfigs().length).toBeGreaterThan(0));
|
|
294
|
+
await act(async () => {
|
|
295
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
296
|
+
});
|
|
297
|
+
// The box re-renders both failed pictures, and the config left in force
|
|
298
|
+
// (the last one: the server renders the last config's set on every later
|
|
299
|
+
// edit) covers every picture.
|
|
300
|
+
const configs = sentConfigs();
|
|
301
|
+
expect(configs.every((c) => c.overlayCrop === undefined)).toBe(true);
|
|
302
|
+
expect(configs.at(-1)?.mockupIds).toEqual(["m1", "m2"]);
|
|
303
|
+
const rerenders = boxRerenders(["m1", "m2"], configs);
|
|
304
|
+
expect(rerenders.at(-1)).toEqual(["m1", "m2"]);
|
|
305
|
+
expect(h.updateMockupIds).not.toHaveBeenCalled();
|
|
306
|
+
errSpy.mockRestore();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("a composite whose frame was superseded mid-drag is dropped, not treated as a failure", async () => {
|
|
310
|
+
// The SDK revokes a mockup's previous blob: frame when the next one lands,
|
|
311
|
+
// so the old composite fails to load it. The newer frame must win and
|
|
312
|
+
// cropped delivery must stay on.
|
|
313
|
+
const { rerender } = render(<Harness version={1} />);
|
|
314
|
+
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
315
|
+
await seedAndFlip(rerender);
|
|
316
|
+
h.sendConfig.mockClear();
|
|
317
|
+
|
|
318
|
+
let rejectOld!: (e: Error) => void;
|
|
319
|
+
h.compositeCroppedFrame.mockImplementationOnce(
|
|
320
|
+
() => new Promise((_, reject) => { rejectOld = reject; })
|
|
321
|
+
);
|
|
322
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
323
|
+
const seen: string[] = [];
|
|
324
|
+
capturedCtx.subscribeMockupResultById("m1", (r) => seen.push(r.imageUrl));
|
|
325
|
+
|
|
326
|
+
h.state.results = [croppedResult("m1", "blob:v3-m1"), fullResult("m2", "https://r/m2-full")];
|
|
327
|
+
await act(async () => rerender(<Harness version={4} />));
|
|
328
|
+
await waitFor(() => expect(h.compositeCroppedFrame).toHaveBeenCalledTimes(1));
|
|
329
|
+
|
|
330
|
+
// v4 lands while v3 is still compositing; the SDK revoked v3's URL.
|
|
331
|
+
h.state.results = [croppedResult("m1", "blob:v4-m1"), fullResult("m2", "https://r/m2-full")];
|
|
332
|
+
await act(async () => rerender(<Harness version={5} />));
|
|
333
|
+
await act(async () => { rejectOld(new Error("E_OVERLAY_FRAME_LOAD")); });
|
|
334
|
+
|
|
335
|
+
await waitFor(() => {
|
|
336
|
+
expect(h.compositeCroppedFrame).toHaveBeenLastCalledWith(
|
|
337
|
+
expect.objectContaining({ frameUrl: "blob:v4-m1" })
|
|
338
|
+
);
|
|
339
|
+
expect(seen.some((u) => u.startsWith("blob:composite-"))).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
|
342
|
+
expect(h.sendConfig).not.toHaveBeenCalled(); // no unflip, no re-request
|
|
343
|
+
expect(h.updateMockupIds).not.toHaveBeenCalled();
|
|
344
|
+
expect(errSpy).not.toHaveBeenCalledWith(
|
|
345
|
+
expect.stringContaining("E_OVERLAY_COMPOSITE_FAILED"),
|
|
346
|
+
expect.anything()
|
|
347
|
+
);
|
|
348
|
+
errSpy.mockRestore();
|
|
349
|
+
});
|
|
350
|
+
|
|
241
351
|
it("drops to full-frame before rendering a never-seeded mockup via updateMockupIds", async () => {
|
|
242
352
|
const { rerender } = render(<Harness version={1} />);
|
|
243
353
|
await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
|
|
@@ -259,4 +369,48 @@ describe("RealtimeProvider cropped delivery", () => {
|
|
|
259
369
|
});
|
|
260
370
|
expect(h.sendConfig).not.toHaveBeenCalled();
|
|
261
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
|
+
});
|
|
262
415
|
});
|
|
416
|
+
|