@snowcone-app/ui 0.4.13 → 0.8.0

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.4.13",
3
+ "version": "0.8.0",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -103,7 +103,7 @@
103
103
  "react-instantsearch": "^7.15.5",
104
104
  "react-zoom-pan-pinch": "^3.6.4",
105
105
  "tailwind-merge": "^3.0.0",
106
- "@snowcone-app/sdk": "0.20.3"
106
+ "@snowcone-app/sdk": "0.23.0"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@chromatic-com/storybook": "^4.1.2",
@@ -92,6 +92,9 @@ export function CanvasIsolationConfigurator({
92
92
  // cancellation broadcaster. Roll back any optimistic
93
93
  // notifyPendingExport that didn't end up producing a real export.
94
94
  notifyExportSkipped: realtimeContext?.notifyPendingExportCancelled,
95
+ // One-in-flight send policy: the held state goes out when the last
96
+ // send's frame lands.
97
+ subscribePipelineSettled: realtimeContext?.subscribePipelineSettled,
95
98
  });
96
99
  }, [
97
100
  realtimeContext?.sendCanvasBlob,
@@ -103,6 +106,7 @@ export function CanvasIsolationConfigurator({
103
106
  realtimeContext?.enableRealtime,
104
107
  realtimeContext?.disableRealtime,
105
108
  realtimeContext?.notifyPendingExportCancelled,
109
+ realtimeContext?.subscribePipelineSettled,
106
110
  placementNameProp,
107
111
  onArtworkChange,
108
112
  clearImageBitmapCache,
@@ -188,6 +192,8 @@ export function useIsolatedCanvas() {
188
192
  handleExport: canvasExportService.handleExport,
189
193
  /** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
190
194
  handleStateChange: canvasExportService.handleStateChange,
195
+ /** Pointer released (drag end): flush a state held behind an in-flight frame. Safe on every pointerup. */
196
+ handleInteractionEnd: canvasExportService.handleInteractionEnd,
191
197
  /** Call when entering editor mode */
192
198
  onEnterEditorMode: () => canvasExportService.onEnterEditorMode(),
193
199
  /** Call when exiting editor mode */
@@ -240,6 +240,11 @@ export const HeroProductImage = memo(function HeroProductImage({
240
240
  return resolveMockupId(context, actualGvid, mockupId);
241
241
  }, [mockupId, actualGvid, context]);
242
242
 
243
+ // The server refused THIS mockup for the session (its scene failed render
244
+ // parity — E_MOCKUP_NOT_BLESSED). Shown as unavailable on top of whatever
245
+ // was last rendered; never a blank or a silently stale image.
246
+ const unavailable = actualMockupId ? (realtimeContext?.unavailableMockups?.get(actualMockupId) ?? null) : null;
247
+
243
248
  // Extract artwork source for initial URL computation
244
249
  const artworkSrc = artwork?.src;
245
250
  const selectedArtworkSrc = designContext?.selectedArtwork?.src;
@@ -1026,7 +1031,17 @@ export const HeroProductImage = memo(function HeroProductImage({
1026
1031
  className={`relative overflow-hidden ${className || ""}`}
1027
1032
  style={style}
1028
1033
  data-hero-image="true"
1034
+ data-mockup-unavailable={unavailable ? unavailable.code : undefined}
1029
1035
  >
1036
+ {unavailable && (
1037
+ <div
1038
+ role="status"
1039
+ title={unavailable.message}
1040
+ className="absolute inset-0 z-20 flex items-center justify-center bg-white/80 text-sm text-neutral-700"
1041
+ >
1042
+ Mockup unavailable
1043
+ </div>
1044
+ )}
1030
1045
  {/* Current image — shows `renderedUrl`, which the crossfade effect only
1031
1046
  advances after the next image has finished preloading. This prevents
1032
1047
  the new → old → new flash that happened when displayUrl was bound
@@ -270,7 +270,8 @@ const PDPInfoPanel = ({
270
270
  </AccordionTrigger>
271
271
  <AccordionContent>
272
272
  <p className="text-base font-caption text-muted-foreground leading-relaxed">
273
- Free shipping on orders over $50. 30-day return policy.
273
+ Made to order just for you. Free shipping on all orders. See
274
+ our Returns Policy for details.
274
275
  </p>
275
276
  </AccordionContent>
276
277
  </AccordionItem>
@@ -240,7 +240,8 @@ const PDPLayout = ({
240
240
  </AccordionTrigger>
241
241
  <AccordionContent>
242
242
  <p className="text-sm text-muted-foreground leading-relaxed">
243
- Free shipping on orders over $50. 30-day returns.
243
+ Made to order just for you. Free shipping on all
244
+ orders. See our Returns Policy for details.
244
245
  </p>
245
246
  </AccordionContent>
246
247
  </AccordionItem>
@@ -88,6 +88,13 @@ export interface RealtimeContextValue {
88
88
  * than blanking it or logging console-only.
89
89
  */
90
90
  renderError: RealtimeRenderError | null;
91
+ /**
92
+ * Mockups the server refused for this session (`E_MOCKUP_NOT_BLESSED`: the
93
+ * mockup's scene failed render parity and is served on no path). Keyed by
94
+ * mockupId; the other mockups keep streaming. Render these as unavailable
95
+ * — never as a stale or blank image.
96
+ */
97
+ unavailableMockups: ReadonlyMap<string, RealtimeRenderError>;
91
98
 
92
99
  // Debug/tracking state (now refs, won't trigger re-renders)
93
100
  isPendingMockups: boolean;
@@ -285,6 +292,13 @@ export interface RealtimeProviderProps {
285
292
  * via a renewable token. Without them, the legacy seal path runs (shim).
286
293
  */
287
294
  grantUrl?: string;
295
+ /**
296
+ * A grant minted server-side while the page rendered (POST grantUrl during
297
+ * SSR). Used for the FIRST connection so the socket opens without a
298
+ * grant round trip (382–566 ms on the PDP, 2026-09-05); renewals and
299
+ * reconnects go through `grantUrl`. Ignored when already expired.
300
+ */
301
+ initialGrant?: { token: string; expiresAt: number };
288
302
  /**
289
303
  * Frame delivery strategy. `"cropped"` (default, the fast path): once every
290
304
  * configured mockup has rendered one full frame (frozen internally as its
@@ -313,6 +327,7 @@ export function RealtimeProvider({
313
327
  autoEnable = false,
314
328
  shop,
315
329
  grantUrl,
330
+ initialGrant,
316
331
  delivery = "cropped",
317
332
  }: RealtimeProviderProps) {
318
333
  const productContext = useProductOptional();
@@ -325,7 +340,14 @@ export function RealtimeProvider({
325
340
  // When a shop + grant endpoint are wired, authorize the session with a
326
341
  // renewable token instead of the legacy bypass seal. Stable identity.
327
342
  const useSessionGrant = Boolean(shop && grantUrl);
343
+ // The SSR-minted grant is spent once: the first connect takes it if it has
344
+ // life left; everything after (renew ~15 s before expiry, reconnects)
345
+ // fetches a fresh one. Ref, not state: consumed inside the token provider.
346
+ const initialGrantRef = useRef(initialGrant);
328
347
  const getToken = useCallback(async () => {
348
+ const minted = initialGrantRef.current;
349
+ initialGrantRef.current = undefined;
350
+ if (minted && minted.expiresAt * 1000 - Date.now() > 30_000) return minted;
329
351
  const res = await fetch(grantUrl as string, {
330
352
  method: "POST",
331
353
  headers: { "Content-Type": "application/json" },
@@ -372,6 +394,13 @@ export function RealtimeProvider({
372
394
  const lastSentColorsRef = useRef<Record<string, string>>({});
373
395
 
374
396
  // Initialize realtime mockup hook
397
+ // Mockups the server refused this session (E_MOCKUP_NOT_BLESSED): keyed by
398
+ // mockupId so a card can show exactly that one as unavailable. Cleared on
399
+ // reconnect (a fixed mockup recompiles and stops being refused).
400
+ const [unavailableMockups, setUnavailableMockups] = useState<ReadonlyMap<string, RealtimeRenderError>>(
401
+ () => new Map(),
402
+ );
403
+
375
404
  const realtimeMockup = useRealtimeMockup({
376
405
  wsUrl,
377
406
  getToken: useSessionGrant ? getToken : undefined,
@@ -404,6 +433,12 @@ export function RealtimeProvider({
404
433
  // Logged for traces; the actionable surface is `renderError` on the
405
434
  // context (and the hook state), which consumers must render visibly.
406
435
  console.error(`[RealtimeProvider] Render error [${detail.code}]:`, error);
436
+ // A per-mockup refusal names the mockup: remember it so that card shows
437
+ // "unavailable" while the others keep streaming.
438
+ if (detail.mockupId) {
439
+ const id = detail.mockupId;
440
+ setUnavailableMockups((prev) => (prev.get(id)?.code === detail.code ? prev : new Map(prev).set(id, detail)));
441
+ }
407
442
  },
408
443
  });
409
444
 
@@ -427,6 +462,7 @@ export function RealtimeProvider({
427
462
  disconnect,
428
463
  flushPendingBlobs,
429
464
  hasPendingBlobs,
465
+ getLastSentVersion,
430
466
  } = realtimeMockup;
431
467
 
432
468
  // PERFORMANCE: Store mockup results in ref only - NO STATE UPDATES
@@ -475,6 +511,12 @@ export function RealtimeProvider({
475
511
  const blobQueuedGenRef = useRef(0);
476
512
  const hasBlobInFlightRef = useRef(false);
477
513
  const pipelineResetCooldownRef = useRef(false);
514
+ // JSON-mode settle: a canvas_state send's version → mockups still due for
515
+ // it (+ when it went out). "Settled" fires once per version, when its last
516
+ // mockup lands, so the export service can keep N edits in flight (pipeline
517
+ // depth) and count landings exactly. Blob sends keep the throttle-queue rule.
518
+ const dueByVersionRef = useRef<Map<number, { due: number; at: number }>>(new Map());
519
+ const configuredMockupIdsRef = useRef<string[]>([]);
478
520
 
479
521
  // Centralized mockup URL cache - stores URLs by mockupId
480
522
  // This is the single source of truth for all mockup URLs (realtime or static)
@@ -670,8 +712,33 @@ export function RealtimeProvider({
670
712
  }
671
713
  });
672
714
 
673
- // Pipeline-settled check
674
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
715
+ // Pipeline-settled check. A JSON send settles when every mockup of its
716
+ // version has landed (one settle per send, in order); a version whose
717
+ // frames never all arrive (a failed mockup) is forgotten after 10 s so
718
+ // it cannot wedge the policy. Blob sends keep the throttle-queue rule.
719
+ let settledVersions = 0;
720
+ const due = dueByVersionRef.current;
721
+ results.forEach((result) => {
722
+ const v = result.requestVersion;
723
+ if (v === undefined) return;
724
+ const entry = due.get(v);
725
+ if (!entry) return;
726
+ if (entry.due <= 1) {
727
+ due.delete(v);
728
+ settledVersions += 1;
729
+ } else {
730
+ entry.due -= 1;
731
+ }
732
+ });
733
+ due.forEach((entry, v) => {
734
+ if (now - entry.at > 10_000) due.delete(v);
735
+ });
736
+ if (settledVersions > 0) {
737
+ if (!pipelineResetCooldownRef.current) {
738
+ hasBlobInFlightRef.current = false;
739
+ for (let i = 0; i < settledVersions; i++) pipelineSettledSubscribersRef.current.forEach((cb) => cb());
740
+ }
741
+ } else if (due.size === 0 && hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
675
742
  hasBlobInFlightRef.current = false;
676
743
  pipelineSettledSubscribersRef.current.forEach((cb) => cb());
677
744
  }
@@ -853,11 +920,19 @@ export function RealtimeProvider({
853
920
  canvasBlobsSentRef.current += 1;
854
921
  lastBlobSentTimeRef.current = new Date().toLocaleTimeString();
855
922
  isPendingMockupsRef.current = true;
923
+ // Frames due for this send: the session's mockups, or the caller's
924
+ // count when it is smaller (a subset shown via updateMockupIds).
925
+ const version = getLastSentVersion();
926
+ if (version > 0) {
927
+ const configured = configuredMockupIdsRef.current.length;
928
+ const expected = Math.max(1, Math.min(configured || Infinity, mockupCount || Infinity));
929
+ dueByVersionRef.current.set(version, { due: Number.isFinite(expected) ? expected : 1, at: Date.now() });
930
+ }
856
931
  pendingBlobSubscribersRef.current.forEach((cb) => cb(placement));
857
932
  }
858
933
  return result;
859
934
  },
860
- [sendCanvasStateRaw]
935
+ [sendCanvasStateRaw, getLastSentVersion]
861
936
  );
862
937
 
863
938
  // Wrap sendColorBlob to track when colors are sent
@@ -882,6 +957,8 @@ export function RealtimeProvider({
882
957
  return () => {
883
958
  disconnect();
884
959
  configSentRef.current = false;
960
+ dueByVersionRef.current.clear(); // versions restart at 1 on the next socket
961
+ setUnavailableMockups((prev) => (prev.size ? new Map() : prev)); // a fixed mockup stops being refused
885
962
  lastSentVariantIdRef.current = null;
886
963
  lastSentBgRef.current = undefined;
887
964
  lastSentSpreadCropsRef.current = "null";
@@ -950,6 +1027,7 @@ export function RealtimeProvider({
950
1027
  }
951
1028
  });
952
1029
  }
1030
+ configuredMockupIdsRef.current = mockupIds;
953
1031
 
954
1032
  // Authed sessions: the grant token (verified on connect) authorizes the
955
1033
  // session and the server self-signs the render, so no seal is sent and the
@@ -1240,6 +1318,7 @@ export function RealtimeProvider({
1240
1318
  const resetPipelineSettled = useCallback(() => {
1241
1319
  pipelineGenRef.current++;
1242
1320
  hasBlobInFlightRef.current = false;
1321
+ dueByVersionRef.current.clear();
1243
1322
  pipelineResetCooldownRef.current = true;
1244
1323
  requestAnimationFrame(() => {
1245
1324
  pipelineResetCooldownRef.current = false;
@@ -1433,6 +1512,7 @@ export function RealtimeProvider({
1433
1512
  isConnected,
1434
1513
  isConfigured,
1435
1514
  renderError,
1515
+ unavailableMockups,
1436
1516
  // Read from ref - this is a snapshot at render time, NOT reactive
1437
1517
  // For reactive updates, use getMockupResultsImmediate() or subscribe functions
1438
1518
  get mockupResults() {
@@ -1478,6 +1558,7 @@ export function RealtimeProvider({
1478
1558
  isConnected,
1479
1559
  isConfigured,
1480
1560
  renderError,
1561
+ unavailableMockups,
1481
1562
  // mockupResults removed from deps - it's a getter that reads from ref
1482
1563
  canvasExportSize,
1483
1564
  mockupWidth,
@@ -104,8 +104,8 @@ export const FAQ: Story = {
104
104
  <AccordionItem value="q3">
105
105
  <AccordionTrigger>What is your return policy?</AccordionTrigger>
106
106
  <AccordionContent>
107
- We offer a 30-day return policy for all unused items in their original
108
- packaging. Custom orders are final sale and cannot be returned.
107
+ Returns and exchanges are handled according to our published returns
108
+ policy. See the Returns page for eligibility details.
109
109
  </AccordionContent>
110
110
  </AccordionItem>
111
111
  <AccordionItem value="q4">
@@ -56,6 +56,21 @@ export interface CanvasExportServiceConfig {
56
56
  enableRealtime?: () => void;
57
57
  /** Disable realtime function (called when exiting editor mode) */
58
58
  disableRealtime?: () => void;
59
+ /**
60
+ * Subscribe to "the frame for the last send landed" (RealtimeProvider's
61
+ * pipeline-settled signal). Drives the one-in-flight send policy: a state
62
+ * held while a frame is in flight goes out the moment this fires.
63
+ */
64
+ subscribePipelineSettled?: (callback: () => void) => () => void;
65
+ /**
66
+ * Pipeline depth: edits allowed in flight before the latest state is held
67
+ * (default 2). The box's window is ~30 ms of a ~96 ms edit→frame at w4000 —
68
+ * the rest is wire and decode — so with one in flight the box idles ~70% of
69
+ * every cycle. Two keeps it busy and never queues on the box while its
70
+ * window stays under half the round trip (the lab's rt_coalesce says when
71
+ * it does not).
72
+ */
73
+ maxInFlight?: number;
59
74
  /**
60
75
  * Function to clear ImageBitmap cache (memory management for iOS Safari)
61
76
  * Pass clearImageBitmapCache from @snowcone-app/canvas
@@ -93,9 +108,17 @@ class CanvasExportService {
93
108
  // Debounce state for handleStateChange
94
109
  private stateChangeThrottleTimeout: ReturnType<typeof setTimeout> | null = null;
95
110
  private stateChangeLastSendTime = 0;
96
- /** How long to wait after last change before sending (quick discrete actions) */
97
- private static SETTLE_MS = 300;
98
- /** During continuous activity (drag), force a send at least this often */
111
+ /** Send policy: send times of the edits whose frames have not landed yet, oldest first. */
112
+ private inFlightSentAt: number[] = [];
113
+ private maxInFlight = 2; // CanvasExportServiceConfig.maxInFlight
114
+ private subscribePipelineSettled: ((callback: () => void) => () => void) | null = null;
115
+ private unsubscribePipelineSettled: (() => void) | null = null;
116
+ /**
117
+ * Safety net for the one-in-flight policy: a held state never waits longer
118
+ * than this for the in-flight frame (a swallowed render error would
119
+ * otherwise hold the pipeline forever), and a change arriving while a
120
+ * frame has been in flight this long is sent regardless.
121
+ */
99
122
  private static MAX_WAIT_MS = 1000;
100
123
 
101
124
  // Internal state for tracking exports
@@ -139,6 +162,14 @@ class CanvasExportService {
139
162
  if (config.disableRealtime !== undefined) {
140
163
  this.disableRealtime = config.disableRealtime;
141
164
  }
165
+ if (config.maxInFlight !== undefined) {
166
+ this.maxInFlight = config.maxInFlight;
167
+ }
168
+ if (config.subscribePipelineSettled !== undefined && config.subscribePipelineSettled !== this.subscribePipelineSettled) {
169
+ this.unsubscribePipelineSettled?.();
170
+ this.subscribePipelineSettled = config.subscribePipelineSettled;
171
+ this.unsubscribePipelineSettled = config.subscribePipelineSettled?.(this.onPipelineSettled) ?? null;
172
+ }
142
173
  if (config.clearImageBitmapCache !== undefined) {
143
174
  this.clearImageBitmapCache = config.clearImageBitmapCache;
144
175
  }
@@ -425,41 +456,77 @@ class CanvasExportService {
425
456
  return;
426
457
  }
427
458
 
428
- // Debounce with max wait: reset timer on each call so rapid drag frames
429
- // coalesce, but if we haven't sent in >= throttleMs, flush immediately
430
- // so long drags still get periodic updates.
431
- const settleMs = CanvasExportService.SETTLE_MS;
432
- const maxWaitMs = CanvasExportService.MAX_WAIT_MS;
433
- const hasSentBefore = this.stateChangeLastSendTime > 0;
434
- const timeSinceLastSend = hasSentBefore ? now - this.stateChangeLastSendTime : 0;
435
-
436
- // Max wait: during a continuous drag, force a send if it's been >= maxWaitMs
437
- // since last send. "Active" means last send was recent (within 3× maxWait).
438
- const isActiveDrag = hasSentBefore && timeSinceLastSend < maxWaitMs * 3;
439
- if (isActiveDrag && timeSinceLastSend >= maxWaitMs) {
440
- console.log(`[CES] handleStateChange #${callId}: MAX-WAIT FLUSH (${timeSinceLastSend}ms >= ${maxWaitMs}ms)`);
441
- if (this.stateChangeThrottleTimeout) {
442
- clearTimeout(this.stateChangeThrottleTimeout);
443
- this.stateChangeThrottleTimeout = null;
444
- }
459
+ // PIPELINE DEPTH (2026-09-05/06, render-speed lab): the box turns an edit
460
+ // into a frame in ~30 ms; the rest of an edit→frame (~96 ms at w4000) is
461
+ // wire and decode. Up to maxInFlight edits go out immediately; beyond that
462
+ // the LATEST state is held and goes out the moment a frame lands (pipeline
463
+ // settled fires once per landed send), so the box is never idle and never
464
+ // more than a frame behind — it renders a session serially and coalesces
465
+ // latest-wins if edits ever pile up. handleInteractionEnd() flushes a held
466
+ // state on pointer release without waiting. Two safety nets: a send whose
467
+ // frame never lands stops counting after MAX_WAIT_MS, and a held state
468
+ // with no further changes flushes on a MAX_WAIT_MS timer.
469
+ this.dropStalledSends(now);
470
+ const inFlight = this.inFlightSentAt.length;
471
+ if (inFlight < this.maxInFlight) {
472
+ console.log(`[CES] handleStateChange #${callId}: SEND now (${inFlight} in flight)`);
473
+ this.clearFlushTimer();
445
474
  this.sendStateNow(state);
446
475
  return;
447
476
  }
477
+ const oldestMs = now - this.inFlightSentAt[0];
478
+ console.log(`[CES] handleStateChange #${callId}: HELD (${inFlight} in flight, oldest ${oldestMs}ms) — sends when one lands`);
479
+ this.armFlushTimer(CanvasExportService.MAX_WAIT_MS - oldestMs);
480
+ };
448
481
 
449
- // Settle timer: reset on every call, fires settleMs after last change.
450
- // Handles discrete actions (type a letter, nudge, color pick).
451
- if (this.stateChangeThrottleTimeout) {
452
- clearTimeout(this.stateChangeThrottleTimeout);
482
+ /** A send landed (every mockup of it): free its slot, release the held state if any. */
483
+ private onPipelineSettled = (): void => {
484
+ this.inFlightSentAt.shift();
485
+ this.clearFlushTimer();
486
+ if (this.pendingCanvasState && this.isRealtimeEnabled) {
487
+ console.log(`[CES] pipeline settled → sending held state`);
488
+ this.sendStateNow(this.pendingCanvasState);
453
489
  }
454
- console.log(`[CES] handleStateChange #${callId}: SETTLE scheduled ${settleMs}ms`);
490
+ };
491
+
492
+ /**
493
+ * The pointer was released (drag end, slider release, key up). Send the held
494
+ * state NOW instead of waiting for the in-flight frame: the server coalesces
495
+ * (latest wins), and the "let go" frame is the one the shopper waits for.
496
+ * A no-op when nothing is held, so it is safe on every pointerup.
497
+ */
498
+ handleInteractionEnd = (): void => {
499
+ if (!this.pendingCanvasState || !this.isRealtimeEnabled || !this.sendCanvasState || !this.serializeStateForServer) return;
500
+ if (this.inFlightSentAt.length < this.maxInFlight) return; // nothing held: the last change already went out
501
+ console.log(`[CES] interaction end → flushing held state`);
502
+ this.clearFlushTimer();
503
+ this.sendStateNow(this.pendingCanvasState);
504
+ };
505
+
506
+ private armFlushTimer(ms: number): void {
507
+ this.clearFlushTimer();
455
508
  this.stateChangeThrottleTimeout = setTimeout(() => {
456
509
  this.stateChangeThrottleTimeout = null;
457
- console.log(`[CES] handleStateChange #${callId}: SETTLE FIRED at ${Date.now()} (${Date.now() - now}ms after call)`);
458
510
  if (this.pendingCanvasState) {
511
+ console.log(`[CES] flush timer → sending held state (no frame landed within ${CanvasExportService.MAX_WAIT_MS}ms)`);
512
+ this.dropStalledSends(Date.now());
459
513
  this.sendStateNow(this.pendingCanvasState);
460
514
  }
461
- }, settleMs);
462
- };
515
+ }, Math.max(0, ms));
516
+ }
517
+
518
+ /** A send whose frame has not landed within MAX_WAIT_MS no longer occupies a slot. */
519
+ private dropStalledSends(now: number): void {
520
+ if (this.inFlightSentAt.length === 0) return;
521
+ this.inFlightSentAt = this.inFlightSentAt.filter((t) => now - t < CanvasExportService.MAX_WAIT_MS);
522
+ }
523
+
524
+ private clearFlushTimer(): void {
525
+ if (this.stateChangeThrottleTimeout) {
526
+ clearTimeout(this.stateChangeThrottleTimeout);
527
+ this.stateChangeThrottleTimeout = null;
528
+ }
529
+ }
463
530
 
464
531
  private sendStateNow(state: any): void {
465
532
  if (!this.sendCanvasState || !this.serializeStateForServer) {
@@ -489,6 +556,7 @@ class CanvasExportService {
489
556
  if (sent) {
490
557
  this.pendingCanvasState = null;
491
558
  this.jsonModeSentSuccessfully = true;
559
+ this.inFlightSentAt.push(Date.now());
492
560
  console.log(`[CES] JSON state sent for ${artboards.length} artboard(s)`);
493
561
  } else {
494
562
  console.log(`[CES] JSON state send FAILED for all ${artboards.length} artboard(s) — state remains pending, jsonEverSent=${this.jsonModeSentSuccessfully}`);
@@ -512,6 +580,7 @@ class CanvasExportService {
512
580
  this.stateChangeThrottleTimeout = null;
513
581
  }
514
582
  this.stateChangeLastSendTime = 0;
583
+ this.inFlightSentAt = [];
515
584
  // A new editor session starts with nothing sent: its first empty state
516
585
  // is a blank mount again, not a deletion (see handleStateChange).
517
586
  this.jsonModeSentSuccessfully = false;
@@ -0,0 +1,132 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { canvasExportService } from "../CanvasExportService";
4
+
5
+ const state = (n: number) => ({
6
+ elements: [{ type: "shape", n }],
7
+ artboards: [{ name: "Front", width: 100, height: 100 }],
8
+ activeArtboard: "Front",
9
+ });
10
+ const nOf = (call: unknown[]) => (call[1] as { elements: { n: number }[] }).elements[0].n;
11
+
12
+ /**
13
+ * Pipeline-depth send policy (2026-09-06): up to maxInFlight (default 2)
14
+ * edits go out immediately; beyond that the latest state is held and goes
15
+ * out when a frame lands (pipeline settled, once per landed send); pointer
16
+ * release flushes a held state immediately; MAX_WAIT (1 s) bounds every
17
+ * hold and retires a send whose frame never lands.
18
+ */
19
+ describe("CanvasExportService — pipeline-depth send policy", () => {
20
+ let send: ReturnType<typeof vi.fn>;
21
+ let settled: (() => void) | null;
22
+ const configure = (maxInFlight?: number) =>
23
+ canvasExportService.configure({
24
+ isRealtimeEnabled: true,
25
+ sendCanvasState: send as never,
26
+ serializeStateForServer: ((s: unknown) => s) as never,
27
+ mockupCount: 1,
28
+ placementName: "Front",
29
+ ...(maxInFlight !== undefined ? { maxInFlight } : {}),
30
+ subscribePipelineSettled: (cb) => {
31
+ settled = cb;
32
+ return () => {
33
+ settled = null;
34
+ };
35
+ },
36
+ });
37
+ beforeEach(() => {
38
+ vi.useFakeTimers();
39
+ vi.spyOn(console, "log").mockImplementation(() => {});
40
+ canvasExportService.reset();
41
+ send = vi.fn(() => true);
42
+ settled = null;
43
+ configure(2);
44
+ });
45
+ afterEach(() => {
46
+ vi.useRealTimers();
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it("sends two changes immediately, holds the third, releases the latest when a frame lands", () => {
51
+ canvasExportService.handleStateChange(state(1));
52
+ canvasExportService.handleStateChange(state(2));
53
+ expect(send).toHaveBeenCalledTimes(2); // two slots, nothing held
54
+
55
+ vi.advanceTimersByTime(50);
56
+ canvasExportService.handleStateChange(state(3)); // held: two in flight
57
+ vi.advanceTimersByTime(50);
58
+ canvasExportService.handleStateChange(state(4)); // replaces the held state
59
+ expect(send).toHaveBeenCalledTimes(2);
60
+
61
+ settled!(); // frame 1 landed → one slot free → the LATEST goes out
62
+ expect(send).toHaveBeenCalledTimes(3);
63
+ expect(nOf(send.mock.calls[2])).toBe(4);
64
+
65
+ settled!(); // frame 2 landed, nothing held → nothing sent
66
+ expect(send).toHaveBeenCalledTimes(3);
67
+ canvasExportService.handleStateChange(state(5)); // one slot free → immediate
68
+ expect(send).toHaveBeenCalledTimes(4);
69
+ });
70
+
71
+ it("a lone change sends exactly once, and its settle sends nothing more", () => {
72
+ canvasExportService.handleStateChange(state(1));
73
+ settled!();
74
+ vi.advanceTimersByTime(2000);
75
+ expect(send).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it("a drag streams one edit per landed frame with two always in flight: never idle, never more than two ahead", () => {
79
+ // 60 Hz changes; each send's frame lands 400 ms after it went out, so the
80
+ // two leading sends (t=0, 16) land at 400/416, their replacements at 800/816…
81
+ let t = 0;
82
+ for (let i = 0; i < 90; i++) {
83
+ canvasExportService.handleStateChange(state(i));
84
+ vi.advanceTimersByTime(16);
85
+ t += 16;
86
+ if (t % 400 < 16 || (t - 16) % 400 < 16) settled!();
87
+ }
88
+ // ~1.44 s of drag: 2 leading + 2 per 400 ms round trip (at 400, 800, 1200)
89
+ expect(send.mock.calls.length).toBeGreaterThanOrEqual(7);
90
+ expect(send.mock.calls.length).toBeLessThanOrEqual(9);
91
+ });
92
+
93
+ it("pointer release flushes the held state without waiting for a frame", () => {
94
+ canvasExportService.handleStateChange(state(1));
95
+ canvasExportService.handleStateChange(state(2));
96
+ canvasExportService.handleStateChange(state(3)); // held
97
+ canvasExportService.handleInteractionEnd();
98
+ expect(send).toHaveBeenCalledTimes(3);
99
+ expect(nOf(send.mock.calls[2])).toBe(3);
100
+ canvasExportService.handleInteractionEnd(); // nothing held now → no-op
101
+ expect(send).toHaveBeenCalledTimes(3);
102
+ });
103
+
104
+ it("frames that never land stop holding after MAX_WAIT (1 s)", () => {
105
+ canvasExportService.handleStateChange(state(1));
106
+ canvasExportService.handleStateChange(state(2));
107
+ canvasExportService.handleStateChange(state(3)); // held, no settle ever
108
+ vi.advanceTimersByTime(999);
109
+ expect(send).toHaveBeenCalledTimes(2);
110
+ vi.advanceTimersByTime(1);
111
+ expect(send).toHaveBeenCalledTimes(3); // flush timer
112
+ expect(nOf(send.mock.calls[2])).toBe(3);
113
+ });
114
+
115
+ it("a change arriving after the in-flight sends have aged past MAX_WAIT is sent regardless", () => {
116
+ canvasExportService.handleStateChange(state(1));
117
+ canvasExportService.handleStateChange(state(2));
118
+ vi.advanceTimersByTime(1000);
119
+ canvasExportService.handleStateChange(state(3));
120
+ expect(send).toHaveBeenCalledTimes(3);
121
+ });
122
+
123
+ it("maxInFlight: 1 is the one-in-flight policy: leading send, hold, release on settle", () => {
124
+ configure(1);
125
+ canvasExportService.handleStateChange(state(1));
126
+ canvasExportService.handleStateChange(state(2)); // held
127
+ expect(send).toHaveBeenCalledTimes(1);
128
+ settled!();
129
+ expect(send).toHaveBeenCalledTimes(2);
130
+ expect(nOf(send.mock.calls[1])).toBe(2);
131
+ });
132
+ });