Skip to content

Commit 06292ce

Browse files
committed
test(collaboration): fix soft-offline feature tests
Add a Phase 1 to each soft-offline test that types content and waits for Hocuspocus to persist it (content_binary present) before switching to the unreachable URL. An empty document never triggers a Y.Doc write, so IndexedDB stayed empty and Phase 2 fell into blocking-offline mode instead of soft-offline mode. For the readonly soft-offline test, Phase 1 now logs in as admin to type the seed content (readonly users cannot write), then switches to the readonly user for the offline visit. Also add blocking: param support to ConnectionErrorNoticeComponent and documents controller to display a distinct "server unreachable" message when no local IndexedDB cache exists.
1 parent 16d76d7 commit 06292ce

File tree

11 files changed

+149
-45
lines changed

11 files changed

+149
-45
lines changed

frontend/src/elements/block-note-element.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class BlockNoteElement extends HTMLElement {
113113

114114
this.renderCallback = (provider:HocuspocusProvider) => {
115115
this.reactRoot?.render(
116-
React.createElement(React.StrictMode, null, this.BlockNoteReactContainer(provider))
116+
React.createElement(React.StrictMode, null, this.BlockNoteReactContainer(provider, LiveCollaborationManager.hasLocalCache))
117117
);
118118
};
119119

@@ -138,7 +138,7 @@ class BlockNoteElement extends HTMLElement {
138138
}
139139
}
140140

141-
private BlockNoteReactContainer = (hocuspocusProvider:HocuspocusProvider) => {
141+
private BlockNoteReactContainer = (hocuspocusProvider:HocuspocusProvider, hasLocalCache:boolean) => {
142142
return React.createElement(
143143
ShadowDomWrapper,
144144
{ target: this.editorMount },
@@ -151,6 +151,7 @@ class BlockNoteElement extends HTMLElement {
151151
attachmentsUploadUrl: this.getAttribute('attachments-upload-url') ?? '',
152152
attachmentsCollectionKey: this.getAttribute('attachments-collection-key') ?? '',
153153
hocuspocusProvider: hocuspocusProvider,
154+
hasLocalCache: hasLocalCache,
154155
errorContainer: this.errorContainer,
155156
}
156157
)

frontend/src/react/OpBlockNoteContainer.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface OpBlockNoteContainerProps {
4444
attachmentsUploadUrl:string;
4545
attachmentsCollectionKey:string;
4646
hocuspocusProvider:HocuspocusProvider;
47+
hasLocalCache?:boolean;
4748
errorContainer?:HTMLElement;
4849
}
4950

@@ -54,10 +55,11 @@ export default function OpBlockNoteContainer({
5455
attachmentsUploadUrl,
5556
attachmentsCollectionKey,
5657
hocuspocusProvider,
58+
hasLocalCache = false,
5759
errorContainer,
5860
}:OpBlockNoteContainerProps) {
5961
const doc:Y.Doc = hocuspocusProvider.document;
60-
const { isLoading, offlineMode } = useCollaboration(hocuspocusProvider);
62+
const { isLoading, offlineMode, blockingOffline } = useCollaboration(hocuspocusProvider, hasLocalCache);
6163
const hadErrorRef = useRef(false);
6264

6365
// Fetch error/recovery template based on connection state
@@ -66,7 +68,7 @@ export default function OpBlockNoteContainer({
6668

6769
if (offlineMode) {
6870
hadErrorRef.current = true;
69-
void fetchConnectionTemplate('error', errorContainer);
71+
void fetchConnectionTemplate('error', errorContainer, { blocking: blockingOffline });
7072
} else if (hadErrorRef.current) {
7173
// Only fetch recovery if we previously had an error (avoid fetching on initial render)
7274
void fetchConnectionTemplate('recovery', errorContainer);
@@ -77,6 +79,14 @@ export default function OpBlockNoteContainer({
7779
return <DocumentLoadingSkeleton />;
7880
}
7981

82+
// When offline with no local cache, show only the error banner (rendered via
83+
// errorContainer) and hide the editor entirely. A fresh empty Y.Doc must not be
84+
// editable — it would be synced as authoritative server state on reconnect,
85+
// overwriting real document content.
86+
if (blockingOffline) {
87+
return null;
88+
}
89+
8090
return (
8191
<OpBlockNoteEditor
8292
activeUser={activeUser}

frontend/src/react/helpers/connection-template-fetcher.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,16 @@ function parseTurboStreamContent(html:string):string|null {
6666
export async function fetchConnectionTemplate(
6767
type:'error'|'recovery',
6868
targetElement:HTMLElement,
69+
options:{ blocking?:boolean } = {},
6970
):Promise<void> {
7071
const documentId = getDocumentIdFromUrl();
7172
if (!documentId) {
7273
console.error('Could not extract document ID from URL');
7374
return;
7475
}
7576

76-
const url = `/documents/${documentId}/render_connection_${type}`;
77+
const url = new URL(`/documents/${documentId}/render_connection_${type}`, window.location.origin);
78+
if (options.blocking) url.searchParams.set('blocking', 'true');
7779

7880
try {
7981
const response = await fetch(url, {

frontend/src/react/hooks/useCollaboration.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -125,19 +125,24 @@ function useProviderAuthError(onAuthError:() => void) {
125125
* exposes it as React state for the BlockNote editor.
126126
*
127127
* Returns:
128-
* - `isLoading` — true while waiting for the first sync after mount.
129-
* - `offlineMode` — true when the connection is lost or timed out;
130-
* the editor remains editable and changes are queued
131-
* locally (IndexedDB) until the server is reachable again.
128+
* - `isLoading` — true while waiting for the first sync after mount.
129+
* - `offlineMode` — true when the connection is lost or timed out; the editor
130+
* remains editable and changes are queued locally (IndexedDB)
131+
* until the server is reachable again (only when hasLocalCache).
132+
* - `blockingOffline` — true when offline AND IndexedDB had no cached content at
133+
* provider initialisation; the caller should hide the editor
134+
* entirely to prevent a fresh empty Y.Doc from being synced as
135+
* authoritative server state on reconnect.
132136
*
133137
* Transitions:
134-
* mount → synced : isLoading false, offlineMode false
135-
* mount → timeout (5s) : isLoading false, offlineMode true
136-
* connected → disconnect : offlineMode true
137-
* offline → re-synced : offlineMode false
138-
* any → auth error : isLoading false, offlineMode true
138+
* mount → synced : isLoading false, offlineMode false
139+
* mount → timeout (5s), hasLocalCache : isLoading false, offlineMode true
140+
* mount → timeout (5s), !hasLocalCache : isLoading false, blockingOffline true
141+
* connected → disconnect : offlineMode true
142+
* offline → re-synced : offlineMode false
143+
* any → auth error : isLoading false, offlineMode true
139144
*/
140-
function useCollaboration(provider:HocuspocusProvider) {
145+
function useCollaboration(provider:HocuspocusProvider, hasLocalCache = false) {
141146
const [isLoading, setIsLoading] = useState(true);
142147
const [offlineMode, setOfflineMode] = useState(false);
143148

@@ -168,7 +173,11 @@ function useCollaboration(provider:HocuspocusProvider) {
168173
useCollaborationProvider(provider, handleSynced, handleDisconnect);
169174
useProviderAuthError(handleAuthError);
170175

171-
return { isLoading, offlineMode } as const;
176+
// When offline with no local cache, block the editor entirely to prevent an
177+
// empty Y.Doc from being synced as the authoritative document on reconnect.
178+
const blockingOffline = offlineMode && !hasLocalCache;
179+
180+
return { isLoading, offlineMode, blockingOffline } as const;
172181
}
173182

174183
export { useCollaboration };

frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ export default class extends Controller {
125125
this.destroyIndexedDBPersistence();
126126
}
127127

128+
// Detect whether IndexedDB contained cached content for this document.
129+
// A non-trivial state vector (> 1 byte) means the Y.Doc has real operations from a previous session.
130+
const hasLocalCache = Y.encodeStateVector(ydoc).byteLength > 1;
131+
LiveCollaborationManager.setHasLocalCache(hasLocalCache);
132+
128133
// If disconnect() was called during the IndexedDB await (e.g., Turbo navigation),
129134
// abort to avoid overwriting the active provider on the new page.
130135
if (!this.element.isConnected) {

frontend/src/stimulus/helpers/live-collaboration-helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ class LiveCollaborationManagerClass {
3838
yjsDocInstance:Doc|null = null;
3939

4040
private listeners:Listener[] = [];
41+
private hasLocalCacheValue = false;
42+
43+
/**
44+
* Records whether IndexedDB had locally-cached document content at the time the
45+
* provider was set up. Used by the editor to decide between soft offline mode
46+
* (has cache → editing allowed) and hard blocking mode (no cache → editor hidden).
47+
*/
48+
setHasLocalCache(value:boolean):void {
49+
this.hasLocalCacheValue = value;
50+
}
51+
52+
get hasLocalCache():boolean {
53+
return this.hasLocalCacheValue;
54+
}
4155

4256
/**
4357
* Initializes the YJS Provider
@@ -84,6 +98,7 @@ class LiveCollaborationManagerClass {
8498
this.destroyYjsDoc();
8599

86100
this.listeners = [];
101+
this.hasLocalCacheValue = false;
87102
}
88103

89104
private destroyYjsProvider():void {

modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.html.erb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737
data: { turbo: false },
3838
size: :medium
3939
) { I18n.t("documents.show_edit_view.connection_error_notice.action") }
40-
description_key = if readonly
40+
description_key = if blocking
41+
"documents.show_edit_view.connection_error_notice.description_server_unavailable"
42+
elsif readonly
4143
"documents.show_edit_view.connection_error_notice.description_readonly"
4244
else
4345
"documents.show_edit_view.connection_error_notice.description"

modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,15 @@ class ConnectionErrorNoticeComponent < ApplicationComponent
3535

3636
alias_method :document, :model
3737

38+
def initialize(document, blocking: false)
39+
super(document)
40+
@blocking = blocking
41+
end
42+
3843
private
3944

45+
attr_reader :blocking
46+
4047
def readonly
4148
@readonly ||= User.current.allowed_in_project?(:view_documents, document.project) &&
4249
!User.current.allowed_in_project?(:manage_documents, document.project)

modules/documents/app/controllers/documents_controller.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ def render_last_saved_at
7979
end
8080

8181
def render_connection_error
82-
update_via_turbo_stream(component: Documents::ShowEditView::ConnectionErrorNoticeComponent.new(@document))
82+
blocking = ActiveRecord::Type::Boolean.new.cast(params[:blocking].presence || false)
83+
update_via_turbo_stream(
84+
component: Documents::ShowEditView::ConnectionErrorNoticeComponent.new(@document, blocking:)
85+
)
8386

8487
respond_with_turbo_streams
8588
end

modules/documents/config/locales/en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ en:
9494
description_readonly: |-
9595
You are currently offline.
9696
Real-time updates will resume once the connection is restored.
97+
description_server_unavailable: |-
98+
Unable to open document because the real-time text collaboration server is unreachable.
99+
Please contact the administrator if the problem persists.
97100
action: Try again
98101
connection_recovery_notice:
99102
description: "The connection to the real-time text collaboration server has been restored."

0 commit comments

Comments
 (0)