From 4621b9acc2153a5f5dabf0e065859d0bd2eb2ba7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 5 May 2026 23:59:34 +0800 Subject: [PATCH] fix: preserve database name on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a new database is created, the title input auto-focuses with the child grid view's name ("Grid") because the container's name resolves a tick later. The remote-update effect skipped re-syncing while focused, so a subsequent blur (without any typing) silently persisted "Grid" via the API โ€” corrupting the container name. After refresh, the corrupted name was loaded. - TitleEditable: allow remote prop updates to flow into the contentEditable while focused as long as the user hasn't typed; skip the blur write when no user edit occurred. - DatabaseView: fall back to the breadcrumb chain to resolve the database container when the shallow outline doesn't yet include it, so the title reflects the container's name immediately after refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/app/DatabaseView.tsx | 25 ++++++- src/components/view-meta/TitleEditable.tsx | 58 +++++++------- .../DatabaseView.databaseContainer.test.tsx | 75 +++++++++++++++++++ 3 files changed, 129 insertions(+), 29 deletions(-) diff --git a/src/components/app/DatabaseView.tsx b/src/components/app/DatabaseView.tsx index 8cb7e5bd..c47315f6 100644 --- a/src/components/app/DatabaseView.tsx +++ b/src/components/app/DatabaseView.tsx @@ -1,15 +1,16 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { ViewComponentProps, ViewLayout, YDatabase, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; +import { View, ViewComponentProps, ViewLayout, YDatabase, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; import { SyncContext } from '@/application/services/js-services/sync-protocol'; +import { isDatabaseContainer } from '@/application/view-utils'; import { findView } from '@/components/_shared/outline/utils'; import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; import CalendarSkeleton from '@/components/_shared/skeleton/CalendarSkeleton'; import DocumentSkeleton from '@/components/_shared/skeleton/DocumentSkeleton'; import GridSkeleton from '@/components/_shared/skeleton/GridSkeleton'; import KanbanSkeleton from '@/components/_shared/skeleton/KanbanSkeleton'; -import { useAppOutline } from '@/components/app/app.hooks'; +import { useAppOutline, useBreadcrumb } from '@/components/app/app.hooks'; import { DATABASE_TAB_VIEW_ID_QUERY_PARAM } from '@/components/app/hooks/resolveSidebarSelectedViewId'; import { Database } from '@/components/database'; import { useContainerVisibleViewIds } from '@/components/database/hooks'; @@ -30,6 +31,7 @@ function DatabaseView(props: DatabaseViewProps) { * This is the main entry point for the database and remains constant. */ const databasePageId = viewMeta.viewId || ''; + const breadcrumbs = useBreadcrumb(); const view = useMemo(() => { if (!outline || !databasePageId) return; @@ -45,8 +47,25 @@ function DatabaseView(props: DatabaseViewProps) { embedded: viewMeta.extra?.embedded, }); + // Breadcrumb-based container fallback. The breadcrumb chain is built with + // server fetches for any ancestor missing from the shallow outline, so it + // resolves the database container even when the outline tree doesn't yet + // include the route view's parent (e.g. immediately after refresh while + // the outline still loads at depth=2). + const breadcrumbContainerView = useMemo((): View | undefined => { + if (containerView) return undefined; + if (viewMeta.extra?.embedded) return undefined; + if (!breadcrumbs?.length) return undefined; + const currentIdx = breadcrumbs.findIndex((crumb) => crumb.view_id === databasePageId); + + if (currentIdx <= 0) return undefined; + const parent = breadcrumbs[currentIdx - 1]; + + return parent && isDatabaseContainer(parent) ? parent : undefined; + }, [breadcrumbs, containerView, databasePageId, viewMeta.extra?.embedded]); + // Use container view (if present) as the "page meta" view for naming/icon operations. - const pageView = containerView || view; + const pageView = containerView || breadcrumbContainerView || view; const pageMeta = useMemo(() => { if (!pageView) { diff --git a/src/components/view-meta/TitleEditable.tsx b/src/components/view-meta/TitleEditable.tsx index f48804cc..2a96571c 100644 --- a/src/components/view-meta/TitleEditable.tsx +++ b/src/components/view-meta/TitleEditable.tsx @@ -92,19 +92,6 @@ function TitleEditable({ const blurTimerRef = useRef(); const cleanupTimerRef = useRef(); - // State checking functions - const isTyping = useCallback(() => { - return Date.now() - lastInputTimeRef.current < 500; // 500ms typing window - }, []); - - const isRecentlyUpdated = useCallback(() => { - return Date.now() - lastUpdateSentTimeRef.current < 2000; // 2s protection window - }, []); - - const isPotentialEcho = useCallback((value: string) => { - return sentValuesRef.current.has(value); - }, []); - // Cache management const cleanOldSentValues = useCallback(() => { const now = Date.now(); @@ -149,25 +136,32 @@ function TitleEditable({ // Handle remote updates with echo prevention useEffect(() => { - // Never overwrite user edits while the title is focused. - // The title uses a plain contentEditable (not Y.js CRDT), so - // last-writer-wins via the API is the correct model. - // On blur, sendUpdateImmediately sends the user's final value. - if (isFocused) { + const now = Date.now(); + const isTyping = now - lastInputTimeRef.current < 500; + const isRecentlyUpdated = now - lastUpdateSentTimeRef.current < 2000; + + // Skip if the user is actively editing โ€” preserves in-progress typing. + // Without this guard, a remote echo would clobber characters mid-keystroke. + if (isTyping || isRecentlyUpdated) { return; } - if (isTyping() || isRecentlyUpdated()) { + if (sentValuesRef.current.has(name)) { return; } - if (isPotentialEcho(name)) { + // If the title was auto-focused on mount but the user hasn't typed yet, + // allow remote updates to flow through. This handles the case where the + // initial `name` prop changes shortly after mount (e.g. database title + // resolving from the child view's "Grid" to the container's real name) + // โ€” without this, a no-op blur would persist the stale initial value. + const hasUserTyped = lastInputTimeRef.current > 0; + + if (isFocused && hasUserTyped) { return; } // Genuine remote update โ€” clean old cache entries - const now = Date.now(); - for (const [value, timestamp] of sentValuesRef.current.entries()) { if (now - timestamp > 5000) { sentValuesRef.current.delete(value); @@ -180,9 +174,14 @@ function TitleEditable({ if (currentContent !== name) { contentRef.current.textContent = name; + + // Restore cursor to end if the input is currently focused. + if (isFocused && contentRef.current === document.activeElement) { + setCursorPosition(contentRef.current, name.length); + } } } - }, [name, isTyping, isRecentlyUpdated, isPotentialEcho, isFocused]); + }, [name, isFocused]); // Initialize component useEffect(() => { @@ -230,10 +229,17 @@ function TitleEditable({ const handleBlur = useCallback(() => { Log.debug('๐Ÿ‘‹ Input blurred'); const currentText = contentRef.current?.textContent || ''; - - sendUpdateImmediately(currentText); + const hasUserTyped = lastInputTimeRef.current > 0; + + // Only persist on blur if the user actually edited the field. Auto-focus + // without typing must not overwrite the stored name with whatever stale + // text was rendered at mount time. + if (hasUserTyped) { + sendUpdateImmediately(currentText); + } + setIsFocused(false); - + blurTimerRef.current = setTimeout(() => { Log.debug('๐Ÿงน Cleaning input state after blur'); lastInputTimeRef.current = 0; diff --git a/src/pages/__tests__/DatabaseView.databaseContainer.test.tsx b/src/pages/__tests__/DatabaseView.databaseContainer.test.tsx index 19fa44b7..66d7ec0d 100644 --- a/src/pages/__tests__/DatabaseView.databaseContainer.test.tsx +++ b/src/pages/__tests__/DatabaseView.databaseContainer.test.tsx @@ -11,6 +11,7 @@ declare global { var __databaseViewTestState: | { outline?: View[]; + breadcrumbs?: View[]; capturedDatabaseProps?: unknown; capturedViewMetaProps?: unknown; } @@ -19,6 +20,7 @@ declare global { jest.mock('@/components/app/app.hooks', () => ({ useAppOutline: () => global.__databaseViewTestState?.outline, + useBreadcrumb: () => global.__databaseViewTestState?.breadcrumbs, })); jest.mock('@/components/database', () => ({ @@ -213,4 +215,77 @@ describe('DatabaseView database container', () => { expect(metaProps?.viewId).toBe(containerId); expect(metaProps?.name).toBe('New Database'); }); + + it('falls back to breadcrumb container when outline lookup fails', () => { + const containerId = 'container-id'; + const gridViewId = 'grid-view-id'; + + // Outline does NOT contain the container (simulating a stale or shallow + // outline where the container hasn't been included yet โ€” e.g. right after + // a hard refresh while loadOutline is still in flight). + const containerView: View = { + view_id: containerId, + name: 'New Database', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true, database_id: 'db-1' }, + children: [], + has_children: true, + is_published: false, + is_private: false, + }; + + global.__databaseViewTestState = { + outline: [], + breadcrumbs: [containerView, { + view_id: gridViewId, + name: 'Grid', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-1' }, + children: [], + is_published: false, + is_private: false, + parent_view_id: containerId, + }], + }; + + // viewMeta lacks parentViewId and database_id (simulating a fallback view + // fetched from the server with minimal metadata). + const viewMeta: ViewMetaProps = { + viewId: gridViewId, + name: 'Grid', + layout: ViewLayout.Grid, + icon: undefined, + extra: { is_space: false }, + workspaceId: 'workspace-id', + visibleViewIds: [], + }; + + render( + + + + ); + + const databaseProps = global.__databaseViewTestState?.capturedDatabaseProps as + | { databaseName: string } + | undefined; + const metaProps = global.__databaseViewTestState?.capturedViewMetaProps as + | { viewId?: string; name?: string } + | undefined; + + expect(databaseProps?.databaseName).toBe('New Database'); + expect(metaProps?.viewId).toBe(containerId); + expect(metaProps?.name).toBe('New Database'); + }); });