Skip to content

Commit 839188e

Browse files
appflowyclaude
andauthored
fix: preserve database name on create (#322)
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) <noreply@anthropic.com>
1 parent 993596b commit 839188e

3 files changed

Lines changed: 129 additions & 29 deletions

File tree

src/components/app/DatabaseView.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import { useSearchParams } from 'react-router-dom';
33

4-
import { ViewComponentProps, ViewLayout, YDatabase, YjsDatabaseKey, YjsEditorKey } from '@/application/types';
4+
import { View, ViewComponentProps, ViewLayout, YDatabase, YjsDatabaseKey, YjsEditorKey } from '@/application/types';
55
import { SyncContext } from '@/application/services/js-services/sync-protocol';
6+
import { isDatabaseContainer } from '@/application/view-utils';
67
import { findView } from '@/components/_shared/outline/utils';
78
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
89
import CalendarSkeleton from '@/components/_shared/skeleton/CalendarSkeleton';
910
import DocumentSkeleton from '@/components/_shared/skeleton/DocumentSkeleton';
1011
import GridSkeleton from '@/components/_shared/skeleton/GridSkeleton';
1112
import KanbanSkeleton from '@/components/_shared/skeleton/KanbanSkeleton';
12-
import { useAppOutline } from '@/components/app/app.hooks';
13+
import { useAppOutline, useBreadcrumb } from '@/components/app/app.hooks';
1314
import { DATABASE_TAB_VIEW_ID_QUERY_PARAM } from '@/components/app/hooks/resolveSidebarSelectedViewId';
1415
import { Database } from '@/components/database';
1516
import { useContainerVisibleViewIds } from '@/components/database/hooks';
@@ -30,6 +31,7 @@ function DatabaseView(props: DatabaseViewProps) {
3031
* This is the main entry point for the database and remains constant.
3132
*/
3233
const databasePageId = viewMeta.viewId || '';
34+
const breadcrumbs = useBreadcrumb();
3335

3436
const view = useMemo(() => {
3537
if (!outline || !databasePageId) return;
@@ -45,8 +47,25 @@ function DatabaseView(props: DatabaseViewProps) {
4547
embedded: viewMeta.extra?.embedded,
4648
});
4749

50+
// Breadcrumb-based container fallback. The breadcrumb chain is built with
51+
// server fetches for any ancestor missing from the shallow outline, so it
52+
// resolves the database container even when the outline tree doesn't yet
53+
// include the route view's parent (e.g. immediately after refresh while
54+
// the outline still loads at depth=2).
55+
const breadcrumbContainerView = useMemo((): View | undefined => {
56+
if (containerView) return undefined;
57+
if (viewMeta.extra?.embedded) return undefined;
58+
if (!breadcrumbs?.length) return undefined;
59+
const currentIdx = breadcrumbs.findIndex((crumb) => crumb.view_id === databasePageId);
60+
61+
if (currentIdx <= 0) return undefined;
62+
const parent = breadcrumbs[currentIdx - 1];
63+
64+
return parent && isDatabaseContainer(parent) ? parent : undefined;
65+
}, [breadcrumbs, containerView, databasePageId, viewMeta.extra?.embedded]);
66+
4867
// Use container view (if present) as the "page meta" view for naming/icon operations.
49-
const pageView = containerView || view;
68+
const pageView = containerView || breadcrumbContainerView || view;
5069

5170
const pageMeta = useMemo(() => {
5271
if (!pageView) {

src/components/view-meta/TitleEditable.tsx

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,6 @@ function TitleEditable({
9292
const blurTimerRef = useRef<NodeJS.Timeout>();
9393
const cleanupTimerRef = useRef<NodeJS.Timeout>();
9494

95-
// State checking functions
96-
const isTyping = useCallback(() => {
97-
return Date.now() - lastInputTimeRef.current < 500; // 500ms typing window
98-
}, []);
99-
100-
const isRecentlyUpdated = useCallback(() => {
101-
return Date.now() - lastUpdateSentTimeRef.current < 2000; // 2s protection window
102-
}, []);
103-
104-
const isPotentialEcho = useCallback((value: string) => {
105-
return sentValuesRef.current.has(value);
106-
}, []);
107-
10895
// Cache management
10996
const cleanOldSentValues = useCallback(() => {
11097
const now = Date.now();
@@ -149,25 +136,32 @@ function TitleEditable({
149136

150137
// Handle remote updates with echo prevention
151138
useEffect(() => {
152-
// Never overwrite user edits while the title is focused.
153-
// The title uses a plain contentEditable (not Y.js CRDT), so
154-
// last-writer-wins via the API is the correct model.
155-
// On blur, sendUpdateImmediately sends the user's final value.
156-
if (isFocused) {
139+
const now = Date.now();
140+
const isTyping = now - lastInputTimeRef.current < 500;
141+
const isRecentlyUpdated = now - lastUpdateSentTimeRef.current < 2000;
142+
143+
// Skip if the user is actively editing — preserves in-progress typing.
144+
// Without this guard, a remote echo would clobber characters mid-keystroke.
145+
if (isTyping || isRecentlyUpdated) {
157146
return;
158147
}
159148

160-
if (isTyping() || isRecentlyUpdated()) {
149+
if (sentValuesRef.current.has(name)) {
161150
return;
162151
}
163152

164-
if (isPotentialEcho(name)) {
153+
// If the title was auto-focused on mount but the user hasn't typed yet,
154+
// allow remote updates to flow through. This handles the case where the
155+
// initial `name` prop changes shortly after mount (e.g. database title
156+
// resolving from the child view's "Grid" to the container's real name)
157+
// — without this, a no-op blur would persist the stale initial value.
158+
const hasUserTyped = lastInputTimeRef.current > 0;
159+
160+
if (isFocused && hasUserTyped) {
165161
return;
166162
}
167163

168164
// Genuine remote update — clean old cache entries
169-
const now = Date.now();
170-
171165
for (const [value, timestamp] of sentValuesRef.current.entries()) {
172166
if (now - timestamp > 5000) {
173167
sentValuesRef.current.delete(value);
@@ -180,9 +174,14 @@ function TitleEditable({
180174

181175
if (currentContent !== name) {
182176
contentRef.current.textContent = name;
177+
178+
// Restore cursor to end if the input is currently focused.
179+
if (isFocused && contentRef.current === document.activeElement) {
180+
setCursorPosition(contentRef.current, name.length);
181+
}
183182
}
184183
}
185-
}, [name, isTyping, isRecentlyUpdated, isPotentialEcho, isFocused]);
184+
}, [name, isFocused]);
186185

187186
// Initialize component
188187
useEffect(() => {
@@ -230,10 +229,17 @@ function TitleEditable({
230229
const handleBlur = useCallback(() => {
231230
Log.debug('👋 Input blurred');
232231
const currentText = contentRef.current?.textContent || '';
233-
234-
sendUpdateImmediately(currentText);
232+
const hasUserTyped = lastInputTimeRef.current > 0;
233+
234+
// Only persist on blur if the user actually edited the field. Auto-focus
235+
// without typing must not overwrite the stored name with whatever stale
236+
// text was rendered at mount time.
237+
if (hasUserTyped) {
238+
sendUpdateImmediately(currentText);
239+
}
240+
235241
setIsFocused(false);
236-
242+
237243
blurTimerRef.current = setTimeout(() => {
238244
Log.debug('🧹 Cleaning input state after blur');
239245
lastInputTimeRef.current = 0;

src/pages/__tests__/DatabaseView.databaseContainer.test.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ declare global {
1111
var __databaseViewTestState:
1212
| {
1313
outline?: View[];
14+
breadcrumbs?: View[];
1415
capturedDatabaseProps?: unknown;
1516
capturedViewMetaProps?: unknown;
1617
}
@@ -19,6 +20,7 @@ declare global {
1920

2021
jest.mock('@/components/app/app.hooks', () => ({
2122
useAppOutline: () => global.__databaseViewTestState?.outline,
23+
useBreadcrumb: () => global.__databaseViewTestState?.breadcrumbs,
2224
}));
2325

2426
jest.mock('@/components/database', () => ({
@@ -213,4 +215,77 @@ describe('DatabaseView database container', () => {
213215
expect(metaProps?.viewId).toBe(containerId);
214216
expect(metaProps?.name).toBe('New Database');
215217
});
218+
219+
it('falls back to breadcrumb container when outline lookup fails', () => {
220+
const containerId = 'container-id';
221+
const gridViewId = 'grid-view-id';
222+
223+
// Outline does NOT contain the container (simulating a stale or shallow
224+
// outline where the container hasn't been included yet — e.g. right after
225+
// a hard refresh while loadOutline is still in flight).
226+
const containerView: View = {
227+
view_id: containerId,
228+
name: 'New Database',
229+
icon: null,
230+
layout: ViewLayout.Grid,
231+
extra: { is_space: false, is_database_container: true, database_id: 'db-1' },
232+
children: [],
233+
has_children: true,
234+
is_published: false,
235+
is_private: false,
236+
};
237+
238+
global.__databaseViewTestState = {
239+
outline: [],
240+
breadcrumbs: [containerView, {
241+
view_id: gridViewId,
242+
name: 'Grid',
243+
icon: null,
244+
layout: ViewLayout.Grid,
245+
extra: { is_space: false, database_id: 'db-1' },
246+
children: [],
247+
is_published: false,
248+
is_private: false,
249+
parent_view_id: containerId,
250+
}],
251+
};
252+
253+
// viewMeta lacks parentViewId and database_id (simulating a fallback view
254+
// fetched from the server with minimal metadata).
255+
const viewMeta: ViewMetaProps = {
256+
viewId: gridViewId,
257+
name: 'Grid',
258+
layout: ViewLayout.Grid,
259+
icon: undefined,
260+
extra: { is_space: false },
261+
workspaceId: 'workspace-id',
262+
visibleViewIds: [],
263+
};
264+
265+
render(
266+
<MemoryRouter initialEntries={['/app/workspace-id/grid-view-id']}>
267+
<DatabaseView
268+
doc={createDatabaseDoc('db-1', [gridViewId])}
269+
workspaceId={'workspace-id'}
270+
readOnly={false}
271+
viewMeta={viewMeta}
272+
updatePage={jest.fn()}
273+
updatePageIcon={jest.fn()}
274+
updatePageName={jest.fn()}
275+
onRendered={jest.fn()}
276+
/>
277+
</MemoryRouter>
278+
);
279+
280+
const databaseProps = global.__databaseViewTestState?.capturedDatabaseProps as
281+
| { databaseName: string }
282+
| undefined;
283+
const metaProps = global.__databaseViewTestState?.capturedViewMetaProps as
284+
| { viewId?: string; name?: string }
285+
| undefined;
286+
287+
expect(databaseProps?.databaseName).toBe('New Database');
288+
expect(metaProps?.viewId).toBe(containerId);
289+
expect(metaProps?.name).toBe('New Database');
290+
});
216291
});

0 commit comments

Comments
 (0)