UNPKG

@atlaskit/editor-plugin-limited-mode

Version:

LimitedMode plugin for @atlaskit/editor-core

209 lines (198 loc) • 9.36 kB
import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { shouldEnableLimitedModeForDocument } from '@atlaskit/editor-common/should-enable-limited-mode'; import { expVal } from '@atlaskit/platform-feature-experiments/exp-val'; import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled'; import { limitedModePluginKey } from './plugin-key'; import { createLatchDetector } from './utils/latch-detector'; import { LatchPolicy } from './utils/latch-policy'; /** * Meta shape used to latch limited mode at runtime. Dispatching this is the entire delivery * mechanism: the transaction flows through `SharedStateAPI.notifyListeners`, which diffs the plugin's * shared state and notifies every consumer. No plugin re-registration and no schema rebuild. */ /** * Hardware hints, reported with a latch so it can be correlated with device class. * * Telemetry only — the policy no longer takes the hardware into account when deciding, so this * exists to answer "which devices are latching" from the data rather than by assumption. Both hints * are optional (`deviceMemory` is Chromium-only) and are simply absent where unsupported. */ const getDeviceHints = () => { if (typeof navigator === 'undefined') { return {}; } const { hardwareConcurrency, deviceMemory } = navigator; return { hardwareConcurrency, deviceMemoryGb: deviceMemory }; }; /** Control-arm state: the document decision alone, exactly as before the experiment. */ const documentOnlyState = doc => { const documentSizeBreachesThreshold = shouldEnableLimitedModeForDocument(doc); return { documentSizeBreachesThreshold, latchPolicyBreached: false, enabled: documentSizeBreachesThreshold }; }; /** Treatment-arm state: whatever the policy says, for both of its reasons. */ const policyState = policy => ({ documentSizeBreachesThreshold: policy.isDocumentBreached(), latchPolicyBreached: policy.isBreached(), enabled: policy.isBreached() }); export const createPlugin = (api, injectedPolicy) => { let detector; let policy; if (injectedPolicy) { policy = injectedPolicy; } else if (isExperimentEnabled('platform_editor_dynamic_limited_mode')) { const config = expVal('platform_editor_dynamic_limited_mode', 'policyConfig', { warmUpMs: 10_000, slowInputMs: 100, latencyWindowSize: 12, latencySlowSamplesRequired: 6, freezeTaskMs: 600, freezeTasksRequired: 3, freezeWindowMs: 30_000, requiredConfirmations: 2, confirmationGapMs: 30_000, bulkChangeNodeSize: 100, bulkChangeSuppressionMs: 2_000, docSizeThreshold: 750_000, nodeCountThreshold: 5_000 }); // Resolved once per editor rather than per transaction. When off, no policy is built and the // document decision runs inline exactly as it did before this experiment. policy = new LatchPolicy({ now: () => performance.now(), config }); } return new SafePlugin({ key: limitedModePluginKey, props: { handleTextInput() { var _detector; (_detector = detector) === null || _detector === void 0 ? void 0 : _detector.measureInput(); // Never handle the input — this is measurement only. return false; } }, view: editorView => { // No policy means the experiment is off: no observers, no per-keystroke measurement. if (!policy) { return {}; } const startedAt = performance.now(); detector = createLatchDetector({ policy, onLatchCriteriaMet: details => { var _api$analytics; // Treatment arm only — the detector does not exist in control. See LimitedModeLatchedAEP. // // `details` is the policy's own snapshot of what it latched on, taken before it cleared // its evidence buffers, so it reports the closing window rather than an empty one. api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({ action: ACTION.LIMITED_MODE_LATCHED, actionSubject: ACTION_SUBJECT.EDITOR, eventType: EVENT_TYPE.OPERATIONAL, attributes: { latched: true, reason: details.reason, firstWindowReason: details.firstWindowReason, requiredConfirmations: details.requiredConfirmations, documentAlreadyBreached: details.documentAlreadyBreached, msFromFirstWindow: details.msFromFirstWindow, latencyMedianMs: details.latencyMedianMs, totalInputSamples: details.totalInputSamples, totalSlowInputs: details.totalSlowInputs, totalFreezes: details.totalFreezes, nodeSize: editorView.state.doc.nodeSize, // Read at latch time rather than at construction: the hints are static, and this // keeps every device fact in one place next to the decision it is compared against. ...getDeviceHints(), // Measured from the plugin view starting, which is a little later than the policy's // own `timeToLatchMs` (it starts at plugin construction). timeToLatch: performance.now() - startedAt } }); // The policy already holds the latch; this transaction only prompts the plugin to // re-read it, which is what notifies every consumer through shared state. editorView.dispatch(editorView.state.tr.setMeta(limitedModePluginKey, { latchPolicyBreached: true })); } }); return { destroy: () => { var _detector2; (_detector2 = detector) === null || _detector2 === void 0 ? void 0 : _detector2.destroy(); detector = undefined; } }; }, state: { init(_config, editorState) { if (!policy) { return documentOnlyState(editorState.doc); } policy.evaluateDocument(editorState.doc); return policyState(policy); }, apply: (tr, currentPluginState, oldState, _newState) => { var _tr$getMeta; const documentReplaced = Boolean(tr.getMeta('replaceDocument')); if (!policy) { // Control arm, unchanged: skip the traversal once already breached, but always re-check // when the document is replaced (e.g. live-to-live page navigation). if (currentPluginState.documentSizeBreachesThreshold && !documentReplaced) { return currentPluginState; } return documentOnlyState(tr.doc); } // The detector's latch arrives as a transaction so that plugin state stays a function of // the transaction stream rather than of when `apply` happens to read the policy. Dev // tooling dispatches the same meta to force limited mode by hand. if ((_tr$getMeta = tr.getMeta(limitedModePluginKey)) !== null && _tr$getMeta !== void 0 && _tr$getMeta.latchPolicyBreached) { policy.latch(); } // Only on replacement, never on an ordinary edit: the check walks the whole document, so // running it per transaction is a full-document scan on every keystroke. The trade-off is // that a document editing its way past the thresholds is not noticed until it next loads. // // Deliberately not skipped when limited mode is already on. Replacement is the one moment // the document verdict can go *down* — live-to-live navigation onto a smaller page — so // skipping it there is what would strand limited mode on forever. It costs one walk per // page navigation, which is nothing next to the navigation itself. if (documentReplaced) { policy.evaluateDocument(tr.doc); } // Report bulk work so the policy can discount it. The policy decides what counts as bulk; // this only supplies the facts. // // Known gap: operations that are expensive but barely change document size — table // resize, drag-and-drop moves (delete + insert nets to ~0), type-ahead — are not // suppressed. `@atlaskit/insm` already tracks exactly these via `startHeavyTask`, but its // public facade does not expose `runningHeavyTasks`, so there is no way to read them from // here today. Closing that gap needs an accessor on the insm package. if (tr.docChanged) { var _detector3; (_detector3 = detector) === null || _detector3 === void 0 ? void 0 : _detector3.noteDocumentChange({ nodeSizeDelta: tr.doc.nodeSize - oldState.doc.nodeSize, isDocumentReplaced: documentReplaced }); } const next = policyState(policy); // Keep the previous object when nothing changed, so shared-state diffing stays cheap and // consumers are not notified for no reason. return next.enabled === currentPluginState.enabled && next.documentSizeBreachesThreshold === currentPluginState.documentSizeBreachesThreshold ? currentPluginState : next; } } }); };