super-productivity/packages/plugin-dev/doc-mode/src/background.ts
Johannes Millan 2580ea6eb1
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality

Closes #7815.

flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.

The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.

Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.

Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).

* refactor(plugins): apply doc-mode no-op-save review findings (#7815)

Multi-review follow-ups for #7815:

- Tighten serializeContextDoc determinism test: previously asserted
  equality between a doc and its `{...doc}` spread, which preserves V8
  insertion order — the test would pass even against a future encoder
  that randomised iteration over Map-backed nodes (the real failure
  mode for the byte-compare). Now asserts repeated-call determinism on
  the same input directly, plus type+non-empty shape so a future return-
  type change (e.g. Uint8Array) breaks here, not in the editor.

- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
  lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
  AFTER `await`. Both are correct (the sync path is fire-and-forget and
  the iframe can be torn down mid-call, so a post-dispatch stamp would
  silently drop on teardown) but the divergence wasn't called out in
  the inline comments.

* refactor(plugins): rename document-mode to doc-mode

Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.

Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
  plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry

Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).

The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.

* refactor(plugins): apply doc-mode rename review findings

Multi-review follow-ups for the rename in 3443bcacd0:

- editor.ts:1920: missed runtime log string ('Document mode:' →
  'doc-mode:') — leaked into the dev console with inconsistent brand
  because the rename sweep matched on the kebab `document-mode` or the
  PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
  `Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
  only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
  `'DocModePlugin'`. Internal global, no consumers, but worth keeping
  consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
  BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
  IDB / op-log / sync wire and must be treated as permanent for any
  plugin that has ever shipped. Records the rule we relied on being
  absent for this rename.

DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
2026-05-27 17:30:38 +02:00

129 lines
4.1 KiB
TypeScript

/**
* Doc-Mode background script. Runs once per plugin load in the host
* page context. Responsible for:
* - Running the legacy → keyed persistence migration on first load
* - Registering the work-context header button
* - Tracking which contexts have document mode enabled
* - Auto-showing / -closing the work-view embed on context navigation
*
* The TipTap editor itself lives in the iframe (src/ui/editor.ts). Each
* persisted entity is now keyed independently (Stage A):
* - `meta` — enabledCtxIds (owned by this script)
* - `doc:${ctxId}` — one entry per context (owned by the editor iframe)
* - `__meta__` — migration stamp
*
* Splitting by entity means a concurrent toggle on one device and an edit
* on another no longer LWW-collide at the blob level.
*/
import {
PluginHooks,
type ActiveWorkContext,
type PluginAPI,
type WorkContextChangePayload,
} from '@super-productivity/plugin-api';
import {
loadEnabledCtxIds,
migrateToKeyedPersistence,
saveEnabledCtxIds,
} from './persistence';
import { reconcileEnabledIds } from './reconcile-enabled';
declare const PluginAPI: PluginAPI;
/**
* Read-modify-write helper. Re-reads the meta entry before mutating so we
* don't overwrite a concurrent toggle that landed since our last read. The
* editor iframe owns `doc:${ctxId}` entries; this script touches only `meta`.
*/
const updateEnabledCtxIds = async (
mutate: (ids: string[]) => string[],
): Promise<string[]> => {
const current = await loadEnabledCtxIds(PluginAPI);
const next = mutate(current);
await saveEnabledCtxIds(PluginAPI, next);
return next;
};
let enabledIds = new Set<string>();
const init = async (): Promise<void> => {
// Migration is idempotent and guarded by a stamp — safe to call on every
// load. Must run before the first keyed read or we'd see "no data" for
// every existing user.
try {
await migrateToKeyedPersistence(PluginAPI);
} catch (err) {
PluginAPI.log.err('doc-mode: migration failed', err);
// Continue anyway: a partial migration leaves the data in place
// (corruption guard in persistence.ts) and the next session will retry.
}
enabledIds = new Set(await loadEnabledCtxIds(PluginAPI));
// If the active context is already enabled, mount the embed immediately
// so the user sees doc mode on app start without an extra click.
const ctx = await PluginAPI.getActiveWorkContext();
if (ctx && enabledIds.has(ctx.id)) {
PluginAPI.showInWorkContext();
}
};
const onContextChange = (ctx: WorkContextChangePayload): void => {
if (ctx && enabledIds.has(ctx.id)) {
PluginAPI.showInWorkContext();
} else {
PluginAPI.closeWorkContextView();
}
};
const onButtonClick = async (ctx: ActiveWorkContext): Promise<void> => {
const wasEnabled = enabledIds.has(ctx.id);
if (wasEnabled) {
enabledIds.delete(ctx.id);
PluginAPI.closeWorkContextView();
} else {
enabledIds.add(ctx.id);
PluginAPI.showInWorkContext();
}
try {
await updateEnabledCtxIds((ids) => {
const set = new Set(ids);
if (wasEnabled) set.delete(ctx.id);
else set.add(ctx.id);
return [...set];
});
} catch (err) {
PluginAPI.log.err('doc-mode: failed to persist toggle', err);
}
};
PluginAPI.registerWorkContextHeaderButton({
label: 'Doc Mode',
icon: 'description',
showFor: ['PROJECT', 'TODAY'],
onClick: (ctx) => {
void onButtonClick(ctx);
},
});
const onPersistedDataChanged = async (): Promise<void> => {
// Snapshot `enabledIds` at entry so two interleaved fires can't race on
// the closure read between awaits inside `reconcileEnabledIds`.
const { next, action } = await reconcileEnabledIds(PluginAPI, enabledIds);
enabledIds = next;
if (action === 'show') {
PluginAPI.showInWorkContext();
} else if (action === 'close') {
PluginAPI.closeWorkContextView();
}
};
PluginAPI.registerHook(PluginHooks.WORK_CONTEXT_CHANGE, (payload) => {
onContextChange(payload as WorkContextChangePayload);
});
PluginAPI.registerHook(PluginHooks.PERSISTED_DATA_CHANGED, () => {
void onPersistedDataChanged();
});
void init();