Skip to content

Commit 9a68491

Browse files
MashdorDevAntoLC
authored andcommitted
🐛(frontend) keep the loading skeleton until an empty doc has synced
A doc never opened on this device has an empty local copy, and the socket opening is not the content arriving either. Rendering on either showed the empty-doc placeholder for a moment, which reads as a doc whose content was deleted. Signed-off-by: Dor Zairi <dorzairidev@gmail.com>
1 parent c5f97ab commit 9a68491

3 files changed

Lines changed: 67 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to
1414

1515
- 🐛(backend) bypass throttling on access/me action from yhub
1616
- 🐛(frontend) switch language when the current one is untranslated #2790
17+
- 🐛(frontend) keep the loading skeleton until an empty doc has synced #2817
1718

1819
## [v6.0.0] - 2026-10-05
1920

‎src/frontend/apps/impress/src/features/docs/doc-management/stores/__tests__/useProviderStore.test.tsx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import * as Y from 'yjs';
23

34
import { useProviderStore } from '../useProviderStore';
45

@@ -377,13 +378,54 @@ describe('useProviderStore', () => {
377378
it('renders as soon as the local copy is loaded, without waiting for a connection', () => {
378379
expect(useProviderStore.getState().isReady).toBe(false);
379380

381+
// the document the store built, which the local copy loads into
382+
const doc = IndexeddbPersistenceMock.mock.calls[0][1] as Y.Doc;
383+
doc
384+
.getXmlFragment('document-store')
385+
.insert(0, [new Y.XmlElement('blockGroup')]);
380386
persistence.emit('synced');
381387

382388
expect(useProviderStore.getState().isReady).toBe(true);
383389
// nothing was connected: this is the offline path
384390
expect(useProviderStore.getState().isConnected).toBe(false);
385391
});
386392

393+
it('keeps loading when the local copy is empty, until the content is synced', () => {
394+
// a device that never opened this doc: rendering now would show the empty-doc placeholder
395+
persistence.emit('synced');
396+
provider.emit('status', { status: 'connected' });
397+
398+
expect(useProviderStore.getState().isReady).toBe(false);
399+
400+
provider.synced = true;
401+
provider.emit('sync', true);
402+
403+
expect(useProviderStore.getState().isReady).toBe(true);
404+
});
405+
406+
it('keeps loading when the socket fails on an empty copy, until the http fallback syncs', () => {
407+
// a network that blocks websocket upgrades, on a device that never opened this doc
408+
persistence.emit('synced');
409+
closeWith(1006);
410+
411+
expect(useProviderStore.getState().isReady).toBe(false);
412+
413+
httpProvider.synced = true;
414+
httpProvider.emit('sync', true);
415+
416+
expect(useProviderStore.getState().isReady).toBe(true);
417+
});
418+
419+
it('renders when the socket fails on a copy that has content', () => {
420+
const doc = IndexeddbPersistenceMock.mock.calls[0][1] as Y.Doc;
421+
doc
422+
.getXmlFragment('document-store')
423+
.insert(0, [new Y.XmlElement('blockGroup')]);
424+
closeWith(1006);
425+
426+
expect(useProviderStore.getState().isReady).toBe(true);
427+
});
428+
387429
it('remembers the document, so the sweep leaves its copy alone', () => {
388430
expect(rememberLocalDocMock).toHaveBeenCalledWith('doc-id');
389431
});

‎src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ const createPersistence = (storeId: string, doc: Y.Doc) => {
123123
}
124124
};
125125

126+
/**
127+
* An empty document is not worth rendering yet: on a device that never opened the doc it would
128+
* show the empty-doc placeholder until the server's content lands, which reads as a deleted doc.
129+
*/
130+
const hasContent = (doc: Y.Doc) =>
131+
doc.getXmlFragment('document-store').length > 0;
132+
126133
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
127134
...defaultValues,
128135
createProvider: (wsUrl, storeId, initialDoc, { readOnly = false } = {}) => {
@@ -146,9 +153,14 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
146153

147154
/**
148155
* The editor waits on `isReady` (see `DocEditor`), and local content is enough to render:
149-
* whatever the connection then brings merges into what is already on screen.
156+
* whatever the connection then brings merges into what is already on screen. An empty local
157+
* copy is not (see `hasContent`): it waits for the sync.
150158
*/
151-
persistence.on('synced', () => set({ isReady: true }));
159+
persistence.on('synced', () => {
160+
if (hasContent(doc)) {
161+
set({ isReady: true });
162+
}
163+
});
152164
}
153165

154166
const provider = new WebsocketProvider(wsUrl, storeId, doc, {
@@ -213,8 +225,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
213225
// for sockets that failed to open).
214226
if (status === 'connected') {
215227
clearTimeout(lostConnectionTimeout);
216-
// An open socket means we are authenticated (auth happens at upgrade)
217-
set({ isConnected: true, isReady: true });
228+
// An open socket means we are authenticated (auth happens at upgrade). Not ready yet:
229+
// the content arrives with the `sync` that follows.
230+
set({ isConnected: true });
218231
}
219232
});
220233

@@ -240,11 +253,16 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
240253

241254
const { isConnected: wasConnected, isReady: wasReady } = get();
242255

256+
// A closed socket is not the content arriving either: while the local copy is empty, the
257+
// `sync` of the http fallback (started by this very close) is what makes the doc ready.
258+
// Offline there is nothing to wait for, `useCollaboration` marks it ready.
259+
const isReady = wasReady || hasContent(doc);
260+
243261
// This also fires on every failed reconnect attempt - forever, on a network that never
244262
// lets the socket open. Skip the `set()` once these are already at this value, or a
245263
// same-value write still hands every no-selector subscriber a new object to re-render on.
246-
if (wasConnected || !wasReady) {
247-
set({ isConnected: false, isReady: true });
264+
if (wasConnected || isReady !== wasReady) {
265+
set({ isConnected: false, isReady });
248266
}
249267

250268
// Only a connection that had actually opened can have been *lost* in a way that means

0 commit comments

Comments
 (0)