@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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.25.0"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -545,6 +545,76 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
545
545
|
};
|
|
546
546
|
}, [mode, realtimeContext?.subscribePendingBlob, placement]);
|
|
547
547
|
|
|
548
|
+
// RENDER-VERSION STAMP — machine-readable truth about this picture, written
|
|
549
|
+
// straight to the DOM (no re-renders mid-drag):
|
|
550
|
+
// data-canvas-version the canvas version the session last sent
|
|
551
|
+
// data-render-version the version the shown picture was rendered from
|
|
552
|
+
// (0 = no live render yet: the pre-edit image)
|
|
553
|
+
// data-render-state current | stale | refused
|
|
554
|
+
// A check asserts every visible picture reaches `current` after an edit; a
|
|
555
|
+
// picture stuck on `stale` is the "editor updates, pictures don't" defect
|
|
556
|
+
// (snowcone.app phone case, 2026-11-11). Absent until the first edit.
|
|
557
|
+
const renderVersionRef = useRef(0);
|
|
558
|
+
// Per-placement versions of the shown frame (servers that stamp them).
|
|
559
|
+
const renderVersionsRef = useRef<Record<string, number> | null>(null);
|
|
560
|
+
const getLatestSentVersions = realtimeContext?.getLatestSentVersions;
|
|
561
|
+
const refusalCodeRef = useRef<string | null>(null);
|
|
562
|
+
refusalCodeRef.current = unavailable?.code ?? null;
|
|
563
|
+
const sessionErrorCodeRef = useRef<string | null>(null);
|
|
564
|
+
sessionErrorCodeRef.current = realtimeContext?.renderError?.code ?? null;
|
|
565
|
+
const getCanvasVersion = realtimeContext?.getCanvasVersion;
|
|
566
|
+
const applyRenderStamp = useCallback(() => {
|
|
567
|
+
const node = containerRef.current;
|
|
568
|
+
if (!node) return;
|
|
569
|
+
const canvas = getCanvasVersion?.() ?? 0;
|
|
570
|
+
if (canvas <= 0) {
|
|
571
|
+
delete node.dataset.canvasVersion;
|
|
572
|
+
delete node.dataset.renderVersion;
|
|
573
|
+
delete node.dataset.renderState;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const rendered = renderVersionRef.current;
|
|
577
|
+
// With per-placement stamps, current = every placement at (or past) the
|
|
578
|
+
// latest version SENT for it. A single max can't say that: a frame can
|
|
579
|
+
// hold one group's newest state and another group's older one.
|
|
580
|
+
const versions = renderVersionsRef.current;
|
|
581
|
+
const sent = versions ? getLatestSentVersions?.() : undefined;
|
|
582
|
+
const behind = versions && sent
|
|
583
|
+
? Object.entries(sent).some(([placement, v]) => (versions[placement] ?? -1) < v)
|
|
584
|
+
: rendered < canvas;
|
|
585
|
+
node.dataset.canvasVersion = String(canvas);
|
|
586
|
+
node.dataset.renderVersion = String(rendered);
|
|
587
|
+
node.dataset.renderState = refusalCodeRef.current || (behind && sessionErrorCodeRef.current)
|
|
588
|
+
? "refused"
|
|
589
|
+
: behind ? "stale" : "current";
|
|
590
|
+
}, [getCanvasVersion, getLatestSentVersions]);
|
|
591
|
+
// Re-apply after every render (the root node can change; refusals are state).
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
applyRenderStamp();
|
|
594
|
+
});
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
const subscribeSent = realtimeContext?.subscribeBlobSent;
|
|
597
|
+
const subscribeResult = realtimeContext?.subscribeMockupResultById;
|
|
598
|
+
if (!actualMockupId || !subscribeSent || !subscribeResult) return;
|
|
599
|
+
const offSent = subscribeSent(() => applyRenderStamp());
|
|
600
|
+
const offResult = subscribeResult(actualMockupId, (result) => {
|
|
601
|
+
if (typeof result?.requestVersion === "number" && result.requestVersion >= renderVersionRef.current) {
|
|
602
|
+
renderVersionRef.current = result.requestVersion;
|
|
603
|
+
renderVersionsRef.current = result.versions ?? null;
|
|
604
|
+
}
|
|
605
|
+
applyRenderStamp();
|
|
606
|
+
});
|
|
607
|
+
return () => {
|
|
608
|
+
offSent?.();
|
|
609
|
+
offResult?.();
|
|
610
|
+
};
|
|
611
|
+
}, [
|
|
612
|
+
actualMockupId,
|
|
613
|
+
realtimeContext?.subscribeBlobSent,
|
|
614
|
+
realtimeContext?.subscribeMockupResultById,
|
|
615
|
+
applyRenderStamp,
|
|
616
|
+
]);
|
|
617
|
+
|
|
548
618
|
// Register with priority context
|
|
549
619
|
useEffect(() => {
|
|
550
620
|
if (!priorityContext || !actualMockupId || !placement) return;
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* MockupPriorityProvider - Priority-based mockup rendering coordinator
|
|
5
5
|
*
|
|
6
6
|
* Manages a 3-level priority system for mockup rendering:
|
|
7
|
-
* - Level 1:
|
|
8
|
-
* - Level 2:
|
|
9
|
-
* - Level 3:
|
|
7
|
+
* - Level 1: Every mockup in view, the most visible first (one request)
|
|
8
|
+
* - Level 2: The rest of the near-view set (preload zone / carousel neighbours)
|
|
9
|
+
* - Level 3: Every other registered mockup — after an edit stops, none is
|
|
10
|
+
* left on an older canvas version
|
|
10
11
|
*
|
|
11
12
|
* Architecture:
|
|
12
13
|
* - Uses refs for visibility state to avoid re-renders during scroll
|
|
@@ -200,17 +201,44 @@ export function MockupPriorityProvider({ children }: MockupPriorityProviderProps
|
|
|
200
201
|
return;
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
// Each level is ONE request for a SET: the server renders exactly the
|
|
205
|
+
// last request's mockupIds, so a level is never split into several
|
|
206
|
+
// requests racing where the last wins.
|
|
207
|
+
// 1 — every picture actually in view (the most visible first). On a wide
|
|
208
|
+
// gallery that is several pictures; level 1 alone was the single
|
|
209
|
+
// largest, so the others in view stayed on the old render mid-drag.
|
|
210
|
+
// 2 — the rest of the near-view set (preload zone / carousel neighbours).
|
|
211
|
+
// 3 — EVERY other registered picture, not just the scroll prefetch: after
|
|
212
|
+
// an edit stops, no picture may be left on an older canvas version.
|
|
213
|
+
const mobile = mobileCarouselRef.current;
|
|
214
|
+
const universe = mobile && mobile.mockupIds.length > 0
|
|
215
|
+
? mobile.mockupIds
|
|
216
|
+
: registrationOrderRef.current;
|
|
217
|
+
const inView: string[] = [];
|
|
218
|
+
if (level1MockupRef.current) {
|
|
219
|
+
inView.push(level1MockupRef.current);
|
|
220
|
+
if (!mobile || mobile.mockupIds.length === 0) {
|
|
221
|
+
visibilityMapRef.current.forEach((info, id) => {
|
|
222
|
+
if (info.visibleArea > 0 && !inView.includes(id)) inView.push(id);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
203
226
|
let mockupIds: string[];
|
|
204
227
|
switch (level) {
|
|
205
228
|
case 1:
|
|
206
|
-
mockupIds =
|
|
229
|
+
mockupIds = inView;
|
|
207
230
|
break;
|
|
208
231
|
case 2:
|
|
209
|
-
mockupIds =
|
|
232
|
+
mockupIds = level2MockupsRef.current.filter((id) => !inView.includes(id));
|
|
210
233
|
break;
|
|
211
|
-
case 3:
|
|
212
|
-
|
|
234
|
+
case 3: {
|
|
235
|
+
const earlier = new Set([...inView, ...level2MockupsRef.current]);
|
|
236
|
+
mockupIds = [
|
|
237
|
+
...level3QueueRef.current.filter((id) => !earlier.has(id)),
|
|
238
|
+
...universe.filter((id) => !earlier.has(id) && !level3QueueRef.current.includes(id)),
|
|
239
|
+
];
|
|
213
240
|
break;
|
|
241
|
+
}
|
|
214
242
|
default:
|
|
215
243
|
mockupIds = [];
|
|
216
244
|
}
|
|
@@ -253,6 +281,20 @@ export function MockupPriorityProvider({ children }: MockupPriorityProviderProps
|
|
|
253
281
|
// Still dragging - stay on Level 1, don't advance to Level 2
|
|
254
282
|
// The next pending blob will re-request Level 1
|
|
255
283
|
currentRenderingLevelRef.current = null; // Ready for next Level 1 request
|
|
284
|
+
// ...but the edit may already be over. A fast render (the CUDA lane answers in ~10 ms) lands the LAST
|
|
285
|
+
// level-1 result inside the debounce window, and no later blob comes to re-trigger: Levels 2/3 — every
|
|
286
|
+
// other picture, the hero included when level 1 isn't it — were never re-rendered after an edit
|
|
287
|
+
// (snowcone.app, phone case, 2026-11-11). Re-check once the window closes; a new pending blob cancels it.
|
|
288
|
+
if (levelAdvanceTimeoutRef.current) {
|
|
289
|
+
clearTimeout(levelAdvanceTimeoutRef.current);
|
|
290
|
+
}
|
|
291
|
+
levelAdvanceTimeoutRef.current = setTimeout(() => {
|
|
292
|
+
levelAdvanceTimeoutRef.current = null;
|
|
293
|
+
const quiet = Date.now() - lastPendingBlobTimeRef.current >= DRAG_STOP_DEBOUNCE_MS;
|
|
294
|
+
if (quiet && currentRenderingLevelRef.current === null && pendingMockupsRef.current.size === 0) {
|
|
295
|
+
requestMockupsForLevel(2);
|
|
296
|
+
}
|
|
297
|
+
}, DRAG_STOP_DEBOUNCE_MS - timeSinceLastPendingBlob + 10);
|
|
256
298
|
return;
|
|
257
299
|
}
|
|
258
300
|
|
|
@@ -62,10 +62,21 @@ const DEFAULT_ARTBOARD_SIZE = 2000;
|
|
|
62
62
|
// Enforce a conservative cap to prevent memory crashes
|
|
63
63
|
const MAX_CONCURRENT_BLOBS = 5;
|
|
64
64
|
|
|
65
|
+
/** `current` in order, then any of `extra` not already in it. */
|
|
66
|
+
function mergeMockupIds(current: readonly string[], extra: readonly string[]): string[] {
|
|
67
|
+
const out = [...current];
|
|
68
|
+
for (const id of extra) if (!out.includes(id)) out.push(id);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
// Realtime mockup types
|
|
66
73
|
export interface MockupResult {
|
|
67
74
|
mockupId: string;
|
|
68
75
|
imageUrl: string;
|
|
76
|
+
/** The canvas version this frame was rendered from (see getCanvasVersion). */
|
|
77
|
+
requestVersion?: number;
|
|
78
|
+
/** Per-placement versions folded into this frame (servers with per-placement stamps; see getLatestSentVersions). */
|
|
79
|
+
versions?: Record<string, number>;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
// Type alias for backward compatibility with code that imported RealtimeState from Product
|
|
@@ -225,6 +236,21 @@ export interface RealtimeContextValue {
|
|
|
225
236
|
callback: (placement: string) => void
|
|
226
237
|
) => () => void;
|
|
227
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The canvas version the session last sent (0 before the first edit
|
|
241
|
+
* reaches the server). Every rendered result carries the `requestVersion`
|
|
242
|
+
* it was rendered from: a picture whose version is below this one shows an
|
|
243
|
+
* older design. Read it after a `subscribeBlobSent` notification.
|
|
244
|
+
*/
|
|
245
|
+
getCanvasVersion: () => number;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The latest canvas version sent per placement. With a server that stamps
|
|
249
|
+
* `versions` on every frame, a picture is current exactly when, for every
|
|
250
|
+
* placement here, its frame's `versions[placement]` is at least this.
|
|
251
|
+
*/
|
|
252
|
+
getLatestSentVersions: () => Record<string, number>;
|
|
253
|
+
|
|
228
254
|
// Flush pending blobs immediately (call on mouse up, action complete, etc.)
|
|
229
255
|
flushPendingBlobs: () => void;
|
|
230
256
|
hasPendingBlobs: () => boolean;
|
|
@@ -433,6 +459,14 @@ export function RealtimeProvider({
|
|
|
433
459
|
// Logged for traces; the actionable surface is `renderError` on the
|
|
434
460
|
// context (and the hook state), which consumers must render visibly.
|
|
435
461
|
console.error(`[RealtimeProvider] Render error [${detail.code}]:`, error);
|
|
462
|
+
// The box refused a `rerender` (rate limit, incomplete placements, …):
|
|
463
|
+
// force the re-render the older way so the failed pictures still update.
|
|
464
|
+
if (detail.code.startsWith("E_RERENDER_") && pendingRerenderRef.current.length > 0) {
|
|
465
|
+
const ids = pendingRerenderRef.current;
|
|
466
|
+
pendingRerenderRef.current = [];
|
|
467
|
+
forceRerenderByConfigRef.current?.(ids);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
436
470
|
// A per-mockup refusal names the mockup: remember it so that card shows
|
|
437
471
|
// "unavailable" while the others keep streaming.
|
|
438
472
|
if (detail.mockupId) {
|
|
@@ -463,6 +497,8 @@ export function RealtimeProvider({
|
|
|
463
497
|
flushPendingBlobs,
|
|
464
498
|
hasPendingBlobs,
|
|
465
499
|
getLastSentVersion,
|
|
500
|
+
getLatestSentVersions,
|
|
501
|
+
rerender: rerenderRaw,
|
|
466
502
|
} = realtimeMockup;
|
|
467
503
|
|
|
468
504
|
// PERFORMANCE: Store mockup results in ref only - NO STATE UPDATES
|
|
@@ -568,6 +604,26 @@ export function RealtimeProvider({
|
|
|
568
604
|
const compositeQueueRef = useRef<Map<string, SdkMockupResult>>(new Map());
|
|
569
605
|
const compositeBusyRef = useRef<Set<string>>(new Set());
|
|
570
606
|
const compositeCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
607
|
+
// The mockupIds the session renders on every edit right now: the last
|
|
608
|
+
// config's set, or the last updateMockupIds set. A full-frame fallback
|
|
609
|
+
// re-sends this set (plus the failed mockups) — it must never shrink it.
|
|
610
|
+
const requestedMockupIdsRef = useRef<string[]>([]);
|
|
611
|
+
// True once a frame carries per-placement `versions`: the box then also
|
|
612
|
+
// understands `rerender` (rendercenter rc#839 + rc#840, one roll).
|
|
613
|
+
const serverStampsVersionsRef = useRef(false);
|
|
614
|
+
// Mockups a `rerender` was sent for, until it is answered or refused.
|
|
615
|
+
const pendingRerenderRef = useRef<string[]>([]);
|
|
616
|
+
const forceRerenderByConfigRef = useRef<((failed: string[]) => void) | null>(null);
|
|
617
|
+
// Cropped frames that could not be shown, waiting for one batched
|
|
618
|
+
// full-frame re-render (see rerenderFullFrame).
|
|
619
|
+
const fullFrameRerenderIdsRef = useRef<Set<string>>(new Set());
|
|
620
|
+
const fullFrameRerenderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
621
|
+
useEffect(
|
|
622
|
+
() => () => {
|
|
623
|
+
if (fullFrameRerenderTimerRef.current) clearTimeout(fullFrameRerenderTimerRef.current);
|
|
624
|
+
},
|
|
625
|
+
[]
|
|
626
|
+
);
|
|
571
627
|
|
|
572
628
|
/**
|
|
573
629
|
* Register a blob URL with lifecycle management
|
|
@@ -614,6 +670,7 @@ export function RealtimeProvider({
|
|
|
614
670
|
const base = lastConfigBaseRef.current;
|
|
615
671
|
if (!base) return;
|
|
616
672
|
croppedActiveRef.current = withCrop;
|
|
673
|
+
requestedMockupIdsRef.current = (base.mockupIds as string[] | undefined) ?? [];
|
|
617
674
|
sendConfigRaw({
|
|
618
675
|
...base,
|
|
619
676
|
placementSettings: { ...placementSettingsOverridesRef.current },
|
|
@@ -629,6 +686,96 @@ export function RealtimeProvider({
|
|
|
629
686
|
sendCropConfig(false);
|
|
630
687
|
}, [sendCropConfig]);
|
|
631
688
|
|
|
689
|
+
/**
|
|
690
|
+
* A cropped frame could not be shown (no frozen background, or the
|
|
691
|
+
* composite failed): drop to full-frame and re-render it WITHOUT narrowing
|
|
692
|
+
* the session. The server renders exactly the last config's mockupIds, so
|
|
693
|
+
* one config per failed mockup left the session rendering only the last
|
|
694
|
+
* one — every later edit re-rendered one off-screen picture and the hero
|
|
695
|
+
* never updated (snowcone.app phone case BC43BE, 2026-11-11: five
|
|
696
|
+
* single-mockup configs in a row, last wins). Failures are batched into ONE
|
|
697
|
+
* full-frame config: everything the session was rendering plus the failed.
|
|
698
|
+
*/
|
|
699
|
+
const sendFullFrameConfig = useCallback(
|
|
700
|
+
(mockupIds: string[]) => {
|
|
701
|
+
const base = lastConfigBaseRef.current;
|
|
702
|
+
if (!base) return;
|
|
703
|
+
sendConfigRaw({
|
|
704
|
+
...base,
|
|
705
|
+
mockupIds,
|
|
706
|
+
placementSettings: { ...placementSettingsOverridesRef.current },
|
|
707
|
+
} as Parameters<typeof sendConfigRaw>[0]);
|
|
708
|
+
},
|
|
709
|
+
[sendConfigRaw]
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Force a re-render of `failed` with configs alone (boxes without the
|
|
714
|
+
* `rerender` message, or one that refused it). The box re-renders on a
|
|
715
|
+
* config only when its render fingerprint changes, and that fingerprint
|
|
716
|
+
* ignores overlayCrop and SORTS mockupIds, so "same set, crop dropped" is no
|
|
717
|
+
* change: nothing re-renders and the failed picture stays old until the next
|
|
718
|
+
* edit (BC43BE swap, prod, 2026-11-11: hero stuck at v1, canvas v3). So: a
|
|
719
|
+
* config that differs from the one in force, then the set in force again,
|
|
720
|
+
* which differs from its predecessor and stays.
|
|
721
|
+
*/
|
|
722
|
+
const forceRerenderByConfig = useCallback(
|
|
723
|
+
(failed: string[]) => {
|
|
724
|
+
const inForce = requestedMockupIdsRef.current;
|
|
725
|
+
const fp = (list: readonly string[]) => [...list].sort().join(",");
|
|
726
|
+
const step = fp(failed) !== fp(inForce) ? failed : inForce.slice(0, -1);
|
|
727
|
+
if (step.length === 0) {
|
|
728
|
+
// One mockup, already the whole session: no differing config exists.
|
|
729
|
+
// The picture keeps its stale stamp and the next edit re-renders it.
|
|
730
|
+
console.error(
|
|
731
|
+
`[RealtimeProvider] E_OVERLAY_RERENDER_UNAVAILABLE: cannot force a full-frame re-render of ${failed.join(", ")} (single-mockup session on a box without \`rerender\`); it updates on the next edit.`
|
|
732
|
+
);
|
|
733
|
+
sendFullFrameConfig(inForce);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
sendFullFrameConfig(step);
|
|
737
|
+
sendFullFrameConfig(inForce);
|
|
738
|
+
},
|
|
739
|
+
[sendFullFrameConfig]
|
|
740
|
+
);
|
|
741
|
+
forceRerenderByConfigRef.current = forceRerenderByConfig;
|
|
742
|
+
|
|
743
|
+
const rerenderFullFrame = useCallback(
|
|
744
|
+
(mockupId: string) => {
|
|
745
|
+
fullFrameRerenderIdsRef.current.add(mockupId);
|
|
746
|
+
if (fullFrameRerenderTimerRef.current) return;
|
|
747
|
+
fullFrameRerenderTimerRef.current = setTimeout(() => {
|
|
748
|
+
fullFrameRerenderTimerRef.current = null;
|
|
749
|
+
const failed = [...fullFrameRerenderIdsRef.current];
|
|
750
|
+
fullFrameRerenderIdsRef.current.clear();
|
|
751
|
+
if (!lastConfigBaseRef.current || failed.length === 0) return;
|
|
752
|
+
const previous = requestedMockupIdsRef.current;
|
|
753
|
+
const ids = mergeMockupIds(previous, failed);
|
|
754
|
+
croppedActiveRef.current = false;
|
|
755
|
+
requestedMockupIdsRef.current = ids;
|
|
756
|
+
const fp = (list: readonly string[]) => [...list].sort().join(",");
|
|
757
|
+
if (fp(ids) !== fp(previous)) {
|
|
758
|
+
// A wider set: the config itself is a render-relevant change, and it
|
|
759
|
+
// re-renders every picture full-frame.
|
|
760
|
+
sendFullFrameConfig(ids);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (serverStampsVersionsRef.current) {
|
|
764
|
+
// The box has `rerender` (rc#840, same roll as the versions stamp
|
|
765
|
+
// seen on its frames): drop the crop (no render by itself), then ask
|
|
766
|
+
// for exactly the failed pictures. A refusal (E_RERENDER_*) falls
|
|
767
|
+
// back to the config way (see onError).
|
|
768
|
+
sendFullFrameConfig(ids);
|
|
769
|
+
pendingRerenderRef.current = failed;
|
|
770
|
+
if (rerenderRaw(failed)) return;
|
|
771
|
+
pendingRerenderRef.current = [];
|
|
772
|
+
}
|
|
773
|
+
forceRerenderByConfig(failed);
|
|
774
|
+
}, 0);
|
|
775
|
+
},
|
|
776
|
+
[sendFullFrameConfig, forceRerenderByConfig, rerenderRaw]
|
|
777
|
+
);
|
|
778
|
+
|
|
632
779
|
/** Flip to cropped delivery once every configured mockup has a frozen
|
|
633
780
|
* background (a cropped frame can't seed one). */
|
|
634
781
|
const maybeFlipCrop = useCallback(() => {
|
|
@@ -674,6 +821,10 @@ export function RealtimeProvider({
|
|
|
674
821
|
else merged.push(result);
|
|
675
822
|
});
|
|
676
823
|
mockupResultsRef.current = merged;
|
|
824
|
+
if (pendingRerenderRef.current.length > 0) {
|
|
825
|
+
const delivered = new Set(results.map((r) => r.mockupId));
|
|
826
|
+
pendingRerenderRef.current = pendingRerenderRef.current.filter((id) => !delivered.has(id));
|
|
827
|
+
}
|
|
677
828
|
|
|
678
829
|
const clearedPlacements = new Set<string>();
|
|
679
830
|
results.forEach((result) => {
|
|
@@ -759,8 +910,7 @@ export function RealtimeProvider({
|
|
|
759
910
|
console.error(
|
|
760
911
|
`[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background — falling back to full-frame and re-rendering it.`
|
|
761
912
|
);
|
|
762
|
-
|
|
763
|
-
updateMockupIdsRaw([result.mockupId]);
|
|
913
|
+
rerenderFullFrame(result.mockupId);
|
|
764
914
|
return;
|
|
765
915
|
}
|
|
766
916
|
try {
|
|
@@ -779,17 +929,30 @@ export function RealtimeProvider({
|
|
|
779
929
|
if (gen !== sceneGenRef.current) return;
|
|
780
930
|
deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
|
|
781
931
|
} catch (err) {
|
|
932
|
+
// A newer frame for this mockup already arrived: the SDK revokes a
|
|
933
|
+
// mockup's previous blob: frame the moment the next one lands, so a
|
|
934
|
+
// composite still loading the old frame fails mid-drag. That is
|
|
935
|
+
// supersession, not a broken pipeline — the newer frame composites
|
|
936
|
+
// next (latest wins). Treating it as a failure killed cropped delivery
|
|
937
|
+
// and re-requested the stale mockup (BC43BE trace, 2026-11-11: five v3
|
|
938
|
+
// composites failed as v4 arrived 0.6 s later).
|
|
939
|
+
if (
|
|
940
|
+
gen !== sceneGenRef.current ||
|
|
941
|
+
compositeQueueRef.current.has(result.mockupId) ||
|
|
942
|
+
processedFrameRef.current.get(result.mockupId) !== result.imageUrl
|
|
943
|
+
) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
782
946
|
console.error(
|
|
783
947
|
"[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed — full-frame delivery for the rest of this session.",
|
|
784
948
|
err
|
|
785
949
|
);
|
|
786
950
|
cropCapableRef.current = false;
|
|
787
|
-
unflipCrop();
|
|
788
951
|
// Re-render the affected mockup full-frame so this edit isn't lost.
|
|
789
|
-
|
|
952
|
+
rerenderFullFrame(result.mockupId);
|
|
790
953
|
}
|
|
791
954
|
},
|
|
792
|
-
[deliverResults, registerBlobUrl,
|
|
955
|
+
[deliverResults, registerBlobUrl, rerenderFullFrame]
|
|
793
956
|
);
|
|
794
957
|
|
|
795
958
|
/** Serial per-mockup composite pump (latest-wins). */
|
|
@@ -824,6 +987,9 @@ export function RealtimeProvider({
|
|
|
824
987
|
console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}${r.cropRect ? ' [crop]' : ''}`));
|
|
825
988
|
|
|
826
989
|
const displayable: SdkMockupResult[] = [];
|
|
990
|
+
if (!serverStampsVersionsRef.current && rawMockupResults.some((r) => r.versions && typeof r.versions === "object")) {
|
|
991
|
+
serverStampsVersionsRef.current = true;
|
|
992
|
+
}
|
|
827
993
|
rawMockupResults.forEach((result) => {
|
|
828
994
|
if (!result.imageUrl) {
|
|
829
995
|
console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
|
|
@@ -1068,6 +1234,7 @@ export function RealtimeProvider({
|
|
|
1068
1234
|
// send — preserving cropped delivery across re-sends that kept the scene
|
|
1069
1235
|
// (e.g. spread-crop changes).
|
|
1070
1236
|
lastConfigBaseRef.current = configPayload as unknown as Record<string, unknown>;
|
|
1237
|
+
requestedMockupIdsRef.current = mockupIds;
|
|
1071
1238
|
sendConfigRaw(
|
|
1072
1239
|
(croppedActiveRef.current
|
|
1073
1240
|
? { ...configPayload, overlayCrop: "auto" as const }
|
|
@@ -1147,6 +1314,7 @@ export function RealtimeProvider({
|
|
|
1147
1314
|
) {
|
|
1148
1315
|
unflipCrop();
|
|
1149
1316
|
}
|
|
1317
|
+
requestedMockupIdsRef.current = [...mockupIds];
|
|
1150
1318
|
return updateMockupIdsRaw(mockupIds);
|
|
1151
1319
|
},
|
|
1152
1320
|
[updateMockupIdsRaw, unflipCrop]
|
|
@@ -1495,6 +1663,13 @@ export function RealtimeProvider({
|
|
|
1495
1663
|
[]
|
|
1496
1664
|
);
|
|
1497
1665
|
|
|
1666
|
+
const getLastSentVersionRef = useRef(getLastSentVersion);
|
|
1667
|
+
getLastSentVersionRef.current = getLastSentVersion;
|
|
1668
|
+
const getCanvasVersion = useCallback(() => getLastSentVersionRef.current?.() ?? 0, []);
|
|
1669
|
+
const getLatestSentVersionsRef = useRef(getLatestSentVersions);
|
|
1670
|
+
getLatestSentVersionsRef.current = getLatestSentVersions;
|
|
1671
|
+
const getLatestSentVersionsStable = useCallback(() => getLatestSentVersionsRef.current?.() ?? {}, []);
|
|
1672
|
+
|
|
1498
1673
|
const stableSubscribePipelineSettled = useCallback(
|
|
1499
1674
|
(callback: () => void) => {
|
|
1500
1675
|
return subscribePipelineSettledRef.current(callback);
|
|
@@ -1547,6 +1722,8 @@ export function RealtimeProvider({
|
|
|
1547
1722
|
// Centralized URL management
|
|
1548
1723
|
getMockupUrl,
|
|
1549
1724
|
getMockupUrls,
|
|
1725
|
+
getCanvasVersion,
|
|
1726
|
+
getLatestSentVersions: getLatestSentVersionsStable,
|
|
1550
1727
|
isMockupLoading,
|
|
1551
1728
|
registerBlobUrl,
|
|
1552
1729
|
// WebRTC streaming
|
|
@@ -1578,6 +1755,8 @@ export function RealtimeProvider({
|
|
|
1578
1755
|
hasPendingBlobs,
|
|
1579
1756
|
getMockupUrl,
|
|
1580
1757
|
getMockupUrls,
|
|
1758
|
+
getCanvasVersion,
|
|
1759
|
+
getLatestSentVersionsStable,
|
|
1581
1760
|
isMockupLoading,
|
|
1582
1761
|
registerBlobUrl,
|
|
1583
1762
|
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After an edit, every picture re-renders — not only level 1 (snowcone.app phone case, 2026-11-11).
|
|
3
|
+
*
|
|
4
|
+
* The CUDA lane answers a level-1 render in ~10 ms, so the LAST level-1 result of an edit lands inside the 250 ms
|
|
5
|
+
* drag-stop window. The provider used to treat that as "still dragging" and wait for the next blob to re-trigger —
|
|
6
|
+
* but after the last edit none comes, so levels 2/3 (the other pictures; the hero, whenever level 1 wasn't it) were
|
|
7
|
+
* never requested again: the editor "updated" and the pictures stayed old.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import React, { useEffect } from "react";
|
|
11
|
+
import { render, act, cleanup } from "@testing-library/react";
|
|
12
|
+
|
|
13
|
+
const rt = vi.hoisted(() => ({
|
|
14
|
+
updateMockupIds: vi.fn((_ids: string[]) => true),
|
|
15
|
+
results: new Set<(r: Array<{ mockupId: string }>) => void>(),
|
|
16
|
+
pending: new Set<(placement: string) => void>(),
|
|
17
|
+
sent: new Set<(placement: string) => void>(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../RealtimeProvider", () => ({
|
|
21
|
+
useRealtimeOptional: () => ({
|
|
22
|
+
isConfigured: true,
|
|
23
|
+
updateMockupIds: rt.updateMockupIds,
|
|
24
|
+
subscribeMockupResults: (cb: (r: Array<{ mockupId: string }>) => void) => { rt.results.add(cb); return () => rt.results.delete(cb); },
|
|
25
|
+
subscribePendingBlob: (cb: (p: string) => void) => { rt.pending.add(cb); return () => rt.pending.delete(cb); },
|
|
26
|
+
subscribeBlobSent: (cb: (p: string) => void) => { rt.sent.add(cb); return () => rt.sent.delete(cb); },
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const { MockupPriorityProvider, useMockupPriority } = await import("../MockupPriorityProvider");
|
|
31
|
+
|
|
32
|
+
const IDS = ["HERO", "SECOND", "THIRD", "FOURTH"];
|
|
33
|
+
function Carousel() {
|
|
34
|
+
const p = useMockupPriority();
|
|
35
|
+
useEffect(() => { p.reportMobileCarouselIndex(0, IDS); }, [p]);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const edit = () => { rt.pending.forEach((cb) => cb("Front")); rt.sent.forEach((cb) => cb("Front")); };
|
|
39
|
+
const rendered = (ids: string[]) => rt.results.forEach((cb) => cb(ids.map((mockupId) => ({ mockupId }))));
|
|
40
|
+
const lastRequest = () => rt.updateMockupIds.mock.calls.at(-1)?.[0];
|
|
41
|
+
|
|
42
|
+
describe("MockupPriorityProvider: the pictures catch up after the last edit", () => {
|
|
43
|
+
beforeEach(() => { vi.useFakeTimers(); rt.updateMockupIds.mockClear(); });
|
|
44
|
+
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
|
45
|
+
|
|
46
|
+
it("a level-1 result inside the drag window still leads to levels 2 and 3 once the edit stops", async () => {
|
|
47
|
+
render(<MockupPriorityProvider><Carousel /></MockupPriorityProvider>);
|
|
48
|
+
await act(async () => { vi.advanceTimersByTime(100); });
|
|
49
|
+
rt.updateMockupIds.mockClear();
|
|
50
|
+
|
|
51
|
+
// the last edit: blob sent → level 1 requested → its render lands 10 ms later (inside the 250 ms window)
|
|
52
|
+
await act(async () => { edit(); });
|
|
53
|
+
expect(lastRequest()).toEqual(["HERO"]);
|
|
54
|
+
await act(async () => { vi.advanceTimersByTime(10); rendered(["HERO"]); });
|
|
55
|
+
expect(lastRequest()).toEqual(["HERO"]); // still in the window: nothing more yet
|
|
56
|
+
|
|
57
|
+
// the window closes with no further edit → level 2 (the neighbour), then level 3 (the rest)
|
|
58
|
+
await act(async () => { vi.advanceTimersByTime(260); });
|
|
59
|
+
expect(lastRequest()).toEqual(["SECOND"]);
|
|
60
|
+
await act(async () => { rendered(["SECOND"]); });
|
|
61
|
+
expect(lastRequest()).toEqual(["THIRD", "FOURTH"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("a further edit inside the window cancels the catch-up (level 1 is re-requested instead)", async () => {
|
|
65
|
+
render(<MockupPriorityProvider><Carousel /></MockupPriorityProvider>);
|
|
66
|
+
await act(async () => { vi.advanceTimersByTime(100); });
|
|
67
|
+
await act(async () => { edit(); vi.advanceTimersByTime(10); rendered(["HERO"]); });
|
|
68
|
+
rt.updateMockupIds.mockClear();
|
|
69
|
+
await act(async () => { vi.advanceTimersByTime(100); edit(); }); // the drag goes on
|
|
70
|
+
expect(lastRequest()).toEqual(["HERO"]);
|
|
71
|
+
await act(async () => { vi.advanceTimersByTime(200); });
|
|
72
|
+
expect(rt.updateMockupIds.mock.calls.every((c) => (c[0] as string[]).includes("HERO"))).toBe(true); // no level 2 mid-drag
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("desktop: the drag renders every picture in view in ONE request, and the catch-up reaches every registered picture", async () => {
|
|
76
|
+
// A wide gallery: HERO and SECOND in view, THIRD in the preload zone,
|
|
77
|
+
// seven more far below. Level 3 used to stop at 5 prefetches.
|
|
78
|
+
const ids = ["HERO", "SECOND", "THIRD", ...Array.from({ length: 7 }, (_, i) => `FAR${i}`)];
|
|
79
|
+
const rect = (top: number) => ({ top, bottom: top + 400, left: 0, right: 500, width: 500, height: 400, x: 0, y: top, toJSON: () => ({}) }) as DOMRect;
|
|
80
|
+
const tops: Record<string, number> = { HERO: 0, SECOND: 300, THIRD: 900 };
|
|
81
|
+
ids.forEach((id, i) => { if (!(id in tops)) tops[id] = 3000 + i * 500; });
|
|
82
|
+
function Gallery() {
|
|
83
|
+
const p = useMockupPriority();
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
ids.forEach((id) => {
|
|
86
|
+
const el = document.createElement("div");
|
|
87
|
+
el.getBoundingClientRect = () => rect(tops[id]);
|
|
88
|
+
p.registerMockup(id, "Front", undefined, el);
|
|
89
|
+
p.updateElementRef(id, el);
|
|
90
|
+
});
|
|
91
|
+
}, [p]);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
render(<MockupPriorityProvider><Gallery /></MockupPriorityProvider>);
|
|
95
|
+
await act(async () => { vi.advanceTimersByTime(200); });
|
|
96
|
+
rt.updateMockupIds.mockClear();
|
|
97
|
+
|
|
98
|
+
await act(async () => { edit(); });
|
|
99
|
+
expect(rt.updateMockupIds).toHaveBeenCalledTimes(1); // one request, not one per picture
|
|
100
|
+
expect(lastRequest()).toEqual(["HERO", "SECOND"]);
|
|
101
|
+
|
|
102
|
+
await act(async () => { vi.advanceTimersByTime(10); rendered(["HERO", "SECOND"]); });
|
|
103
|
+
await act(async () => { vi.advanceTimersByTime(260); });
|
|
104
|
+
expect(lastRequest()).toEqual(["THIRD"]);
|
|
105
|
+
await act(async () => { rendered(["THIRD"]); });
|
|
106
|
+
expect([...(lastRequest() ?? [])].sort()).toEqual(ids.filter((id) => id.startsWith("FAR")).sort());
|
|
107
|
+
|
|
108
|
+
const requested = new Set(rt.updateMockupIds.mock.calls.flatMap((c) => c[0] as string[]));
|
|
109
|
+
expect([...requested].sort()).toEqual([...ids].sort());
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|