@snowcone-app/ui 0.4.9 → 0.4.10

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.
@@ -38,11 +38,20 @@ import React, {
38
38
  } from "react";
39
39
  import { useRealtimeMockup } from "@snowcone-app/sdk/react";
40
40
  import { resolveVariantId as resolveVariantIdUtil } from "@snowcone-app/sdk";
41
- import type { RealtimeRenderError } from "@snowcone-app/sdk";
41
+ import type {
42
+ RealtimeRenderError,
43
+ MockupResult as SdkMockupResult,
44
+ } from "@snowcone-app/sdk";
42
45
  import { readEnv } from "../lib/env";
43
46
  import { useProductOptional } from "./Product";
44
47
  import type { ReactProductContext } from "./Product";
45
48
  import { useMockupBackgroundOptional } from "./MockupBackground";
49
+ import {
50
+ allBackgroundsSeeded,
51
+ compositeCroppedFrame,
52
+ isFullRect,
53
+ loadImageElement,
54
+ } from "./croppedFrameComposite";
46
55
 
47
56
  // Default artboard size for export calculations (matches @snowcone-app/canvas default)
48
57
  const DEFAULT_ARTBOARD_SIZE = 2000;
@@ -276,6 +285,18 @@ export interface RealtimeProviderProps {
276
285
  * via a renewable token. Without them, the legacy seal path runs (shim).
277
286
  */
278
287
  grantUrl?: string;
288
+ /**
289
+ * Frame delivery strategy. `"cropped"` (default, the fast path): once every
290
+ * configured mockup has rendered one full frame (frozen internally as its
291
+ * background), the session flips to `overlayCrop: 'auto'` — the server ships
292
+ * only the product-region pixels per edit and the provider composites them
293
+ * over the frozen background before notifying consumers, so subscribers and
294
+ * the URL cache only ever see full-frame images. Opt out with
295
+ * `"full-frame"` to ship the whole scene on every render (the pre-crop
296
+ * behavior). Any composite failure falls back to full-frame delivery for
297
+ * the rest of the session automatically.
298
+ */
299
+ delivery?: "cropped" | "full-frame";
279
300
  }
280
301
 
281
302
  /**
@@ -292,6 +313,7 @@ export function RealtimeProvider({
292
313
  autoEnable = false,
293
314
  shop,
294
315
  grantUrl,
316
+ delivery = "cropped",
295
317
  }: RealtimeProviderProps) {
296
318
  const productContext = useProductOptional();
297
319
  // Page background for renders (see MockupBackground) — sent on the config so
@@ -472,74 +494,307 @@ export function RealtimeProvider({
472
494
  // Track which mockupIds are pending (derived from placements)
473
495
  const pendingMockupIdsRef = useRef<Set<string>>(new Set());
474
496
 
475
- // Sync rawMockupResults to ref, update URL cache, and notify subscribers (NO STATE UPDATES)
476
- useEffect(() => {
477
- if (rawMockupResults.length === 0) {
478
- if (mockupResultsRef.current.length > 0) {
479
- mockupResultsRef.current = [];
480
- mockupSubscribersRef.current.forEach((cb) => cb([]));
497
+ // ============================================================================
498
+ // CROPPED-FRAME DELIVERY (internal — consumers only ever see full frames)
499
+ // ============================================================================
500
+
501
+ // Frozen full-frame background per mockup (the composite base for cropped
502
+ // frames). Values are server URLs, never blob URLs.
503
+ const backgroundsRef = useRef<
504
+ Map<string, { url: string; imgPromise?: Promise<HTMLImageElement> }>
505
+ >(new Map());
506
+ // Whether the last-sent config included overlayCrop: 'auto'.
507
+ const croppedActiveRef = useRef(false);
508
+ // Composite kill-switch: any composite failure disables cropped delivery
509
+ // for the rest of the session (full-frame is always correct, just heavier).
510
+ const cropCapableRef = useRef(true);
511
+ // Last config payload sent, WITHOUT overlayCrop — flip/unflip re-sends it.
512
+ const lastConfigBaseRef = useRef<Record<string, unknown> | null>(null);
513
+ // Scene generation — bumped when a config change invalidates backgrounds
514
+ // (variant/bg). In-flight composites from an older scene are dropped.
515
+ const sceneGenRef = useRef(0);
516
+ // Frame URL last processed per mockup. The sdk hook's results array is
517
+ // cumulative state (not a delta), so this gates background seeding and
518
+ // composite work across effect re-runs. Cleared on scene invalidation —
519
+ // safe because the sdk clears mockupResults on every config send, so an
520
+ // old-scene frame is never re-observed after the config that replaced it;
521
+ // clearing is what lets a reconnect re-seed from identical (render-cached)
522
+ // frame URLs.
523
+ const processedFrameRef = useRef<Map<string, string>>(new Map());
524
+ // Latest-wins composite queue per mockup + in-flight tracking. Frames can
525
+ // stream faster than a device composites; only the newest matters.
526
+ const compositeQueueRef = useRef<Map<string, SdkMockupResult>>(new Map());
527
+ const compositeBusyRef = useRef<Set<string>>(new Set());
528
+ const compositeCanvasRef = useRef<HTMLCanvasElement | null>(null);
529
+
530
+ /**
531
+ * Register a blob URL with lifecycle management
532
+ */
533
+ const registerBlobUrl = useCallback((blobOrUrl: Blob | string, placementName?: string): string => {
534
+ let url: string;
535
+
536
+ if (blobOrUrl instanceof Blob) {
537
+ // Enforce blob URL limit before creating new one
538
+ if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
539
+ // Revoke oldest blob URL (first entry in Set maintains insertion order)
540
+ const oldest = registeredBlobUrlsRef.current.values().next().value;
541
+ if (oldest) {
542
+ URL.revokeObjectURL(oldest);
543
+ registeredBlobUrlsRef.current.delete(oldest);
544
+
545
+ // Also remove from URL cache and the results snapshot if present, so
546
+ // late subscribers are never replayed a revoked blob URL.
547
+ mockupUrlCacheRef.current.forEach((value, key) => {
548
+ if (value.url === oldest) {
549
+ mockupUrlCacheRef.current.delete(key);
550
+ }
551
+ });
552
+ mockupResultsRef.current = mockupResultsRef.current.filter(
553
+ (r) => r.imageUrl !== oldest
554
+ );
555
+ }
481
556
  }
482
- return;
557
+
558
+ url = URL.createObjectURL(blobOrUrl);
559
+ registeredBlobUrlsRef.current.add(url);
560
+ } else {
561
+ url = blobOrUrl;
483
562
  }
484
563
 
485
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}`));
564
+ return url;
565
+ }, []);
486
566
 
487
- // Update ref immediately
488
- mockupResultsRef.current = rawMockupResults;
567
+ const croppedDeliveryEnabled = delivery !== "full-frame";
568
+
569
+ /** Re-send the last config with/without overlayCrop (delivery flip only). */
570
+ const sendCropConfig = useCallback(
571
+ (withCrop: boolean) => {
572
+ const base = lastConfigBaseRef.current;
573
+ if (!base) return;
574
+ croppedActiveRef.current = withCrop;
575
+ sendConfigRaw({
576
+ ...base,
577
+ placementSettings: { ...placementSettingsOverridesRef.current },
578
+ ...(withCrop ? { overlayCrop: "auto" as const } : {}),
579
+ } as Parameters<typeof sendConfigRaw>[0]);
580
+ },
581
+ [sendConfigRaw]
582
+ );
489
583
 
490
- // Update centralized URL cache with new results
491
- const now = Date.now();
492
- rawMockupResults.forEach((result) => {
493
- if (result.imageUrl) {
584
+ /** Drop back to full-frame delivery (always correct, just heavier). */
585
+ const unflipCrop = useCallback(() => {
586
+ if (!croppedActiveRef.current) return;
587
+ sendCropConfig(false);
588
+ }, [sendCropConfig]);
589
+
590
+ /** Flip to cropped delivery once every configured mockup has a frozen
591
+ * background (a cropped frame can't seed one). */
592
+ const maybeFlipCrop = useCallback(() => {
593
+ if (!croppedDeliveryEnabled || !cropCapableRef.current) return;
594
+ if (croppedActiveRef.current) return;
595
+ const base = lastConfigBaseRef.current;
596
+ if (!base) return;
597
+ const ids = (base.mockupIds as string[] | undefined) ?? [];
598
+ const seeded = new Set(backgroundsRef.current.keys());
599
+ if (allBackgroundsSeeded(ids, seeded)) {
600
+ console.log(`[RTP] Cropped delivery ON — all ${ids.length} mockup background(s) seeded`);
601
+ sendCropConfig(true);
602
+ } else {
603
+ const missing = ids.filter((id) => !seeded.has(id));
604
+ console.log(`[RTP] Cropped delivery waiting — ${missing.length}/${ids.length} mockup(s) unseeded: [${missing.slice(0, 8).join(", ")}]`);
605
+ }
606
+ }, [croppedDeliveryEnabled, sendCropConfig]);
607
+
608
+ /** Scene changed (variant/bg) or session reset — every frozen background
609
+ * is stale. */
610
+ const invalidateBackgrounds = useCallback(() => {
611
+ sceneGenRef.current += 1;
612
+ backgroundsRef.current.clear();
613
+ processedFrameRef.current.clear();
614
+ compositeQueueRef.current.clear();
615
+ croppedActiveRef.current = false;
616
+ }, []);
617
+
618
+ /**
619
+ * Deliver displayable (always full-frame) results: update the snapshot and
620
+ * URL cache, clear pending state, notify subscribers, check settle.
621
+ */
622
+ const deliverResults = useCallback(
623
+ (results: SdkMockupResult[]) => {
624
+ if (results.length === 0) return;
625
+ const now = Date.now();
626
+
627
+ // Upsert into the results snapshot (latest per mockup)
628
+ const merged = [...mockupResultsRef.current];
629
+ results.forEach((result) => {
630
+ const idx = merged.findIndex((r) => r.mockupId === result.mockupId);
631
+ if (idx >= 0) merged[idx] = result;
632
+ else merged.push(result);
633
+ });
634
+ mockupResultsRef.current = merged;
635
+
636
+ const clearedPlacements = new Set<string>();
637
+ results.forEach((result) => {
494
638
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
495
639
  mockupUrlCacheRef.current.set(result.mockupId, {
496
640
  url: result.imageUrl,
497
- isBlob: false, // Server URLs are not blob URLs
641
+ isBlob: result.imageUrl.startsWith("blob:"),
498
642
  timestamp: now,
499
643
  });
500
-
501
- // Clear pending state for this mockup
502
644
  pendingMockupIdsRef.current.delete(result.mockupId);
503
645
  if (wasPending) {
504
646
  console.log(`[RTP] Cleared pending for ${result.mockupId} — url cached`);
505
647
  }
648
+ if (result.placement) clearedPlacements.add(result.placement);
649
+ });
650
+
651
+ // Clear pending placement state so notifyPendingExport can re-fire on
652
+ // the next edit. Placement-scoped when known, otherwise clear all.
653
+ if (clearedPlacements.size > 0) {
654
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
506
655
  } else {
507
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
656
+ pendingPlacementsRef.current.clear();
508
657
  }
509
- });
510
658
 
511
- // Clear pending placement state so notifyPendingExport can re-fire on next edit.
512
- // We clear based on result placements when available, otherwise clear all.
513
- const clearedPlacements = new Set<string>();
514
- rawMockupResults.forEach((result) => {
515
- if (result.placement) clearedPlacements.add(result.placement);
516
- });
517
- if (clearedPlacements.size > 0) {
518
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
519
- } else {
520
- pendingPlacementsRef.current.clear();
659
+ // Notify subscribers immediately (no throttle needed - no state updates!)
660
+ mockupSubscribersRef.current.forEach((cb) => cb(results));
661
+
662
+ // Notify filtered subscribers
663
+ results.forEach((result) => {
664
+ const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
665
+ if (subscribers && subscribers.size > 0) {
666
+ console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
667
+ subscribers.forEach((cb) => cb(result));
668
+ } else {
669
+ console.log(`[RTP] No subscribers for ${result.mockupId} — result received but nobody listening`);
670
+ }
671
+ });
672
+
673
+ // Pipeline-settled check
674
+ if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
675
+ hasBlobInFlightRef.current = false;
676
+ pipelineSettledSubscribersRef.current.forEach((cb) => cb());
677
+ }
678
+ },
679
+ [hasPendingBlobs]
680
+ );
681
+
682
+ /** Composite one cropped frame over its frozen background and deliver it. */
683
+ const runComposite = useCallback(
684
+ async (result: SdkMockupResult) => {
685
+ const gen = sceneGenRef.current;
686
+ const bg = backgroundsRef.current.get(result.mockupId);
687
+ if (!bg) {
688
+ // A cropped frame arrived for a mockup with no frozen background
689
+ // (e.g. a priority request raced the flip). Drop the session back to
690
+ // full-frame and re-render this mockup from the server's cached
691
+ // blobs so the edit isn't lost; the flip re-arms once it seeds.
692
+ console.error(
693
+ `[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background — falling back to full-frame and re-rendering it.`
694
+ );
695
+ unflipCrop();
696
+ updateMockupIdsRaw([result.mockupId]);
697
+ return;
698
+ }
699
+ try {
700
+ if (!bg.imgPromise) bg.imgPromise = loadImageElement(bg.url);
701
+ const background = await bg.imgPromise;
702
+ if (!compositeCanvasRef.current && typeof document !== "undefined") {
703
+ compositeCanvasRef.current = document.createElement("canvas");
704
+ }
705
+ const blob = await compositeCroppedFrame({
706
+ background,
707
+ frameUrl: result.imageUrl,
708
+ rect: result.cropRect!,
709
+ canvas: compositeCanvasRef.current ?? undefined,
710
+ });
711
+ // Scene changed while compositing (variant/bg flip) — stale, drop.
712
+ if (gen !== sceneGenRef.current) return;
713
+ deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
714
+ } catch (err) {
715
+ console.error(
716
+ "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed — full-frame delivery for the rest of this session.",
717
+ err
718
+ );
719
+ cropCapableRef.current = false;
720
+ unflipCrop();
721
+ // Re-render the affected mockup full-frame so this edit isn't lost.
722
+ updateMockupIdsRaw([result.mockupId]);
723
+ }
724
+ },
725
+ [deliverResults, registerBlobUrl, unflipCrop, updateMockupIdsRaw]
726
+ );
727
+
728
+ /** Serial per-mockup composite pump (latest-wins). */
729
+ const pumpCompositeQueue = useCallback(
730
+ (mockupId: string) => {
731
+ if (compositeBusyRef.current.has(mockupId)) return;
732
+ const job = compositeQueueRef.current.get(mockupId);
733
+ if (!job) return;
734
+ compositeQueueRef.current.delete(mockupId);
735
+ compositeBusyRef.current.add(mockupId);
736
+ void runComposite(job).finally(() => {
737
+ compositeBusyRef.current.delete(mockupId);
738
+ pumpCompositeQueue(mockupId);
739
+ });
740
+ },
741
+ [runComposite]
742
+ );
743
+
744
+ // Sync sdk results into the delivery pipeline (NO STATE UPDATES).
745
+ // Full frames pass straight through (and seed/refresh their mockup's frozen
746
+ // background); cropped frames composite over that background first — so
747
+ // consumers only ever receive full-frame URLs, whatever the wire delivery.
748
+ useEffect(() => {
749
+ if (rawMockupResults.length === 0) {
750
+ if (mockupResultsRef.current.length > 0) {
751
+ mockupResultsRef.current = [];
752
+ mockupSubscribersRef.current.forEach((cb) => cb([]));
753
+ }
754
+ return;
521
755
  }
522
756
 
523
- // Notify subscribers immediately (no throttle needed - no state updates!)
524
- mockupSubscribersRef.current.forEach((cb) => cb(rawMockupResults));
757
+ console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}${r.cropRect ? ' [crop]' : ''}`));
525
758
 
526
- // Notify filtered subscribers
759
+ const displayable: SdkMockupResult[] = [];
527
760
  rawMockupResults.forEach((result) => {
528
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
529
- if (subscribers && subscribers.size > 0) {
530
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
531
- subscribers.forEach((cb) => cb(result));
532
- } else {
533
- console.log(`[RTP] No subscribers for ${result.mockupId} — result received but nobody listening`);
761
+ if (!result.imageUrl) {
762
+ console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
763
+ return;
764
+ }
765
+ const firstSight =
766
+ processedFrameRef.current.get(result.mockupId) !== result.imageUrl;
767
+ // Only frames observed under a live session config may seed backgrounds
768
+ // or composite — a leftover pre-config array (e.g. across a reconnect)
769
+ // may belong to a scene this session hasn't configured yet.
770
+ const sessionLive = configSentRef.current;
771
+
772
+ if (isFullRect(result.cropRect)) {
773
+ if (firstSight && sessionLive) {
774
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
775
+ // Seed/refresh the frozen background — the freshest full frame is
776
+ // always the best composite base.
777
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl });
778
+ }
779
+ // Full frames re-deliver on every run (matches pre-crop behavior —
780
+ // consumers dedupe by URL; delivery also clears pending state).
781
+ displayable.push(result);
782
+ } else if (firstSight && sessionLive) {
783
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
784
+ compositeQueueRef.current.set(result.mockupId, result);
785
+ pumpCompositeQueue(result.mockupId);
786
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
787
+ // Already-composited frame re-observed while the mockup is marked
788
+ // pending (e.g. a render that resolved to the identical frame).
789
+ // Re-deliver the composited URL so pending state can't stick.
790
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
791
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
534
792
  }
535
793
  });
536
794
 
537
- // Pipeline-settled check
538
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
539
- hasBlobInFlightRef.current = false;
540
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
541
- }
542
- }, [rawMockupResults]);
795
+ deliverResults(displayable);
796
+ maybeFlipCrop();
797
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
543
798
 
544
799
  // Wrap sendCanvasBlob to track when blobs are sent
545
800
  const sendCanvasBlob = useCallback(
@@ -620,9 +875,12 @@ export function RealtimeProvider({
620
875
  lastSentBgRef.current = undefined;
621
876
  lastSentSpreadCropsRef.current = "null";
622
877
  lastSentColorsRef.current = {};
878
+ // A new session must re-seed backgrounds before cropped delivery.
879
+ invalidateBackgrounds();
880
+ lastConfigBaseRef.current = null;
623
881
  };
624
882
  }
625
- }, [isEnabled, connect, disconnect, wsUrl]);
883
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
626
884
 
627
885
  // Clear pending state when mockup results arrive
628
886
  useEffect(() => {
@@ -664,6 +922,14 @@ export function RealtimeProvider({
664
922
  return; // Nothing changed, skip
665
923
  }
666
924
 
925
+ // Variant/bg changes change the SCENE — every frozen cropped-delivery
926
+ // background is stale. Drop back to full-frame delivery; the flip re-arms
927
+ // once every mockup has re-seeded. (Spread-crop changes only remap
928
+ // artwork inside the product region — backgrounds stay valid.)
929
+ if (variantChanged || bgChanged) {
930
+ invalidateBackgrounds();
931
+ }
932
+
667
933
  // Get mockup IDs from product.mockups
668
934
  const mockupIds: string[] = [];
669
935
  if (product.mockups && Array.isArray(product.mockups)) {
@@ -710,7 +976,15 @@ export function RealtimeProvider({
710
976
  });
711
977
  }
712
978
 
713
- sendConfigRaw(configPayload);
979
+ // Remember the crop-less payload so delivery flips can re-send it, then
980
+ // send — preserving cropped delivery across re-sends that kept the scene
981
+ // (e.g. spread-crop changes).
982
+ lastConfigBaseRef.current = configPayload as unknown as Record<string, unknown>;
983
+ sendConfigRaw(
984
+ (croppedActiveRef.current
985
+ ? { ...configPayload, overlayCrop: "auto" as const }
986
+ : configPayload) as Parameters<typeof sendConfigRaw>[0]
987
+ );
714
988
 
715
989
  configSentRef.current = true;
716
990
  lastSentVariantIdRef.current = variantId;
@@ -735,6 +1009,7 @@ export function RealtimeProvider({
735
1009
  productContext?.selection,
736
1010
  sendConfigRaw,
737
1011
  sendColorBlobTracked,
1012
+ invalidateBackgrounds,
738
1013
  mockupWidth,
739
1014
  mockupBg,
740
1015
  spreadCropsVersion,
@@ -772,6 +1047,23 @@ export function RealtimeProvider({
772
1047
 
773
1048
  // Canvas integration uses @snowcone-app/canvas with sendCanvasBlob directly
774
1049
 
1050
+ // Guard cropped delivery on priority requests: a mockup that has never
1051
+ // rendered has no frozen background to composite onto, so drop the session
1052
+ // to full-frame first — its full render seeds the background and the flip
1053
+ // re-arms automatically once every requested mockup is seeded.
1054
+ const updateMockupIdsGuarded = useCallback(
1055
+ (mockupIds: string[]) => {
1056
+ if (
1057
+ croppedActiveRef.current &&
1058
+ mockupIds.some((id) => !backgroundsRef.current.has(id))
1059
+ ) {
1060
+ unflipCrop();
1061
+ }
1062
+ return updateMockupIdsRaw(mockupIds);
1063
+ },
1064
+ [updateMockupIdsRaw, unflipCrop]
1065
+ );
1066
+
775
1067
  // Wrap updatePlacementSettings to also track in ref (for config re-sends)
776
1068
  const updatePlacementSettingsWrapped = useCallback(
777
1069
  (settings: Record<string, any>) => {
@@ -1052,39 +1344,6 @@ export function RealtimeProvider({
1052
1344
  return pendingMockupIdsRef.current.has(mockupId);
1053
1345
  }, []);
1054
1346
 
1055
- /**
1056
- * Register a blob URL with lifecycle management
1057
- */
1058
- const registerBlobUrl = useCallback((blobOrUrl: Blob | string, placementName?: string): string => {
1059
- let url: string;
1060
-
1061
- if (blobOrUrl instanceof Blob) {
1062
- // Enforce blob URL limit before creating new one
1063
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
1064
- // Revoke oldest blob URL (first entry in Set maintains insertion order)
1065
- const oldest = registeredBlobUrlsRef.current.values().next().value;
1066
- if (oldest) {
1067
- URL.revokeObjectURL(oldest);
1068
- registeredBlobUrlsRef.current.delete(oldest);
1069
-
1070
- // Also remove from URL cache if present
1071
- mockupUrlCacheRef.current.forEach((value, key) => {
1072
- if (value.url === oldest) {
1073
- mockupUrlCacheRef.current.delete(key);
1074
- }
1075
- });
1076
- }
1077
- }
1078
-
1079
- url = URL.createObjectURL(blobOrUrl);
1080
- registeredBlobUrlsRef.current.add(url);
1081
- } else {
1082
- url = blobOrUrl;
1083
- }
1084
-
1085
- return url;
1086
- }, []);
1087
-
1088
1347
  // Store subscription functions in refs so they remain stable across context recreations
1089
1348
  // This prevents race conditions where notifications are lost during re-subscription
1090
1349
  const subscribeMockupResultsRef = useRef(subscribeMockupResults);
@@ -1179,7 +1438,7 @@ export function RealtimeProvider({
1179
1438
  disableRealtime,
1180
1439
  sendCanvasBlob,
1181
1440
  sendCanvasState,
1182
- updateMockupIds: updateMockupIdsRaw,
1441
+ updateMockupIds: updateMockupIdsGuarded,
1183
1442
  updatePlacementSettings: updatePlacementSettingsWrapped,
1184
1443
  setSpreadCrops,
1185
1444
  getMockupResultsImmediate: () => mockupResultsRef.current,
@@ -1216,7 +1475,7 @@ export function RealtimeProvider({
1216
1475
  disableRealtime,
1217
1476
  sendCanvasBlob,
1218
1477
  sendCanvasState,
1219
- updateMockupIdsRaw,
1478
+ updateMockupIdsGuarded,
1220
1479
  updatePlacementSettingsWrapped,
1221
1480
  setSpreadCrops,
1222
1481
  stableSubscribeMockupResults,