@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.
@@ -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: config re-sent WITHOUT overlayCrop
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
- // The lost edit is re-rendered full-frame
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
+