Running the SDK self-hosted, comments submitted in the browser stayed visible in the tab but never became durable: SQLite kept revision=1, marks='{}', zero document_y_updates and no comment events, and both GET /documents/:slug/state and the bridge marks endpoint returned {}.
The trigger is in the browser console:
[HocuspocusProvider] Connection closed with status Unauthorized: Invalid or expired collab session token
Comments added after that close are the ones lost. A fresh document exercised through the same UI persists comments correctly, so this is degraded-session handling rather than the server, auth, or the comment API.
Four things line up, and each one alone is enough to lose the comment.
1. Renewal never runs proactively. src/editor/index.ts (startCollabRefreshLoop) returns early when the connection is connected && isSynced:
if ((expiresAtMs - now) > 60_000) return;
if (this.collabConnectionStatus === 'connected' && this.collabIsSynced) return;
That is exactly the state a healthy session is in for the whole minute before it expires, so the token is always allowed to lapse and renewal only becomes reachable after the server has already closed the socket.
2. The immediate-refresh hook is unreachable. It is gated on collabClient.terminalCloseReason === 'permission-denied', but nothing in src/ ever assigns a non-null value to terminalCloseReason — the only four writes are = null. authenticationFailed records lastAuthenticationFailureReason and leaves terminalCloseReason alone, so the fast path back can never fire. The two other consumers of that field are dead for the same reason.
3. The REST safety path is skipped on a dead provider. flushShareMarks decides durability from capability flags only:
if (!this.collabEnabled || !this.collabCanEdit || LEGACY_REST_FALLBACK) {
void shareClient.pushMarks(...)
}
collabEnabled and collabCanEdit describe what the session is permitted to do, not whether a provider is alive to do it. Both stay true after the socket closes, so the mark goes into the local Y.Doc and nowhere else — with no unsaved indication.
4. Unsaved work blocks its own recovery. shouldDeferExpiringCollabRefresh defers whenever there is pending local state, without considering connection status. Adding a comment sets unsyncedChanges = 1, which then defers the refresh indefinitely — the comment blocks the very renewal that would have saved it. The one path that can still fire, maybeRecoverStalledCollab, calls refreshCollabSessionAndReconnect(false), and preserveLocalState: false resets the Y.Doc and discards the comment outright.
Either branch ends with no durable copy. The comment keeps rendering because the mark is a ProseMirror decoration applied locally at submit time, independent of the Yjs marksMap — which is why the UI looks correct while storage is empty.
Suggested behaviour
- Renew ahead of expiry rather than waiting to be closed; a healthy connection should not suppress renewal.
- Deferral for typing or unsaved work should apply only while the connection is still usable, and never past a hard deadline.
- An authentication failure should take a deterministic refresh that preserves local state.
- Durable persistence should key off provider liveness, not capability flags.
- A comment should not present as saved until it is durably acknowledged.
PR follows.
Running the SDK self-hosted, comments submitted in the browser stayed visible in the tab but never became durable: SQLite kept
revision=1,marks='{}', zerodocument_y_updatesand no comment events, and bothGET /documents/:slug/stateand the bridge marks endpoint returned{}.The trigger is in the browser console:
Comments added after that close are the ones lost. A fresh document exercised through the same UI persists comments correctly, so this is degraded-session handling rather than the server, auth, or the comment API.
Four things line up, and each one alone is enough to lose the comment.
1. Renewal never runs proactively.
src/editor/index.ts(startCollabRefreshLoop) returns early when the connection isconnected && isSynced:That is exactly the state a healthy session is in for the whole minute before it expires, so the token is always allowed to lapse and renewal only becomes reachable after the server has already closed the socket.
2. The immediate-refresh hook is unreachable. It is gated on
collabClient.terminalCloseReason === 'permission-denied', but nothing insrc/ever assigns a non-null value toterminalCloseReason— the only four writes are= null.authenticationFailedrecordslastAuthenticationFailureReasonand leavesterminalCloseReasonalone, so the fast path back can never fire. The two other consumers of that field are dead for the same reason.3. The REST safety path is skipped on a dead provider.
flushShareMarksdecides durability from capability flags only:collabEnabledandcollabCanEditdescribe what the session is permitted to do, not whether a provider is alive to do it. Both stay true after the socket closes, so the mark goes into the localY.Docand nowhere else — with no unsaved indication.4. Unsaved work blocks its own recovery.
shouldDeferExpiringCollabRefreshdefers whenever there is pending local state, without considering connection status. Adding a comment setsunsyncedChanges = 1, which then defers the refresh indefinitely — the comment blocks the very renewal that would have saved it. The one path that can still fire,maybeRecoverStalledCollab, callsrefreshCollabSessionAndReconnect(false), andpreserveLocalState: falseresets theY.Docand discards the comment outright.Either branch ends with no durable copy. The comment keeps rendering because the mark is a ProseMirror decoration applied locally at submit time, independent of the Yjs
marksMap— which is why the UI looks correct while storage is empty.Suggested behaviour
PR follows.