@atlaskit/editor-synced-block-provider 6.6.12 → 6.7.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.
Files changed (25) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cjs/store-manager/referenceSyncBlockStoreManager.js +207 -25
  3. package/dist/cjs/store-manager/syncBlockBatchFetcher.js +35 -0
  4. package/dist/cjs/store-manager/syncBlockStoreManager.js +19 -0
  5. package/dist/cjs/store-manager/syncBlockSubscriptionManager.js +33 -15
  6. package/dist/cjs/utils/errorHandling.js +44 -1
  7. package/dist/es2019/store-manager/referenceSyncBlockStoreManager.js +180 -15
  8. package/dist/es2019/store-manager/syncBlockBatchFetcher.js +29 -0
  9. package/dist/es2019/store-manager/syncBlockStoreManager.js +21 -1
  10. package/dist/es2019/store-manager/syncBlockSubscriptionManager.js +34 -15
  11. package/dist/es2019/utils/errorHandling.js +46 -0
  12. package/dist/esm/store-manager/referenceSyncBlockStoreManager.js +208 -26
  13. package/dist/esm/store-manager/syncBlockBatchFetcher.js +35 -0
  14. package/dist/esm/store-manager/syncBlockStoreManager.js +21 -1
  15. package/dist/esm/store-manager/syncBlockSubscriptionManager.js +34 -15
  16. package/dist/esm/utils/errorHandling.js +43 -0
  17. package/dist/types/store-manager/referenceSyncBlockStoreManager.d.ts +25 -0
  18. package/dist/types/store-manager/syncBlockBatchFetcher.d.ts +8 -0
  19. package/dist/types/store-manager/syncBlockSubscriptionManager.d.ts +4 -0
  20. package/dist/types/utils/errorHandling.d.ts +15 -0
  21. package/dist/types-ts4.5/store-manager/referenceSyncBlockStoreManager.d.ts +25 -0
  22. package/dist/types-ts4.5/store-manager/syncBlockBatchFetcher.d.ts +8 -0
  23. package/dist/types-ts4.5/store-manager/syncBlockSubscriptionManager.d.ts +4 -0
  24. package/dist/types-ts4.5/utils/errorHandling.d.ts +15 -0
  25. package/package.json +6 -3
@@ -3,7 +3,7 @@ import isEqual from 'lodash/isEqual';
3
3
  import { logException } from '@atlaskit/editor-common/monitoring';
4
4
  import { fg } from '@atlaskit/platform-feature-flags';
5
5
  import { SyncBlockError } from '../common/types';
6
- import { fetchErrorPayload, fetchSuccessPayload, getSourceInfoErrorPayload, updateReferenceErrorPayload } from '../utils/errorHandling';
6
+ import { cacheDeletionForcedPayload, fetchErrorPayload, fetchSuccessPayload, getSourceInfoErrorPayload, sourceInfoOrphanedPayload, updateReferenceErrorPayload } from '../utils/errorHandling';
7
7
  import { getFetchExperience, getFetchSourceInfoExperience, getSaveReferenceExperience } from '../utils/experienceTracking';
8
8
  import { resolveSyncBlockInstance } from '../utils/resolveSyncBlockInstance';
9
9
  import { createSyncBlockNode, getSourceProductFromResourceIdSafe, stripAnnotationMarksFromJSONContent } from '../utils/utils';
@@ -15,6 +15,13 @@ const CACHE_KEY_PREFIX = 'sync-block-data-';
15
15
  const ENTITY_NOT_FOUND_MAX_RETRIES = 3;
16
16
  const ENTITY_NOT_FOUND_INITIAL_DELAY_MS = 2000;
17
17
 
18
+ // Grace period before a cache entry is removed after the last subscriber
19
+ // unsubscribes (gated by `platform_synced_block_patch_14`). Guards are
20
+ // re-checked at fire time; if any are positive, the timer is rescheduled.
21
+ const CACHE_DELETION_GRACE_PERIOD_MS = 30_000;
22
+ // Max reschedules before force-deleting with an analytics event (~5 min).
23
+ const CACHE_DELETION_MAX_RESCHEDULES = 10;
24
+
18
25
  // A store manager responsible for the lifecycle and state management of reference sync blocks in an editor instance.
19
26
  // Designed to manage local in-memory state and synchronize with an external data provider.
20
27
  // Supports fetch, cache, and subscription for sync block data.
@@ -36,6 +43,13 @@ export class ReferenceSyncBlockStoreManager {
36
43
  // Track retry attempts for EntityNotFound errors (block may be in the process of being created)
37
44
  _defineProperty(this, "entityNotFoundRetryCount", new Map());
38
45
  _defineProperty(this, "entityNotFoundRetryTimers", new Map());
46
+ // Pending cache deletion timers keyed by resourceId (gated by
47
+ // `platform_synced_block_patch_14`). Cancelled when a subscriber re-attaches.
48
+ _defineProperty(this, "pendingCacheDeletions", new Map());
49
+ // Reschedule counter per resource — reset on actual deletion or re-subscribe.
50
+ _defineProperty(this, "cacheDeletionRescheduleCounts", new Map());
51
+ // Set by destroy() so in-flight timer callbacks can early-return.
52
+ _defineProperty(this, "isDestroyed", false);
39
53
  this.dataProvider = dataProvider;
40
54
  this.viewMode = viewMode;
41
55
  this.syncBlockFetchDataRequests = new Map();
@@ -51,7 +65,11 @@ export class ReferenceSyncBlockStoreManager {
51
65
  getFireAnalyticsEvent: () => this.fireAnalyticsEvent,
52
66
  markCacheDirty: () => {
53
67
  this.isCacheDirty = true;
54
- }
68
+ },
69
+ // Delegate cache lifecycle to the store manager so guards can be
70
+ // checked atomically (gated by `platform_synced_block_patch_14`).
71
+ scheduleCacheDeletion: rid => this.scheduleCacheDeletion(rid),
72
+ cancelPendingCacheDeletion: rid => this.cancelPendingCacheDeletion(rid)
55
73
  });
56
74
  this._providerFactoryManager = new SyncBlockProviderFactoryManager({
57
75
  getDataProvider: () => this.dataProvider,
@@ -507,6 +525,19 @@ export class ReferenceSyncBlockStoreManager {
507
525
  }
508
526
  updateCacheWithSourceInfo(resourceId, sourceInfo) {
509
527
  const existingSyncBlock = this.getFromCache(resourceId);
528
+ // If the cache entry was deleted while the source-info request was
529
+ // in flight, fire an analytics event so the race is observable.
530
+ if (!existingSyncBlock && fg('platform_synced_block_patch_14')) {
531
+ var _this$fireAnalyticsEv1;
532
+ (_this$fireAnalyticsEv1 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv1 === void 0 ? void 0 : _this$fireAnalyticsEv1.call(this, sourceInfoOrphanedPayload(resourceId, getSourceProductFromResourceIdSafe(resourceId), {
533
+ hasPendingDeletion: this.pendingCacheDeletions.has(resourceId),
534
+ hasSubscribers: this._subscriptionManager.getSubscriptions().has(resourceId)
535
+ }));
536
+ logException(new Error('updateCacheWithSourceInfo: cache entry missing for resource'), {
537
+ location: 'editor-synced-block-provider/referenceSyncBlockStoreManager/orphaned-source-info'
538
+ });
539
+ return;
540
+ }
510
541
  if (existingSyncBlock && existingSyncBlock.data) {
511
542
  existingSyncBlock.data.sourceURL = sourceInfo === null || sourceInfo === void 0 ? void 0 : sourceInfo.url;
512
543
  existingSyncBlock.data = {
@@ -546,6 +577,125 @@ export class ReferenceSyncBlockStoreManager {
546
577
  var _this$dataProvider5;
547
578
  (_this$dataProvider5 = this.dataProvider) === null || _this$dataProvider5 === void 0 ? void 0 : _this$dataProvider5.removeFromCache([resourceId]);
548
579
  this._providerFactoryManager.deleteFactory(resourceId);
580
+ // Evict in-flight source-info promise and reset reschedule counter
581
+ // so a stale resolution can't silently merge into a re-fetched entry.
582
+ if (fg('platform_synced_block_patch_14')) {
583
+ this.syncBlockSourceInfoRequests.delete(resourceId);
584
+ this.cacheDeletionRescheduleCounts.delete(resourceId);
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Returns true if the cache entry for `resourceId` is safe to delete:
590
+ * no active subscribers, no in-flight source-info request, and no
591
+ * queued/in-flight batch fetch (gated by `platform_synced_block_patch_14`).
592
+ */
593
+ canDeleteCache(resourceId) {
594
+ if (this._subscriptionManager.getSubscriptions().has(resourceId)) {
595
+ return false;
596
+ }
597
+ if (this.syncBlockSourceInfoRequests.has(resourceId)) {
598
+ return false;
599
+ }
600
+ if (this._batchFetcher.hasPendingFetch(resourceId)) {
601
+ return false;
602
+ }
603
+ return true;
604
+ }
605
+
606
+ /**
607
+ * Schedules cache deletion for `resourceId` after the grace period
608
+ * (gated by `platform_synced_block_patch_14`). Called when the last
609
+ * subscriber unsubscribes. Guards are re-checked at fire time; if any
610
+ * are positive the timer is rescheduled up to MAX_RESCHEDULES times.
611
+ */
612
+ scheduleCacheDeletion(resourceId) {
613
+ if (!fg('platform_synced_block_patch_14')) {
614
+ return;
615
+ }
616
+ if (this.isDestroyed) {
617
+ return;
618
+ }
619
+ // Cancel any existing timer \u2014 restart the grace period \u2014 but DO NOT
620
+ // reset the reschedule counter. The counter is reset only by
621
+ // `cancelPendingCacheDeletion` (called when a real subscriber returns)
622
+ // or when the cache is actually deleted.
623
+ const existing = this.pendingCacheDeletions.get(resourceId);
624
+ if (existing) {
625
+ clearTimeout(existing);
626
+ this.pendingCacheDeletions.delete(resourceId);
627
+ }
628
+ const timer = setTimeout(() => {
629
+ // Guard against timer callback running after destroy. clearTimeout
630
+ // is synchronous so this should be unreachable in practice, but
631
+ // belt-and-braces.
632
+ if (this.isDestroyed) {
633
+ return;
634
+ }
635
+ this.pendingCacheDeletions.delete(resourceId);
636
+ this.onCacheDeletionTimerFire(resourceId);
637
+ }, CACHE_DELETION_GRACE_PERIOD_MS);
638
+ this.pendingCacheDeletions.set(resourceId, timer);
639
+ }
640
+
641
+ /**
642
+ * Cancels any pending cache deletion timer for `resourceId` and resets the
643
+ * reschedule counter (gated by `platform_synced_block_patch_14`). Called
644
+ * when a new subscriber arrives.
645
+ */
646
+ cancelPendingCacheDeletion(resourceId) {
647
+ if (!fg('platform_synced_block_patch_14')) {
648
+ return;
649
+ }
650
+ const existing = this.pendingCacheDeletions.get(resourceId);
651
+ if (existing) {
652
+ clearTimeout(existing);
653
+ this.pendingCacheDeletions.delete(resourceId);
654
+ }
655
+ // Subscribers returning resets the reschedule counter \u2014 the resource is
656
+ // active again.
657
+ this.cacheDeletionRescheduleCounts.delete(resourceId);
658
+ }
659
+
660
+ /** Returns whether a cache deletion timer is pending for `resourceId`. */
661
+ hasPendingCacheDeletion(resourceId) {
662
+ return this.pendingCacheDeletions.has(resourceId);
663
+ }
664
+ onCacheDeletionTimerFire(resourceId) {
665
+ var _this$cacheDeletionRe;
666
+ if (this.canDeleteCache(resourceId)) {
667
+ // `deleteFromCache` resets the reschedule counter under the flag.
668
+ this.deleteFromCache(resourceId);
669
+ return;
670
+ }
671
+ const currentCount = (_this$cacheDeletionRe = this.cacheDeletionRescheduleCounts.get(resourceId)) !== null && _this$cacheDeletionRe !== void 0 ? _this$cacheDeletionRe : 0;
672
+ if (currentCount >= CACHE_DELETION_MAX_RESCHEDULES) {
673
+ var _this$fireAnalyticsEv10;
674
+ // Stuck guard — force deletion to prevent unbounded memory growth and
675
+ // fire analytics so the stuck state is visible in production telemetry.
676
+ //
677
+ // NOTE: If active React subscribers still exist at force-delete time
678
+ // (e.g. an in-flight batch fetch never settled), the cache entry is
679
+ // removed without notifying subscribers. Those components will
680
+ // continue to render with stale data until their next re-render
681
+ // triggers a new batch fetch — typically within ~1 frame. We accept
682
+ // this brief stale window in exchange for bounded memory growth.
683
+ (_this$fireAnalyticsEv10 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv10 === void 0 ? void 0 : _this$fireAnalyticsEv10.call(this, cacheDeletionForcedPayload(currentCount, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
684
+ logException(new Error(`Cache deletion forced after ${currentCount} reschedules — stuck in-flight guard`), {
685
+ location: 'editor-synced-block-provider/referenceSyncBlockStoreManager/cache-deletion-forced'
686
+ });
687
+ // `deleteFromCache` resets the reschedule counter under the flag.
688
+ this.deleteFromCache(resourceId);
689
+ // If subscribers still exist, kick off a fresh fetch so they get
690
+ // fresh data on the next batch tick instead of holding stale data
691
+ // indefinitely.
692
+ if (this._subscriptionManager.getSubscriptions().has(resourceId)) {
693
+ this.debouncedBatchedFetchSyncBlocks(resourceId);
694
+ }
695
+ return;
696
+ }
697
+ this.cacheDeletionRescheduleCounts.set(resourceId, currentCount + 1);
698
+ this.scheduleCacheDeletion(resourceId);
549
699
  }
550
700
 
551
701
  /**
@@ -628,11 +778,11 @@ export class ReferenceSyncBlockStoreManager {
628
778
  }
629
779
  return this._subscriptionManager.subscribeToSyncBlock(resourceId, localId, callback);
630
780
  } catch (error) {
631
- var _this$fireAnalyticsEv1;
781
+ var _this$fireAnalyticsEv11;
632
782
  logException(error, {
633
783
  location: 'editor-synced-block-provider/referenceSyncBlockStoreManager'
634
784
  });
635
- (_this$fireAnalyticsEv1 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv1 === void 0 ? void 0 : _this$fireAnalyticsEv1.call(this, fetchErrorPayload(error.message));
785
+ (_this$fireAnalyticsEv11 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv11 === void 0 ? void 0 : _this$fireAnalyticsEv11.call(this, fetchErrorPayload(error.message));
636
786
  return () => {};
637
787
  }
638
788
  }
@@ -643,12 +793,8 @@ export class ReferenceSyncBlockStoreManager {
643
793
  * @returns
644
794
  */
645
795
  getSyncBlockURL(resourceId) {
646
- var _syncBlock$data2;
647
- const syncBlock = this.getFromCache(resourceId);
648
- if (!syncBlock) {
649
- return undefined;
650
- }
651
- return (_syncBlock$data2 = syncBlock.data) === null || _syncBlock$data2 === void 0 ? void 0 : _syncBlock$data2.sourceURL;
796
+ var _this$getFromCache, _this$getFromCache$da;
797
+ return (_this$getFromCache = this.getFromCache(resourceId)) === null || _this$getFromCache === void 0 ? void 0 : (_this$getFromCache$da = _this$getFromCache.data) === null || _this$getFromCache$da === void 0 ? void 0 : _this$getFromCache$da.sourceURL;
652
798
  }
653
799
  getProviderFactory(resourceId) {
654
800
  return this._providerFactoryManager.getProviderFactory(resourceId);
@@ -722,15 +868,15 @@ export class ReferenceSyncBlockStoreManager {
722
868
  (_this$saveExperience = this.saveExperience) === null || _this$saveExperience === void 0 ? void 0 : _this$saveExperience.start();
723
869
  const updateResult = await this.dataProvider.updateReferenceData(blocks);
724
870
  if (!updateResult.success) {
725
- var _this$saveExperience2, _this$fireAnalyticsEv10;
871
+ var _this$saveExperience2, _this$fireAnalyticsEv12;
726
872
  success = false;
727
873
  (_this$saveExperience2 = this.saveExperience) === null || _this$saveExperience2 === void 0 ? void 0 : _this$saveExperience2.failure({
728
874
  reason: updateResult.error || 'Failed to update reference synced blocks on the document'
729
875
  });
730
- (_this$fireAnalyticsEv10 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv10 === void 0 ? void 0 : _this$fireAnalyticsEv10.call(this, updateReferenceErrorPayload(updateResult.error || 'Failed to update reference synced blocks on the document'));
876
+ (_this$fireAnalyticsEv12 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv12 === void 0 ? void 0 : _this$fireAnalyticsEv12.call(this, updateReferenceErrorPayload(updateResult.error || 'Failed to update reference synced blocks on the document'));
731
877
  }
732
878
  } catch (error) {
733
- var _this$saveExperience3, _this$fireAnalyticsEv11;
879
+ var _this$saveExperience3, _this$fireAnalyticsEv13;
734
880
  success = false;
735
881
  logException(error, {
736
882
  location: 'editor-synced-block-provider/referenceSyncBlockStoreManager'
@@ -739,7 +885,7 @@ export class ReferenceSyncBlockStoreManager {
739
885
  reason: error.message
740
886
  });
741
887
  // No `resourceId` available in this catch — sourceProduct is intentionally omitted.
742
- (_this$fireAnalyticsEv11 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv11 === void 0 ? void 0 : _this$fireAnalyticsEv11.call(this, updateReferenceErrorPayload(error.message));
888
+ (_this$fireAnalyticsEv13 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv13 === void 0 ? void 0 : _this$fireAnalyticsEv13.call(this, updateReferenceErrorPayload(error.message));
743
889
  } finally {
744
890
  if (!success) {
745
891
  // set isCacheDirty back to true for cases where it failed to update the reference synced blocks on the BE
@@ -768,6 +914,9 @@ export class ReferenceSyncBlockStoreManager {
768
914
  }
769
915
  destroy() {
770
916
  var _this$dataProvider6, _this$saveExperience5, _this$fetchExperience0, _this$fetchSourceInfo6;
917
+ // Mark destroyed first so in-flight timer callbacks can early-return.
918
+ this.isDestroyed = true;
919
+
771
920
  // Cancel any queued flush to prevent it from running after destroy
772
921
  if (this.queuedFlushTimeout) {
773
922
  clearTimeout(this.queuedFlushTimeout);
@@ -778,6 +927,11 @@ export class ReferenceSyncBlockStoreManager {
778
927
  this.entityNotFoundRetryTimers.forEach(timer => clearTimeout(timer));
779
928
  this.entityNotFoundRetryTimers.clear();
780
929
  this.entityNotFoundRetryCount.clear();
930
+
931
+ // Cancel pending cache deletion timers.
932
+ this.pendingCacheDeletions.forEach(timer => clearTimeout(timer));
933
+ this.pendingCacheDeletions.clear();
934
+ this.cacheDeletionRescheduleCounts.clear();
781
935
  this._subscriptionManager.destroy();
782
936
  this._providerFactoryManager.destroy();
783
937
  this._batchFetcher.destroy();
@@ -795,6 +949,17 @@ export class ReferenceSyncBlockStoreManager {
795
949
  reason: 'editorDestroyed'
796
950
  });
797
951
  this.fireAnalyticsEvent = undefined;
798
- syncBlockInMemorySessionCache.clear();
952
+
953
+ // Under `platform_synced_block_patch_14`, `destroy()` is now wired to
954
+ // React component unmount via `useMemoizedSyncBlockStoreManager`.
955
+ // Clearing the module-level singleton on unmount would wipe SSR session
956
+ // cache data that a sibling/successor manager (e.g. the editor
957
+ // instance that mounts immediately after the renderer unmounts during
958
+ // the view-mode transition) is about to read.
959
+ // Let entries age out naturally instead — the in-memory cache is
960
+ // naturally bounded by `maxSize` (LRU) and cleared on hard navigation.
961
+ if (!fg('platform_synced_block_patch_14')) {
962
+ syncBlockInMemorySessionCache.clear();
963
+ }
799
964
  }
800
965
  }
@@ -11,6 +11,11 @@ import { createSyncBlockNode, getSourceProductFromResourceIdSafe } from '../util
11
11
  export class SyncBlockBatchFetcher {
12
12
  constructor(deps) {
13
13
  _defineProperty(this, "pendingFetchRequests", new Set());
14
+ // Tracks resourceIds whose batched fetch is in flight (after RAF drains
15
+ // pendingFetchRequests, before the promise settles). Ensures
16
+ // `hasPendingFetch` remains true during the network window.
17
+ _defineProperty(this, "inFlightFetches", new Set());
18
+ _defineProperty(this, "isDestroyed", false);
14
19
  this.deps = deps;
15
20
  this.scheduledBatchFetch = rafSchedule(() => {
16
21
  if (this.pendingFetchRequests.size === 0) {
@@ -23,6 +28,9 @@ export class SyncBlockBatchFetcher {
23
28
  return createSyncBlockNode(firstLocalId, resId);
24
29
  });
25
30
  this.pendingFetchRequests.clear();
31
+
32
+ // Track in-flight before the fetch so guards remain positive.
33
+ resourceIds.forEach(resId => this.inFlightFetches.add(resId));
26
34
  this.deps.fetchSyncBlocksData(syncBlockNodes).catch(error => {
27
35
  logException(error, {
28
36
  location: 'editor-synced-block-provider/syncBlockBatchFetcher/batchedFetchSyncBlocks'
@@ -31,6 +39,15 @@ export class SyncBlockBatchFetcher {
31
39
  var _this$deps$getFireAna;
32
40
  (_this$deps$getFireAna = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 ? void 0 : _this$deps$getFireAna(fetchErrorPayload(error.message, resId, getSourceProductFromResourceIdSafe(resId)));
33
41
  });
42
+ }).finally(() => {
43
+ // If the fetcher was destroyed while the request was in flight,
44
+ // skip cleanup — `destroy()` already cleared `inFlightFetches`
45
+ // and there's nothing observable to update.
46
+ if (this.isDestroyed) {
47
+ return;
48
+ }
49
+ // Clear in-flight tracking once the fetch settles.
50
+ resourceIds.forEach(resId => this.inFlightFetches.delete(resId));
34
51
  });
35
52
  });
36
53
  }
@@ -43,6 +60,15 @@ export class SyncBlockBatchFetcher {
43
60
  this.pendingFetchRequests.delete(resourceId);
44
61
  }
45
62
  }
63
+
64
+ /**
65
+ * Returns true if a batched fetch is queued or in flight for `resourceId`.
66
+ * Used by cache deletion guards to prevent deleting while a fetch is
67
+ * about to populate the entry.
68
+ */
69
+ hasPendingFetch(resourceId) {
70
+ return this.pendingFetchRequests.has(resourceId) || this.inFlightFetches.has(resourceId);
71
+ }
46
72
  cancel() {
47
73
  this.scheduledBatchFetch.cancel();
48
74
  }
@@ -50,7 +76,10 @@ export class SyncBlockBatchFetcher {
50
76
  this.pendingFetchRequests.clear();
51
77
  }
52
78
  destroy() {
79
+ this.isDestroyed = true;
53
80
  this.cancel();
54
81
  this.clearPending();
82
+ // Clear in-flight tracking to prevent stale entries after teardown.
83
+ this.inFlightFetches.clear();
55
84
  }
56
85
  }
@@ -1,5 +1,6 @@
1
- import { useMemo, useRef } from 'react';
1
+ import { useEffect, useMemo, useRef } from 'react';
2
2
  import { logException } from '@atlaskit/editor-common/monitoring';
3
+ import { fg } from '@atlaskit/platform-feature-flags';
3
4
  import { getProductFromSourceAri } from '../clients/block-service/ari';
4
5
  import { SyncBlockError } from '../common/types';
5
6
  import { fetchReferencesErrorPayload } from '../utils/errorHandling';
@@ -13,6 +14,7 @@ import { SourceSyncBlockStoreManager } from './sourceSyncBlockStoreManager';
13
14
  // ReferenceSyncBlockStoreManager is responsible for the lifecycle and state management of reference sync blocks in an editor instance.
14
15
  // SourceSyncBlockStoreManager is responsible for the lifecycle and state management of source sync blocks in an editor instance.
15
16
  // Can be used in both editor and renderer contexts.
17
+
16
18
  export class SyncBlockStoreManager {
17
19
  constructor(dataProvider, viewMode, isLivePage) {
18
20
  // In future, if reference manager needs to reach to source manager and read its current in memory cache
@@ -206,5 +208,23 @@ export const useMemoizedSyncBlockStoreManager = (dataProvider, fireAnalyticsEven
206
208
  prevFireAnalyticsEventRef.current = fireAnalyticsEvent;
207
209
  syncBlockStoreManager.setFireAnalyticsEvent(fireAnalyticsEvent);
208
210
  }
211
+
212
+ // Gated by platform_synced_block_patch_14:
213
+ // Destroy the SyncBlockStoreManager when:
214
+ // (a) the component unmounts — manager is fully cleaned up, or
215
+ // (b) dataProvider changes — the old manager (now orphaned by the
216
+ // useMemo recalculation) is destroyed before the new one takes over.
217
+ //
218
+ // Without this, orphaned managers leak timers, GQL subscriptions, and
219
+ // in-flight fetches indefinitely. The effect dep is `syncBlockStoreManager`
220
+ // (the useMemo result) — it changes identity precisely when dataProvider
221
+ // changes, triggering the cleanup for the old instance.
222
+ useEffect(() => {
223
+ return () => {
224
+ if (fg('platform_synced_block_patch_14')) {
225
+ syncBlockStoreManager.destroy();
226
+ }
227
+ };
228
+ }, [syncBlockStoreManager]);
209
229
  return syncBlockStoreManager;
210
230
  };
@@ -1,5 +1,6 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  import { logException } from '@atlaskit/editor-common/monitoring';
3
+ import { fg } from '@atlaskit/platform-feature-flags';
3
4
  import { fetchErrorPayload, fetchSuccessPayload } from '../utils/errorHandling';
4
5
  import { resolveSyncBlockInstance } from '../utils/resolveSyncBlockInstance';
5
6
  import { getSourceProductFromResourceIdSafe } from '../utils/utils';
@@ -9,6 +10,7 @@ import { getSourceProductFromResourceIdSafe } from '../utils/utils';
9
10
  * and provides a listener API so React components can react when the set
10
11
  * of subscribed resource IDs changes.
11
12
  */
13
+
12
14
  export class SyncBlockSubscriptionManager {
13
15
  constructor(deps) {
14
16
  _defineProperty(this, "subscriptions", new Map());
@@ -85,8 +87,13 @@ export class SyncBlockSubscriptionManager {
85
87
  // This handles the case where a block is moved - the old component unmounts
86
88
  // (scheduling deletion) but the new component mounts and subscribes before
87
89
  // the deletion timeout fires.
90
+ //
91
+ // Under the flag, cache deletion is owned by the store manager.
92
+ // With the flag off, the legacy 1s timer path is preserved.
88
93
  const pendingDeletion = this.pendingCacheDeletions.get(resourceId);
89
- if (pendingDeletion) {
94
+ if (fg('platform_synced_block_patch_14')) {
95
+ this.deps.cancelPendingCacheDeletion(resourceId);
96
+ } else if (pendingDeletion) {
90
97
  clearTimeout(pendingDeletion);
91
98
  this.pendingCacheDeletions.delete(resourceId);
92
99
  }
@@ -123,7 +130,8 @@ export class SyncBlockSubscriptionManager {
123
130
  // Unsubscription means a reference synced block is removed from the document
124
131
  this.deps.markCacheDirty();
125
132
  delete resourceSubscriptions[localId];
126
- if (Object.keys(resourceSubscriptions).length === 0) {
133
+ const remainingIds = Object.keys(resourceSubscriptions);
134
+ if (remainingIds.length === 0) {
127
135
  this.subscriptions.delete(resourceId);
128
136
 
129
137
  // Clean up GraphQL subscription when no more local subscribers
@@ -132,19 +140,30 @@ export class SyncBlockSubscriptionManager {
132
140
  // Notify listeners that subscription was removed
133
141
  this.notifySubscriptionChangeListeners();
134
142
 
135
- // Delay cache deletion to handle block moves (unmount/remount).
136
- // When a block is moved, the old component unmounts before the new one mounts.
137
- // By delaying deletion, we give the new component time to subscribe and
138
- // cancel this pending deletion, preserving the cached data.
139
- // TODO: EDITOR-4152 - Rework this logic
140
- const deletionTimeout = setTimeout(() => {
141
- // Only delete if still no subscribers (wasn't re-subscribed)
142
- if (!this.subscriptions.has(resourceId)) {
143
- this.deps.deleteFromCache(resourceId);
144
- }
145
- this.pendingCacheDeletions.delete(resourceId);
146
- }, 1000);
147
- this.pendingCacheDeletions.set(resourceId, deletionTimeout);
143
+ // Under the flag, delegate cache deletion to the store manager
144
+ // which uses a 30s grace period with guard re-checks.
145
+ if (fg('platform_synced_block_patch_14')) {
146
+ this.deps.scheduleCacheDeletion(resourceId);
147
+ } else {
148
+ // Legacy path (unchanged): delay cache deletion to handle
149
+ // block moves (unmount/remount). When a block is moved, the
150
+ // old component unmounts before the new one mounts. By
151
+ // delaying deletion, we give the new component time to
152
+ // subscribe and cancel this pending deletion, preserving
153
+ // the cached data.
154
+ // TODO: EDITOR-4152 - Rework this logic (superseded by
155
+ // `platform_synced_block_patch_14`).
156
+ const deletionTimeout = setTimeout(() => {
157
+ const hasSubscribers = this.subscriptions.has(resourceId);
158
+
159
+ // Only delete if still no subscribers (wasn't re-subscribed)
160
+ if (!hasSubscribers) {
161
+ this.deps.deleteFromCache(resourceId);
162
+ }
163
+ this.pendingCacheDeletions.delete(resourceId);
164
+ }, 1000);
165
+ this.pendingCacheDeletions.set(resourceId, deletionTimeout);
166
+ }
148
167
  } else {
149
168
  this.subscriptions.set(resourceId, resourceSubscriptions);
150
169
  }
@@ -35,6 +35,52 @@ export const updateReferenceErrorPayload = (error, resourceId, sourceProduct) =>
35
35
  export const createErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, error, resourceId, sourceProduct);
36
36
  export const deleteErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_DELETE, error, resourceId, sourceProduct);
37
37
  export const updateCacheErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE_CACHE, error, resourceId, sourceProduct);
38
+ /**
39
+ * Payload for `SYNCED_BLOCK_SOURCE_INFO_ORPHANED`. Fired when source-info
40
+ * resolves into a cache that has already been deleted — should be unreachable
41
+ * under `platform_synced_block_patch_14`.
42
+ */
43
+ export const sourceInfoOrphanedPayload = (resourceId, sourceProduct, context) => ({
44
+ action: ACTION.ERROR,
45
+ actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
46
+ actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_SOURCE_INFO_ORPHANED,
47
+ eventType: EVENT_TYPE.OPERATIONAL,
48
+ attributes: {
49
+ ...(resourceId && {
50
+ resourceId
51
+ }),
52
+ ...(sourceProduct && {
53
+ sourceProduct
54
+ }),
55
+ ...((context === null || context === void 0 ? void 0 : context.hasPendingDeletion) !== undefined && {
56
+ hasPendingDeletion: context.hasPendingDeletion
57
+ }),
58
+ ...((context === null || context === void 0 ? void 0 : context.hasSubscribers) !== undefined && {
59
+ hasSubscribers: context.hasSubscribers
60
+ })
61
+ }
62
+ });
63
+
64
+ /**
65
+ * Payload for `SYNCED_BLOCK_CACHE_DELETION_FORCED`. Fired when the cache
66
+ * deletion timer has been rescheduled `MAX_RESCHEDULE_COUNT` times and we force
67
+ * the deletion to avoid leaking memory. Indicates a stuck in-flight flag.
68
+ */
69
+ export const cacheDeletionForcedPayload = (rescheduleCount, resourceId, sourceProduct) => ({
70
+ action: ACTION.ERROR,
71
+ actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
72
+ actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_CACHE_DELETION_FORCED,
73
+ eventType: EVENT_TYPE.OPERATIONAL,
74
+ attributes: {
75
+ rescheduleCount,
76
+ ...(resourceId && {
77
+ resourceId
78
+ }),
79
+ ...(sourceProduct && {
80
+ sourceProduct
81
+ })
82
+ }
83
+ });
38
84
  export const fetchReferencesErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH_REFERENCES, error, resourceId, sourceProduct);
39
85
 
40
86
  // Success payloads