From 2bc818f0747946ae75360f02aac8ac14777900cc Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:04:45 -0400 Subject: [PATCH 01/14] fix(content): stabilize database toolbar state --- .../DatabaseView.error-toasts.test.tsx | 185 +++++++++++++++++- .../editor/database/DatabaseView.tsx | 143 ++++++++++---- .../content/app/hooks/use-content-database.ts | 10 +- ...ss-and-personal-filters-reload-reliably.md | 5 + 4 files changed, 298 insertions(+), 45 deletions(-) create mode 100644 templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index d013c5966b..e688d10149 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -1,7 +1,9 @@ import type { BuilderCmsModelSummary, + ContentDatabasePersonalViewResponse, ContentDatabaseResponse, } from "@shared/api"; +import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "@shared/api"; // @vitest-environment happy-dom // // Mount the real DatabaseView with an empty mocked database so UI regressions @@ -16,6 +18,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const toastErrorMock = vi.hoisted(() => vi.fn()); const toastSuccessMock = vi.hoisted(() => vi.fn()); const contentDatabaseQueryMock = vi.hoisted(() => vi.fn()); +const keepaliveActionMock = vi.hoisted(() => vi.fn()); +const personalViewQuery = vi.hoisted<{ + data: ContentDatabasePersonalViewResponse | undefined; + isLoading: boolean; +}>(() => ({ data: undefined, isLoading: false })); +const personalViewMutation = vi.hoisted(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, +})); +const updateViewMutation = vi.hoisted(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, +})); + +vi.mock("@agent-native/core/client/hooks", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + tryCallActionKeepalive: keepaliveActionMock, + }; +}); vi.mock("sonner", async (importOriginal) => { const actual = await importOriginal(); @@ -135,9 +161,9 @@ vi.mock("@/hooks/use-content-database", () => ({ useExecuteBuilderSourceExecution: () => benignMutation, useCancelPreparedBuilderSourceUpdate: () => benignMutation, useSetContentDatabaseSourceWriteMode: () => benignMutation, - useContentDatabasePersonalView: () => ({ data: undefined, isLoading: false }), - useUpdateContentDatabasePersonalView: () => benignMutation, - useUpdateContentDatabaseView: () => benignMutation, + useContentDatabasePersonalView: () => personalViewQuery, + useUpdateContentDatabasePersonalView: () => personalViewMutation, + useUpdateContentDatabaseView: () => updateViewMutation, useDeleteDatabaseItems: () => benignMutation, useDuplicateDatabaseItems: () => benignMutation, useMoveDatabaseItem: () => benignMutation, @@ -229,6 +255,19 @@ describe("DatabaseView UI regressions", () => { toastErrorMock.mockReset(); toastSuccessMock.mockReset(); contentDatabaseQueryMock.mockReset(); + keepaliveActionMock.mockReset(); + keepaliveActionMock.mockReturnValue({ + accepted: true, + bodyBytes: 1, + completion: Promise.resolve({ + databaseId: "database-1", + overrides: null, + }), + }); + personalViewQuery.data = undefined; + personalViewQuery.isLoading = false; + personalViewMutation.mutate.mockReset(); + updateViewMutation.mutate.mockReset(); addItemMutation.mutateAsync.mockReset(); attachSourceMutation.mutateAsync.mockReset(); databasePagination.totalItems = 0; @@ -257,6 +296,7 @@ describe("DatabaseView UI regressions", () => { afterEach(() => { act(() => root.unmount()); + vi.useRealTimers(); container.remove(); vi.unstubAllGlobals(); ( @@ -310,15 +350,20 @@ describe("DatabaseView UI regressions", () => { expect(sortButton?.getAttribute("aria-expanded")).toBe("true"); expect(document.querySelector("[role=menu]")).toBeTruthy(); + const sortPickerInput = + document.querySelector("[role=menu] input"); + expect(sortPickerInput).toBeTruthy(); await act(async () => { - document.activeElement?.dispatchEvent( + sortPickerInput?.focus(); + sortPickerInput?.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), ); await Promise.resolve(); }); expect(sortButton?.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(sortButton); await act(async () => { filterButton?.dispatchEvent( @@ -329,6 +374,138 @@ describe("DatabaseView UI regressions", () => { expect(filterButton?.getAttribute("aria-expanded")).toBe("true"); expect(document.querySelector("[role=menu]")).toBeTruthy(); + + const filterPickerInput = + document.querySelector("[role=menu] input"); + expect(filterPickerInput).toBeTruthy(); + await act(async () => { + filterPickerInput?.focus(); + filterPickerInput?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), + ); + await Promise.resolve(); + }); + + expect(filterButton?.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(filterButton); + }); + + it("does not expose shared database state before personal overrides hydrate", async () => { + personalViewQuery.isLoading = true; + await renderDatabaseView(); + + expect(container.querySelector('button[aria-label="Filter"]')).toBeNull(); + + personalViewQuery.data = { + databaseId: "database-1", + overrides: { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: databaseViewConfig.activeViewId, + views: databaseViewConfig.views.map((view) => ({ + id: view.id, + sorts: [], + filters: + view.id === databaseViewConfig.activeViewId + ? [ + { + key: "name", + label: "Name", + operator: "contains" as const, + value: "Personal only", + }, + ] + : [], + filterMode: "and" as const, + })), + }, + }; + personalViewQuery.isLoading = false; + await renderDatabaseView(); + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('button[aria-label="1 active filters"]'), + ).toBeTruthy(); + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + expect(updateViewMutation.mutate).not.toHaveBeenCalled(); + }); + + it("opens both toolbar menus with Enter, Space, and ArrowDown", async () => { + await renderDatabaseView(); + + for (const label of ["Sort", "Filter"]) { + for (const key of ["Enter", " ", "ArrowDown"]) { + const button = container.querySelector( + `button[aria-label="${label}"]`, + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.focus(); + button?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key }), + ); + await Promise.resolve(); + }); + + expect(button?.getAttribute("aria-expanded")).toBe("true"); + const pickerInput = + document.querySelector("[role=menu] input"); + expect(pickerInput).toBeTruthy(); + await act(async () => { + pickerInput?.focus(); + pickerInput?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), + ); + await Promise.resolve(); + }); + + expect(button?.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(button); + } + } + }); + + it("persists one personal override and protects a pending reload with keepalive", async () => { + vi.useFakeTimers(); + await renderDatabaseView(); + const filterButton = container.querySelector( + 'button[aria-label="Filter"]', + ); + + await act(async () => { + filterButton?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + await Promise.resolve(); + }); + const nameItem = [ + ...document.querySelectorAll("[role=menuitem]"), + ].find((item) => item.textContent?.trim() === "Name"); + expect(nameItem).toBeTruthy(); + + await act(async () => { + nameItem?.click(); + await Promise.resolve(); + }); + window.dispatchEvent(new Event("pagehide")); + + expect(keepaliveActionMock).toHaveBeenCalledTimes(1); + expect(keepaliveActionMock).toHaveBeenCalledWith( + "update-content-database-personal-view", + expect.objectContaining({ databaseId: "database-1" }), + ); + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it("shows a toast and does not create a row when addItem.mutateAsync rejects", async () => { diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 4e9e7a0e91..76c6dcba7f 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -3,6 +3,7 @@ import { agentNativePath } from "@agent-native/core/client/api-path"; import { getBrowserTabId, setClientAppState, + tryCallActionKeepalive, useSession, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; @@ -23,6 +24,7 @@ import { type ProcessBuilderBodyHydrationResponse, type SourceJoinSuggestion, type ContentDatabasePersonalViewOverrides, + type ContentDatabasePersonalViewResponse, type ContentDatabaseView, type ContentDatabaseViewConfig, type ContentDatabaseColumnCalculation, @@ -103,6 +105,7 @@ import { useRef, useState, type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, @@ -680,12 +683,19 @@ export function DatabaseView({ isActive, }: DatabaseViewProps) { const { data: document } = useDocument(databaseDocumentId); + const personalView = useContentDatabasePersonalView(databaseId); - if (!document?.database || document.database.id !== databaseId) return null; + if ( + !document?.database || + document.database.id !== databaseId || + personalView.isLoading + ) + return null; return ( | null>(null); const [settingsPanel, setSettingsPanel] = useState("main"); - const [viewConfig, setViewConfig] = useState( - defaultDatabaseViewConfig(), + const initialSavedViewConfig = normalizeClientDatabaseViewConfig( + document.database?.viewConfig, + ); + const initialViewConfig = applyPersonalDatabaseViewOverrides( + initialSavedViewConfig, + normalizePersonalDatabaseViewOverrides(personalView?.overrides), ); + const [viewConfig, setViewConfig] = + useState(initialViewConfig); const [savedViewConfig, setSavedViewConfig] = - useState(defaultDatabaseViewConfig()); - const [personalQueryDirty, setPersonalQueryDirty] = useState(false); + useState(initialSavedViewConfig); + const [personalQueryDirty, setPersonalQueryDirty] = useState(() => + databaseViewHasPersonalQueryChanges( + initialViewConfig, + initialSavedViewConfig, + ), + ); const [dateViewMonth, setDateViewMonth] = useState(() => startOfMonth(new Date()), ); @@ -862,11 +884,19 @@ function DatabaseTable({ () => databaseDateViewRange(activeView.type, dateViewMonth), [activeView.type, dateViewMonth], ); - const hydratedViewRef = useRef(""); + const hydratedViewRef = useRef( + databaseViewStateKey(expectedDatabaseId, initialViewConfig), + ); const saveViewTimerRef = useRef | null>(null); const personalViewSaveTimerRef = useRef | null>( null, ); + const pendingPersonalViewSaveRef = useRef<{ + databaseId: string; + overrides: PersonalDatabaseViewOverrides; + } | null>(null); + const updatePersonalViewRef = useRef(updatePersonalView); + updatePersonalViewRef.current = updatePersonalView; const autoContinueBuilderSourceRef = useRef>(new Set()); const builderContinuationWatchdogRef = useRef>(new Map()); const refreshSourceInFlightRef = useRef(null); @@ -1579,6 +1609,7 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); personalViewSaveTimerRef.current = null; } + pendingPersonalViewSaveRef.current = null; updatePersonalView.mutate({ databaseId, overrides: null }); setSavedViewConfig(nextViewConfig); setViewConfig(nextViewConfig); @@ -1601,6 +1632,7 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); personalViewSaveTimerRef.current = null; } + pendingPersonalViewSaveRef.current = null; setViewConfig((current) => { const nextViewConfig = databaseViewConfigWithSavedQueryState( current, @@ -1619,6 +1651,49 @@ function DatabaseTable({ }); } + const flushPendingPersonalViewSave = useCallback(() => { + if (personalViewSaveTimerRef.current) { + clearTimeout(personalViewSaveTimerRef.current); + personalViewSaveTimerRef.current = null; + } + const pending = pendingPersonalViewSaveRef.current; + if (!pending) return; + pendingPersonalViewSaveRef.current = null; + updatePersonalViewRef.current.mutate(pending, { + onError: (err) => { + toast.error(dbText("failedToSaveView"), { + description: + err instanceof Error ? err.message : dbText("somethingWentWrong"), + }); + }, + }); + }, []); + + const sendPendingPersonalViewSaveKeepalive = useCallback(() => { + const pending = pendingPersonalViewSaveRef.current; + if (!pending) return; + const attempt = tryCallActionKeepalive( + "update-content-database-personal-view", + pending, + ); + if (!attempt.accepted) { + flushPendingPersonalViewSave(); + return; + } + if (personalViewSaveTimerRef.current) { + clearTimeout(personalViewSaveTimerRef.current); + personalViewSaveTimerRef.current = null; + } + pendingPersonalViewSaveRef.current = null; + void attempt.completion.catch(() => { + if (pendingPersonalViewSaveRef.current) return; + pendingPersonalViewSaveRef.current = pending; + personalViewSaveTimerRef.current = setTimeout(() => { + flushPendingPersonalViewSave(); + }, 300); + }); + }, [flushPendingPersonalViewSave]); + function schedulePersonalDatabaseViewOverrideWrite( databaseId: string, viewConfig: ContentDatabaseViewConfig, @@ -1627,21 +1702,9 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); } const overrides = personalDatabaseViewOverridesFromConfig(viewConfig); + pendingPersonalViewSaveRef.current = { databaseId, overrides }; personalViewSaveTimerRef.current = setTimeout(() => { - personalViewSaveTimerRef.current = null; - updatePersonalView.mutate( - { databaseId, overrides }, - { - onError: (err) => { - toast.error(dbText("failedToSaveView"), { - description: - err instanceof Error - ? err.message - : dbText("somethingWentWrong"), - }); - }, - }, - ); + flushPendingPersonalViewSave(); }, 300); } @@ -2131,13 +2194,12 @@ function DatabaseTable({ ]); useEffect(() => { if (!data?.database.id) return; - if (personalView.isLoading) return; const nextSavedViewConfig = normalizeClientDatabaseViewConfig( data.database.viewConfig, ); const nextViewConfig = applyPersonalDatabaseViewOverrides( nextSavedViewConfig, - normalizePersonalDatabaseViewOverrides(personalView.data?.overrides), + normalizePersonalDatabaseViewOverrides(personalView?.overrides), ); const nextKey = databaseViewStateKey(data.database.id, nextViewConfig); if (hydratedViewRef.current === nextKey) return; @@ -2151,20 +2213,18 @@ function DatabaseTable({ ? current : nextViewConfig, ); - }, [ - data?.database.id, - data?.database.viewConfig, - personalView.data?.overrides, - personalView.isLoading, - ]); + }, [data?.database.id, data?.database.viewConfig, personalView?.overrides]); useEffect(() => { + const handlePageHide = () => { + sendPendingPersonalViewSaveKeepalive(); + }; + window.addEventListener("pagehide", handlePageHide); return () => { - if (personalViewSaveTimerRef.current) { - clearTimeout(personalViewSaveTimerRef.current); - } + window.removeEventListener("pagehide", handlePageHide); + flushPendingPersonalViewSave(); }; - }, []); + }, [flushPendingPersonalViewSave, sendPendingPersonalViewSaveKeepalive]); useEffect(() => { if (!databaseId) return; @@ -2192,7 +2252,7 @@ function DatabaseTable({ ? applyPersonalDatabaseViewOverrides( nextViewConfig, normalizePersonalDatabaseViewOverrides( - personalView.data?.overrides, + personalView?.overrides, ), ) : nextViewConfig, @@ -2210,7 +2270,7 @@ function DatabaseTable({ canEdit, databaseId, personalQueryDirty, - personalView.data?.overrides, + personalView?.overrides, savedViewConfig, updateView, viewConfig, @@ -10865,7 +10925,7 @@ function DatabasePropertyPickerSearch({ onChange(event.target.value)} - onKeyDown={(event) => event.stopPropagation()} + onKeyDown={stopDatabasePickerKeydownPropagation} placeholder={placeholder} aria-label={placeholder} className="h-6 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" @@ -10875,6 +10935,13 @@ function DatabasePropertyPickerSearch({ ); } +function stopDatabasePickerKeydownPropagation( + event: ReactKeyboardEvent, +) { + if (event.key === "Escape" || event.key === "Tab") return; + event.stopPropagation(); +} + function DatabasePropertyPickerItem({ item, selected, @@ -16964,7 +17031,7 @@ function SortMenu({ className={cn("w-[340px]", sorts.length === 0 && "w-64 p-0")} > {sorts.length === 0 ? ( -
event.stopPropagation()}> +
-
event.stopPropagation()}> +
("update-content-database-personal-view", { + skipActionQueryInvalidation: true, onSuccess: (data) => { - if (!databaseId) return; queryClient.setQueryData( - ["action", "get-content-database-personal-view", { databaseId }], + [ + "action", + "get-content-database-personal-view", + { databaseId: data.databaseId }, + ], data, ); }, diff --git a/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md b/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md new file mode 100644 index 0000000000..19136228ee --- /dev/null +++ b/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md @@ -0,0 +1,5 @@ +--- +type: fixed +date: 2026-07-23 +--- +Database Sort and Filter menus now dismiss cleanly with Escape, restore keyboard focus, and reload personal filters without flashing shared defaults. From bc09b295519605c15d30e5ef7c40e9b9af9748c0 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:08:08 -0400 Subject: [PATCH 02/14] chore(content): format toolbar changelog --- ...toolbar-menus-dismiss-and-personal-filters-reload-reliably.md | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md b/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md index 19136228ee..325050f66a 100644 --- a/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md +++ b/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md @@ -2,4 +2,5 @@ type: fixed date: 2026-07-23 --- + Database Sort and Filter menus now dismiss cleanly with Escape, restore keyboard focus, and reload personal filters without flashing shared defaults. From 41e3d3cf5cc47da7c947a1784db42029e814a01c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:12:08 -0400 Subject: [PATCH 03/14] fix(content): type personal view schema version --- templates/content/shared/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 9a0a31840a..2620d121d6 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -344,7 +344,7 @@ export interface ContentDatabaseViewConfig { export const CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION = 2; export interface ContentDatabasePersonalViewOverrides { - version: number; + version: typeof CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION; activeViewId?: string; views: Array<{ id: string; From 57f8c39e62b645350c28eb936084dcbf9be7d94d Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:35:56 -0400 Subject: [PATCH 04/14] test(content): assert one personal filter write --- .../DatabaseView.error-toasts.test.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index e688d10149..2e4604c43e 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -468,7 +468,40 @@ describe("DatabaseView UI regressions", () => { } }); - it("persists one personal override and protects a pending reload with keepalive", async () => { + it("persists exactly one personal override after filter selection", async () => { + vi.useFakeTimers(); + await renderDatabaseView(); + const filterButton = container.querySelector( + 'button[aria-label="Filter"]', + ); + + await act(async () => { + filterButton?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + await Promise.resolve(); + }); + const nameItem = [ + ...document.querySelectorAll("[role=menuitem]"), + ].find((item) => item.textContent?.trim() === "Name"); + expect(nameItem).toBeTruthy(); + + await act(async () => { + nameItem?.click(); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(personalViewMutation.mutate).toHaveBeenCalledTimes(1); + expect(personalViewMutation.mutate).toHaveBeenCalledWith( + expect.objectContaining({ databaseId: "database-1" }), + expect.any(Object), + ); + expect(keepaliveActionMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("protects a pending personal override during reload with one keepalive", async () => { vi.useFakeTimers(); await renderDatabaseView(); const filterButton = container.querySelector( From cabaa5f1e3c821259727b473b9eb728767709887 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:38:02 -0400 Subject: [PATCH 05/14] fix(content): keep latest personal filter state --- .../DatabaseView.error-toasts.test.tsx | 23 ++++++++++++ .../editor/database/DatabaseView.tsx | 9 ++++- .../content/app/hooks/use-content-database.ts | 36 +++++++++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 2e4604c43e..69973100fa 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -488,6 +488,29 @@ describe("DatabaseView UI regressions", () => { await act(async () => { nameItem?.click(); + await Promise.resolve(); + }); + + personalViewQuery.data = { + databaseId: "database-1", + overrides: { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: databaseViewConfig.activeViewId, + views: databaseViewConfig.views.map((view) => ({ + id: view.id, + sorts: [], + filters: [], + filterMode: "and" as const, + })), + }, + }; + await renderDatabaseView(); + + expect( + container.querySelector('button[aria-label="1 active filters"]'), + ).toBeTruthy(); + + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 76c6dcba7f..40a3f8eea7 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -2194,6 +2194,8 @@ function DatabaseTable({ ]); useEffect(() => { if (!data?.database.id) return; + if (pendingPersonalViewSaveRef.current || updatePersonalView.isPending) + return; const nextSavedViewConfig = normalizeClientDatabaseViewConfig( data.database.viewConfig, ); @@ -2213,7 +2215,12 @@ function DatabaseTable({ ? current : nextViewConfig, ); - }, [data?.database.id, data?.database.viewConfig, personalView?.overrides]); + }, [ + data?.database.id, + data?.database.viewConfig, + personalView?.overrides, + updatePersonalView.isPending, + ]); useEffect(() => { const handlePageHide = () => { diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index faf0cfaf48..e15b2b39cc 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -51,6 +51,7 @@ import type { } from "@shared/api"; import type { Query, QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; +import { useRef } from "react"; export function contentDatabaseQueryKey(documentId: string) { return ["action", "get-content-database", { documentId }] as const; @@ -815,21 +816,50 @@ export function useUpdateContentDatabasePersonalView( _databaseId: string | null, ) { const queryClient = useQueryClient(); + const latestMutationRef = useRef<{ + variables: UpdateContentDatabasePersonalViewRequest; + previous: ContentDatabasePersonalViewResponse | undefined; + } | null>(null); return useActionMutation< ContentDatabasePersonalViewResponse, UpdateContentDatabasePersonalViewRequest >("update-content-database-personal-view", { skipActionQueryInvalidation: true, - onSuccess: (data) => { + onMutate: (variables) => { + const queryKey = [ + "action", + "get-content-database-personal-view", + { databaseId: variables.databaseId }, + ] as const; + latestMutationRef.current = { + variables, + previous: + queryClient.getQueryData( + queryKey, + ), + }; + queryClient.setQueryData(queryKey, { + databaseId: variables.databaseId, + overrides: variables.overrides, + }); + }, + onError: (_error, variables) => { + const latest = latestMutationRef.current; + if (latest?.variables !== variables) return; queryClient.setQueryData( [ "action", "get-content-database-personal-view", - { databaseId: data.databaseId }, + { databaseId: variables.databaseId }, ], - data, + latest.previous, ); }, + onSettled: (_data, _error, variables) => { + if (latestMutationRef.current?.variables === variables) { + latestMutationRef.current = null; + } + }, }); } From 72db8d845b4bd578feb350222b0af858fdae3838 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:39:33 -0400 Subject: [PATCH 06/14] fix(content): refetch personal view after save failure --- .../content/app/hooks/use-content-database.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index e15b2b39cc..bbb3cb6e16 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -818,7 +818,6 @@ export function useUpdateContentDatabasePersonalView( const queryClient = useQueryClient(); const latestMutationRef = useRef<{ variables: UpdateContentDatabasePersonalViewRequest; - previous: ContentDatabasePersonalViewResponse | undefined; } | null>(null); return useActionMutation< ContentDatabasePersonalViewResponse, @@ -833,10 +832,6 @@ export function useUpdateContentDatabasePersonalView( ] as const; latestMutationRef.current = { variables, - previous: - queryClient.getQueryData( - queryKey, - ), }; queryClient.setQueryData(queryKey, { databaseId: variables.databaseId, @@ -846,14 +841,15 @@ export function useUpdateContentDatabasePersonalView( onError: (_error, variables) => { const latest = latestMutationRef.current; if (latest?.variables !== variables) return; - queryClient.setQueryData( - [ + return queryClient.invalidateQueries({ + queryKey: [ "action", "get-content-database-personal-view", { databaseId: variables.databaseId }, ], - latest.previous, - ); + exact: true, + refetchType: "all", + }); }, onSettled: (_data, _error, variables) => { if (latestMutationRef.current?.variables === variables) { From bfaeef748409a5a2d452b6194fbce3d4c7262689 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:43:28 -0400 Subject: [PATCH 07/14] fix(content): serialize personal view saves --- .../DatabaseView.error-toasts.test.tsx | 38 +++++++++++++++++++ .../editor/database/DatabaseView.tsx | 11 +++++- .../content/app/hooks/use-content-database.ts | 14 ------- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 69973100fa..9b3df08ef4 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -266,6 +266,7 @@ describe("DatabaseView UI regressions", () => { }); personalViewQuery.data = undefined; personalViewQuery.isLoading = false; + personalViewMutation.isPending = false; personalViewMutation.mutate.mockReset(); updateViewMutation.mutate.mockReset(); addItemMutation.mutateAsync.mockReset(); @@ -564,6 +565,43 @@ describe("DatabaseView UI regressions", () => { vi.useRealTimers(); }); + it("serializes a newer personal override behind an active save", async () => { + vi.useFakeTimers(); + personalViewMutation.isPending = true; + await renderDatabaseView(); + const filterButton = container.querySelector( + 'button[aria-label="Filter"]', + ); + + await act(async () => { + filterButton?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + await Promise.resolve(); + }); + const nameItem = [ + ...document.querySelectorAll("[role=menuitem]"), + ].find((item) => item.textContent?.trim() === "Name"); + expect(nameItem).toBeTruthy(); + + await act(async () => { + nameItem?.click(); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + + personalViewMutation.isPending = false; + await renderDatabaseView(); + await act(async () => { + vi.advanceTimersByTime(50); + await Promise.resolve(); + }); + + expect(personalViewMutation.mutate).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + it("shows a toast and does not create a row when addItem.mutateAsync rejects", async () => { addItemMutation.mutateAsync.mockRejectedValue(new Error("network down")); await renderDatabaseView(); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 40a3f8eea7..438fbc0206 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -895,6 +895,7 @@ function DatabaseTable({ databaseId: string; overrides: PersonalDatabaseViewOverrides; } | null>(null); + const unmountingRef = useRef(false); const updatePersonalViewRef = useRef(updatePersonalView); updatePersonalViewRef.current = updatePersonalView; const autoContinueBuilderSourceRef = useRef>(new Set()); @@ -1652,10 +1653,17 @@ function DatabaseTable({ } const flushPendingPersonalViewSave = useCallback(() => { + if (unmountingRef.current) return; if (personalViewSaveTimerRef.current) { clearTimeout(personalViewSaveTimerRef.current); personalViewSaveTimerRef.current = null; } + if (updatePersonalViewRef.current.isPending) { + personalViewSaveTimerRef.current = setTimeout(() => { + flushPendingPersonalViewSave(); + }, 50); + return; + } const pending = pendingPersonalViewSaveRef.current; if (!pending) return; pendingPersonalViewSaveRef.current = null; @@ -2229,7 +2237,8 @@ function DatabaseTable({ window.addEventListener("pagehide", handlePageHide); return () => { window.removeEventListener("pagehide", handlePageHide); - flushPendingPersonalViewSave(); + sendPendingPersonalViewSaveKeepalive(); + unmountingRef.current = true; }; }, [flushPendingPersonalViewSave, sendPendingPersonalViewSaveKeepalive]); diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index bbb3cb6e16..1f92b79888 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -51,7 +51,6 @@ import type { } from "@shared/api"; import type { Query, QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { useRef } from "react"; export function contentDatabaseQueryKey(documentId: string) { return ["action", "get-content-database", { documentId }] as const; @@ -816,9 +815,6 @@ export function useUpdateContentDatabasePersonalView( _databaseId: string | null, ) { const queryClient = useQueryClient(); - const latestMutationRef = useRef<{ - variables: UpdateContentDatabasePersonalViewRequest; - } | null>(null); return useActionMutation< ContentDatabasePersonalViewResponse, UpdateContentDatabasePersonalViewRequest @@ -830,17 +826,12 @@ export function useUpdateContentDatabasePersonalView( "get-content-database-personal-view", { databaseId: variables.databaseId }, ] as const; - latestMutationRef.current = { - variables, - }; queryClient.setQueryData(queryKey, { databaseId: variables.databaseId, overrides: variables.overrides, }); }, onError: (_error, variables) => { - const latest = latestMutationRef.current; - if (latest?.variables !== variables) return; return queryClient.invalidateQueries({ queryKey: [ "action", @@ -851,11 +842,6 @@ export function useUpdateContentDatabasePersonalView( refetchType: "all", }); }, - onSettled: (_data, _error, variables) => { - if (latestMutationRef.current?.variables === variables) { - latestMutationRef.current = null; - } - }, }); } From 1a08f579b68c1b1ae5cc0f7f43554f6095eab0ea Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:43:48 -0400 Subject: [PATCH 08/14] fix(content): type recursive personal save flush --- .../content/app/components/editor/database/DatabaseView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 438fbc0206..044efb8043 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1652,7 +1652,7 @@ function DatabaseTable({ }); } - const flushPendingPersonalViewSave = useCallback(() => { + const flushPendingPersonalViewSave: () => void = useCallback(() => { if (unmountingRef.current) return; if (personalViewSaveTimerRef.current) { clearTimeout(personalViewSaveTimerRef.current); From 78d7ac2db015f3634752e7903e9fd35b3e8e4843 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:50:52 -0400 Subject: [PATCH 09/14] fix(content): order personal view writes atomically --- .changeset/calm-filters-arrive.md | 5 ++ packages/core/src/settings/index.ts | 1 + .../core/src/settings/user-settings.spec.ts | 26 +++++++ packages/core/src/settings/user-settings.ts | 13 ++++ .../_content-database-personal-view.ts | 53 ++++++++++++- ...ate-content-database-personal-view.test.ts | 77 ++++++++++++++++++- .../update-content-database-personal-view.ts | 53 ++++++++++--- .../editor/database/DatabaseView.tsx | 38 ++++++--- .../content/app/hooks/use-content-database.ts | 11 +++ templates/content/shared/api.ts | 2 + 10 files changed, 256 insertions(+), 23 deletions(-) create mode 100644 .changeset/calm-filters-arrive.md diff --git a/.changeset/calm-filters-arrive.md b/.changeset/calm-filters-arrive.md new file mode 100644 index 0000000000..3c52af8603 --- /dev/null +++ b/.changeset/calm-filters-arrive.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose atomic user-scoped setting mutations for ordered personal preferences. diff --git a/packages/core/src/settings/index.ts b/packages/core/src/settings/index.ts index 50d86697be..f6d7b00826 100644 --- a/packages/core/src/settings/index.ts +++ b/packages/core/src/settings/index.ts @@ -22,6 +22,7 @@ export { readSetting, writeSetting, removeSetting } from "./script-helpers.js"; export { getUserSetting, putUserSetting, + mutateUserSetting, deleteUserSetting, } from "./user-settings.js"; diff --git a/packages/core/src/settings/user-settings.spec.ts b/packages/core/src/settings/user-settings.spec.ts index cda62b0bcc..30b8c7824a 100644 --- a/packages/core/src/settings/user-settings.spec.ts +++ b/packages/core/src/settings/user-settings.spec.ts @@ -4,15 +4,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockGetSetting = vi.fn(); const mockPutSetting = vi.fn(); const mockDeleteSetting = vi.fn(); +const mockMutateSetting = vi.fn(); vi.mock("./store.js", () => ({ getSetting: (...args: any[]) => mockGetSetting(...args), putSetting: (...args: any[]) => mockPutSetting(...args), deleteSetting: (...args: any[]) => mockDeleteSetting(...args), + mutateSetting: (...args: any[]) => mockMutateSetting(...args), })); import { getUserSetting, + mutateUserSetting, putUserSetting, deleteUserSetting, } from "./user-settings.js"; @@ -80,6 +83,29 @@ describe("user-settings", () => { }); }); + describe("mutateUserSetting", () => { + it("atomically mutates only the prefixed user key", async () => { + const updater = vi.fn((current) => ({ + count: Number(current?.count ?? 0) + 1, + })); + mockMutateSetting.mockResolvedValue({ count: 2 }); + + const result = await mutateUserSetting( + "alice@test.com", + "counter", + updater, + { requestSource: "tab-1" }, + ); + + expect(mockMutateSetting).toHaveBeenCalledWith( + "u:alice@test.com:counter", + updater, + { requestSource: "tab-1" }, + ); + expect(result).toEqual({ count: 2 }); + }); + }); + describe("deleteUserSetting", () => { it("prefixes key with u::", async () => { mockDeleteSetting.mockResolvedValue(true); diff --git a/packages/core/src/settings/user-settings.ts b/packages/core/src/settings/user-settings.ts index 48c9ff7312..086c536512 100644 --- a/packages/core/src/settings/user-settings.ts +++ b/packages/core/src/settings/user-settings.ts @@ -12,6 +12,7 @@ import { getSetting, putSetting, deleteSetting, + mutateSetting, type StoreWriteOptions, } from "./store.js"; @@ -37,6 +38,18 @@ export async function putUserSetting( return putSetting(userKey(email, key), value, options); } +/** Atomically derive and persist one user-scoped setting. */ +export async function mutateUserSetting( + email: string, + key: string, + updater: ( + current: Record | null, + ) => Record | Promise>, + options?: StoreWriteOptions, +): Promise> { + return mutateSetting(userKey(email, key), updater, options); +} + /** Delete a user-scoped setting. */ export async function deleteUserSetting( email: string, diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index f825b31719..44cbacfb31 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -64,6 +64,51 @@ const legacyPersonalViewOverridesSchema = z.object({ ...personalViewOverridesFields, }); +const storedPersonalViewStateSchema = z.object({ + storageVersion: z.literal(1), + overrides: personalViewOverridesSchema.nullable(), + mutationSequences: z.record(z.string(), z.number().int().nonnegative()), +}); + +export function normalizeStoredPersonalDatabaseViewState( + stored: Record | null, +) { + const state = storedPersonalViewStateSchema.safeParse(stored); + if (state.success) return state.data; + const overrides = personalViewOverridesSchema.safeParse(stored); + return { + storageVersion: 1 as const, + overrides: overrides.success ? overrides.data : null, + mutationSequences: {} as Record, + }; +} + +export function orderedPersonalDatabaseViewState(args: { + current: Record | null; + mutationSource: string; + mutationSequence: number; + overrides: z.infer | null; +}) { + const current = normalizeStoredPersonalDatabaseViewState(args.current); + if ( + args.mutationSequence <= + (current.mutationSequences[args.mutationSource] ?? -1) + ) { + return current; + } + const recentSources = Object.entries(current.mutationSequences) + .filter(([source]) => source !== args.mutationSource) + .slice(-15); + return { + storageVersion: 1 as const, + overrides: args.overrides, + mutationSequences: Object.fromEntries([ + ...recentSources, + [args.mutationSource, args.mutationSequence], + ]), + }; +} + export async function assertContentDatabaseViewerAccess(databaseId: string) { const db = getDb(); const [database] = await db @@ -93,10 +138,14 @@ export async function readPersonalDatabaseViewOverrides( userEmail, personalDatabaseViewSettingKey(databaseId), ); - const parsed = personalViewOverridesSchema.safeParse(stored); + const storedState = storedPersonalViewStateSchema.safeParse(stored); + const storedOverrides = storedState.success + ? storedState.data.overrides + : stored; + const parsed = personalViewOverridesSchema.safeParse(storedOverrides); if (parsed.success) return parsed.data; - const legacy = legacyPersonalViewOverridesSchema.safeParse(stored); + const legacy = legacyPersonalViewOverridesSchema.safeParse(storedOverrides); if (!legacy.success) return null; const [database] = await getDb() .select({ systemRole: schema.contentDatabases.systemRole }) diff --git a/templates/content/actions/update-content-database-personal-view.test.ts b/templates/content/actions/update-content-database-personal-view.test.ts index f6a316b8ca..309c6c1926 100644 --- a/templates/content/actions/update-content-database-personal-view.test.ts +++ b/templates/content/actions/update-content-database-personal-view.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION } from "./_content-database-personal-view"; +import { + PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + normalizeStoredPersonalDatabaseViewState, + orderedPersonalDatabaseViewState, +} from "./_content-database-personal-view"; import action from "./update-content-database-personal-view"; describe("update content database personal view", () => { @@ -44,4 +48,75 @@ describe("update content database personal view", () => { }).overrides, ).toBeNull(); }); + + it("requires mutation ordering fields together", () => { + expect(() => + action.schema.parse({ + databaseId: "database", + overrides: null, + mutationSource: "tab-1", + }), + ).toThrow(/mutationSource and mutationSequence/); + }); + + it("keeps the newest override when requests arrive out of order", () => { + const first = orderedPersonalDatabaseViewState({ + current: null, + mutationSource: "tab-1", + mutationSequence: 2, + overrides: personalOverrides("newest"), + }); + const stale = orderedPersonalDatabaseViewState({ + current: first, + mutationSource: "tab-1", + mutationSequence: 1, + overrides: personalOverrides("stale"), + }); + + expect( + normalizeStoredPersonalDatabaseViewState(stale).overrides?.views[0] + ?.filters[0]?.value, + ).toBe("newest"); + }); + + it("keeps an ordered clear as a tombstone against a stale write", () => { + const cleared = orderedPersonalDatabaseViewState({ + current: null, + mutationSource: "tab-1", + mutationSequence: 2, + overrides: null, + }); + const stale = orderedPersonalDatabaseViewState({ + current: cleared, + mutationSource: "tab-1", + mutationSequence: 1, + overrides: personalOverrides("stale"), + }); + + expect( + normalizeStoredPersonalDatabaseViewState(stale).overrides, + ).toBeNull(); + }); }); + +function personalOverrides(value: string) { + return { + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + activeViewId: "table", + views: [ + { + id: "table", + sorts: [], + filters: [ + { + key: "name", + label: "Name", + operator: "contains" as const, + value, + }, + ], + filterMode: "and" as const, + }, + ], + }; +} diff --git a/templates/content/actions/update-content-database-personal-view.ts b/templates/content/actions/update-content-database-personal-view.ts index 2ea3ec8eea..f1b8aca2df 100644 --- a/templates/content/actions/update-content-database-personal-view.ts +++ b/templates/content/actions/update-content-database-personal-view.ts @@ -1,9 +1,11 @@ import { defineAction } from "@agent-native/core"; -import { deleteUserSetting, putUserSetting } from "@agent-native/core/settings"; +import { mutateUserSetting } from "@agent-native/core/settings"; import { z } from "zod"; import { assertContentDatabaseViewerAccess, + normalizeStoredPersonalDatabaseViewState, + orderedPersonalDatabaseViewState, personalDatabaseViewSettingKey, personalViewOverridesSchema, } from "./_content-database-personal-view.js"; @@ -11,20 +13,51 @@ import { export default defineAction({ description: "Update or clear the current user's personal saved filter, sort, and active view overrides for a content database.", - schema: z.object({ - databaseId: z.string().describe("Database ID"), - overrides: personalViewOverridesSchema.nullable(), - }), - run: async ({ databaseId, overrides }, ctx) => { + schema: z + .object({ + databaseId: z.string().describe("Database ID"), + overrides: personalViewOverridesSchema.nullable(), + mutationSource: z.string().min(1).max(200).optional(), + mutationSequence: z.number().int().nonnegative().optional(), + }) + .refine( + ({ mutationSource, mutationSequence }) => + (mutationSource == null) === (mutationSequence == null), + { + message: + "mutationSource and mutationSequence must be provided together.", + }, + ), + run: async ( + { databaseId, overrides, mutationSource, mutationSequence }, + ctx, + ) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); await assertContentDatabaseViewerAccess(databaseId); const key = personalDatabaseViewSettingKey(databaseId); - if (overrides) { - await putUserSetting(ctx.userEmail, key, overrides); - } else { - await deleteUserSetting(ctx.userEmail, key); + if (mutationSource != null && mutationSequence != null) { + const stored = await mutateUserSetting( + ctx.userEmail, + key, + (current) => + orderedPersonalDatabaseViewState({ + current, + mutationSource, + mutationSequence, + overrides, + }), + { requestSource: mutationSource }, + ); + return { + databaseId, + overrides: normalizeStoredPersonalDatabaseViewState(stored).overrides, + }; } + await mutateUserSetting(ctx.userEmail, key, (current) => ({ + ...normalizeStoredPersonalDatabaseViewState(current), + overrides, + })); return { databaseId, overrides }; }, diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 044efb8043..91e0b459ca 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -42,6 +42,7 @@ import { type DocumentPropertyOption, type DocumentPropertyType, type DocumentPropertyValue, + type UpdateContentDatabasePersonalViewRequest, } from "@shared/api"; import { contentDatabaseFormQuestions } from "@shared/database-form"; import { @@ -359,6 +360,8 @@ export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = export const BUILDER_SOURCE_CONTINUATION_STALL_MS = 5_000; export const BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS = 30_000; +let personalViewMutationSequence = Date.now() * 1_000; + export type PersonalDatabaseViewOverrides = ContentDatabasePersonalViewOverrides; @@ -891,10 +894,8 @@ function DatabaseTable({ const personalViewSaveTimerRef = useRef | null>( null, ); - const pendingPersonalViewSaveRef = useRef<{ - databaseId: string; - overrides: PersonalDatabaseViewOverrides; - } | null>(null); + const pendingPersonalViewSaveRef = + useRef(null); const unmountingRef = useRef(false); const updatePersonalViewRef = useRef(updatePersonalView); updatePersonalViewRef.current = updatePersonalView; @@ -1611,7 +1612,9 @@ function DatabaseTable({ personalViewSaveTimerRef.current = null; } pendingPersonalViewSaveRef.current = null; - updatePersonalView.mutate({ databaseId, overrides: null }); + updatePersonalView.mutate( + personalDatabaseViewMutationRequest(databaseId, null), + ); setSavedViewConfig(nextViewConfig); setViewConfig(nextViewConfig); setPersonalQueryDirty(false); @@ -1639,10 +1642,9 @@ function DatabaseTable({ current, savedViewConfig, ); - updatePersonalView.mutate({ - databaseId, - overrides: null, - }); + updatePersonalView.mutate( + personalDatabaseViewMutationRequest(databaseId, null), + ); setPersonalQueryDirty(false); hydratedViewRef.current = databaseViewStateKey( databaseId, @@ -1710,7 +1712,10 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); } const overrides = personalDatabaseViewOverridesFromConfig(viewConfig); - pendingPersonalViewSaveRef.current = { databaseId, overrides }; + pendingPersonalViewSaveRef.current = personalDatabaseViewMutationRequest( + databaseId, + overrides, + ); personalViewSaveTimerRef.current = setTimeout(() => { flushPendingPersonalViewSave(); }, 300); @@ -13574,6 +13579,19 @@ function personalDatabaseViewOverridesFromConfig( }; } +function personalDatabaseViewMutationRequest( + databaseId: string, + overrides: PersonalDatabaseViewOverrides | null, +): UpdateContentDatabasePersonalViewRequest { + personalViewMutationSequence += 1; + return { + databaseId, + overrides, + mutationSource: getBrowserTabId(), + mutationSequence: personalViewMutationSequence, + }; +} + export function applyPersonalDatabaseViewOverrides( savedViewConfig: ContentDatabaseViewConfig, overrides: PersonalDatabaseViewOverrides | null, diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 1f92b79888..9a63e37c95 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -831,6 +831,17 @@ export function useUpdateContentDatabasePersonalView( overrides: variables.overrides, }); }, + onSuccess: (data) => { + return queryClient.invalidateQueries({ + queryKey: [ + "action", + "get-content-database-personal-view", + { databaseId: data.databaseId }, + ], + exact: true, + refetchType: "all", + }); + }, onError: (_error, variables) => { return queryClient.invalidateQueries({ queryKey: [ diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 2620d121d6..f1fe66f6ec 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -362,6 +362,8 @@ export interface ContentDatabasePersonalViewResponse { export interface UpdateContentDatabasePersonalViewRequest { databaseId: string; overrides: ContentDatabasePersonalViewOverrides | null; + mutationSource?: string; + mutationSequence?: number; } export interface ContentDatabaseMembership { From 576bd2ccdb723da28756540aeb9ff23830e5adf3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:14:41 -0400 Subject: [PATCH 10/14] test(content): activate personal filter before asserting --- .../database/DatabaseView.error-toasts.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 9b3df08ef4..7b853bb666 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -491,6 +491,19 @@ describe("DatabaseView UI regressions", () => { nameItem?.click(); await Promise.resolve(); }); + const filterValueInput = container.querySelector( + 'input[placeholder="Text"]', + ); + expect(filterValueInput).toBeTruthy(); + await act(async () => { + if (!filterValueInput) return; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set?.call(filterValueInput, "Personal only"); + filterValueInput.dispatchEvent(new Event("input", { bubbles: true })); + await Promise.resolve(); + }); personalViewQuery.data = { databaseId: "database-1", From fbf13ed1de5f4e88d1ce792d1888dfaba3a573a6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:16:46 -0400 Subject: [PATCH 11/14] test(content): assert persisted personal filter payload --- .../DatabaseView.error-toasts.test.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 7b853bb666..0a18d2bf71 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -531,7 +531,23 @@ describe("DatabaseView UI regressions", () => { expect(personalViewMutation.mutate).toHaveBeenCalledTimes(1); expect(personalViewMutation.mutate).toHaveBeenCalledWith( - expect.objectContaining({ databaseId: "database-1" }), + expect.objectContaining({ + databaseId: "database-1", + overrides: expect.objectContaining({ + activeViewId: databaseViewConfig.activeViewId, + views: expect.arrayContaining([ + expect.objectContaining({ + id: databaseViewConfig.activeViewId, + filters: [ + expect.objectContaining({ + key: "name", + value: "Personal only", + }), + ], + }), + ]), + }), + }), expect.any(Object), ); expect(keepaliveActionMock).not.toHaveBeenCalled(); From df16c1c72a3198dfee4a23f11037be1f9d09600a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:39:38 -0400 Subject: [PATCH 12/14] test(content): settle filter editor before input --- .../editor/database/DatabaseView.error-toasts.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 0a18d2bf71..5792ea3f3d 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -491,6 +491,7 @@ describe("DatabaseView UI regressions", () => { nameItem?.click(); await Promise.resolve(); }); + await renderDatabaseView(); const filterValueInput = container.querySelector( 'input[placeholder="Text"]', ); From 37b782e2828567cfbbc079dea9a26972e3ac33d7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:00:36 -0400 Subject: [PATCH 13/14] test(content): target text filter value input --- .../editor/database/DatabaseView.error-toasts.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 5792ea3f3d..2fab21db6f 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -493,7 +493,7 @@ describe("DatabaseView UI regressions", () => { }); await renderDatabaseView(); const filterValueInput = container.querySelector( - 'input[placeholder="Text"]', + 'input[placeholder="Value"]', ); expect(filterValueInput).toBeTruthy(); await act(async () => { From 2f1af50ab6e9981e012a2c44d726439a6c81b759 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:23:15 -0400 Subject: [PATCH 14/14] test(content): query portaled filter input --- .../editor/database/DatabaseView.error-toasts.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 2fab21db6f..360e378a89 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -492,7 +492,7 @@ describe("DatabaseView UI regressions", () => { await Promise.resolve(); }); await renderDatabaseView(); - const filterValueInput = container.querySelector( + const filterValueInput = document.querySelector( 'input[placeholder="Value"]', ); expect(filterValueInput).toBeTruthy();