mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
18472e86b9
commit
2580ea6eb1
32 changed files with 157 additions and 99 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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
|
||||
|
Before Width: | Height: | Size: 359 B After Width: | Height: | Size: 359 B |
|
|
@ -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",
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
|
@ -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.
|
||||
|
|
@ -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' };
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Document Mode</title>
|
||||
<title>Doc Mode</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 ~30–100 KB, plugin configs are KB-scale,
|
||||
* doc-mode TipTap doc is ~30–100 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue