@snowcone-app/ui 0.4.14 → 0.8.1
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 +48 -0
- package/dist/index.cjs +143 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -4
- package/dist/index.d.ts +59 -4
- package/dist/index.js +143 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CanvasIsolationBoundary.tsx +6 -0
- package/src/composed/HeroProductImage.tsx +15 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +2 -1
- package/src/ecommerce/stories/PDPLayout.stories.tsx +2 -1
- package/src/patterns/RealtimeProvider.tsx +84 -3
- package/src/primitives/stories/Accordion.stories.tsx +2 -2
- package/src/services/CanvasExportService.ts +96 -50
- package/src/services/__tests__/CanvasExportService.leadingEdge.test.ts +93 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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.
|
|
106
|
+
"@snowcone-app/sdk": "0.24.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
/**
|
|
97
|
-
private
|
|
98
|
-
|
|
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,64 +456,77 @@ class CanvasExportService {
|
|
|
425
456
|
return;
|
|
426
457
|
}
|
|
427
458
|
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
// composite when it arrives quickly. Continuous activity (typing, drags)
|
|
457
|
-
// is unaffected: only an idle→active transition is leading.
|
|
458
|
-
const isIdle = !this.stateChangeThrottleTimeout && (!hasSentBefore || timeSinceLastSend >= settleMs);
|
|
459
|
-
if (isIdle) {
|
|
460
|
-
console.log(`[CES] handleStateChange #${callId}: LEADING send (idle ${hasSentBefore ? timeSinceLastSend : 'forever'}ms)`);
|
|
461
|
-
this.sendStateNow(state);
|
|
462
|
-
// Arm the settle timer so a follow-up change within settleMs still
|
|
463
|
-
// coalesces into one trailing send; with no follow-up it finds nothing
|
|
464
|
-
// pending and sends nothing.
|
|
465
|
-
this.stateChangeThrottleTimeout = setTimeout(() => {
|
|
466
|
-
this.stateChangeThrottleTimeout = null;
|
|
467
|
-
if (this.pendingCanvasState) this.sendStateNow(this.pendingCanvasState);
|
|
468
|
-
}, settleMs);
|
|
469
|
-
return;
|
|
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);
|
|
470
489
|
}
|
|
490
|
+
};
|
|
471
491
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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();
|
|
478
508
|
this.stateChangeThrottleTimeout = setTimeout(() => {
|
|
479
509
|
this.stateChangeThrottleTimeout = null;
|
|
480
|
-
console.log(`[CES] handleStateChange #${callId}: SETTLE FIRED at ${Date.now()} (${Date.now() - now}ms after call)`);
|
|
481
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());
|
|
482
513
|
this.sendStateNow(this.pendingCanvasState);
|
|
483
514
|
}
|
|
484
|
-
},
|
|
485
|
-
}
|
|
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
|
+
}
|
|
486
530
|
|
|
487
531
|
private sendStateNow(state: any): void {
|
|
488
532
|
if (!this.sendCanvasState || !this.serializeStateForServer) {
|
|
@@ -512,6 +556,7 @@ class CanvasExportService {
|
|
|
512
556
|
if (sent) {
|
|
513
557
|
this.pendingCanvasState = null;
|
|
514
558
|
this.jsonModeSentSuccessfully = true;
|
|
559
|
+
this.inFlightSentAt.push(Date.now());
|
|
515
560
|
console.log(`[CES] JSON state sent for ${artboards.length} artboard(s)`);
|
|
516
561
|
} else {
|
|
517
562
|
console.log(`[CES] JSON state send FAILED for all ${artboards.length} artboard(s) — state remains pending, jsonEverSent=${this.jsonModeSentSuccessfully}`);
|
|
@@ -535,6 +580,7 @@ class CanvasExportService {
|
|
|
535
580
|
this.stateChangeThrottleTimeout = null;
|
|
536
581
|
}
|
|
537
582
|
this.stateChangeLastSendTime = 0;
|
|
583
|
+
this.inFlightSentAt = [];
|
|
538
584
|
// A new editor session starts with nothing sent: its first empty state
|
|
539
585
|
// is a blank mount again, not a deletion (see handleStateChange).
|
|
540
586
|
this.jsonModeSentSuccessfully = false;
|
|
@@ -7,54 +7,126 @@ const state = (n: number) => ({
|
|
|
7
7
|
artboards: [{ name: "Front", width: 100, height: 100 }],
|
|
8
8
|
activeArtboard: "Front",
|
|
9
9
|
});
|
|
10
|
+
const nOf = (call: unknown[]) => (call[1] as { elements: { n: number }[] }).elements[0].n;
|
|
10
11
|
|
|
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", () => {
|
|
12
20
|
let send: ReturnType<typeof vi.fn>;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
16
|
-
canvasExportService.reset();
|
|
17
|
-
send = vi.fn(() => true);
|
|
21
|
+
let settled: (() => void) | null;
|
|
22
|
+
const configure = (maxInFlight?: number) =>
|
|
18
23
|
canvasExportService.configure({
|
|
19
24
|
isRealtimeEnabled: true,
|
|
20
25
|
sendCanvasState: send as never,
|
|
21
26
|
serializeStateForServer: ((s: unknown) => s) as never,
|
|
22
27
|
mockupCount: 1,
|
|
23
28
|
placementName: "Front",
|
|
29
|
+
...(maxInFlight !== undefined ? { maxInFlight } : {}),
|
|
30
|
+
subscribePipelineSettled: (cb) => {
|
|
31
|
+
settled = cb;
|
|
32
|
+
return () => {
|
|
33
|
+
settled = null;
|
|
34
|
+
};
|
|
35
|
+
},
|
|
24
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);
|
|
25
44
|
});
|
|
26
45
|
afterEach(() => {
|
|
27
46
|
vi.useRealTimers();
|
|
28
47
|
vi.restoreAllMocks();
|
|
29
48
|
});
|
|
30
49
|
|
|
31
|
-
it("sends
|
|
50
|
+
it("sends two changes immediately, holds the third, releases the latest when a frame lands", () => {
|
|
32
51
|
canvasExportService.handleStateChange(state(1));
|
|
33
|
-
|
|
52
|
+
canvasExportService.handleStateChange(state(2));
|
|
53
|
+
expect(send).toHaveBeenCalledTimes(2); // two slots, nothing held
|
|
34
54
|
|
|
35
|
-
vi.advanceTimersByTime(
|
|
36
|
-
canvasExportService.handleStateChange(state(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(send).toHaveBeenCalledTimes(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
expect(
|
|
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);
|
|
43
69
|
});
|
|
44
70
|
|
|
45
|
-
it("a lone change sends exactly once
|
|
71
|
+
it("a lone change sends exactly once, and its settle sends nothing more", () => {
|
|
46
72
|
canvasExportService.handleStateChange(state(1));
|
|
73
|
+
settled!();
|
|
47
74
|
vi.advanceTimersByTime(2000);
|
|
48
75
|
expect(send).toHaveBeenCalledTimes(1);
|
|
49
76
|
});
|
|
50
77
|
|
|
51
|
-
it("a
|
|
52
|
-
|
|
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++) {
|
|
53
83
|
canvasExportService.handleStateChange(state(i));
|
|
54
|
-
vi.advanceTimersByTime(
|
|
84
|
+
vi.advanceTimersByTime(16);
|
|
85
|
+
t += 16;
|
|
86
|
+
if (t % 400 < 16 || (t - 16) % 400 < 16) settled!();
|
|
55
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
|
|
56
127
|
expect(send).toHaveBeenCalledTimes(1);
|
|
57
|
-
|
|
128
|
+
settled!();
|
|
58
129
|
expect(send).toHaveBeenCalledTimes(2);
|
|
130
|
+
expect(nOf(send.mock.calls[1])).toBe(2);
|
|
59
131
|
});
|
|
60
132
|
});
|