diff --git a/build/release-notes.md b/build/release-notes.md index ca3928ca50..9c02afd6a3 100644 --- a/build/release-notes.md +++ b/build/release-notes.md @@ -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. diff --git a/e2e/tests/plugins/document-mode-bundled.spec.ts b/e2e/tests/plugins/doc-mode-bundled.spec.ts similarity index 80% rename from e2e/tests/plugins/document-mode-bundled.spec.ts rename to e2e/tests/plugins/doc-mode-bundled.spec.ts index 33899fc2e2..90d5421087 100644 --- a/e2e/tests/plugins/document-mode-bundled.spec.ts +++ b/e2e/tests/plugins/doc-mode-bundled.spec.ts @@ -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(); }); }); diff --git a/e2e/tests/plugins/document-mode-migration.spec.ts b/e2e/tests/plugins/doc-mode-migration.spec.ts similarity index 96% rename from e2e/tests/plugins/document-mode-migration.spec.ts rename to e2e/tests/plugins/doc-mode-migration.spec.ts index 89c81adc9a..40f98887a0 100644 --- a/e2e/tests/plugins/document-mode-migration.spec.ts +++ b/e2e/tests/plugins/doc-mode-migration.spec.ts @@ -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: diff --git a/packages/plugin-dev/document-mode/.gitignore b/packages/plugin-dev/doc-mode/.gitignore similarity index 100% rename from packages/plugin-dev/document-mode/.gitignore rename to packages/plugin-dev/doc-mode/.gitignore diff --git a/packages/plugin-dev/document-mode/package-lock.json b/packages/plugin-dev/doc-mode/package-lock.json similarity index 99% rename from packages/plugin-dev/document-mode/package-lock.json rename to packages/plugin-dev/doc-mode/package-lock.json index af65920dc4..314124e4ce 100644 --- a/packages/plugin-dev/document-mode/package-lock.json +++ b/packages/plugin-dev/doc-mode/package-lock.json @@ -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", diff --git a/packages/plugin-dev/document-mode/package.json b/packages/plugin-dev/doc-mode/package.json similarity index 96% rename from packages/plugin-dev/document-mode/package.json rename to packages/plugin-dev/doc-mode/package.json index c2ae0c45c8..f5e3a5e444 100644 --- a/packages/plugin-dev/document-mode/package.json +++ b/packages/plugin-dev/doc-mode/package.json @@ -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, diff --git a/packages/plugin-dev/document-mode/scripts/build.js b/packages/plugin-dev/doc-mode/scripts/build.js similarity index 95% rename from packages/plugin-dev/document-mode/scripts/build.js rename to packages/plugin-dev/doc-mode/scripts/build.js index bd2f70e925..98e35f9451 100644 --- a/packages/plugin-dev/document-mode/scripts/build.js +++ b/packages/plugin-dev/doc-mode/scripts/build.js @@ -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, diff --git a/packages/plugin-dev/document-mode/scripts/deploy.js b/packages/plugin-dev/doc-mode/scripts/deploy.js similarity index 98% rename from packages/plugin-dev/document-mode/scripts/deploy.js rename to packages/plugin-dev/doc-mode/scripts/deploy.js index 480932d564..04efd5bca7 100644 --- a/packages/plugin-dev/document-mode/scripts/deploy.js +++ b/packages/plugin-dev/doc-mode/scripts/deploy.js @@ -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. diff --git a/packages/plugin-dev/document-mode/scripts/test.js b/packages/plugin-dev/doc-mode/scripts/test.js similarity index 97% rename from packages/plugin-dev/document-mode/scripts/test.js rename to packages/plugin-dev/doc-mode/scripts/test.js index c289f76b14..7709f8a1d4 100644 --- a/packages/plugin-dev/document-mode/scripts/test.js +++ b/packages/plugin-dev/doc-mode/scripts/test.js @@ -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 diff --git a/packages/plugin-dev/document-mode/src/background.ts b/packages/plugin-dev/doc-mode/src/background.ts similarity index 94% rename from packages/plugin-dev/document-mode/src/background.ts rename to packages/plugin-dev/doc-mode/src/background.ts index b5d6c58bf9..11feb1d6c1 100644 --- a/packages/plugin-dev/document-mode/src/background.ts +++ b/packages/plugin-dev/doc-mode/src/background.ts @@ -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 => { 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 => { 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) => { diff --git a/packages/plugin-dev/document-mode/src/doc-transform.spec.ts b/packages/plugin-dev/doc-mode/src/doc-transform.spec.ts similarity index 100% rename from packages/plugin-dev/document-mode/src/doc-transform.spec.ts rename to packages/plugin-dev/doc-mode/src/doc-transform.spec.ts diff --git a/packages/plugin-dev/document-mode/src/doc-transform.ts b/packages/plugin-dev/doc-mode/src/doc-transform.ts similarity index 99% rename from packages/plugin-dev/document-mode/src/doc-transform.ts rename to packages/plugin-dev/doc-mode/src/doc-transform.ts index a18454452b..e77a44df1d 100644 --- a/packages/plugin-dev/document-mode/src/doc-transform.ts +++ b/packages/plugin-dev/doc-mode/src/doc-transform.ts @@ -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 diff --git a/packages/plugin-dev/document-mode/src/icon.svg b/packages/plugin-dev/doc-mode/src/icon.svg similarity index 100% rename from packages/plugin-dev/document-mode/src/icon.svg rename to packages/plugin-dev/doc-mode/src/icon.svg diff --git a/packages/plugin-dev/document-mode/src/manifest.json b/packages/plugin-dev/doc-mode/src/manifest.json similarity index 90% rename from packages/plugin-dev/document-mode/src/manifest.json rename to packages/plugin-dev/doc-mode/src/manifest.json index 29ece6c29e..98839ce667 100644 --- a/packages/plugin-dev/document-mode/src/manifest.json +++ b/packages/plugin-dev/doc-mode/src/manifest.json @@ -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", diff --git a/packages/plugin-dev/document-mode/src/persistence.spec.ts b/packages/plugin-dev/doc-mode/src/persistence.spec.ts similarity index 83% rename from packages/plugin-dev/document-mode/src/persistence.spec.ts rename to packages/plugin-dev/doc-mode/src/persistence.spec.ts index 93986fdf9f..03e2188e2e 100644 --- a/packages/plugin-dev/document-mode/src/persistence.spec.ts +++ b/packages/plugin-dev/doc-mode/src/persistence.spec.ts @@ -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: and returns the raw string written', async () => { +test('persistContextDocRaw: writes the exact raw string under doc:', 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); }); /* -------------------------------------------------------------------------- */ diff --git a/packages/plugin-dev/document-mode/src/persistence.ts b/packages/plugin-dev/doc-mode/src/persistence.ts similarity index 90% rename from packages/plugin-dev/document-mode/src/persistence.ts rename to packages/plugin-dev/doc-mode/src/persistence.ts index 7f123b0a37..fe5fb6b433 100644 --- a/packages/plugin-dev/document-mode/src/persistence.ts +++ b/packages/plugin-dev/doc-mode/src/persistence.ts @@ -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 => { - const raw = serializeContextDoc(doc); - await api.persistDataSynced(raw, docKey(ctxId)); - return raw; -}; + raw: string, +): Promise => api.persistDataSynced(raw, docKey(ctxId)); /** * One-shot migration from the legacy single-blob shape to keyed entries. diff --git a/packages/plugin-dev/document-mode/src/reconcile-enabled.spec.ts b/packages/plugin-dev/doc-mode/src/reconcile-enabled.spec.ts similarity index 100% rename from packages/plugin-dev/document-mode/src/reconcile-enabled.spec.ts rename to packages/plugin-dev/doc-mode/src/reconcile-enabled.spec.ts diff --git a/packages/plugin-dev/document-mode/src/reconcile-enabled.ts b/packages/plugin-dev/doc-mode/src/reconcile-enabled.ts similarity index 95% rename from packages/plugin-dev/document-mode/src/reconcile-enabled.ts rename to packages/plugin-dev/doc-mode/src/reconcile-enabled.ts index 6d86870413..590a858cbd 100644 --- a/packages/plugin-dev/document-mode/src/reconcile-enabled.ts +++ b/packages/plugin-dev/doc-mode/src/reconcile-enabled.ts @@ -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' }; diff --git a/packages/plugin-dev/document-mode/src/ui/doc-nav.spec.ts b/packages/plugin-dev/doc-mode/src/ui/doc-nav.spec.ts similarity index 100% rename from packages/plugin-dev/document-mode/src/ui/doc-nav.spec.ts rename to packages/plugin-dev/doc-mode/src/ui/doc-nav.spec.ts diff --git a/packages/plugin-dev/document-mode/src/ui/doc-nav.ts b/packages/plugin-dev/doc-mode/src/ui/doc-nav.ts similarity index 99% rename from packages/plugin-dev/document-mode/src/ui/doc-nav.ts rename to packages/plugin-dev/doc-mode/src/ui/doc-nav.ts index cfa64af71f..6fa4b414b8 100644 --- a/packages/plugin-dev/document-mode/src/ui/doc-nav.ts +++ b/packages/plugin-dev/doc-mode/src/ui/doc-nav.ts @@ -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 diff --git a/packages/plugin-dev/document-mode/src/ui/editor.ts b/packages/plugin-dev/doc-mode/src/ui/editor.ts similarity index 96% rename from packages/plugin-dev/document-mode/src/ui/editor.ts rename to packages/plugin-dev/doc-mode/src/ui/editor.ts index 396d9bfa61..bc01d6ccca 100644 --- a/packages/plugin-dev/document-mode/src/ui/editor.ts +++ b/packages/plugin-dev/doc-mode/src/ui/editor.ts @@ -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 => { 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 => { 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 => 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); } diff --git a/packages/plugin-dev/document-mode/src/ui/icons.ts b/packages/plugin-dev/doc-mode/src/ui/icons.ts similarity index 97% rename from packages/plugin-dev/document-mode/src/ui/icons.ts rename to packages/plugin-dev/doc-mode/src/ui/icons.ts index 159812bdac..b77ec84575 100644 --- a/packages/plugin-dev/document-mode/src/ui/icons.ts +++ b/packages/plugin-dev/doc-mode/src/ui/icons.ts @@ -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. */ diff --git a/packages/plugin-dev/document-mode/src/ui/index.html b/packages/plugin-dev/doc-mode/src/ui/index.html similarity index 99% rename from packages/plugin-dev/document-mode/src/ui/index.html rename to packages/plugin-dev/doc-mode/src/ui/index.html index 0690fd8530..24a1a8d362 100644 --- a/packages/plugin-dev/document-mode/src/ui/index.html +++ b/packages/plugin-dev/doc-mode/src/ui/index.html @@ -2,7 +2,7 @@ - Document Mode + Doc Mode