@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.
- package/CHANGELOG.md +22 -0
- package/dist/cjs/store-manager/referenceSyncBlockStoreManager.js +207 -25
- package/dist/cjs/store-manager/syncBlockBatchFetcher.js +35 -0
- package/dist/cjs/store-manager/syncBlockStoreManager.js +19 -0
- package/dist/cjs/store-manager/syncBlockSubscriptionManager.js +33 -15
- package/dist/cjs/utils/errorHandling.js +44 -1
- package/dist/es2019/store-manager/referenceSyncBlockStoreManager.js +180 -15
- package/dist/es2019/store-manager/syncBlockBatchFetcher.js +29 -0
- package/dist/es2019/store-manager/syncBlockStoreManager.js +21 -1
- package/dist/es2019/store-manager/syncBlockSubscriptionManager.js +34 -15
- package/dist/es2019/utils/errorHandling.js +46 -0
- package/dist/esm/store-manager/referenceSyncBlockStoreManager.js +208 -26
- package/dist/esm/store-manager/syncBlockBatchFetcher.js +35 -0
- package/dist/esm/store-manager/syncBlockStoreManager.js +21 -1
- package/dist/esm/store-manager/syncBlockSubscriptionManager.js +34 -15
- package/dist/esm/utils/errorHandling.js +43 -0
- package/dist/types/store-manager/referenceSyncBlockStoreManager.d.ts +25 -0
- package/dist/types/store-manager/syncBlockBatchFetcher.d.ts +8 -0
- package/dist/types/store-manager/syncBlockSubscriptionManager.d.ts +4 -0
- package/dist/types/utils/errorHandling.d.ts +15 -0
- package/dist/types-ts4.5/store-manager/referenceSyncBlockStoreManager.d.ts +25 -0
- package/dist/types-ts4.5/store-manager/syncBlockBatchFetcher.d.ts +8 -0
- package/dist/types-ts4.5/store-manager/syncBlockSubscriptionManager.d.ts +4 -0
- package/dist/types-ts4.5/utils/errorHandling.d.ts +15 -0
- 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$
|
|
781
|
+
var _this$fireAnalyticsEv11;
|
|
632
782
|
logException(error, {
|
|
633
783
|
location: 'editor-synced-block-provider/referenceSyncBlockStoreManager'
|
|
634
784
|
});
|
|
635
|
-
(_this$
|
|
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
|
|
647
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|