Skip to content

feat: improve offline editing and page navigation#22

Merged
Chefski merged 2 commits into
devfrom
codex/crdt-native-offline-edits
Jul 19, 2026
Merged

feat: improve offline editing and page navigation#22
Chefski merged 2 commits into
devfrom
codex/crdt-native-offline-edits

Conversation

@Chefski

@Chefski Chefski commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • make offline document edits CRDT-native
  • refresh page discovery and remember the last selected space
  • show editor author bylines
  • add a TestFlight badge and download link to the README

Validation

  • git diff --check
  • SwiftLint strict mode: 0 violations across 43 changed Swift files

Xcode builds and tests were not run locally, per repository instructions.

@Chefski
Chefski merged commit 7bab5e1 into dev Jul 19, 2026
7 of 8 checks passed
@Chefski
Chefski deleted the codex/crdt-native-offline-edits branch July 19, 2026 04:13
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR upgrades offline collaborative editing from REST-body replacement to CRDT-native Yjs state persistence, adds per-account last-selected-space memory, shows an author byline in the editor, and adds a TestFlight badge to the README.

  • CRDT offline editing: Offline saves now capture a Yjs stateUpdate binary alongside the ProseMirrorDocument projection. During queue replay, a fresh engine is loaded with the queued state, flushed via flushPendingLocalChanges, and merged against the live collaboration server through the new NativeEditorOfflineCRDTSynchronizer; title conflicts are resolved by the new OfflinePageTitleReplayDecision. The CachedCRDTDocument SwiftData model persists the latest Yjs state per page so collaborative documents can be restored offline.
  • Page discovery & space memory: pageDiscoveryRevision (overflow-safe integer) is bumped after every save and used as a .task(id:) key to refresh the page tree and recent-pages views; the last-selected space ID is saved to UserDefaults per account scope and restored on selectDefaultSpaceIfNeeded, with a guard that falls back to the first available space if the saved one has been deleted.
  • Byline view: NativeEditorBylineView renders the page creator's name above the document body when available.

Confidence Score: 4/5

The CRDT offline editing path is safe to merge; the one ordering edge case in the synchronizer is unlikely to trigger with Hocuspocus's typical event delivery order.

The CRDT save-and-replay pipeline is well-exercised by new unit and integration tests covering coalescing, conflict resolution, round-trip serialization, and end-to-end replay. The waitForSynchronization loop will silently time out if the collaboration server delivers syncStatus(true) before the authenticated event — an unusual but possible ordering — leaving the mutation stranded in the queue until the next reconciliation pass. All other logic (space memory, page discovery revision, byline, SwiftData model) is straightforward and correctly integrated.

docmostly/Features/Editor/NativeEditorOfflineCRDTSynchronizer.swift — the event ordering assumption in waitForSynchronization is worth a second look before this ships to TestFlight.

Important Files Changed

Filename Overview
docmostly/Features/Editor/NativeEditorOfflineCRDTSynchronizer.swift New offline CRDT sync actor; has a latent ordering issue in waitForSynchronization where syncStatus(true) arriving before authenticated is silently dropped, causing a false disconnectedBeforeSync error.
docmostly/App/AppState+OfflineQueue.swift Adds replayCRDTPageUpdate which applies the queued Yjs state to a freshly created engine, syncs it against the live collaboration server via NativeEditorOfflineCRDTSynchronizer, and then updates the title separately; flow and error propagation look correct.
docmostly/App/AppState+EditorPersistence.swift Replaces baseDocument-diff conflict detection with isCollaborationEquivalent for CRDT pages; adds persistCRDTPageLocally and supersedePendingCRDTPageUpdate helpers; guard against empty crdtStateUpdate is present at every entry point.
docmostly/Persistence/OfflineMutationQueue.swift Adds supersedePendingCRDTPageUpdate, pageUpdateMutations, and resolvedPageUpdatePayload; extracts shared DB query logic; CRDT and REST mutations share the same coalescing key and both cases are handled correctly.
docmostly/Persistence/OfflineMutationPayload.swift Adds .updatePageCRDT case with correct Codable round-trip, coalescing key, comment-ID patching (ProseMirrorDocument only — stateUpdate intentionally preserved), and pageUpdateBaseTitle helper.
docmostly/Persistence/CachedCRDTDocument.swift New SwiftData model persisting per-page Yjs state; all properties have default values (required for CloudKit), correctly included in clearAll and the model schema.
docmostly/App/AppState+Collaboration.swift Extends makeCRDTDocumentEngine to restore cached Yjs state on load and introduces persistCRDTStateUpdate; correctly shows offline-unavailable message when cached state is absent and device is offline.
docmostly/App/AppState+Navigation.swift Adds rememberSelectedSpace and restores the last-selected space from LocalSettingsStore on selectDefaultSpaceIfNeeded; remembered space is validated against the current space list before use.
docmostly/Config/LocalSettingsStore.swift Adds saveLastSelectedSpaceID/loadLastSelectedSpaceID backed by a UserDefaults dictionary keyed by serverBaseURL\nuserID; scoped correctly per account.
docmostly/Features/Editor/NativeEditorBylineView.swift New author byline view; uses hardcoded .padding(.horizontal, 10) and .padding(.vertical, 6) values, which violates the project's no-hardcoded-padding rule.
docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift Adds end-to-end CRDT replay test with mock engine, synchronizer, and HTTP loader; verifies applied updates, synchronized states, requested API paths, and final cached CRDT state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant VM as NativeRichEditorViewModel
    participant AS as AppState
    participant Q as OfflineMutationQueue
    participant DB as CachedCRDTDocument
    participant Sync as NativeEditorOfflineCRDTSynchronizer
    participant Server as Collaboration Server

    Note over VM,Server: Online save path
    VM->>AS: updateCollaborativePageTitle(crdtStateUpdate:)
    AS->>DB: persistCRDTStateUpdate(pageID:update:)
    AS->>Server: REST page update
    Server-->>AS: updated page

    Note over VM,Server: Offline save path
    VM->>AS: updateCollaborativePageTitle(crdtStateUpdate:)
    AS->>Q: supersedePendingCRDTPageUpdate(stateUpdate:)
    Q-->>AS: .enqueued / .superseded
    AS->>DB: persistCRDTStateUpdate(pageID:update:)

    Note over AS,Server: Queue replay (back online)
    AS->>Q: next .updatePageCRDT mutation
    AS->>AS: makeDocumentEngine then applyRemoteUpdate(queuedState)
    AS->>AS: flushPendingLocalChanges then preparedState
    AS->>DB: persistCRDTStateUpdate(preparedState)
    AS->>Server: GET /api/auth/collab-token
    Server-->>AS: token
    AS->>Sync: synchronize(engine, url, token)
    Sync->>Server: WebSocket Yjs sync
    Server-->>Sync: authenticated + syncStatus(true)
    Sync-->>AS: done
    AS->>DB: persistCRDTStateUpdate(mergedState)
    AS->>Server: GET /api/pages/info title reconcile
    Server-->>AS: serverPage
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant VM as NativeRichEditorViewModel
    participant AS as AppState
    participant Q as OfflineMutationQueue
    participant DB as CachedCRDTDocument
    participant Sync as NativeEditorOfflineCRDTSynchronizer
    participant Server as Collaboration Server

    Note over VM,Server: Online save path
    VM->>AS: updateCollaborativePageTitle(crdtStateUpdate:)
    AS->>DB: persistCRDTStateUpdate(pageID:update:)
    AS->>Server: REST page update
    Server-->>AS: updated page

    Note over VM,Server: Offline save path
    VM->>AS: updateCollaborativePageTitle(crdtStateUpdate:)
    AS->>Q: supersedePendingCRDTPageUpdate(stateUpdate:)
    Q-->>AS: .enqueued / .superseded
    AS->>DB: persistCRDTStateUpdate(pageID:update:)

    Note over AS,Server: Queue replay (back online)
    AS->>Q: next .updatePageCRDT mutation
    AS->>AS: makeDocumentEngine then applyRemoteUpdate(queuedState)
    AS->>AS: flushPendingLocalChanges then preparedState
    AS->>DB: persistCRDTStateUpdate(preparedState)
    AS->>Server: GET /api/auth/collab-token
    Server-->>AS: token
    AS->>Sync: synchronize(engine, url, token)
    Sync->>Server: WebSocket Yjs sync
    Server-->>Sync: authenticated + syncStatus(true)
    Sync-->>AS: done
    AS->>DB: persistCRDTStateUpdate(mergedState)
    AS->>Server: GET /api/pages/info title reconcile
    Server-->>AS: serverPage
Loading

Reviews (1): Last reviewed commit: "feat: polish page navigation and discove..." | Re-trigger Greptile

Comment on lines +12 to +13
.padding(.horizontal, 10)
.padding(.vertical, 6)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded padding values

.padding(.horizontal, 10) and .padding(.vertical, 6) are literal point values. The project style guide requires avoiding hardcoded padding and spacing in favor of system-default spacing so the layout can adapt to Dynamic Type and platform conventions. Consider removing both explicit padding calls or using .padding(.horizontal) / .padding(.vertical) with no argument to accept the default inset.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +69 to +89
}

private func waitForSynchronization(
events: AsyncThrowingStream<NativeEditorCollaborationEvent, any Error>
) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
var hasWriteAccess = false
for try await event in events {
switch event {
case .authenticated(let scope):
guard scope.allowsLocalDocumentUpdates else {
throw NativeEditorOfflineCRDTSyncError.writeAccessRequired
}
hasWriteAccess = true
case .syncStatus(true) where hasWriteAccess:
return
case .awareness, .stateless, .syncStatus:
continue
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 syncStatus(true) before authenticated silently drops the sync completion

hasWriteAccess starts as false. If the stream delivers .syncStatus(true) before the .authenticated event — possible if the server signals sync completion mid-handshake — the where hasWriteAccess guard fails and the event is swallowed by the case .awareness, .stateless, .syncStatus: continue arm. A subsequent .authenticated sets the flag, but no further syncStatus(true) will arrive, so the loop drains until the stream closes and throws disconnectedBeforeSync. The replay then propagates that error and the mutation stays in the offline queue, even though the actual sync succeeded. The 30-second timeout also fires, blocking the reconciliation loop unnecessarily.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a24795903

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

throw OfflinePageUpdateReplayConflict(pageID: pageId)
}

_ = try await saveLocalEditableDraft(pageId: pageId, title: persistedTitle, document: document)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cache the merged CRDT document after replay

When an offline CRDT edit reconnects after another collaborator has changed the document, synchronize(...) can merge remote updates into the engine before this cache write, but this line persists the original queued ProseMirror snapshot rather than the engine's merged document. Because the replay record is removed immediately afterward, offline editable loads can keep showing the stale pre-merge body until the page is fetched online again; persist a fresh snapshot from the engine after synchronization instead of document.

Useful? React with 👍 / 👎.

Comment on lines +358 to +363
return .updatePageCRDT(
pageId: pageId,
title: title,
document: patchedDocument,
stateUpdate: stateUpdate,
baseTitle: baseTitle

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Patch queued CRDT state when replacing comment IDs

When an offline inline comment is created before a queued CRDT page update, replaceQueuedInlineCommentID reaches this branch after the comment receives its server ID, but only the ProseMirror projection is patched while the Yjs stateUpdate that replay actually applies and synchronizes is carried forward unchanged. In that scenario the server document can still end up with the temporary offline-comment-* mark even though the local projection now shows the server comment ID, so the CRDT state needs the same replacement or the replay should be regenerated from the patched document.

Useful? React with 👍 / 👎.

Comment on lines +84 to +85
case .syncStatus(true) where hasWriteAccess:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait until local CRDT updates are sent

For an offline replay with buffered local updates, this returns as soon as the initial collaboration sync reports true, but the local update sender that drains syncDriver.localUpdates() runs in a separate task and is never awaited before synchronize disconnects the WebSocket. If the sync status arrives before that sender has sent the queued Yjs update, the replay is removed even though the offline edit never reached the server; wait for the local update stream/send completion (or an acknowledgement after sending) before treating the replay as synchronized.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant