@atlaskit/editor-synced-block-provider 8.6.0 → 8.6.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.
@@ -1,4 +1,6 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import { bind } from 'bind-event-listener';
3
+ import { getDocument } from '@atlaskit/browser-apis';
2
4
  import { logException } from '@atlaskit/editor-common/monitoring';
3
5
  import { fg } from '@atlaskit/platform-feature-flags';
4
6
  import { buildFetchErrorAttribution, fetchErrorPayload, fetchSuccessPayload } from '../utils/errorHandling';
@@ -45,6 +47,22 @@ export class SyncBlockSubscriptionManager {
45
47
  // backoff cap (gate ON)
46
48
  _defineProperty(this, "retryAttempts", new Map());
47
49
  _defineProperty(this, "pendingRetries", new Map());
50
+ // Resources whose reconnection exhausted while the tab was hidden: parked here and
51
+ // re-armed on the next online / visibilitychange→visible instead of surfacing a
52
+ // terminal error. Only a re-armed attempt that also exhausts while visible fails.
53
+ _defineProperty(this, "deferredExhausted", new Set());
54
+ _defineProperty(this, "wakeDebounceTimer", null);
55
+ // `online` always sweeps (network is back regardless of tab visibility); a
56
+ // `visibilitychange` only sweeps when the tab became visible.
57
+ _defineProperty(this, "onOnline", () => this.scheduleWakeSweep());
58
+ _defineProperty(this, "onVisibilityChange", () => {
59
+ if (!this.isDocumentHidden()) {
60
+ this.scheduleWakeSweep();
61
+ }
62
+ });
63
+ // Cleanup fns returned by `bind` for the registered wake listeners; a non-empty
64
+ // array means listeners are currently registered. Emptied on teardown.
65
+ _defineProperty(this, "wakeListenerCleanups", []);
48
66
  this.deps = deps;
49
67
  }
50
68
 
@@ -306,14 +324,26 @@ export class SyncBlockSubscriptionManager {
306
324
  const attempts = (_this$retryAttempts$g = this.retryAttempts.get(resourceId)) !== null && _this$retryAttempts$g !== void 0 ? _this$retryAttempts$g : 0;
307
325
  const maxAttempts = this.getMaxRetryAttempts();
308
326
  if (attempts >= maxAttempts) {
309
- var _this$deps$getFireAna3;
327
+ var _this$deps$getFireAna4;
310
328
  // Exhausted all attempts — the only place a WS drop surfaces as a
311
329
  // fetch error under the gate (EDITOR-7861).
312
330
  const errorMessage = `Subscription reconnection failed after ${attempts} attempts`;
331
+
332
+ // Tab hidden at exhaustion: don't surface a terminal failure (user isn't
333
+ // looking, and most exhaustions self-recover once foregrounded). Park + re-arm
334
+ // on wake, emitting a benign `deferred` signal so suppression stays auditable.
335
+ const shouldDefer = fg('platform_editor_blocks_patch_3') && this.isDocumentHidden();
336
+ if (shouldDefer) {
337
+ var _this$deps$getFireAna3;
338
+ this.deferredExhausted.add(resourceId);
339
+ this.registerWakeListeners();
340
+ (_this$deps$getFireAna3 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna3 === void 0 ? void 0 : _this$deps$getFireAna3(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(true, errorMessage, undefined, /* deferred */true)));
341
+ return;
342
+ }
313
343
  logException(new Error(errorMessage), {
314
344
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/max-retries-exhausted'
315
345
  });
316
- (_this$deps$getFireAna3 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna3 === void 0 ? void 0 : _this$deps$getFireAna3(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), errorMessage)));
346
+ (_this$deps$getFireAna4 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna4 === void 0 ? void 0 : _this$deps$getFireAna4(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), errorMessage)));
317
347
  return;
318
348
  }
319
349
  const delay = this.getReconnectionDelay(attempts);
@@ -344,6 +374,85 @@ export class SyncBlockSubscriptionManager {
344
374
  resetRetryCount(resourceId) {
345
375
  this.retryAttempts.delete(resourceId);
346
376
  }
377
+
378
+ /**
379
+ * Whether the tab is currently hidden. SSR/non-browser is treated as "not hidden" so
380
+ * we never defer where wake events can't fire (realtime never subscribes there anyway).
381
+ */
382
+ isDocumentHidden() {
383
+ var _getDocument;
384
+ return ((_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.hidden) === true;
385
+ }
386
+
387
+ /**
388
+ * Lazily register shared `online` / `visibilitychange` listeners on first deferral.
389
+ * Torn down by `unregisterWakeListeners` (via `handleWake`). No-op in SSR / non-browser.
390
+ */
391
+ registerWakeListeners() {
392
+ if (this.wakeListenerCleanups.length > 0 || typeof window === 'undefined') {
393
+ return;
394
+ }
395
+ this.wakeListenerCleanups.push(bind(window, {
396
+ type: 'online',
397
+ listener: this.onOnline
398
+ }));
399
+ const doc = getDocument();
400
+ if (doc) {
401
+ this.wakeListenerCleanups.push(bind(doc, {
402
+ type: 'visibilitychange',
403
+ listener: this.onVisibilityChange
404
+ }));
405
+ }
406
+ }
407
+ unregisterWakeListeners() {
408
+ if (this.wakeDebounceTimer !== null) {
409
+ clearTimeout(this.wakeDebounceTimer);
410
+ this.wakeDebounceTimer = null;
411
+ }
412
+ for (const cleanup of this.wakeListenerCleanups) {
413
+ cleanup();
414
+ }
415
+ this.wakeListenerCleanups = [];
416
+ }
417
+
418
+ /**
419
+ * Coalesce a burst of wake events into one debounced re-arm sweep. Callers decide
420
+ * eligibility: `onOnline` always sweeps; `onVisibilityChange` only sweeps when visible.
421
+ */
422
+ scheduleWakeSweep() {
423
+ if (this.wakeDebounceTimer !== null) {
424
+ return;
425
+ }
426
+ this.wakeDebounceTimer = setTimeout(() => {
427
+ this.wakeDebounceTimer = null;
428
+ this.handleWake();
429
+ }, SyncBlockSubscriptionManager.WAKE_DEBOUNCE_MS);
430
+ }
431
+
432
+ /**
433
+ * Re-arm every deferred resource: full reset (attempts→0) + immediate reconnect now
434
+ * the tab is visible / online. Healthy subscriptions are untouched. A re-armed cycle
435
+ * that later exhausts while visible fires the terminal error normally.
436
+ */
437
+ handleWake() {
438
+ if (this.deferredExhausted.size === 0) {
439
+ this.unregisterWakeListeners();
440
+ return;
441
+ }
442
+ const toRearm = Array.from(this.deferredExhausted);
443
+ this.deferredExhausted.clear();
444
+ for (const resourceId of toRearm) {
445
+ // Only re-arm resources that still have active subscribers and realtime on.
446
+ if (!this.subscriptions.has(resourceId) || !this.shouldUseRealTime()) {
447
+ continue;
448
+ }
449
+ this.resetRetryCount(resourceId);
450
+ this.cancelPendingRetry(resourceId);
451
+ this.setupSubscription(resourceId);
452
+ }
453
+ // No deferred resources remain until another hidden exhaustion re-adds one.
454
+ this.unregisterWakeListeners();
455
+ }
347
456
  cancelPendingRetry(resourceId) {
348
457
  const timer = this.pendingRetries.get(resourceId);
349
458
  if (timer) {
@@ -357,6 +466,9 @@ export class SyncBlockSubscriptionManager {
357
466
  }
358
467
  this.pendingRetries.clear();
359
468
  this.retryAttempts.clear();
469
+ // Nothing left to re-arm: drop parked exhaustions and tear down wake listeners.
470
+ this.deferredExhausted.clear();
471
+ this.unregisterWakeListeners();
360
472
  }
361
473
  cleanupSubscription(resourceId) {
362
474
  const unsubscribe = this.graphqlSubscriptions.get(resourceId);
@@ -366,6 +478,11 @@ export class SyncBlockSubscriptionManager {
366
478
  }
367
479
  this.cancelPendingRetry(resourceId);
368
480
  this.retryAttempts.delete(resourceId);
481
+ // No subscribers left, so this resource can't be re-armed.
482
+ this.deferredExhausted.delete(resourceId);
483
+ if (this.deferredExhausted.size === 0) {
484
+ this.unregisterWakeListeners();
485
+ }
369
486
  }
370
487
  setupSubscriptionsForAllBlocks() {
371
488
  for (const resourceId of this.subscriptions.keys()) {
@@ -404,21 +521,28 @@ export class SyncBlockSubscriptionManager {
404
521
  this.deps.updateCache(resolved);
405
522
  if (!syncBlockInstance.error) {
406
523
  this.resetRetryCount(syncBlockInstance.resourceId);
524
+ // A healthy update means a parked exhaustion recovered on its own before any
525
+ // wake sweep — drop it and tear down listeners if nothing else is parked.
526
+ if (this.deferredExhausted.delete(syncBlockInstance.resourceId)) {
527
+ if (this.deferredExhausted.size === 0) {
528
+ this.unregisterWakeListeners();
529
+ }
530
+ }
407
531
  const callbacks = this.subscriptions.get(syncBlockInstance.resourceId);
408
532
  const localIds = callbacks ? Object.keys(callbacks) : [];
409
533
  localIds.forEach(localId => {
410
- var _this$deps$getFireAna4, _syncBlockInstance$da, _syncBlockInstance$da2;
411
- (_this$deps$getFireAna4 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna4 === void 0 ? void 0 : _this$deps$getFireAna4(fetchSuccessPayload(syncBlockInstance.resourceId, localId, (_syncBlockInstance$da = (_syncBlockInstance$da2 = syncBlockInstance.data) === null || _syncBlockInstance$da2 === void 0 ? void 0 : _syncBlockInstance$da2.product) !== null && _syncBlockInstance$da !== void 0 ? _syncBlockInstance$da : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId)));
534
+ var _this$deps$getFireAna5, _syncBlockInstance$da, _syncBlockInstance$da2;
535
+ (_this$deps$getFireAna5 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna5 === void 0 ? void 0 : _this$deps$getFireAna5(fetchSuccessPayload(syncBlockInstance.resourceId, localId, (_syncBlockInstance$da = (_syncBlockInstance$da2 = syncBlockInstance.data) === null || _syncBlockInstance$da2 === void 0 ? void 0 : _syncBlockInstance$da2.product) !== null && _syncBlockInstance$da !== void 0 ? _syncBlockInstance$da : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId)));
412
536
  });
413
537
  this.deps.fetchSyncBlockSourceInfo(resolved.resourceId);
414
538
  } else {
415
- var _syncBlockInstance$er, _syncBlockInstance$er2, _this$deps$getFireAna5, _syncBlockInstance$da3, _syncBlockInstance$da4, _syncBlockInstance$er3, _syncBlockInstance$er4, _syncBlockInstance$er5;
539
+ var _syncBlockInstance$er, _syncBlockInstance$er2, _this$deps$getFireAna6, _syncBlockInstance$da3, _syncBlockInstance$da4, _syncBlockInstance$er3, _syncBlockInstance$er4, _syncBlockInstance$er5;
416
540
  const errorMessage = ((_syncBlockInstance$er = syncBlockInstance.error) === null || _syncBlockInstance$er === void 0 ? void 0 : _syncBlockInstance$er.reason) || ((_syncBlockInstance$er2 = syncBlockInstance.error) === null || _syncBlockInstance$er2 === void 0 ? void 0 : _syncBlockInstance$er2.type);
417
541
 
418
542
  // Prefer the structured `type` (a `SyncBlockError` enum value) for classification
419
543
  // and fall back to the free-text `reason` so source-state/permission strings are
420
544
  // still bucketed (EDITOR-7862). The emitted free-text `error` attribute is unchanged.
421
- (_this$deps$getFireAna5 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna5 === void 0 ? void 0 : _this$deps$getFireAna5(fetchErrorPayload(errorMessage, syncBlockInstance.resourceId, (_syncBlockInstance$da3 = (_syncBlockInstance$da4 = syncBlockInstance.data) === null || _syncBlockInstance$da4 === void 0 ? void 0 : _syncBlockInstance$da4.product) !== null && _syncBlockInstance$da3 !== void 0 ? _syncBlockInstance$da3 : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), ((_syncBlockInstance$er3 = syncBlockInstance.error) === null || _syncBlockInstance$er3 === void 0 ? void 0 : _syncBlockInstance$er3.type) || ((_syncBlockInstance$er4 = syncBlockInstance.error) === null || _syncBlockInstance$er4 === void 0 ? void 0 : _syncBlockInstance$er4.reason), (_syncBlockInstance$er5 = syncBlockInstance.error) === null || _syncBlockInstance$er5 === void 0 ? void 0 : _syncBlockInstance$er5.statusCode)));
545
+ (_this$deps$getFireAna6 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna6 === void 0 ? void 0 : _this$deps$getFireAna6(fetchErrorPayload(errorMessage, syncBlockInstance.resourceId, (_syncBlockInstance$da3 = (_syncBlockInstance$da4 = syncBlockInstance.data) === null || _syncBlockInstance$da4 === void 0 ? void 0 : _syncBlockInstance$da4.product) !== null && _syncBlockInstance$da3 !== void 0 ? _syncBlockInstance$da3 : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), ((_syncBlockInstance$er3 = syncBlockInstance.error) === null || _syncBlockInstance$er3 === void 0 ? void 0 : _syncBlockInstance$er3.type) || ((_syncBlockInstance$er4 = syncBlockInstance.error) === null || _syncBlockInstance$er4 === void 0 ? void 0 : _syncBlockInstance$er4.reason), (_syncBlockInstance$er5 = syncBlockInstance.error) === null || _syncBlockInstance$er5 === void 0 ? void 0 : _syncBlockInstance$er5.statusCode)));
422
546
  }
423
547
  }
424
548
  }
@@ -429,4 +553,6 @@ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS", 5);
429
553
  // legacy (gate OFF)
430
554
  _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS_HARDENED", 8);
431
555
  // gate ON (EDITOR-7861)
432
- _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_DELAY_MS", 30000);
556
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_DELAY_MS", 30000);
557
+ // Coalesce wake-event bursts into one re-arm sweep to avoid a reconnection storm.
558
+ _defineProperty(SyncBlockSubscriptionManager, "WAKE_DEBOUNCE_MS", 1000);
@@ -188,16 +188,20 @@ export const classifyFetchErrorReason = error => {
188
188
  * `gateEnabled` is injected by the caller (the store managers evaluate `fg(...)`) so this
189
189
  * helper stays pure and trivially unit-testable for both gate states.
190
190
  */
191
- export const buildFetchErrorAttribution = (gateEnabled, error, statusCode) => {
191
+ export const buildFetchErrorAttribution = (gateEnabled, error, statusCode, deferred) => {
192
192
  if (!gateEnabled) {
193
193
  return undefined;
194
194
  }
195
195
  const reason = classifyFetchErrorReason(error);
196
196
  return {
197
197
  reason,
198
- benign: FETCH_BENIGN_REASONS.has(reason),
198
+ // A deferred (tab-hidden) exhaustion is benign regardless of reason bucket.
199
+ benign: deferred === true || FETCH_BENIGN_REASONS.has(reason),
199
200
  ...(statusCode !== undefined && {
200
201
  statusCode
202
+ }),
203
+ ...(deferred === true && {
204
+ deferred: true
201
205
  })
202
206
  };
203
207
  };
@@ -246,6 +250,11 @@ export function getErrorPayload(actionSubjectId, error, resourceId, sourceProduc
246
250
  // `attribution.benign` is accessible.
247
251
  ...(attribution && 'benign' in attribution && {
248
252
  benign: attribution.benign
253
+ }),
254
+ // `deferred` is only present on fetch/subscribe attribution, and only when a
255
+ // tab-hidden exhaustion was deferred rather than surfaced. Boolean, never UGC.
256
+ ...(attribution && 'deferred' in attribution && attribution.deferred === true && {
257
+ deferred: true
249
258
  })
250
259
  }
251
260
  };
@@ -1013,7 +1013,7 @@ export var ReferenceSyncBlockStoreManager = /*#__PURE__*/function () {
1013
1013
  _context4.next = 1;
1014
1014
  break;
1015
1015
  }
1016
- return _context4.abrupt("return", fg('platform_editor_blocks_patch_2'));
1016
+ return _context4.abrupt("return", true);
1017
1017
  case 1:
1018
1018
  if (this.isCacheDirty) {
1019
1019
  _context4.next = 2;