Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/components/app/DatabaseView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
58 changes: 32 additions & 26 deletions src/components/view-meta/TitleEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,6 @@ function TitleEditable({
const blurTimerRef = useRef<NodeJS.Timeout>();
const cleanupTimerRef = useRef<NodeJS.Timeout>();

// 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();
Expand Down Expand Up @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand Down
75 changes: 75 additions & 0 deletions src/pages/__tests__/DatabaseView.databaseContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare global {
var __databaseViewTestState:
| {
outline?: View[];
breadcrumbs?: View[];
capturedDatabaseProps?: unknown;
capturedViewMetaProps?: unknown;
}
Expand All @@ -19,6 +20,7 @@ declare global {

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

jest.mock('@/components/database', () => ({
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={['/app/workspace-id/grid-view-id']}>
<DatabaseView
doc={createDatabaseDoc('db-1', [gridViewId])}
workspaceId={'workspace-id'}
readOnly={false}
viewMeta={viewMeta}
updatePage={jest.fn()}
updatePageIcon={jest.fn()}
updatePageName={jest.fn()}
onRendered={jest.fn()}
/>
</MemoryRouter>
);

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');
});
});
Loading