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.
This commit is contained in:
Johannes Millan 2026-05-27 17:30:38 +02:00 committed by GitHub
parent 18472e86b9
commit 2580ea6eb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 157 additions and 99 deletions

View file

@ -33,6 +33,6 @@ For all current downloads, package links, and platform-specific notes: [check th
### Plugins & Advanced
- Added plugin support for work-context header buttons, an embed slot, the WORK_CONTEXT_CHANGE hook, and iframe-only installs.
- Added a TipTap-based document-mode plugin and reduced redundant synced chip data.
- Added a TipTap-based doc-mode plugin and reduced redundant synced chip data.
- Added distribution-target suffixes to Electron version strings.
- Fixed SuperSync Caddy healthchecks, PostgreSQL connection headroom, Docker sync package inclusion, and the F-Droid build dependency issue.

View file

@ -5,9 +5,9 @@ import {
} from '../../helpers/plugin-test.helpers';
// Smoke test for the BUNDLED_PLUGIN_PATHS entry added in commit 199e816479.
// A typo or accidental removal of 'assets/bundled-plugins/document-mode'
// A typo or accidental removal of 'assets/bundled-plugins/doc-mode'
// would silently regress this; the test fails loudly instead.
test.describe('Document Mode bundled plugin', () => {
test.describe('Doc Mode bundled plugin', () => {
test('appears in plugin management list', async ({ page, workViewPage }) => {
test.setTimeout(60000);
@ -26,7 +26,7 @@ test.describe('Document Mode bundled plugin', () => {
const pluginReady = await waitForPluginManagementInit(page);
expect(pluginReady).toBeTruthy();
// Manifest declares the user-visible name as "Document Mode (Alpha)".
// Manifest declares the user-visible name as "Doc Mode (Alpha)".
// Match on the prefix so a future Alpha→Beta label change doesn't break.
const titles = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
@ -35,7 +35,7 @@ test.describe('Document Mode bundled plugin', () => {
.filter(Boolean);
});
const hasDocumentMode = titles.some((t) => t.startsWith('Document Mode'));
expect(hasDocumentMode).toBeTruthy();
const hasDocMode = titles.some((t) => t.startsWith('Doc Mode'));
expect(hasDocMode).toBeTruthy();
});
});

View file

@ -7,8 +7,8 @@ import {
/**
* End-to-end coverage of the Stage A keyed persistence migration in the
* bundled document-mode plugin. The migration logic is unit-tested against
* a mock PluginAPI (`packages/plugin-dev/document-mode/src/persistence.spec.ts`),
* bundled doc-mode plugin. The migration logic is unit-tested against
* a mock PluginAPI (`packages/plugin-dev/doc-mode/src/persistence.spec.ts`),
* but those tests can't catch real-iframe quirks: postMessage handling of
* `undefined` second args, commit-chain timing across the host's rate
* limiter, hydration ordering against the op-log.
@ -21,7 +21,7 @@ import {
* `doc:${ctxId}` keyed entries and tombstones the legacy id.
*/
const PLUGIN_ID = 'document-mode';
const PLUGIN_ID = 'doc-mode';
// Underscore-cased so the @typescript-eslint/naming-convention rule on
// object literal keys is happy. The keys are opaque context ids; their
// shape doesn't matter to the host.
@ -79,7 +79,7 @@ const findEntry = (
id: string,
): PluginUserDataEntry | undefined => entries?.find((e) => e.id === id);
test.describe('Document Mode Stage A migration', () => {
test.describe('Doc Mode Stage A migration', () => {
test('stamps migration success on a fresh install', async ({ page, workViewPage }) => {
test.setTimeout(60000);
@ -96,7 +96,7 @@ test.describe('Document Mode Stage A migration', () => {
const pluginReady = await waitForPluginManagementInit(page);
expect(pluginReady).toBeTruthy();
const enabled = await enablePluginWithVerification(page, 'Document Mode');
const enabled = await enablePluginWithVerification(page, 'Doc Mode');
expect(enabled).toBeTruthy();
// The migration runs once the plugin's background.js executes init().
@ -169,7 +169,7 @@ test.describe('Document Mode Stage A migration', () => {
const pluginReady = await waitForPluginManagementInit(page);
expect(pluginReady).toBeTruthy();
const enabled = await enablePluginWithVerification(page, 'Document Mode');
const enabled = await enablePluginWithVerification(page, 'Doc Mode');
expect(enabled).toBeTruthy();
// Wait for the migration to stamp success. The full sequence is:

View file

@ -1,11 +1,11 @@
{
"name": "document-mode",
"name": "doc-mode",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "document-mode",
"name": "doc-mode",
"version": "0.1.0",
"dependencies": {
"@super-productivity/plugin-api": "file:../../plugin-api",

View file

@ -1,5 +1,5 @@
{
"name": "document-mode",
"name": "doc-mode",
"version": "0.1.0",
"description": "Document-style editor for projects and Today using TipTap",
"private": true,

View file

@ -9,7 +9,7 @@ const SRC_DIR = path.join(ROOT_DIR, 'src');
const DIST_DIR = path.join(ROOT_DIR, 'dist');
async function buildPlugin() {
console.log('Building document-mode plugin with esbuild...');
console.log('Building doc-mode plugin with esbuild...');
if (fs.existsSync(DIST_DIR)) {
fs.rmSync(DIST_DIR, { recursive: true });
@ -25,7 +25,7 @@ async function buildPlugin() {
platform: 'browser',
target: 'es2020',
format: 'iife',
globalName: 'DocumentModePlugin',
globalName: 'DocModePlugin',
logLevel: 'info',
minify: true,
sourcemap: false,

View file

@ -13,7 +13,7 @@ const TARGET_DIR = path.join(
'src',
'assets',
'bundled-plugins',
'document-mode',
'doc-mode',
);
// editor.js is inlined into index.html, so it doesn't need to be deployed.

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */
/**
* Unit-test runner for the document-mode plugin.
* Unit-test runner for the doc-mode plugin.
*
* The plugin has no browser-based test setup its testable logic is the pure
* `doc-transform` module. This script transpiles every `*.spec.ts` under

View file

@ -1,5 +1,5 @@
/**
* Document-Mode background script. Runs once per plugin load in the host
* 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
@ -54,7 +54,7 @@ const init = async (): Promise<void> => {
try {
await migrateToKeyedPersistence(PluginAPI);
} catch (err) {
PluginAPI.log.err('document-mode: migration failed', 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.
}
@ -94,12 +94,12 @@ const onButtonClick = async (ctx: ActiveWorkContext): Promise<void> => {
return [...set];
});
} catch (err) {
PluginAPI.log.err('document-mode: failed to persist toggle', err);
PluginAPI.log.err('doc-mode: failed to persist toggle', err);
}
};
PluginAPI.registerWorkContextHeaderButton({
label: 'Document Mode',
label: 'Doc Mode',
icon: 'description',
showFor: ['PROJECT', 'TODAY'],
onClick: (ctx) => {

View file

@ -1,5 +1,5 @@
/**
* Pure document-transform helpers for Document Mode.
* Pure document-transform helpers for Doc Mode.
*
* Everything here operates on plain ProseMirror-JSON (`PMNode`) and a task
* lookup function no TipTap editor, no DOM, no module-global cache. That

View file

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 359 B

Before After
Before After

View file

@ -1,7 +1,7 @@
{
"id": "document-mode",
"id": "doc-mode",
"manifestVersion": 1,
"name": "Document Mode (Alpha)",
"name": "Doc Mode (Alpha)",
"version": "0.1.0",
"description": "Document-style editor for projects and Today, powered by TipTap.",
"author": "SuperProductivity",

View file

@ -12,8 +12,9 @@ import {
loadContextDoc,
loadEnabledCtxIds,
migrateToKeyedPersistence,
saveContextDoc,
persistContextDocRaw,
saveEnabledCtxIds,
serializeContextDoc,
} from './persistence';
/**
@ -84,7 +85,7 @@ test('loadContextDoc: returns {raw:null, parsed:null} when the entry is missing'
test('loadContextDoc: returns both the raw string and parsed value', async () => {
const { api } = createMockApi();
const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
await saveContextDoc(api, 'p1', doc);
await persistContextDocRaw(api, 'p1', serializeContextDoc(doc));
const loaded = await loadContextDoc(api, 'p1');
// raw is what the editor needs for the lastSeenDocBytes byte-compare (#7752)
assert.equal(loaded.raw, JSON.stringify(doc));
@ -117,14 +118,51 @@ test('loadContextDoc: primitive JSON values pass through parsed as the primitive
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: '"hi"', parsed: 'hi' });
});
test('saveContextDoc: writes under doc:<ctxId> and returns the raw string written', async () => {
test('persistContextDocRaw: writes the exact raw string under doc:<ctxId>', async () => {
const { api, store, writes } = createMockApi();
// Pre-computed raw (mirrors the flushSave path: serialize once, byte-compare,
// then persist). The helper must not re-stringify — that would defeat the
// self-echo baseline (#7752) and the no-op skip (#7815).
const raw = serializeContextDoc({ type: 'doc' });
await persistContextDocRaw(api, 'TODAY', raw);
assert.equal(store.get('doc:TODAY'), raw);
assert.deepEqual(writes, [{ key: 'doc:TODAY', data: raw }]);
});
test('serializeContextDoc: deterministic across repeated calls on the same input', () => {
// The no-op save short-circuit (#7815) in flushSave / flushSaveSync compares
// the freshly-serialised bytes against the `lastSeenDocBytes` baseline. That
// comparison is only meaningful if the encoder is deterministic — same input
// must always produce byte-identical output, otherwise a "real revert" cycle
// would compute different bytes each call and the skip would never fire (or,
// worse, would fire when bytes actually differ if the comparator hashed).
// Lock this in so a future swap to a non-deterministic encoder (e.g. one
// that randomises iteration order over Map-backed nodes) breaks here, not
// silently in the editor.
const doc = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
};
const first = serializeContextDoc(doc);
const second = serializeContextDoc(doc);
assert.equal(first, second);
// Also asserts the obvious shape so a future signature change (e.g.
// returning a Uint8Array) fails compile + runtime, not just structurally.
assert.equal(typeof first, 'string');
assert.ok(first.length > 0);
});
test('persistContextDocRaw: re-persisting identical bytes is a no-op at the store level', async () => {
// The optimization premise: when the editor's no-op short-circuit fires, the
// persist call never reaches here. But IF a caller did call this twice with
// the same raw, the result is byte-identical state — confirming the skip is
// safe (no information is lost by not making the second write).
const { api, store } = createMockApi();
const expected = JSON.stringify({ type: 'doc' });
const returned = await saveContextDoc(api, 'TODAY', { type: 'doc' });
assert.equal(store.get('doc:TODAY'), expected);
// Returned string must match exactly so the editor can stamp it as the
// self-echo baseline.
assert.equal(returned, expected);
const raw = serializeContextDoc({ type: 'doc', content: [] });
await persistContextDocRaw(api, 'p1', raw);
const after1 = store.get('doc:p1');
await persistContextDocRaw(api, 'p1', raw);
assert.equal(store.get('doc:p1'), after1);
});
/* -------------------------------------------------------------------------- */

View file

@ -96,27 +96,25 @@ export const loadContextDoc = async (
/**
* Serialise an editor doc to the exact bytes that will land in storage.
* Extracted so `flushSave` (async, via `saveContextDoc`) and `flushSaveSync`
* (sync, dispatching the persist directly) produce byte-identical output
* Extracted so `flushSave` and `flushSaveSync` produce byte-identical output
* the self-echo baseline (#7752) compares raw against raw and silently
* desyncs if the two paths ever diverge on encoding.
* desyncs if the two paths ever diverge on encoding. Also used by both flush
* paths to short-circuit no-op saves (#7815) by comparing the would-be-written
* bytes against `lastSeenDocBytes` before dispatching.
*/
export const serializeContextDoc = (doc: unknown): string => JSON.stringify(doc);
/**
* Persist one context's editor doc. Returns the exact string handed to
* `persistDataSynced` so the caller can store it as `lastSeenDocBytes`
* and recognise the host's own self-echo fire (#7752).
* Persist a pre-serialised raw doc string under `doc:${ctxId}`. The caller
* is expected to have already computed the bytes via `serializeContextDoc`
* (so a no-op short-circuit against `lastSeenDocBytes` can run before this
* is invoked see editor.ts `flushSave` for #7815).
*/
export const saveContextDoc = async (
export const persistContextDocRaw = (
api: PluginAPI,
ctxId: string,
doc: unknown,
): Promise<string> => {
const raw = serializeContextDoc(doc);
await api.persistDataSynced(raw, docKey(ctxId));
return raw;
};
raw: string,
): Promise<void> => api.persistDataSynced(raw, docKey(ctxId));
/**
* One-shot migration from the legacy single-blob shape to keyed entries.

View file

@ -48,7 +48,7 @@ export const reconcileEnabledIds = async (
try {
next = new Set(await loadEnabledCtxIds(api));
} catch (err) {
api.log.err('document-mode: enabled-ids reload failed', err);
api.log.err('doc-mode: enabled-ids reload failed', err);
return { next: new Set(prev), action: 'noop' };
}
// Skip the visibility reconcile when membership is identical — most fires
@ -69,7 +69,7 @@ export const reconcileEnabledIds = async (
const ctx = await api.getActiveWorkContext();
activeId = ctx?.id ?? null;
} catch (err) {
api.log.err('document-mode: active-ctx read failed', err);
api.log.err('doc-mode: active-ctx read failed', err);
return { next, action: 'noop' };
}
if (activeId === null) return { next, action: 'noop' };

View file

@ -1,5 +1,5 @@
/**
* Pure top-level-navigation helpers for the Document Mode editor.
* Pure top-level-navigation helpers for the Doc Mode editor.
*
* Every function here operates on a `DocLike` the minimal structural
* slice of a ProseMirror document node that the drag / move / keyboard

View file

@ -1,5 +1,5 @@
/**
* Document-Mode editor runs inside the plugin iframe. Notion-style UX:
* Doc-Mode editor runs inside the plugin iframe. Notion-style UX:
* inline bubble menu on text selection, block hover gutter with insert
* (`+`) and grip (`⋮⋮`) buttons, slash menu for inserts and turn-into,
* and a custom taskRef atom node tied to Super Productivity tasks.
@ -32,7 +32,7 @@ import {
docKey,
loadContextDoc,
migrateToKeyedPersistence,
saveContextDoc,
persistContextDocRaw,
serializeContextDoc,
} from '../persistence';
import { iconSvg } from './icons';
@ -113,7 +113,7 @@ const logErr = (msg: string, err?: unknown): void => {
// ignore — fall through to console
}
// eslint-disable-next-line no-console
console.error('[document-mode]', msg, err);
console.error('[doc-mode]', msg, err);
};
/**
@ -244,18 +244,28 @@ const flushSave = async (): Promise<void> => {
saveTimer = null;
}
if (!currentCtx || !editor) return;
saveInFlight = true;
// Same guard as scheduleSave + flushSaveSync — never overwrite a doc we
// couldn't read. 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
// guard is repeated here for symmetry.
if (isDocCorrupt) return;
const savedCtxId = currentCtx.id;
// Compute the would-be-written bytes before the await so we can short-circuit
// no-op saves (#7815): a typed-then-reverted cycle inside the 30s throttle
// window produces bytes byte-identical to the baseline. Skipping here avoids
// an unnecessary postMessage round-trip, IDB transaction, and op-log entry.
// The baseline is NOT updated on a skip — it already equals these bytes, so
// the self-echo invariant is preserved.
const raw = serializeContextDoc(stripChipContent(editor.getJSON()));
if (raw === lastSeenDocBytes) return;
saveInFlight = true;
try {
// Keyed storage (Stage A): write only this context's doc. No need to
// read+merge any sibling state — the meta entry (enabledCtxIds) lives
// in its own LWW-resolved entity owned by background.ts, and sibling
// contexts each have their own `doc:${ctxId}` entry.
const raw = await saveContextDoc(
PluginAPI,
savedCtxId,
stripChipContent(editor.getJSON()),
);
await persistContextDocRaw(PluginAPI, savedCtxId, raw);
// Update the self-echo baseline only if we still own this context; a
// mid-flight context switch would otherwise stamp the new context's
// baseline with the old context's bytes.
@ -298,13 +308,22 @@ const flushSaveSync = (): void => {
// Same guard as scheduleSave — never overwrite a doc we couldn't read.
if (isDocCorrupt) return;
try {
// Stamp the self-echo baseline synchronously even though the dispatch
// is fire-and-forget — the host serialises to the exact same string we
// compute here, so an inbound hook caused by this write will recognise
// itself when matched against `lastSeenDocBytes`. Shared
// `serializeContextDoc` keeps the sync + async flush paths byte-equal.
lastSeenDocBytes = serializeContextDoc(stripChipContent(editor.getJSON()));
void PluginAPI.persistDataSynced(lastSeenDocBytes, docKey(currentCtx.id));
// Compute first, then short-circuit no-op saves (#7815) before stamping
// the baseline — stamping unconditionally would still be correct (the new
// value equals the old) but the early return also skips the fire-and-forget
// persist dispatch and the op-log entry it produces.
const raw = serializeContextDoc(stripChipContent(editor.getJSON()));
if (raw === lastSeenDocBytes) return;
// Stamp the self-echo baseline BEFORE the dispatch. Asymmetric vs
// flushSave, which stamps AFTER `await`: this path is fire-and-forget —
// the iframe can be torn down mid-call and the promise never resolves,
// so a post-dispatch stamp would silently drop on teardown. Pre-stamping
// is safe because the host serialises to the exact same string we compute
// here, so an inbound hook caused by this write recognises itself when
// matched against `lastSeenDocBytes`. Shared `serializeContextDoc` keeps
// the sync + async flush paths byte-equal.
lastSeenDocBytes = raw;
void persistContextDocRaw(PluginAPI, currentCtx.id, raw);
} catch (err) {
logErr('persistDataSynced (sync flush) failed', err);
}
@ -1891,14 +1910,14 @@ const mount = async (): Promise<void> => {
try {
await migrateToKeyedPersistence(PluginAPI);
} catch (err) {
logErr('document-mode: migration failed', err);
logErr('doc-mode: migration failed', err);
}
updateDocStatusBanner();
const initialCtx = await PluginAPI.getActiveWorkContext();
const root = document.getElementById('editor-root');
if (!root) {
logErr('Document mode: #editor-root not found');
logErr('doc-mode: #editor-root not found');
isMounted = false;
return;
}
@ -2130,7 +2149,7 @@ const waitForPluginAPI = (): Promise<void> =>
if (attempts >= MAX_ATTEMPTS) {
// eslint-disable-next-line no-console
console.error(
'[document-mode] PluginAPI not injected after',
'[doc-mode] PluginAPI not injected after',
MAX_ATTEMPTS * INTERVAL_MS,
'ms — giving up',
);
@ -2154,7 +2173,7 @@ void waitForPluginAPI()
const msg = document.createElement('div');
msg.className = 'doc-error-state';
msg.textContent =
'Document Mode could not connect to Super Productivity. ' +
'Doc Mode could not connect to Super Productivity. ' +
'Try closing and reopening this panel.';
root.appendChild(msg);
}

View file

@ -1,5 +1,5 @@
/**
* Inline SVG icons for Document Mode. Kept as a static path map so the
* Inline SVG icons for Doc Mode. Kept as a static path map so the
* iframe renders icons offline no Material Icons web font, no Google
* Fonts request. Paths are the standard Material Symbols 24×24 outlines.
*/

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Document Mode</title>
<title>Doc Mode</title>
<style>
html,
body {

View file

@ -347,15 +347,15 @@ const plugins = [
},
},
{
name: 'document-mode',
path: 'document-mode',
name: 'doc-mode',
path: 'doc-mode',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/document-mode',
'../../../src/assets/bundled-plugins/doc-mode',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });

View file

@ -1151,7 +1151,7 @@ describe('ConflictResolutionService', () => {
const conflicts: EntityConflict[] = [
{
entityType: 'PLUGIN_USER_DATA',
entityId: 'document-mode',
entityId: 'doc-mode',
localOps: [
{
id: 'local-plugin-data',
@ -1159,8 +1159,8 @@ describe('ConflictResolutionService', () => {
actionType: '[Plugin] Upsert User Data' as ActionType,
opType: OpType.Update,
entityType: 'PLUGIN_USER_DATA',
entityId: 'document-mode',
payload: { id: 'document-mode', data: 'local-blob' },
entityId: 'doc-mode',
payload: { id: 'doc-mode', data: 'local-blob' },
vectorClock: { clientA: 1 },
timestamp: now - 500,
schemaVersion: 1,
@ -1173,8 +1173,8 @@ describe('ConflictResolutionService', () => {
actionType: '[Plugin] Upsert User Data' as ActionType,
opType: OpType.Update,
entityType: 'PLUGIN_USER_DATA',
entityId: 'document-mode',
payload: { id: 'document-mode', data: 'remote-blob' },
entityId: 'doc-mode',
payload: { id: 'doc-mode', data: 'remote-blob' },
vectorClock: { clientB: 1 },
timestamp: now,
schemaVersion: 1,

View file

@ -110,27 +110,24 @@ describe('PluginHooksService.dispatchHookToPlugin', () => {
});
it('fires every handler when a plugin registers more than once for the same hook', async () => {
// document-mode registers from both its background script and its iframe
// doc-mode registers from both its background script and its iframe
// editor under the same pluginId — pre-fix the second registration
// silently overwrote the first, so iframe registration permanently
// shadowed background's enabledIds-reconcile handler.
const bgHandler = jasmine.createSpy('bgHandler');
const iframeHandler = jasmine.createSpy('iframeHandler');
service.registerHookHandler(
'document-mode',
'doc-mode',
PluginHooks.PERSISTED_DATA_CHANGED,
bgHandler,
);
service.registerHookHandler(
'document-mode',
'doc-mode',
PluginHooks.PERSISTED_DATA_CHANGED,
iframeHandler,
);
await service.dispatchHookToPlugin(
'document-mode',
PluginHooks.PERSISTED_DATA_CHANGED,
);
await service.dispatchHookToPlugin('doc-mode', PluginHooks.PERSISTED_DATA_CHANGED);
expect(bgHandler).toHaveBeenCalledTimes(1);
expect(iframeHandler).toHaveBeenCalledTimes(1);
@ -143,23 +140,23 @@ describe('PluginHooksService.dispatchHookToPlugin', () => {
const otherPlugin = jasmine.createSpy('otherPlugin');
service.registerHookHandler(
'document-mode',
'doc-mode',
PluginHooks.PERSISTED_DATA_CHANGED,
dataChanged,
);
service.registerHookHandler(
'document-mode',
'doc-mode',
PluginHooks.PERSISTED_DATA_CHANGED,
dataChanged2,
);
service.registerHookHandler('document-mode', PluginHooks.TASK_COMPLETE, taskComplete);
// Sibling plugin's handler must survive a teardown targeting document-mode.
service.registerHookHandler('doc-mode', PluginHooks.TASK_COMPLETE, taskComplete);
// Sibling plugin's handler must survive a teardown targeting doc-mode.
service.registerHookHandler('other', PluginHooks.TASK_COMPLETE, otherPlugin);
service.unregisterPluginHooks('document-mode');
service.unregisterPluginHooks('doc-mode');
return Promise.all([
service.dispatchHookToPlugin('document-mode', PluginHooks.PERSISTED_DATA_CHANGED),
service.dispatchHookToPlugin('doc-mode', PluginHooks.PERSISTED_DATA_CHANGED),
service.dispatchHook(PluginHooks.TASK_COMPLETE),
]).then(() => {
expect(dataChanged).not.toHaveBeenCalled();

View file

@ -7,7 +7,7 @@ import { PluginLog } from '../core/log';
* Each handler has a 5s timeout to prevent hung handlers from blocking others.
*
* A plugin may register multiple handlers for the same hook under one
* pluginId for example, document-mode registers from both its background
* pluginId for example, doc-mode registers from both its background
* script (host page) and its iframe editor. All registered handlers fire on
* dispatch.
*/

View file

@ -2,7 +2,7 @@
* Maximum size of a single plugin-persistence write, in bytes (256 KB).
*
* Sized for the realistic upper bound of plugin payloads a heavy
* document-mode TipTap doc is ~30100 KB, plugin configs are KB-scale,
* doc-mode TipTap doc is ~30100 KB, plugin configs are KB-scale,
* automation rules / AI prompts are KB-scale. 256 KB gives several×
* headroom while keeping the per-plugin storage growth bounded:
* a misbehaving plugin with N keyed entries can still hold N × 256 KB,
@ -11,7 +11,7 @@
* The pre-Stage-A cap was 1 MB because one blob held every context's
* data; with the keyed split each context gets its own entry, so the
* per-write budget can shrink. Legacy migrations skip individual docs
* that exceed this (see `document-mode/src/persistence.ts`).
* that exceed this (see `doc-mode/src/persistence.ts`).
*/
export const MAX_PLUGIN_DATA_SIZE = 256 * 1024; // 256 KB

View file

@ -45,6 +45,12 @@ import {
NodeExecutionConsentDialogResult,
} from './util/plugin-consent.util';
// Each plugin's `id` (from its manifest.json, distinct from the asset path
// here) becomes the entityId prefix for all data it persists via
// `persistDataSynced` — keyed entries land under `<pluginId>:<key>` in IDB,
// the op-log, and on the sync wire. Once a plugin ships, renaming its id
// orphans every user's stored data: there is no automatic re-keying. Treat
// pluginIds as permanent for any plugin that has ever been published.
const BUNDLED_PLUGIN_PATHS = [
'assets/bundled-plugins/yesterday-tasks-plugin',
'assets/bundled-plugins/sync-md',
@ -57,7 +63,7 @@ const BUNDLED_PLUGIN_PATHS = [
'assets/bundled-plugins/voice-reminder',
'assets/bundled-plugins/google-calendar-provider',
'assets/bundled-plugins/caldav-calendar-provider',
'assets/bundled-plugins/document-mode',
'assets/bundled-plugins/doc-mode',
] as const;
@Injectable({

View file

@ -45,7 +45,7 @@ export const extractOwnerPluginId = (entityId: string): string => {
/**
* Bound on a single plugin's persistence key length. Generous for any
* realistic per-plugin keyspace (e.g. document-mode uses `doc:<uuid>`,
* realistic per-plugin keyspace (e.g. doc-mode uses `doc:<uuid>`,
* well under 100 chars). Prevents a compromised iframe from passing a
* multi-megabyte `key` that would be stored verbatim in NgRx state,
* IndexedDB, the op-log, and on the sync wire bypassing the