docs/plans/2026-05-23-persisted-data-update-hook.md
PluginHooks.PERSISTED_DATA_CHANGEDStatus: proposal (post multi-review v6)
Date: 2026-05-23 (v5–v6: 2026-05-27)
Trigger: Multi-review of 199e816479 flagged that document-mode's
editor goes stale on remote PLUGIN_USER_DATA updates because nothing
notifies the plugin. The hook PluginHooks.PERSISTED_DATA_UPDATE is
declared in packages/plugin-api/src/types.ts:24 but never dispatched
on the host side (grep of src/app/ confirms zero hits).
Related: Stage A plan
risks table — same staleness gap, Stage A does not fix it on its own.
Fire the dead hook. This PR ships the host capability only — the selector subscription, the per-plugin dispatch, the enum rename, the API contract. Document-mode's adoption (banner UX, dirty tracking, selection preservation) is tracked separately and ships after this groundwork has baked: see "Follow-up: document-mode adoption" below.
Host mechanism: a selector subscription in PluginHooksEffects on
selectPluginUserDataFeatureState. Skipped during the sync window
(SYNC_IMPORT / BACKUP_IMPORT replace state wholesale — semantically a
reload, not a change). No cache, no payload, no separate delete hook.
Plugins receive a void signal and re-call loadSyncedData() to get
fresh data. Rename the enum entry to PERSISTED_DATA_CHANGED for
consistency with sibling hooks (*_CHANGE); the rename is free since
nothing fires the hook today.
Prerequisite folded in: widen PluginHooksService._handlers from
Map<Hooks, Map<pluginId, handler>> to allow multiple handlers per
(hook, pluginId). Today .set() silently overwrites — so a plugin
that registers from both its background script and its iframe (the
document-mode case) loses one of the two registrations to whichever
runs second. Discovered during the follow-up review (#7752); fixing
it here keeps the host PR self-contained and unblocks doc-mode's
adoption without a separate prereq merge.
Estimated 3–4 hours including specs.
Plugins get a notification when their persisted data changes for any reason other than wholesale state replacement (SYNC_IMPORT, BACKUP_IMPORT, app boot). Local user write, remote sync-apply, and cross-tab writes all fire.
source flag was considered and rejected as YAGNI —
re-add only if a real plugin needs to discriminate.)PERSISTED_DATA_DELETED hook. No plugin-callable delete
API exists today; the only fire path would be on uninstall when the
plugin is being torn down and can't usefully react. YAGNI.loadSyncedData() on
init for fresh state, then registerHook(...) for subsequent
changes." Explicit, no surprise.In src/app/plugins/plugin-hooks.effects.ts, parallel to the existing
hook effects but selector-based, not action-based. Action-based
effects inject LOCAL_ACTIONS (sync rule 1, :55) and would miss
remote upserts which arrive through bulkApplyOperations (see
src/app/op-log/apply/bulk-hydration.meta-reducer.ts) — the action
type is the bulk wrapper, not upsertPluginUserData, so an
ofType(upsertPluginUserData) filter never fires for remote ops. The
state still changes, so a state-selector subscription does observe it.
Note: this is a new pattern in the codebase. The existing
PROJECT_LIST_UPDATEeffect atplugin-hooks.effects.ts:341is action-based, not selector-based, and is consequently blind to remote project changes (likely a latent bug, not this PR's problem). Earlier draft incorrectly cited it as precedent.
Skeleton:
firePersistedDataChanged$ = createEffect(
() =>
this.store.pipe(
select(selectPluginUserDataFeatureState),
startWith([] as PluginUserData[]), // determinism for pairwise
pairwise(),
skipDuringSyncWindow(), // see "Sync window" below
map(([prev, next]) => diffChangedPluginIds(prev, next)),
filter((ids) => ids.length > 0),
switchMap((ids) =>
from(ids).pipe(
tap((pluginId) =>
this.pluginService.dispatchHookToPlugin(
pluginId,
PluginHooks.PERSISTED_DATA_CHANGED,
),
),
),
),
),
{ dispatch: false },
);
diffChangedPluginIdsPure function. Compares prev and next arrays by both id-membership
and data field. Returns the set of pluginIds where:
id is present in next but not prev (added), ORid is present in both but data differs (updated), ORid is present in prev but not next (deleted)Encoding is deterministic (gzip + base64; verified — same input →
identical bytes), so a no-op local write that round-trips through the
service produces identical data strings and is correctly skipped by
the differ. No separate dedupe cache is needed. The differ alone
suppresses self-echoes from no-op writes; for writes that do change
data, the plugin's own handler firing is harmless and idempotent per
contract.
skipDuringSyncWindow() (sync rule 2) is the right operator here, but
the reasoning is non-obvious:
bulkApplyOperations). Those land outside the
sync window and correctly fire the hook.After SYNC_IMPORT the plugin re-initialises via the normal load path,
which itself calls loadSyncedData() — semantically equivalent to
"every plugin gets a fresh-state event." No separate signal needed
yet; revisit if a plugin asks for SYNC_IMPORTED explicitly.
require-hydration-guard lint rule will accept skipDuringSyncWindow()
without further action.
(hook, pluginId)PluginHooksService._handlers at plugin-hooks.ts:14 is
Map<Hooks, Map<pluginId, PluginHookHandler<any>>> and
registerHookHandler calls .set(pluginId, handler) at line 27 —
a second registration for the same (hook, pluginId) silently
overwrites the first.
This bites any plugin that registers from two JS contexts under the
same id. document-mode is the concrete case: background.ts runs in
the host page (registers directly via the host-side bridge) while
ui/editor.ts runs in the iframe (registers via the
plugin-iframe.util.ts:622 proxy). Both use pluginId document-mode.
Whichever runs second wipes the other.
Widen to allow multiple handlers per slot:
// plugin-hooks.ts
private _handlers = new Map<Hooks, Map<string, Set<PluginHookHandler<any>>>>();
registerHookHandler<T extends Hooks>(pluginId, hook, handler) {
if (!this._handlers.has(hook)) this._handlers.set(hook, new Map());
const perPlugin = this._handlers.get(hook)!;
if (!perPlugin.has(pluginId)) perPlugin.set(pluginId, new Set());
perPlugin.get(pluginId)!.add(handler);
}
unregisterPluginHooks(pluginId): void {
for (const perPlugin of this._handlers.values()) perPlugin.delete(pluginId);
}
Update dispatchHook and dispatchHookToPlugin (below) to iterate the
Set rather than calling a single handler. Per-handler timeout +
catch stays unchanged.
No new public unregister API. unregisterPluginHooks(pluginId) already
clears the whole set for that plugin and is the existing teardown path
called by PluginService.deactivatePlugin. A surgical
unregisterHookHandler(pluginId, hook, handler) was considered for
iframe-teardown handler cleanup but cut as unused public surface — the
host accepts a stale postMessage proxy as a 5 s timeout slot today,
and if that cost ever becomes real, add the method then.
PluginHooksService.dispatchHook (plugin-hooks.ts:34-57) currently
fans out to all registered handlers for a hook. For per-plugin
data, we want only the affected plugin's handler(s). Add:
// plugin-hooks.ts
async dispatchHookToPlugin<T extends Hooks>(
pluginId: string,
hook: T,
payload?: HookPayloadMap[T],
): Promise<void> {
const handlers = this._handlers.get(hook)?.get(pluginId);
if (!handlers || handlers.size === 0) return;
for (const handler of handlers) {
// Same 5 s timeout + catch pattern as dispatchHook.
}
}
JSDoc both methods so the asymmetry is clear: dispatchHook is fan-out
(task / project / language events affect all plugins); dispatchHookToPlugin
is scoped (data events are per-owner — but still fan out across all
handlers that plugin registered).
Expose on PluginService as dispatchHookToPlugin(pluginId, hook, payload?),
mirroring the existing dispatchHook passthrough at
plugin.service.ts:1042-1048.
void. The plugin's handler is already scoped to its plugin id (it
registered with that id; dispatchHookToPlugin routes by id; the
handler runs in the plugin's own context where the id is implicit).
Update PersistedDataUpdatePayload accordingly — see "API surface
changes."
In packages/plugin-api/src/types.ts:
Rename enum entry: PERSISTED_DATA_UPDATE → PERSISTED_DATA_CHANGED.
Value string changes from 'persistedDataUpdate' to
'persistedDataChanged'. Pre-emptive grep confirmed no plugin imports
the type or references the value (only the enum entry exists, and the
bundled sync-md/plugin.js references the enum name only). Safe to
rename.
Remove PersistedDataUpdatePayload (or alias it to void if there
are external @super-productivity/plugin-api consumers; check
packages/plugin-api/README.md for any documented public contract).
Update HookPayloadMap[PluginHooks.PERSISTED_DATA_CHANGED] to void.
Add to packages/plugin-api/README.md one paragraph:
PERSISTED_DATA_CHANGEDfires when this plugin's persisted data has changed for any reason other than full app state replacement (SYNC_IMPORT, BACKUP_IMPORT, app boot — which the plugin handles via the normal init load). Handler receives no payload; re-callloadSyncedData()to get fresh data. Contract: callloadSyncedData()on plugin init for the initial state; then use this hook for subsequent changes. There is no replay-on-register and no guaranteed ordering across rapid changes. Handlers must be idempotent.
Tracked at super-productivity#7752. Blocked on this PR landing.
Scope (post v6 multi-review — "Option A: banner-only, minimum"):
background.ts — register PERSISTED_DATA_CHANGED; reconcile
enabledIds; call showInWorkContext / closeWorkContextView
only when the active context's membership flipped.ui/editor.ts — register PERSISTED_DATA_CHANGED; on fire,
loadSyncedData(docKey(currentCtx.id)) to get the raw stored
string; compare to a closure-scoped lastSeenRemoteData (also a
raw string); if equal noop (self-echo or another context's fire); if
different, show a one-button "Remote changes — reload" banner. When
isDocCorrupt === true, skip the banner and call
setActiveContext(currentCtx) directly (the corruption banner
already says "your saved data is untouched" — taking the remote may
be the fix).lastSeenRemoteData is captured at two points (both as the
raw loadSyncedData string, not parsed JSON, not editor
getJSON()):
setActiveContext right after the loadSyncedData(docKey)
call resolves. This requires routing through loadSyncedData
directly rather than loadContextDoc (which parses internally
and discards the raw string), or refactoring loadContextDoc to
return { raw, parsed }. ~5 LOC persistence-layer change.flushSave right after saveContextDoc resolves, capturing
the exact stringified blob just persisted. Same shape change to
saveContextDoc to return / accept the raw string.Why this scope (versus what v3/v4 and even v5 reviewed):
setContent is
expensive ProseMirror surgery for "idle reader sees content change
under them" — Notion / Linear / Figma offline mode use a reload
prompt instead.remote-update.ts cut in v6). The
banner-fire decision is one !== and the show/close membership
diff is six lines of Set.has — inline in the call sites and cover
via integration specs.Pre-existing invariants the follow-up must preserve (do not re-litigate):
setActiveContext (editor.ts:344-410) already calls
loadContextDoc on every switch — the "mount-race" concern raised
in v3 review is already handled. Don't remove the re-read.saveTimer !== null || saveInFlight stays for
flushSaveSync. Banner shows on byte-diff regardless of dirty.Estimated ~1.25 hours for the doc-mode side: ~30 min handlers +
lastSeenRemoteData plumbing (including the loadContextDoc /
saveContextDoc raw-string change), ~30 min banner + integration
spec + E2E append.
() => void | Promise<void>.registerHook is
not delivered. Plugins read fresh on init.In plugin-hooks.effects.spec.ts:
upsertPluginUserData
with new data; effect fires hook for that pluginId.bulkApplyOperations with
PLUGIN_USER_DATA payload changes the state; effect fires hook.skipDuringSyncWindow test
harness used by other selector effects.next → fire hook for that pluginId. (The handler will
call loadSyncedData() and receive null; that's the delete
signal.)loadPluginUserData(pluginId); gets the value the host just
committed (existing _committing / _pendingData path,
plugin-user-persistence.service.ts:206-233).In plugin-hooks.spec.ts:
dispatchHookToPlugin filters correctly. Registers two
plugins' handlers; dispatch to one fires only one handler;
non-registered pluginId is a no-op (no error).(hook, pluginId) all fire. Register
two handlers under the same pluginId+hook; dispatch fires both
exactly once. unregisterPluginHooks clears the whole set for
that pluginId across all hooks. (No per-handler unregister API.)(Plugin-side specs live in the follow-up issue's PR, not here.)
| Risk | Mitigation |
|---|---|
skipDuringSyncWindow mis-applied — suppresses legitimate remote ops outside the SYNC_IMPORT window | Verify operator semantics in src/app/util/skip-during-sync-window.operator.ts before coding; spec #3 + #5 distinguish the two cases. |
Hook reentrancy: handler calls persistDataSynced, which triggers another emission | Persistence service already serializes per-plugin via _commitChain (plugin-user-persistence.service.ts:62); handler's write awaits its own commit. Hook fire is tap-based (fire-and-forget), so emissions are not blocked by slow handlers. Ordering across rapid emissions is not guaranteed — document on the contract. |
pairwise swallows first emission | startWith([]) prepends an empty array so the first real state IS the second emission and pairwise yields [[], firstState]. Matches the onCurrentTaskChange$ pattern at plugin-hooks.effects.ts:94. |
| Plugin uninstall / re-register leaves stale handlers | Existing unregisterPluginHooks (plugin-hooks.ts:65-68) already clears all hooks per plugin. No additional work. |
| Cross-tab "self-echo" | Tab B legitimately sees Tab A's write as remote — there is no self-echo to defend against. Spec the case as part of test #3. |
PERSISTED_DATA_DELETED hook — no real consumer.source: 'local' | 'remote' payload flag — YAGNI; idempotent contract
is enough; revisit if a plugin needs the discriminator.key (the pluginId argument to dispatchHookToPlugin
becomes a composite id and the plugin can parse it). Until then, the
hook fires once per pluginId, period.BACKUP_IMPORT distinction from SYNC_IMPORT. Both are suppressed by
the same sync-window operator; same rationale applies.plugin-hooks.ts — widen _handlers to Set<handler>; update
registerHookHandler, dispatchHook, unregisterPluginHooks. ~20
LOC.plugin-hooks.ts — add dispatchHookToPlugin (iterates Set). ~15
LOC + JSDoc on both dispatch methods.plugin.service.ts — passthrough dispatchHookToPlugin. ~5 LOC.plugin-data-diff.util.ts or inline in the effects
file) — diffChangedPluginIds(prev, next): string[]. Pure. ~20 LOC.plugin-hooks.effects.ts — the effect per "Skeleton" above. ~20 LOC.packages/plugin-api/src/types.ts — rename enum entry; payload to
void. ~3 LOC.packages/plugin-api/README.md — one paragraph per the snippet
under "API surface changes."Total: ~85 LOC + specs. Realistic estimate 3–4 hours end to end.
PERSISTED_DATA_DELETED
hook, document-mode adoption in same PR. Multi-reviewed.PERSISTED_DATA_CHANGED for consistency with
*_CHANGE siblings (Reviewer 3).void (Reviewer 2 + 3).PERSISTED_DATA_DELETED (all three reviewers).skipDuringSyncWindow
(Reviewer 1 + 2). Reasoning documented; rule 2 satisfied.PROJECT_LIST_UPDATE precedent citation; selector
approach presented on its own merits (Reviewer 1).startWith([]) for pairwise determinism (Reviewer 1).background.ts and ui/editor.ts adoption
sections, banner UX rationale ("no auto-replace when dirty"),
plugin-side specs (#9–13), and the deploy step. Estimate revised to
6–8 hours.setContent destroys
selection; mount race; bare reload-only banner doesn't prevent
clobber) which are now seeds for the follow-up issue rather than
fixes attempted here. Restored host-only scope; estimate back to
3–4 hours. Doc-mode adoption notes preserved in "Follow-up" section
so the follow-up implementer starts from the multi-reviewed
insights, not a blank page._handlers map keys
by (hook, pluginId) with a single handler slot, so document-mode's
background + iframe registrations clobber each other under the
shared pluginId. Folded the fix into this PR (widen to
Set<handler>) since it's small, in the same file, and blocks any
doc-mode adoption regardless of UX direction. Same review rejected
the elaborate UX previously sketched in the v3/v4 "Follow-up"
section ("Keep mine" promises durability LWW cannot deliver; silent
swap costs more than it's worth) — replaced with the leaner
"Option A" scope. Estimate revised to 3.5–4.5 h for this host PR;
doc-mode follow-up trimmed to ~2 h.lastSeenRemoteData capture point ("raw stored blob after
loadContextDoc") is mechanically impossible because
loadContextDoc parses and discards the raw string, and editor
encoding via getJSON() is not byte-stable across the load path
(prepareStoredDoc mutates chip content against the local task
cache); (b) the pre-reload backup adds a cross-device-synced
persisted-key class with no GC, no UI to discover the backup, and a
load-encoding-dependent double-click race. Resolved by collapsing
both: drop the backup entirely; redefine lastSeenRemoteData to be
the raw loadSyncedData() string compared against itself (no
editor-side encoding involved). Also cut from this host PR:
unregisterHookHandler (unused public API surface — host plan
explicitly didn't wire iframe-side teardown to call it). Host PR
estimate back to 3–4 h; doc-mode follow-up to ~1.25 h.