From 873fb49de943d2d735fe2b68861e443a5ef2a5c5 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 26 May 2026 10:09:15 +0800 Subject: [PATCH 1/3] Align default document initialization ids --- .../slate-yjs/__tests__/yjs-utils.test.ts | 106 ++++++++++++++++-- src/application/slate-yjs/utils/yjs.ts | 84 ++++++++++---- 2 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/application/slate-yjs/__tests__/yjs-utils.test.ts b/src/application/slate-yjs/__tests__/yjs-utils.test.ts index 03eda8729..8b7824c69 100644 --- a/src/application/slate-yjs/__tests__/yjs-utils.test.ts +++ b/src/application/slate-yjs/__tests__/yjs-utils.test.ts @@ -3,8 +3,11 @@ import * as Y from 'yjs'; import { pageIdFromDocumentId, + defaultDocumentInitClientId, initializeDocumentStructure, createEmptyDocument, + deleteBlock, + getDocument, } from '../utils/yjs'; import { YjsEditorKey, BlockType, YSharedRoot } from '@/application/types'; @@ -19,11 +22,12 @@ describe('pageIdFromDocumentId', () => { // Should be a valid UUID format expect(pageId1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(pageId1).toBe('69d1e3fb-c2f3-5156-b8e3-636273bad252'); }); it('should generate different page_ids for different document_ids', () => { const documentId1 = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; - const documentId2 = '7f02259c-f53b-67c2-c1b1-69gcbb42663e'; + const documentId2 = '7f02259c-f53b-67c2-a1b1-69fcbb42663e'; const pageId1 = pageIdFromDocumentId(documentId1); const pageId2 = pageIdFromDocumentId(documentId2); @@ -31,15 +35,12 @@ describe('pageIdFromDocumentId', () => { expect(pageId1).not.toBe(pageId2); }); - it('should handle non-UUID strings by generating UUID first', () => { - const nonUuidString = 'some-random-string'; - const pageId = pageIdFromDocumentId(nonUuidString); - - // Should still produce a valid UUID - expect(pageId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + it('should reject non-UUID document ids', () => { + expect(() => pageIdFromDocumentId('vxWayiyi2Q')).toThrow('documentId must be a valid UUID string'); + }); - // Should be deterministic - expect(pageIdFromDocumentId(nonUuidString)).toBe(pageId); + it('should generate deterministic initial Yjs client id from document id', () => { + expect(defaultDocumentInitClientId('6e91148b-e42a-56b1-b9a0-58fbaa31552d')).toBe(2957736978); }); }); @@ -112,6 +113,93 @@ describe('initializeDocumentStructure', () => { expect(pageId).toBe(expectedPageId); }); + it('should use document-scoped initial paragraph ids when documentId is provided', () => { + const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; + initializeDocumentStructure(doc, true, documentId); + + const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const document = sharedRoot.get(YjsEditorKey.document); + const pageId = document.get(YjsEditorKey.page_id); + const blocks = document.get(YjsEditorKey.blocks); + const meta = document.get(YjsEditorKey.meta); + const childrenMap = meta.get(YjsEditorKey.children_map); + const textMap = meta.get(YjsEditorKey.text_map); + const pageChildren = childrenMap.get(pageId); + const paragraphId = pageChildren.get(0); + const paragraphBlock = blocks.get(paragraphId); + + expect(paragraphId).toBe('TdtM1tmgqJ'); + expect(paragraphBlock.get(YjsEditorKey.block_children)).toBe('NyYr0DfnAu'); + expect(paragraphBlock.get(YjsEditorKey.block_external_id)).toBe('VbaxI-lEf5'); + expect(childrenMap.has('NyYr0DfnAu')).toBe(true); + expect(textMap.has('VbaxI-lEf5')).toBe(true); + }); + + it('should merge edits from two independently initialized default documents', () => { + const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; + const first = new Y.Doc(); + const second = new Y.Doc(); + const firstClientId = first.clientID; + const secondClientId = second.clientID; + + initializeDocumentStructure(first, true, documentId); + initializeDocumentStructure(second, true, documentId); + + expect(first.clientID).toBe(firstClientId); + expect(second.clientID).toBe(secondClientId); + + const firstRoot = first.getMap(YjsEditorKey.data_section) as YSharedRoot; + const secondRoot = second.getMap(YjsEditorKey.data_section) as YSharedRoot; + const firstTextMap = getDocument(firstRoot).get(YjsEditorKey.meta).get(YjsEditorKey.text_map); + const secondTextMap = getDocument(secondRoot).get(YjsEditorKey.meta).get(YjsEditorKey.text_map); + const firstText = firstTextMap.get('VbaxI-lEf5') as Y.Text; + const secondText = secondTextMap.get('VbaxI-lEf5') as Y.Text; + + firstText.insert(0, 'client one'); + for (let i = 0; i < 25; i += 1) { + secondText.insert(secondText.length, ` b${i}`); + } + + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); + Y.applyUpdate(second, Y.encodeStateAsUpdate(first)); + Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); + + const firstFinalText = (firstTextMap.get('VbaxI-lEf5') as Y.Text).toString(); + const secondFinalText = (secondTextMap.get('VbaxI-lEf5') as Y.Text).toString(); + + expect(firstFinalText).toBe(secondFinalText); + expect(firstFinalText).toContain('client one'); + for (let i = 0; i < 25; i += 1) { + expect(firstFinalText).toContain(` b${i}`); + } + }); + + it('should clean up document-scoped paragraph children and text ids when deleted', () => { + const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; + + initializeDocumentStructure(doc, true, documentId); + + const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const document = sharedRoot.get(YjsEditorKey.document); + const pageId = document.get(YjsEditorKey.page_id); + const blocks = document.get(YjsEditorKey.blocks); + const meta = document.get(YjsEditorKey.meta); + const childrenMap = meta.get(YjsEditorKey.children_map); + const textMap = meta.get(YjsEditorKey.text_map); + const pageChildren = childrenMap.get(pageId); + const paragraphId = pageChildren.get(0); + const paragraphBlock = blocks.get(paragraphId); + const paragraphChildrenId = paragraphBlock.get(YjsEditorKey.block_children); + const paragraphTextId = paragraphBlock.get(YjsEditorKey.block_external_id); + + deleteBlock(sharedRoot, paragraphId); + + expect(blocks.has(paragraphId)).toBe(false); + expect(childrenMap.has(paragraphChildrenId)).toBe(false); + expect(textMap.has(paragraphTextId)).toBe(false); + expect(pageChildren.toArray()).toEqual([]); + }); + it('should skip initialization if document already exists', () => { // First initialization initializeDocumentStructure(doc, false); diff --git a/src/application/slate-yjs/utils/yjs.ts b/src/application/slate-yjs/utils/yjs.ts index 05f38e7d9..d8f53df84 100644 --- a/src/application/slate-yjs/utils/yjs.ts +++ b/src/application/slate-yjs/utils/yjs.ts @@ -24,9 +24,9 @@ import { } from '@/application/types'; import { Log } from '@/utils/log'; -// UUID namespace OID (same as Rust's Uuid::NAMESPACE_OID) -// Note: 6ba7b812 (not 6ba7b810 which is NAMESPACE_DNS) -const UUID_NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'; +const RUST_NANOID_SAFE_ALPHABET = '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const DEFAULT_ID_LEN = 10; +const DEFAULT_DOCUMENT_INIT_CLIENT_ROLE = 'default-document-init-client'; /** * Generate a deterministic page_id from document_id. @@ -34,8 +34,9 @@ const UUID_NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'; * * ```rust * pub fn page_id_from_document_id(document_id: &str) -> Option { - * let doc_id = document_id_from_any_string(document_id); - * Some(Uuid::new_v5(&doc_id, PAGE.as_bytes()).to_string()) + * Uuid::parse_str(document_id) + * .ok() + * .map(|doc_id| Uuid::new_v5(&doc_id, PAGE.as_bytes()).to_string()) * } * ``` * @@ -43,25 +44,49 @@ const UUID_NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'; * @returns The page_id as a UUID string */ export function pageIdFromDocumentId(documentId: string): string { - // If documentId is a valid UUID, use it directly as the namespace - // Otherwise, generate a deterministic UUID from the string (same as document_id_from_any_string) - const docUuid = uuidValidate(documentId) - ? documentId - : uuidv5(documentId, UUID_NAMESPACE_OID); + if (!uuidValidate(documentId)) { + throw new Error('documentId must be a valid UUID string'); + } // Generate page_id as UUID v5 with document_id as namespace and "page" as name - const pageId = uuidv5('page', docUuid); + const pageId = uuidv5('page', documentId); Log.debug('[pageIdFromDocumentId]', { documentId, - isValidUuid: uuidValidate(documentId), - docUuid, pageId, }); return pageId; } +function idFromDocumentId(documentId: string, role: string): string { + if (!uuidValidate(documentId)) { + throw new Error('documentId must be a valid UUID string'); + } + + return uuidv5(role, documentId); +} + +function nanoidFromDocumentId(documentId: string, role: string): string { + const uuid = idFromDocumentId(documentId, role).replace(/-/g, ''); + let id = ''; + + for (let i = 0; i < DEFAULT_ID_LEN; i += 1) { + const byte = parseInt(uuid.slice(i * 2, i * 2 + 2), 16); + + id += RUST_NANOID_SAFE_ALPHABET[byte & 0x3f]; + } + + return id; +} + +export function defaultDocumentInitClientId(documentId: string): number { + const uuid = idFromDocumentId(documentId, DEFAULT_DOCUMENT_INIT_CLIENT_ROLE).replace(/-/g, ''); + const clientId = parseInt(uuid.slice(0, 8), 16); + + return Math.max(clientId, 1); +} + export function getTextMap(sharedRoot: YSharedRoot) { const document = sharedRoot.get(YjsEditorKey.document); const meta = document.get(YjsEditorKey.meta) as YMeta; @@ -328,6 +353,7 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph = documentId, pageId, includeInitialParagraph, + initClientId: documentId ? defaultDocumentInitClientId(documentId) : undefined, }); const meta = new Y.Map(); const childrenMap = new Y.Map() as YChildrenMap; @@ -357,27 +383,41 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph = if (includeInitialParagraph) { // Create an empty paragraph block as child of page // The Slate editor requires at least one text block to render properly - const paragraphId = nanoid(8); + const paragraphId = documentId ? nanoidFromDocumentId(documentId, 'block') : nanoid(8); + const paragraphChildrenId = documentId ? nanoidFromDocumentId(documentId, 'children') : paragraphId; + const paragraphTextId = documentId ? nanoidFromDocumentId(documentId, 'text') : paragraphId; const paragraphBlock = new Y.Map(); paragraphBlock.set(YjsEditorKey.block_id, paragraphId); paragraphBlock.set(YjsEditorKey.block_type, BlockType.Paragraph); - paragraphBlock.set(YjsEditorKey.block_children, paragraphId); - paragraphBlock.set(YjsEditorKey.block_external_id, paragraphId); + paragraphBlock.set(YjsEditorKey.block_children, paragraphChildrenId); + paragraphBlock.set(YjsEditorKey.block_external_id, paragraphTextId); paragraphBlock.set(YjsEditorKey.block_external_type, YjsEditorKey.text); paragraphBlock.set(YjsEditorKey.block_data, '{}'); paragraphBlock.set(YjsEditorKey.block_parent, pageId); blocks.set(paragraphId, paragraphBlock); pageChildren.push([paragraphId]); - childrenMap.set(paragraphId, new Y.Array()); - textMap.set(paragraphId, new Y.Text()); + childrenMap.set(paragraphChildrenId, new Y.Array()); + textMap.set(paragraphTextId, new Y.Text()); } childrenMap.set(pageId, pageChildren); textMap.set(pageId, new Y.Text()); - sharedRoot.set(YjsEditorKey.document, document); + const originalClientId = doc.clientID; + + if (documentId) { + doc.clientID = defaultDocumentInitClientId(documentId); + } + + try { + sharedRoot.set(YjsEditorKey.document, document); + } finally { + if (documentId) { + doc.clientID = originalClientId; + } + } Log.debug('[initializeDocumentStructure] completed', { docGuid: doc.guid, @@ -450,6 +490,8 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { const meta = document.get(YjsEditorKey.meta) as YMeta; const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap; const textMap = meta.get(YjsEditorKey.text_map) as YTextMap; + const blockChildrenId = block.get(YjsEditorKey.block_children); + const blockExternalId = block.get(YjsEditorKey.block_external_id); const parent = getBlock(parentId, sharedRoot); @@ -467,8 +509,8 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { } blocks.delete(blockId); - childrenMap.delete(blockId); - textMap.delete(blockId); + childrenMap.delete(blockChildrenId); + textMap.delete(blockExternalId); // delete parent if it's empty column block if (parentType === BlockType.ColumnBlock && afterDeletedLength === 0) { From e53d0913bc3fb6b311e4e6531e4c846a7c76ca46 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 26 May 2026 13:29:13 +0800 Subject: [PATCH 2/3] Use UUID view id in publish snapshot fixture --- .../publish-snapshot/__fixtures__/published-page-snapshots.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts b/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts index c5458ec6d..1fb56b07c 100644 --- a/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts +++ b/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts @@ -18,7 +18,7 @@ export const publishedDocumentPayload: PublishedDocumentSnapshotPayload = { namespace: 'published-namespace', publishName: 'published-document', view: { - viewId: 'published-document-view-id', + viewId: '6e91148b-e42a-56b1-b9a0-58fbaa31552d', name: 'Published document', icon: { ty: ViewIconType.Icon, From a93dcd057ed1e595b24e32c292c6f437fcafc5d7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 26 Jun 2026 14:42:14 +0800 Subject: [PATCH 3/3] Fix embedded row document initialization --- .../slate-yjs/__tests__/yjs-utils.test.ts | 52 +---- src/application/slate-yjs/utils/yjs.ts | 23 +-- .../database-row/DatabaseRowSubDocument.tsx | 185 +++++++----------- src/components/editor/EditorContext.tsx | 8 + .../editor/__tests__/EditorContext.test.tsx | 39 ++++ 5 files changed, 124 insertions(+), 183 deletions(-) create mode 100644 src/components/editor/__tests__/EditorContext.test.tsx diff --git a/src/application/slate-yjs/__tests__/yjs-utils.test.ts b/src/application/slate-yjs/__tests__/yjs-utils.test.ts index 8b7824c69..5abaf27a7 100644 --- a/src/application/slate-yjs/__tests__/yjs-utils.test.ts +++ b/src/application/slate-yjs/__tests__/yjs-utils.test.ts @@ -2,12 +2,10 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import * as Y from 'yjs'; import { - pageIdFromDocumentId, - defaultDocumentInitClientId, - initializeDocumentStructure, createEmptyDocument, deleteBlock, - getDocument, + initializeDocumentStructure, + pageIdFromDocumentId, } from '../utils/yjs'; import { YjsEditorKey, BlockType, YSharedRoot } from '@/application/types'; @@ -38,10 +36,6 @@ describe('pageIdFromDocumentId', () => { it('should reject non-UUID document ids', () => { expect(() => pageIdFromDocumentId('vxWayiyi2Q')).toThrow('documentId must be a valid UUID string'); }); - - it('should generate deterministic initial Yjs client id from document id', () => { - expect(defaultDocumentInitClientId('6e91148b-e42a-56b1-b9a0-58fbaa31552d')).toBe(2957736978); - }); }); describe('initializeDocumentStructure', () => { @@ -135,43 +129,13 @@ describe('initializeDocumentStructure', () => { expect(textMap.has('VbaxI-lEf5')).toBe(true); }); - it('should merge edits from two independently initialized default documents', () => { + it('should not override the local Yjs client id when initializing document structure', () => { const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; - const first = new Y.Doc(); - const second = new Y.Doc(); - const firstClientId = first.clientID; - const secondClientId = second.clientID; - - initializeDocumentStructure(first, true, documentId); - initializeDocumentStructure(second, true, documentId); - - expect(first.clientID).toBe(firstClientId); - expect(second.clientID).toBe(secondClientId); - - const firstRoot = first.getMap(YjsEditorKey.data_section) as YSharedRoot; - const secondRoot = second.getMap(YjsEditorKey.data_section) as YSharedRoot; - const firstTextMap = getDocument(firstRoot).get(YjsEditorKey.meta).get(YjsEditorKey.text_map); - const secondTextMap = getDocument(secondRoot).get(YjsEditorKey.meta).get(YjsEditorKey.text_map); - const firstText = firstTextMap.get('VbaxI-lEf5') as Y.Text; - const secondText = secondTextMap.get('VbaxI-lEf5') as Y.Text; - - firstText.insert(0, 'client one'); - for (let i = 0; i < 25; i += 1) { - secondText.insert(secondText.length, ` b${i}`); - } - - Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); - Y.applyUpdate(second, Y.encodeStateAsUpdate(first)); - Y.applyUpdate(first, Y.encodeStateAsUpdate(second)); - - const firstFinalText = (firstTextMap.get('VbaxI-lEf5') as Y.Text).toString(); - const secondFinalText = (secondTextMap.get('VbaxI-lEf5') as Y.Text).toString(); - - expect(firstFinalText).toBe(secondFinalText); - expect(firstFinalText).toContain('client one'); - for (let i = 0; i < 25; i += 1) { - expect(firstFinalText).toContain(` b${i}`); - } + const originalClientId = doc.clientID; + + initializeDocumentStructure(doc, true, documentId); + + expect(doc.clientID).toBe(originalClientId); }); it('should clean up document-scoped paragraph children and text ids when deleted', () => { diff --git a/src/application/slate-yjs/utils/yjs.ts b/src/application/slate-yjs/utils/yjs.ts index d8f53df84..0799c55df 100644 --- a/src/application/slate-yjs/utils/yjs.ts +++ b/src/application/slate-yjs/utils/yjs.ts @@ -26,7 +26,6 @@ import { Log } from '@/utils/log'; const RUST_NANOID_SAFE_ALPHABET = '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const DEFAULT_ID_LEN = 10; -const DEFAULT_DOCUMENT_INIT_CLIENT_ROLE = 'default-document-init-client'; /** * Generate a deterministic page_id from document_id. @@ -80,13 +79,6 @@ function nanoidFromDocumentId(documentId: string, role: string): string { return id; } -export function defaultDocumentInitClientId(documentId: string): number { - const uuid = idFromDocumentId(documentId, DEFAULT_DOCUMENT_INIT_CLIENT_ROLE).replace(/-/g, ''); - const clientId = parseInt(uuid.slice(0, 8), 16); - - return Math.max(clientId, 1); -} - export function getTextMap(sharedRoot: YSharedRoot) { const document = sharedRoot.get(YjsEditorKey.document); const meta = document.get(YjsEditorKey.meta) as YMeta; @@ -353,7 +345,6 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph = documentId, pageId, includeInitialParagraph, - initClientId: documentId ? defaultDocumentInitClientId(documentId) : undefined, }); const meta = new Y.Map(); const childrenMap = new Y.Map() as YChildrenMap; @@ -405,19 +396,7 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph = childrenMap.set(pageId, pageChildren); textMap.set(pageId, new Y.Text()); - const originalClientId = doc.clientID; - - if (documentId) { - doc.clientID = defaultDocumentInitClientId(documentId); - } - - try { - sharedRoot.set(YjsEditorKey.document, document); - } finally { - if (documentId) { - doc.clientID = originalClientId; - } - } + sharedRoot.set(YjsEditorKey.document, document); Log.debug('[initializeDocumentStructure] completed', { docGuid: doc.guid, diff --git a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx index 445a3b6cc..a230b4e6f 100644 --- a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx +++ b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx @@ -15,7 +15,7 @@ import { useUpdateRowMetaDispatch } from '@/application/database-yjs/dispatch'; import { openCollabDB } from '@/application/db'; import { getCachedRowSubDoc, getOrCreateRowSubDoc, trackRowDocEnsure } from '@/application/services/js-services/cache'; import { YjsEditor } from '@/application/slate-yjs'; -import { dataStringTOJson, initializeDocumentStructure } from '@/application/slate-yjs/utils/yjs'; +import { dataStringTOJson } from '@/application/slate-yjs/utils/yjs'; import { BlockType, CollabOrigin, @@ -129,7 +129,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const lastIsEmptyRef = useRef(null); const pendingMetaUpdateRef = useRef | null>(null); const pendingNonEmptyRef = useRef(false); - const pendingOpenLocalRef = useRef(false); const docReadyRef = useRef(false); // Track if document is loaded to prevent retry timer from resetting it const rowDocEnsuredRef = useRef(false); // Track if row document has been ensured on server to avoid redundant API calls @@ -378,51 +377,10 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { [rowId] ); - // Fallback: Open document with local structure (when server unavailable) - const openLocalDocument = useCallback( - async (documentId: string) => { - if (!documentId) return; - try { - docReadyRef.current = false; - setDoc(null); - - // Use cached doc to preserve sync state across reopens - // This ensures the same Y.Doc instance is reused when reopening, - // preventing content loss from "different doc instance" sync replacement - const doc = await getOrCreateRowSubDoc(documentId); - - // Initialize with empty document structure if needed - // Pass true to include initial paragraph - required for Slate editor to render - // Pass documentId to ensure page_id matches server's algorithm - initializeDocumentStructure(doc, true, documentId); - - // Store metadata for sync binding - const docWithMeta = doc as YDocWithMeta; - - docWithMeta.object_id = documentId; - docWithMeta.view_id = documentId; - docWithMeta._collabType = Types.Document; - docWithMeta._syncBound = false; - - setDoc(doc); - docReadyRef.current = true; - Log.debug('[DatabaseRowSubDocument] openLocalDocument ready', { - rowId, - documentId, - }); - // eslint-disable-next-line - } catch (e: any) { - Log.error('[DatabaseRowSubDocument] openLocalDocument failed', e); - } - }, - [rowId] - ); - const handleCreateDocument = useCallback( async (documentId: string, requireServerReady: boolean = false): Promise => { if (!documentId) return false; setLoading(true); - let opened = false; let docState: Uint8Array | null = null; Log.debug('[DatabaseRowSubDocument] handleCreateDocument', { @@ -438,72 +396,63 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const localHasContent = await hasLocalDocContent(documentId); if (localHasContent) { - Log.debug('[DatabaseRowSubDocument] local doc has content; skipping server create', { + Log.debug('[DatabaseRowSubDocument] local doc has content; creating server collab before binding', { rowId, documentId, }); - await openLocalDocument(documentId); - opened = true; - return true; } - if (requireServerReady) { - if (!createRowDocument) { - Log.debug('[DatabaseRowSubDocument] createRowDocument not available, returning false'); - setLoading(false); // Clear loading on early failure - return false; - } + if (!createRowDocument) { + Log.debug('[DatabaseRowSubDocument] createRowDocument not available; cannot open synced row document', { + documentId, + requireServerReady, + }); + return false; + } - try { - Log.debug('[DatabaseRowSubDocument] calling createRowDocument', { documentId }); - docState = await createRowDocument(documentId); - Log.debug('[DatabaseRowSubDocument] createRowDocument success', { - documentId, - docStateSize: docState?.length ?? 0, - }); - } catch (e) { - Log.error('[DatabaseRowSubDocument] createRowDocument failed', e); - setLoading(false); // Clear loading on error - return false; - } - } else if (createRowDocument) { - try { - Log.debug('[DatabaseRowSubDocument] calling createRowDocument (non-blocking)', { documentId }); - docState = await createRowDocument(documentId); - Log.debug('[DatabaseRowSubDocument] createRowDocument success (non-blocking)', { - documentId, - docStateSize: docState?.length ?? 0, - }); - } catch (e) { - Log.warn('[DatabaseRowSubDocument] createRowDocument failed (continuing)', e); - // Continue to local document if server create fails. - } + try { + Log.debug('[DatabaseRowSubDocument] calling createRowDocument', { documentId, requireServerReady }); + docState = await createRowDocument(documentId); + Log.debug('[DatabaseRowSubDocument] createRowDocument success', { + documentId, + docStateSize: docState?.length ?? 0, + }); + } catch (e) { + Log.error('[DatabaseRowSubDocument] createRowDocument failed', e); + return false; } - // Use server's doc_state if available, otherwise create structure locally + // Use the server-created doc_state as the only source of default document structure. if (docState && docState.length > 0) { const success = await openDocumentWithState(documentId, docState); - if (!success) { - Log.warn('[DatabaseRowSubDocument] server doc_state invalid, falling back to local', { - documentId, - docStateSize: docState.length, - }); - await openLocalDocument(documentId); + if (success) { + return true; } - } else { - await openLocalDocument(documentId); + + Log.warn('[DatabaseRowSubDocument] server doc_state invalid; will retry without local fallback', { + documentId, + docStateSize: docState.length, + }); } - opened = true; - return true; - } finally { - if (opened || !requireServerReady) { - setLoading(false); + if (loadRowDocument) { + const loaded = await handleOpenDocument(documentId); + + if (loaded) { + return true; + } } + + Log.warn('[DatabaseRowSubDocument] row document was not opened after server create', { + documentId, + }); + return false; + } finally { + setLoading(false); } }, - [createRowDocument, hasLocalDocContent, openDocumentWithState, openLocalDocument, rowId] + [createRowDocument, handleOpenDocument, hasLocalDocContent, loadRowDocument, openDocumentWithState, rowId] ); const scheduleEnsureRowDocumentExists = useCallback(() => { @@ -531,12 +480,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { await createRowDocument(documentId); } - if (pendingOpenLocalRef.current && (exists || createRowDocument)) { - pendingOpenLocalRef.current = false; - await openLocalDocument(documentId); - setLoading(false); - } - if (pendingNonEmptyRef.current) { const editor = editorRef.current; @@ -565,7 +508,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { documentId, isDocumentEmpty, updateRowMeta, - openLocalDocument, rowId, ]); @@ -612,19 +554,17 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { retryLoadTimerRef.current = null; - // After max retries, create the document anyway unless we already have local content + // After max retries, create the document on the server. Do not initialize a local + // synced document here; the server doc_state is the source of the default structure. if (retryCount >= MAX_RETRIES) { const localHasContent = await hasLocalDocContent(documentId); if (localHasContent) { - Log.debug('[DatabaseRowSubDocument] max retries reached; local content found, opening local doc', { + Log.debug('[DatabaseRowSubDocument] max retries reached; local content found, creating server doc before binding', { rowId, documentId, retryCount, }); - await openLocalDocument(documentId); - setLoading(false); - return; } Log.debug('[DatabaseRowSubDocument] max retries reached; creating document', { @@ -632,7 +572,12 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { documentId, retryCount, }); - void handleCreateDocument(documentId, true); + const created = await handleCreateDocument(documentId, true); + + if (!created && !cancelled) { + scheduleRetry(); + } + return; } @@ -670,14 +615,19 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { return; } - // Document is empty - just open locally without creating on server. - // The server document will be created when user actually edits (via handleDocUpdate). - Log.debug('[DatabaseRowSubDocument] row meta says empty; opening local doc only (no API call)', { + // Document is empty, but the editor must still bind to a server-side + // collab before accepting edits. Otherwise a paste-and-close flow can + // enqueue the first update before the orphaned collab exists, then a + // fast reopen loads the still-empty server state over the local cache. + Log.debug('[DatabaseRowSubDocument] row meta says empty; creating row doc before opening editor', { rowId, documentId, }); - await openLocalDocument(documentId); - setLoading(false); + const created = await handleCreateDocument(documentId, false); + + if (!created && !cancelled) { + scheduleRetry(); + } return; } @@ -688,16 +638,19 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const localHasContent = await hasLocalDocContent(documentId); if (localHasContent) { - Log.debug('[DatabaseRowSubDocument] doc not found; local content found, opening local doc', { + Log.debug('[DatabaseRowSubDocument] doc not found; local content found, creating server doc before binding', { rowId, documentId, }); - await openLocalDocument(documentId); - setLoading(false); - return; } - void handleCreateDocument(documentId, true); + void (async () => { + const created = await handleCreateDocument(documentId, true); + + if (!created && !cancelled) { + scheduleRetry(); + } + })(); return; } @@ -767,11 +720,9 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { checkIfRowDocumentExists, isDocumentEmptyResolved, hasLocalDocContent, - scheduleEnsureRowDocumentExists, createRowDocument, rowId, doc, - openLocalDocument, ]); useEffect(() => { diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx index ce401c811..d24f0b85a 100644 --- a/src/components/editor/EditorContext.tsx +++ b/src/components/editor/EditorContext.tsx @@ -76,6 +76,8 @@ export interface EditorContextState { loadViewMeta?: LoadViewMeta; loadView?: LoadView; loadRowDocument?: (documentId: string) => Promise; + checkIfRowDocumentExists?: (documentId: string) => Promise; + createRowDocument?: (documentId: string) => Promise; createRow?: CreateRow; bindViewSync?: (doc: YDoc) => SyncContext | null; readSummary?: boolean; @@ -121,6 +123,8 @@ export const EditorContextProvider = ({ loadViewMeta, loadView, loadRowDocument, + checkIfRowDocumentExists, + createRowDocument, createRow, bindViewSync, readSummary, @@ -204,6 +208,8 @@ export const EditorContextProvider = ({ loadViewMeta, loadView, loadRowDocument, + checkIfRowDocumentExists, + createRowDocument, createRow, bindViewSync, readSummary, @@ -244,6 +250,8 @@ export const EditorContextProvider = ({ loadViewMeta, loadView, loadRowDocument, + checkIfRowDocumentExists, + createRowDocument, createRow, bindViewSync, readSummary, diff --git a/src/components/editor/__tests__/EditorContext.test.tsx b/src/components/editor/__tests__/EditorContext.test.tsx new file mode 100644 index 000000000..5ab8b0554 --- /dev/null +++ b/src/components/editor/__tests__/EditorContext.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; + +import { EditorContextProvider, useEditorContext } from '../EditorContext'; + +function RowDocumentContextProbe() { + const context = useEditorContext(); + + return ( +
+ ); +} + +describe('EditorContextProvider', () => { + it('preserves row document operations for embedded databases', () => { + render( + + + + ); + + const probe = screen.getByTestId('row-document-context'); + + expect(probe.getAttribute('data-has-check')).toBe('true'); + expect(probe.getAttribute('data-has-create')).toBe('true'); + expect(probe.getAttribute('data-has-load')).toBe('true'); + }); +});