mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-26 11:35:05 +02:00
✈️(frontend) add offline support with yhub
The offline support couldn't work with the existing implementation anymore, because there is no request to get or save data anymore, everything is handled with web sockets. In order to support offline functionality, we leveraged y-indexeddb to store and synchronize local changes, ensuring that the application remains functional even when offline.
This commit is contained in:
@@ -18,6 +18,7 @@ export const CONFIG = {
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED: false,
|
||||
AI_FEATURE_LEGACY_ENABLED: true,
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
|
||||
COLLABORATION_LOCAL_DOC_RETENTION_DAYS: 30,
|
||||
COLLABORATION_VERSION_GRANULARITY_MS: 60000,
|
||||
COLLABORATION_WS_INACTIVITY_TIMEOUT: 15,
|
||||
COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL,
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@floating-ui/react": "^0.27.19",
|
||||
"@floating-ui/react": "0.27.19",
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.3.3",
|
||||
"@fontsource/material-icons": "5.3.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"styled-components": "6.5.3",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.2",
|
||||
"y-indexeddb": "9.0.12",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"y-protocols": "1.0.7",
|
||||
"y-websocket": "3.1.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { sweepLocalDocs } from '@/docs/doc-management/localDocs';
|
||||
import { useAuthQuery } from '@/features/auth';
|
||||
import {
|
||||
useCustomTranslations,
|
||||
@@ -65,6 +66,16 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
setTheme(conf.FRONTEND_THEME);
|
||||
}, [conf?.FRONTEND_THEME, setTheme]);
|
||||
|
||||
/**
|
||||
* Offline local document sweep based on retention days.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!conf?.COLLABORATION_LOCAL_DOC_RETENTION_DAYS) {
|
||||
return;
|
||||
}
|
||||
void sweepLocalDocs(conf.COLLABORATION_LOCAL_DOC_RETENTION_DAYS);
|
||||
}, [conf?.COLLABORATION_LOCAL_DOC_RETENTION_DAYS]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.POSTHOG_KEY || !conf?.POSTHOG_HOST) {
|
||||
return;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface ConfigResponse {
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED?: boolean;
|
||||
AI_FEATURE_LEGACY_ENABLED?: boolean;
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH?: number;
|
||||
COLLABORATION_LOCAL_DOC_RETENTION_DAYS?: number;
|
||||
COLLABORATION_VERSION_GRANULARITY_MS?: number;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
COLLABORATION_WS_INACTIVITY_TIMEOUT?: number | null;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useConfig } from '../api';
|
||||
* Where the collaboration server's rooms live, independent of which document is
|
||||
* being opened. Kept apart so the two hooks below cannot answer differently.
|
||||
*/
|
||||
const useCollaborationBaseUrl = () => {
|
||||
export const useCollaborationUrl = () => {
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
return (
|
||||
@@ -17,17 +17,6 @@ const useCollaborationBaseUrl = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const useCollaborationUrl = (room?: string) => {
|
||||
const baseUrl = useCollaborationBaseUrl();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The room is appended to the base URL by the provider (y-websocket)
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* y/hub serves the same rooms over two transports, mounted side by side under one prefix:
|
||||
* `{prefix}/ws/v1/{org}/{docid}` for the websocket and `{prefix}/ydoc/v1/{org}/{docid}` over
|
||||
@@ -65,7 +54,7 @@ export const collaborationHttpTarget = (wsUrl: string) => {
|
||||
* serves.
|
||||
*/
|
||||
export const useCollaborationTarget = (): CollaborationTarget | undefined => {
|
||||
const baseUrl = useCollaborationBaseUrl();
|
||||
const baseUrl = useCollaborationUrl();
|
||||
|
||||
return baseUrl ? collaborationHttpTarget(baseUrl) : undefined;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useBroadcastStore } from '@/stores/useBroadcastStore';
|
||||
* been permitted to send — but never looser.
|
||||
*/
|
||||
export const useCollaboration = (room: string, readOnly = false) => {
|
||||
const collaborationUrl = useCollaborationUrl(room);
|
||||
const collaborationUrl = useCollaborationUrl();
|
||||
const { addTask } = useBroadcastStore();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
export const AlertOffline = () => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="--docs--alert-offline"
|
||||
aria-label={t('Alert offline document')}
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$padding="xs"
|
||||
$flex={1}
|
||||
$gap="3xs"
|
||||
$justify="space-between"
|
||||
$theme="warning"
|
||||
>
|
||||
<Box $withThemeInherited $direction="row" $align="center" $gap="2xs">
|
||||
<Icon
|
||||
$withThemeInherited
|
||||
iconName="android_wifi_4_bar_off"
|
||||
variant="symbols-outlined"
|
||||
$shrink="0"
|
||||
/>
|
||||
<Text $theme="warning">
|
||||
{t(
|
||||
"You're offline. You can keep editing, and your changes will sync automatically once you're back online.",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
useDocTitleUpdate,
|
||||
useDocUtils,
|
||||
} from '@/docs/doc-management';
|
||||
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
|
||||
|
||||
import { AlertOffline } from './AlertOffline';
|
||||
import { AlertRestore } from './AlertRestore';
|
||||
import { DocHeaderInfo } from './DocHeaderInfo';
|
||||
import { DocTitle } from './DocTitle';
|
||||
@@ -24,9 +26,11 @@ interface DocHeaderProps {
|
||||
export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
// Emoji Management
|
||||
const isOffline = useIsOffline((state) => state.isOffline);
|
||||
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
|
||||
const { isTopRoot } = useDocUtils(doc);
|
||||
const displayEmojiButton = doc.abilities.partial_update && !isTopRoot;
|
||||
const latestTitleRef = useRef(doc.title ?? '');
|
||||
@@ -64,6 +68,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
}}
|
||||
>
|
||||
{isDeletedDoc && <AlertRestore doc={doc} />}
|
||||
{isOffline && <AlertOffline />}
|
||||
</Box>
|
||||
<Box $gap="sm">
|
||||
<Box>
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { rememberLocalDoc, sweepLocalDocs } from '../localDocs';
|
||||
|
||||
// stands in for the instance's COLLABORATION_LOCAL_DOC_RETENTION_DAYS
|
||||
const RETENTION_DAYS = 30;
|
||||
const sweep = (days: number = RETENTION_DAYS) => sweepLocalDocs(days);
|
||||
|
||||
/**
|
||||
* A Map-backed stand-in for the index database. jsdom has no `indexedDB`, and
|
||||
* the module only needs `get` / `put` / `delete` / `getAllKeys` / `close` off
|
||||
* one store.
|
||||
*/
|
||||
const store = new Map<string, unknown>();
|
||||
|
||||
vi.mock('idb', () => ({
|
||||
openDB: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
get: (_s: string, key: string) => Promise.resolve(store.get(key)),
|
||||
getAll: (_s: string) => Promise.resolve([...store.values()]),
|
||||
getAllKeys: (_s: string) => Promise.resolve([...store.keys()]),
|
||||
put: (_s: string, value: unknown, key: string) => {
|
||||
store.set(key, value);
|
||||
return Promise.resolve(key);
|
||||
},
|
||||
delete: (_s: string, key: string) => {
|
||||
store.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
close: () => undefined,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const mockedClearDocument = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('y-indexeddb', () => ({
|
||||
clearDocument: (name: string) => mockedClearDocument(name),
|
||||
}));
|
||||
|
||||
const uuid = (n: number) =>
|
||||
`0000000${n}-0000-4000-8000-000000000000`.slice(-36);
|
||||
|
||||
const daysAgo = (days: number) => Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
const seedIndex = (entries: Record<string, number>) => {
|
||||
for (const [id, at] of Object.entries(entries)) {
|
||||
store.set(id, at);
|
||||
}
|
||||
};
|
||||
|
||||
/** Make `indexedDB.databases()` report these names present on the origin. */
|
||||
const stubDatabases = (names: string[]) =>
|
||||
vi.stubGlobal('indexedDB', {
|
||||
databases: () => Promise.resolve(names.map((name) => ({ name }))),
|
||||
});
|
||||
|
||||
describe('localDocs', () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
// most tests are about the index alone; the enumeration path is opt-in
|
||||
vi.stubGlobal('indexedDB', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('remembers a document that was opened', async () => {
|
||||
await rememberLocalDoc(uuid(1));
|
||||
|
||||
expect(store.get(uuid(1))).toBeGreaterThan(daysAgo(1));
|
||||
});
|
||||
|
||||
it('moves a document back out of reach of the sweep when reopened', async () => {
|
||||
seedIndex({ [uuid(1)]: daysAgo(90) });
|
||||
|
||||
await rememberLocalDoc(uuid(1));
|
||||
|
||||
expect(await sweep()).toEqual([]);
|
||||
expect(mockedClearDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the copies of documents nobody has opened for a month', async () => {
|
||||
seedIndex({
|
||||
[uuid(1)]: daysAgo(1),
|
||||
[uuid(2)]: daysAgo(29),
|
||||
[uuid(3)]: daysAgo(31),
|
||||
[uuid(4)]: daysAgo(400),
|
||||
});
|
||||
|
||||
expect(await sweep()).toEqual([uuid(3), uuid(4)]);
|
||||
expect(mockedClearDocument).toHaveBeenCalledWith(uuid(3));
|
||||
expect(mockedClearDocument).toHaveBeenCalledWith(uuid(4));
|
||||
expect([...store.keys()]).toEqual([uuid(1), uuid(2)]);
|
||||
});
|
||||
|
||||
it('keeps a document just inside the limit until it passes it', async () => {
|
||||
seedIndex({
|
||||
[uuid(1)]: daysAgo(RETENTION_DAYS) + 60_000,
|
||||
});
|
||||
|
||||
expect(await sweep()).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the retention window it is given', async () => {
|
||||
seedIndex({ [uuid(1)]: daysAgo(3), [uuid(2)]: daysAgo(10) });
|
||||
|
||||
expect(await sweep(7)).toEqual([uuid(2)]);
|
||||
expect([...store.keys()]).toEqual([uuid(1)]);
|
||||
});
|
||||
|
||||
it('leaves a copy it could not drop in the index, to retry next time', async () => {
|
||||
mockedClearDocument.mockRejectedValueOnce(new Error('quota'));
|
||||
seedIndex({ [uuid(1)]: daysAgo(90) });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await sweep();
|
||||
|
||||
expect([...store.keys()]).toEqual([uuid(1)]);
|
||||
});
|
||||
|
||||
it('does nothing when this browser holds no local copy', async () => {
|
||||
expect(await sweep()).toEqual([]);
|
||||
expect(mockedClearDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('enumeration, where the browser supports it', () => {
|
||||
it('drops a copy on disk that the index never knew about', async () => {
|
||||
stubDatabases([uuid(1), 'api-docs-db', 'docs-local-index']);
|
||||
|
||||
// the index is empty; only the enumeration sees uuid(1)
|
||||
expect(await sweep()).toEqual([uuid(1)]);
|
||||
expect(mockedClearDocument).toHaveBeenCalledWith(uuid(1));
|
||||
// the app's own databases are left alone
|
||||
expect(mockedClearDocument).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps an enumerated copy that the index still vouches for', async () => {
|
||||
seedIndex({ [uuid(1)]: daysAgo(1) });
|
||||
stubDatabases([uuid(1)]);
|
||||
|
||||
expect(await sweep()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sweeps the index and the orphans in one pass', async () => {
|
||||
seedIndex({ [uuid(1)]: daysAgo(1), [uuid(2)]: daysAgo(90) });
|
||||
stubDatabases([uuid(1), uuid(2), uuid(3)]);
|
||||
|
||||
expect((await sweep()).sort()).toEqual([uuid(2), uuid(3)]);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hang on a delete the browser blocks', async () => {
|
||||
vi.useFakeTimers();
|
||||
seedIndex({ [uuid(1)]: daysAgo(90) });
|
||||
// a blocked deleteDatabase never resolves
|
||||
mockedClearDocument.mockReturnValueOnce(new Promise(() => undefined));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const swept = sweep();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(await swept).toEqual([]);
|
||||
// still in the index, for the next startup to retry
|
||||
expect([...store.keys()]).toEqual([uuid(1)]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
export * from './localDocs';
|
||||
export * from './stores';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Drop the local copy of every document that has not been opened for
|
||||
* `retentionDays` (the instance's `COLLABORATION_LOCAL_DOC_RETENTION_DAYS`),
|
||||
* plus any copy on disk that the index has lost track of where the browser lets
|
||||
* us enumerate.
|
||||
*
|
||||
* `IndexeddbPersistence` has no expiry of its own, so without this every
|
||||
* document ever opened is kept until the browser evicts it under storage
|
||||
* pressure — which it does without asking and without order.
|
||||
*
|
||||
* Run once on startup rather than on a timer: it must not run while this tab has
|
||||
* a document open — it would delete the database that document's provider is
|
||||
* writing to — and a document opened today is not a candidate anyway.
|
||||
*
|
||||
* Returns the ids it dropped, which is what the tests read.
|
||||
*/
|
||||
|
||||
import { openDB } from 'idb';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
import { clearDocument } from 'y-indexeddb';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const INDEX_DB = 'docs-local-index';
|
||||
const INDEX_STORE = 'opened';
|
||||
|
||||
const openIndex = () =>
|
||||
openDB(INDEX_DB, 1, {
|
||||
upgrade: (db) => {
|
||||
db.createObjectStore(INDEX_STORE);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* `indexedDB.deleteDatabase` blocks silently while a connection is open — a copy
|
||||
* of this document held by another tab — and never resolves. The sweep must not
|
||||
* hang on one, so a delete that has not returned in a few seconds is abandoned
|
||||
* and left for the next startup, by when that tab has likely gone.
|
||||
*/
|
||||
const DELETE_TIMEOUT_MS = 4000;
|
||||
|
||||
const drop = (docId: string) =>
|
||||
Promise.race([
|
||||
clearDocument(docId),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`deleting ${docId} timed out`)),
|
||||
DELETE_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Record that a document has just been opened, which is what keeps its local
|
||||
* copy alive.
|
||||
*
|
||||
* Best effort: if this write is lost, the sweep's enumeration still finds the
|
||||
* copy, and the worst case is that a document opened once and never again is
|
||||
* kept an extra cycle rather than dropped on time.
|
||||
*/
|
||||
export const rememberLocalDoc = async (docId: string) => {
|
||||
try {
|
||||
const db = await openIndex();
|
||||
await db.put(INDEX_STORE, Date.now(), docId);
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to record the local copy of a document',
|
||||
docId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sweepLocalDocs = async (retentionDays: number) => {
|
||||
let db;
|
||||
try {
|
||||
db = await openIndex();
|
||||
} catch (error) {
|
||||
console.error('Failed to open the local document index', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const expiry = Date.now() - retentionDays * DAY_MS;
|
||||
|
||||
// id -> last opened, from the index. `getAllKeys` and `getAll` return in the
|
||||
// same order, so they zip.
|
||||
const opened = new Map<string, number>();
|
||||
const keys = await db.getAllKeys(INDEX_STORE);
|
||||
const times = await db.getAll(INDEX_STORE);
|
||||
keys.forEach((id, i) => {
|
||||
if (typeof id === 'string' && typeof times[i] === 'number') {
|
||||
opened.set(id, times[i]);
|
||||
}
|
||||
});
|
||||
|
||||
const present = new Set<string>();
|
||||
if (typeof indexedDB !== 'undefined' && 'databases' in indexedDB) {
|
||||
try {
|
||||
for (const { name } of await indexedDB.databases()) {
|
||||
if (name && uuidValidate(name)) {
|
||||
present.add(name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enumerate the local documents', error);
|
||||
}
|
||||
}
|
||||
|
||||
const dropped: string[] = [];
|
||||
|
||||
for (const id of new Set([...opened.keys(), ...present])) {
|
||||
const at = opened.get(id);
|
||||
|
||||
// known to the index and still fresh
|
||||
if (at !== undefined && at > expiry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await drop(id);
|
||||
if (opened.has(id)) {
|
||||
await db.delete(INDEX_STORE, id);
|
||||
}
|
||||
dropped.push(id);
|
||||
} catch (error) {
|
||||
// left as it is, so the next startup tries again
|
||||
console.error('Failed to drop the local copy of a document', id, error);
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
return dropped;
|
||||
};
|
||||
+156
@@ -74,8 +74,28 @@ class FakeHttpProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in for `IndexeddbPersistence`. What the store asks of it is that it exists, that its
|
||||
* `synced` reaches `isReady` - local content is enough to render an editor - and that it is
|
||||
* detached with the document.
|
||||
*/
|
||||
class FakePersistence {
|
||||
public destroy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
private listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
(this.listeners[event] ??= []).push(listener);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
this.listeners[event]?.forEach((listener) => listener(...args));
|
||||
}
|
||||
}
|
||||
|
||||
let provider: FakeProvider;
|
||||
let httpProvider: FakeHttpProvider;
|
||||
let persistence: FakePersistence;
|
||||
let stopFallback: ReturnType<typeof vi.fn>;
|
||||
|
||||
vi.mock('y-websocket', () => ({
|
||||
@@ -85,6 +105,27 @@ vi.mock('y-websocket', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { IndexeddbPersistenceMock } = vi.hoisted(() => ({
|
||||
IndexeddbPersistenceMock: vi.fn(function (..._args: unknown[]) {
|
||||
return undefined as never;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('y-indexeddb', () => ({
|
||||
IndexeddbPersistence: IndexeddbPersistenceMock,
|
||||
clearDocument: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// its own IndexedDB plumbing is exercised in localDocs.test — here we only
|
||||
// check that opening a document records it
|
||||
const { rememberLocalDocMock } = vi.hoisted(() => ({
|
||||
rememberLocalDocMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../localDocs', () => ({
|
||||
rememberLocalDoc: rememberLocalDocMock,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Stands in for `createWebsocketFallback`, emulating the one reaction the store has to order
|
||||
* itself against: on `closed` the real helper starts the http provider, so the store's own
|
||||
@@ -123,7 +164,15 @@ describe('useProviderStore', () => {
|
||||
vi.useFakeTimers();
|
||||
provider = new FakeProvider();
|
||||
httpProvider = new FakeHttpProvider();
|
||||
persistence = new FakePersistence();
|
||||
stopFallback = vi.fn();
|
||||
// jsdom has none, and the store treats its absence as "no local copy"
|
||||
vi.stubGlobal('indexedDB', {});
|
||||
IndexeddbPersistenceMock.mockClear();
|
||||
IndexeddbPersistenceMock.mockImplementation(function () {
|
||||
return persistence as never;
|
||||
});
|
||||
rememberLocalDocMock.mockClear();
|
||||
createWebsocketFallback.mockClear();
|
||||
HttpProviderMock.mockClear();
|
||||
// the store is a module-level singleton: put it back to its defaults, or
|
||||
@@ -137,9 +186,12 @@ describe('useProviderStore', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps reconnecting when the connection is merely lost', () => {
|
||||
// the socket had opened before it dropped
|
||||
provider.emit('status', { status: 'connected' });
|
||||
closeWith(1006);
|
||||
vi.runAllTimers();
|
||||
|
||||
@@ -153,6 +205,33 @@ describe('useProviderStore', () => {
|
||||
expect(stopFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not refetch the document while a socket that never opened retries', () => {
|
||||
// a network that blocks websocket upgrades: the socket never connects, and
|
||||
// `connection-close` fires on every failed attempt
|
||||
closeWith(1006);
|
||||
closeWith(1006);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(provider.shouldConnect).toBe(true);
|
||||
expect(useProviderStore.getState().isPermanentlyClosed).toBe(false);
|
||||
// no refetch storm on the retry cadence — the http fallback carries the doc
|
||||
expect(useProviderStore.getState().hasLostConnection).toBe(false);
|
||||
expect(stopFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-render subscribers of the store on a repeat retry that changes nothing', () => {
|
||||
// components that read the store without a selector (most of them, here) get a new
|
||||
// object on every `set()` — even a same-value one — so a redundant `set()` on this
|
||||
// retry loop would flicker every one of them, forever
|
||||
closeWith(1006);
|
||||
const listener = vi.fn();
|
||||
useProviderStore.subscribe(listener);
|
||||
|
||||
closeWith(1006);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a deleted document', 4404],
|
||||
['a revoked access', 4401],
|
||||
@@ -283,6 +362,83 @@ describe('useProviderStore', () => {
|
||||
expect(provider.destroy).toHaveBeenCalled();
|
||||
expect(provider.awareness.destroy).toHaveBeenCalled();
|
||||
expect(provider.doc.destroy).toHaveBeenCalled();
|
||||
// detached before the document is, so the last updates are written
|
||||
expect(persistence.destroy).toHaveBeenCalled();
|
||||
expect(useProviderStore.getState().httpProvider).toBeUndefined();
|
||||
expect(useProviderStore.getState().persistence).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a local copy of the document, under its own id', () => {
|
||||
expect(IndexeddbPersistenceMock).toHaveBeenCalledTimes(1);
|
||||
expect(IndexeddbPersistenceMock.mock.calls[0][0]).toBe('doc-id');
|
||||
expect(useProviderStore.getState().persistence).toBe(persistence);
|
||||
});
|
||||
|
||||
it('renders as soon as the local copy is loaded, without waiting for a connection', () => {
|
||||
expect(useProviderStore.getState().isReady).toBe(false);
|
||||
|
||||
persistence.emit('synced');
|
||||
|
||||
expect(useProviderStore.getState().isReady).toBe(true);
|
||||
// nothing was connected: this is the offline path
|
||||
expect(useProviderStore.getState().isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('remembers the document, so the sweep leaves its copy alone', () => {
|
||||
expect(rememberLocalDocMock).toHaveBeenCalledWith('doc-id');
|
||||
});
|
||||
|
||||
it("drops a reader's http writes instead of letting the server refuse them", async () => {
|
||||
useProviderStore.getState().destroyProvider();
|
||||
HttpProviderMock.mockClear();
|
||||
const realFetch = vi.fn().mockResolvedValue(new Response(null));
|
||||
vi.stubGlobal('fetch', realFetch);
|
||||
|
||||
useProviderStore
|
||||
.getState()
|
||||
.createProvider(
|
||||
'ws://localhost/collaboration/ws/v1/docs',
|
||||
'doc-id',
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { fetch: providerFetch } = HttpProviderMock.mock.calls[0][3] as {
|
||||
fetch: (input: string, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A reader's PATCH would take a 403, and a 4xx stops the provider for good -
|
||||
* before its first GET, since the PATCH comes first in a round. The socket
|
||||
* drops a reader's updates and stays open; this makes http agree.
|
||||
*/
|
||||
const patched = await providerFetch('http://collab/ydoc/v1/docs/doc-id', {
|
||||
method: 'PATCH',
|
||||
});
|
||||
|
||||
expect(patched.status).toBe(204);
|
||||
expect(realFetch).not.toHaveBeenCalled();
|
||||
|
||||
// reading is what a reader is allowed to do, and still goes to the network
|
||||
await providerFetch('http://collab/ydoc/v1/docs/doc-id');
|
||||
|
||||
expect(realFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still builds an editor in a browser that has no indexeddb', () => {
|
||||
useProviderStore.getState().destroyProvider();
|
||||
vi.stubGlobal('indexedDB', undefined);
|
||||
IndexeddbPersistenceMock.mockClear();
|
||||
|
||||
useProviderStore
|
||||
.getState()
|
||||
.createProvider('ws://localhost/collaboration/ws/v1/docs', 'doc-id');
|
||||
|
||||
expect(IndexeddbPersistenceMock).not.toHaveBeenCalled();
|
||||
expect(useProviderStore.getState().persistence).toBeUndefined();
|
||||
// the connection still drives the editor, exactly as before
|
||||
expect(useProviderStore.getState().provider).toBe(provider);
|
||||
});
|
||||
});
|
||||
|
||||
+74
-5
@@ -1,4 +1,5 @@
|
||||
import { HttpProvider, createWebsocketFallback } from '@y/yhub-http-fallback';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
@@ -6,6 +7,8 @@ import { create } from 'zustand';
|
||||
import { collaborationHttpTarget } from '@/core/config/hooks/useCollaborationUrl';
|
||||
import { Base64 } from '@/docs/doc-management';
|
||||
|
||||
import { rememberLocalDoc } from '../localDocs';
|
||||
|
||||
/**
|
||||
* `readOnly` decides whether this client may publish presence. It has to be known
|
||||
* when the providers are built, not merely when the editor renders: the http
|
||||
@@ -29,6 +32,7 @@ export interface UseCollaborationStore {
|
||||
resumeFromInactivity: () => void;
|
||||
provider: WebsocketProvider | undefined;
|
||||
httpProvider: HttpProvider | undefined;
|
||||
persistence: IndexeddbPersistence | undefined;
|
||||
isConnected: boolean;
|
||||
isReady: boolean;
|
||||
isSynced: boolean;
|
||||
@@ -42,6 +46,7 @@ export interface UseCollaborationStore {
|
||||
const defaultValues = {
|
||||
provider: undefined,
|
||||
httpProvider: undefined,
|
||||
persistence: undefined,
|
||||
isConnected: false,
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
@@ -89,6 +94,35 @@ const suspendFallback = (httpProvider: HttpProvider | undefined) => {
|
||||
httpProvider?.disconnect();
|
||||
};
|
||||
|
||||
/**
|
||||
* What a reader's `PATCH /ydoc` becomes: dropped here, and reported as accepted.
|
||||
* A reader may not write.
|
||||
*/
|
||||
const readerWriteDropped = () =>
|
||||
Promise.resolve(new Response(null, { status: 204 }));
|
||||
|
||||
/**
|
||||
* Keep a local copy of the document, when the browser lets us.
|
||||
*
|
||||
* `indexedDB` is absent more often than it looks - a browser told to block site
|
||||
* data, some private windows, and every non-browser context this module is
|
||||
* imported into. Local persistence is a convenience, so a browser without it
|
||||
* gets an editor that works exactly as it did before rather than no editor:
|
||||
* `undefined` here, and every caller treats that as "no local copy".
|
||||
*/
|
||||
const createPersistence = (storeId: string, doc: Y.Doc) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new IndexeddbPersistence(storeId, doc);
|
||||
} catch (error) {
|
||||
console.error('Failed to open the local copy of the document', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDoc, { readOnly = false } = {}) => {
|
||||
@@ -100,6 +134,23 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for the offline mode, it keeps a local copy of the document in IndexedDB so that
|
||||
* the editor can display the last known state even when the network is unavailable.
|
||||
*/
|
||||
const persistence = createPersistence(storeId, doc);
|
||||
|
||||
if (persistence) {
|
||||
// Record the local copy immediately to prevent it from being swept as an orphan.
|
||||
void rememberLocalDoc(storeId);
|
||||
|
||||
/**
|
||||
* The editor waits on `isReady` (see `DocEditor`), and local content is enough to render:
|
||||
* whatever the connection then brings merges into what is already on screen.
|
||||
*/
|
||||
persistence.on('synced', () => set({ isReady: true }));
|
||||
}
|
||||
|
||||
const provider = new WebsocketProvider(wsUrl, storeId, doc, {
|
||||
// BroadcastChannel would bypass server auth
|
||||
disableBc: true,
|
||||
@@ -149,7 +200,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
gc: true,
|
||||
// the session cookie is the credential here too, exactly as on the ws upgrade
|
||||
fetch: (input, init) =>
|
||||
fetch(input, { ...init, credentials: 'include' }),
|
||||
readOnly && init?.method === 'PATCH'
|
||||
? readerWriteDropped()
|
||||
: fetch(input, { ...init, credentials: 'include' }),
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
@@ -185,9 +238,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// The editor renders from the last snapshot, and the http fallback takes over, while
|
||||
// y-websocket retries
|
||||
set({ isConnected: false, isReady: true });
|
||||
const { isConnected: wasConnected, isReady: wasReady } = get();
|
||||
|
||||
// This also fires on every failed reconnect attempt - forever, on a network that never
|
||||
// lets the socket open. Skip the `set()` once these are already at this value, or a
|
||||
// same-value write still hands every no-selector subscriber a new object to re-render on.
|
||||
if (wasConnected || !wasReady) {
|
||||
set({ isConnected: false, isReady: true });
|
||||
}
|
||||
|
||||
// Only a connection that had actually opened can have been *lost* in a way that means
|
||||
// our access changed. A socket that never opens retries forever; refetching the document
|
||||
// on each attempt would only thrash the query while the http fallback carries it fine.
|
||||
if (!wasConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(lostConnectionTimeout);
|
||||
// Jitter spreading: Math.random() generates a random delay to avoid
|
||||
@@ -230,12 +295,13 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
set({
|
||||
provider,
|
||||
httpProvider,
|
||||
persistence,
|
||||
});
|
||||
|
||||
return provider;
|
||||
},
|
||||
destroyProvider: () => {
|
||||
const { provider, httpProvider } = get();
|
||||
const { provider, httpProvider, persistence } = get();
|
||||
|
||||
stopFallback?.();
|
||||
stopFallback = undefined;
|
||||
@@ -243,6 +309,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
// publishes a farewell awareness state, best effort, so the others see us leave
|
||||
httpProvider?.destroy();
|
||||
|
||||
// Destroy the persistence layer, which keeps the local copy of the document.
|
||||
void persistence?.destroy();
|
||||
|
||||
if (provider) {
|
||||
/**
|
||||
* destroy() emits 'connection-close' synchronously before removing
|
||||
|
||||
@@ -12,8 +12,6 @@ interface OptionsReadonly {
|
||||
type: 'list' | 'item';
|
||||
}
|
||||
|
||||
// TODO(yhub): Used to work offline, we need to implement the patch mechanism
|
||||
// It will be probably linked to the HTTP fallback mechanism of yhub
|
||||
interface OptionsMutate {
|
||||
type: 'update' | 'delete' | 'create';
|
||||
}
|
||||
|
||||
@@ -27,6 +27,37 @@ export const isApiUrl = (href: string) => {
|
||||
const isDocumentApiUrl = (url: URL) =>
|
||||
isApiUrl(url.href) && /.*\/documents\/([a-z0-9-]+)\/$/g.test(url.href);
|
||||
|
||||
const isCollaborationUrl = (url: URL, endpoint: string) =>
|
||||
new RegExp(`/${endpoint}/v1/[^/]+/[^/]+/?$`).test(url.pathname);
|
||||
|
||||
/**
|
||||
* The collaboration server's rest api: document content (`ydoc`), the editing
|
||||
* history (`activity`, `changeset`) and the restore it feeds (`rollback`).
|
||||
*
|
||||
* `NetworkOnly`, and not by default: the server is on the app's own origin
|
||||
* unless an instance moves it, so without a route here these fall into the
|
||||
* catch-all `StaleWhileRevalidate` and get served from cache - a document
|
||||
* frozen at first read (offline, indistinguishable from a synced round), or a
|
||||
* version list missing every version since.
|
||||
*
|
||||
* Offline content is handled at the doc, not here: the websocket never reaches
|
||||
* a service worker, so `IndexeddbPersistence` owns it - see `useProviderStore`.
|
||||
*/
|
||||
[
|
||||
{ endpoint: 'ydoc', methods: ['GET', 'PATCH'] as const },
|
||||
{ endpoint: 'activity', methods: ['GET'] as const },
|
||||
{ endpoint: 'changeset', methods: ['GET'] as const },
|
||||
{ endpoint: 'rollback', methods: ['POST'] as const },
|
||||
].forEach(({ endpoint, methods }) => {
|
||||
methods.forEach((method) => {
|
||||
registerRoute(
|
||||
({ url }) => isCollaborationUrl(url, endpoint),
|
||||
new NetworkOnly({ plugins: [new OfflinePlugin()] }),
|
||||
method,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* API routes
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@ type AppPropsWithLayout = AppProps & {
|
||||
export default function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||
useSWRegister();
|
||||
useOffline();
|
||||
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -2077,7 +2077,7 @@
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.7.6"
|
||||
|
||||
"@floating-ui/react@^0.27.18", "@floating-ui/react@^0.27.19":
|
||||
"@floating-ui/react@0.27.19", "@floating-ui/react@^0.27.18", "@floating-ui/react@^0.27.19":
|
||||
version "0.27.19"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.19.tgz#d8d5d895b7cb97dac370bfbf55f3e630878fdf1f"
|
||||
integrity sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==
|
||||
@@ -11726,7 +11726,7 @@ lib0@1.0.0-rc.22:
|
||||
resolved "https://registry.yarnpkg.com/lib0/-/lib0-1.0.0-rc.22.tgz#c154151f5188009afc7f73e1de3f4888011e6fb9"
|
||||
integrity sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==
|
||||
|
||||
lib0@^0.2.102, lib0@^0.2.109, lib0@^0.2.99:
|
||||
lib0@^0.2.102, lib0@^0.2.109, lib0@^0.2.74, lib0@^0.2.99:
|
||||
version "0.2.117"
|
||||
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716"
|
||||
integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==
|
||||
@@ -16220,6 +16220,13 @@ xtend@~4.0.1:
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
|
||||
y-indexeddb@9.0.12:
|
||||
version "9.0.12"
|
||||
resolved "https://registry.yarnpkg.com/y-indexeddb/-/y-indexeddb-9.0.12.tgz#73657f31d52886d7532256610babf5cca4ad5e58"
|
||||
integrity sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==
|
||||
dependencies:
|
||||
lib0 "^0.2.74"
|
||||
|
||||
y-prosemirror@1.3.7:
|
||||
version "1.3.7"
|
||||
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.3.7.tgz#f88e553da4ea33278b114cf0b6a0ea978b154e84"
|
||||
|
||||
Reference in New Issue
Block a user