diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx
index a25de7f2716..f18ba06d62a 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx
@@ -12,17 +12,16 @@ import {
interface SaveViewModalProps {
open: boolean
onOpenChange: (open: boolean) => void
- /** Pre-filled when renaming an existing view; empty when saving a new one. */
+ /** Pre-filled when renaming an existing view; empty when creating a new one. */
initialName?: string
- /** `new` starts blank and is configured after; `create` captures what is
- * already applied; `rename` retitles an existing view. */
- mode: 'new' | 'create' | 'rename'
+ /** `new` starts blank and is configured after; `rename` retitles an existing view. */
+ mode: 'new' | 'rename'
onSubmit: (name: string) => void
isSubmitting: boolean
}
/**
- * Names a view — used both for "Save as view" and for renaming an existing one.
+ * Names a new view or renames an existing one.
* A view name is free-form (no identifier rules), so the only guard is emptiness.
*/
export function SaveViewModal({
@@ -43,7 +42,7 @@ export function SaveViewModal({
}
const trimmed = name.trim()
- const title = mode === 'new' ? 'New view' : mode === 'create' ? 'Save as view' : 'Rename view'
+ const title = mode === 'new' ? 'New view' : 'Rename view'
const handleSubmit = () => {
if (!trimmed || isSubmitting) return
@@ -68,7 +67,14 @@ export function SaveViewModal({
onCancel={() => onOpenChange(false)}
cancelDisabled={isSubmitting}
primaryAction={{
- label: isSubmitting ? 'Saving...' : 'Save',
+ label:
+ mode === 'new'
+ ? isSubmitting
+ ? 'Creating...'
+ : 'Create'
+ : isSubmitting
+ ? 'Saving...'
+ : 'Save',
onClick: handleSubmit,
disabled: !trimmed || isSubmitting,
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx
new file mode 100644
index 00000000000..fc9507426b5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx
@@ -0,0 +1,83 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import type { TableViewWire } from '@/lib/api/contracts/tables'
+import { ViewsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu'
+
+const DEFAULT_VIEW: TableViewWire = {
+ id: 'view-default',
+ tableId: 'table-1',
+ name: 'Default',
+ config: {},
+ isDefault: true,
+ createdBy: 'user-1',
+ createdAt: new Date('2026-08-15T01:00:00.000Z'),
+ updatedAt: new Date('2026-08-15T01:00:00.000Z'),
+}
+
+const SAVED_VIEW: TableViewWire = {
+ ...DEFAULT_VIEW,
+ id: 'view-saved',
+ name: 'Saved',
+ isDefault: false,
+}
+
+function renderMenu(views: TableViewWire[], activeViewId: string | null): string {
+ return renderToStaticMarkup(
+
+ )
+}
+
+describe('ViewsMenu', () => {
+ it('shows the persisted default while its URL selection is being adopted', () => {
+ const markup = renderMenu([DEFAULT_VIEW], null)
+
+ expect(markup).toContain('Default')
+ expect(markup).not.toContain('>View<')
+ })
+
+ it('shows All only for a legacy table without a persisted default', () => {
+ const markup = renderMenu([], null)
+
+ expect(markup).toContain('All')
+ expect(markup).not.toContain('>View<')
+ })
+
+ it('only offers deletion for non-default views', () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+
+ act(() => {
+ root.render(
+
+ )
+ })
+ act(() => container.querySelector('button[aria-label="Views"]')?.click())
+
+ expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1)
+
+ act(() => root.unmount())
+ container.remove()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx
index 08a3643653d..14b833f8108 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx
@@ -15,8 +15,9 @@ import {
} from '@sim/emcn'
import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons'
import type { TableViewWire } from '@/lib/api/contracts/tables'
+import { resolveTableViewSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
-/** Label for the built-in unfiltered state. Not a stored row — `null` view id. */
+/** Legacy label for tables that do not yet have a persisted default view. */
export const ALL_ROWS_VIEW_LABEL = 'All'
/** Matches the breadcrumb location popover's hover-intent grace period. */
@@ -29,7 +30,7 @@ const VIEW_ACTION_SLOT_PX = 22
interface ViewsMenuProps {
views: TableViewWire[]
- /** `null` selects the built-in "All" state. */
+ /** `null` selects the legacy "All" state while a table awaits backfill. */
activeViewId: string | null
onSelect: (viewId: string | null) => void
onRename: (viewId: string) => void
@@ -41,8 +42,8 @@ interface ViewsMenuProps {
}
/**
- * View switcher for the table options bar. Reads "View" until one is selected,
- * then carries the active view's name.
+ * View switcher for the table options bar. Carries the active view's name, or
+ * resolves an absent selection to the persisted default while the URL catches up.
*
* Opens on hover-intent like the header's breadcrumb location popover, so the
* list of views is discoverable without a click.
@@ -59,8 +60,9 @@ export const ViewsMenu = memo(function ViewsMenu({
const [open, setOpen] = useState(false)
const closeTimeoutRef = useRef | null>(null)
- const activeView = activeViewId ? views.find((view) => view.id === activeViewId) : undefined
- const label = activeView?.name ?? 'View'
+ const { activeView, defaultView } = resolveTableViewSelection(views, activeViewId)
+ const hasDefaultView = defaultView !== null
+ const label = activeView?.name ?? ALL_ROWS_VIEW_LABEL
const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
@@ -132,11 +134,13 @@ export const ViewsMenu = memo(function ViewsMenu({
Views
-
runAndClose(() => onSelect(null))}
- />
+ {!hasDefaultView && (
+ runAndClose(() => onSelect(null))}
+ />
+ )}
{views.map((view) => (
runAndClose(() => onRename(view.id)),
},
- {
- icon: Trash,
- label: 'Delete',
- onClick: () => runAndClose(() => onDelete(view.id)),
- },
+ ...(!view.isDefault
+ ? [
+ {
+ icon: Trash,
+ label: 'Delete',
+ onClick: () => runAndClose(() => onDelete(view.id)),
+ },
+ ]
+ : []),
]
: undefined
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index 7a2ba8f792e..f368f06908d 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -5,6 +5,7 @@ import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
import { Download, Lock, Pencil, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { isEqual } from 'es-toolkit'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
@@ -39,6 +40,13 @@ import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presen
import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import {
+ getTableViewRevision,
+ resolveTableViewConfig,
+ resolveTableViewSelection,
+ shouldApplyTableViewRevision,
+ type TableViewRevision,
+} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog'
import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu'
import { useLogByExecutionId } from '@/hooks/queries/logs'
@@ -161,45 +169,13 @@ function slideoutReducer(_state: SlideoutState, action: SlideoutAction): Slideou
/** Stable identity so a loading/disabled views query doesn't remint `[]` each render. */
const NO_VIEWS: TableViewWire[] = []
-/** `blank` starts the view from "All" (no filter/sort/hidden) so it is configured
- * after naming, rather than capturing whatever is currently applied. */
-type ViewModalState =
- | { mode: 'create'; blank?: boolean }
- | { mode: 'rename'; viewId: string }
- | null
+/** New views are named before configuration; rename targets an existing view. */
+type ViewModalState = { mode: 'new' } | { mode: 'rename'; viewId: string } | null
-/**
- * Order-insensitive JSON, used to compare a locally-built config against one that
- * has round-tripped through Postgres. `jsonb` does not preserve object key order
- * (`{status,plan}` comes back `{plan,status}`), so a plain `JSON.stringify` would
- * report any multi-key filter as permanently dirty. Array order is preserved —
- * it is meaningful for `columnOrder`.
- */
-function stableStringify(value: unknown): string {
- if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
- const entries = Object.entries(value as Record)
- .filter(([, entry]) => entry !== undefined)
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
- return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}`
-}
-
-/**
- * Structural equality for the parts of a view config the user edits directly.
- * Column layout (widths/order/pinning) is excluded — it auto-saves into the
- * active view as the user drags, so it can never be the thing that is "unsaved".
- *
- * Compares serialized form rather than field-by-field because `filter` is an
- * arbitrarily nested predicate tree.
- */
-function isSameViewConfig(a: TableViewConfig, b: TableViewConfig): boolean {
- const normalize = (config: TableViewConfig) =>
- stableStringify({
- filter: config.filter ?? null,
- sort: config.sort ?? null,
- hiddenColumns: [...(config.hiddenColumns ?? [])].sort(),
- })
- return normalize(a) === normalize(b)
+interface ViewConfigKeep {
+ sort?: boolean
+ filter?: boolean
+ hiddenColumns?: boolean
}
/**
@@ -282,8 +258,8 @@ export function Table({
const [{ sort: sortColumn, dir: sortDirection, view: activeViewId }, setTableParams] =
useQueryStates(tableDetailParsers, tableDetailUrlKeys)
- // Read-only mirrors for the resolve effect: it must know whether the user has
- // already applied a filter / hidden columns without re-running when they change.
+ // Read-only mirrors for the resolve effect and replaceFilter's echo check:
+ // both must read the current values without re-running when they change.
const filterRef = useRef(filter)
filterRef.current = filter
const hiddenColumnsRef = useRef(hiddenColumns)
@@ -405,29 +381,43 @@ export function Table({
tableId,
queryOptions,
})
+ const tableAvailable = tableData !== undefined
const createViewMutation = useCreateTableView({ workspaceId, tableId })
const updateViewMutation = useUpdateTableView({ workspaceId, tableId })
const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId })
const deleteViewMutation = useDeleteTableView({ workspaceId, tableId })
- /** The selected view, or `null` for the built-in "All" state. A view id that no
- * longer resolves (deleted, stale bookmark) falls back to "All" rather than
- * rendering an empty view. */
- const activeView = activeViewId ? (views.find((view) => view.id === activeViewId) ?? null) : null
+ /** Resolve the default synchronously so the grid, autosave owner, and menu all
+ * agree before the URL effect records the adopted view id. */
+ const { selectedView, defaultView, activeView } = resolveTableViewSelection(views, activeViewId)
+ const activeViewConfig = useMemo(
+ () => resolveTableViewConfig(tableData?.metadata, activeView?.config ?? null),
+ [tableData?.metadata, activeView?.config]
+ )
const [viewModal, setViewModal] = useState(null)
- /** Which view id the local filter/sort/hidden state was last seeded from.
+ /** Which persisted view revision last seeded the local filter/sort/hidden state.
* `undefined` means "nothing seeded yet" so the first resolve still runs. */
- const seededViewIdRef = useRef(undefined)
+ const appliedViewRevisionRef = useRef(undefined)
/**
* A view this client just created, held only until the list refetch carries it.
- * Distinct from `seededViewIdRef`, which is stamped on EVERY selection — reusing
- * that for the create race also matched a view that had been selected normally
- * and then deleted, so the delete never cleaned up.
+ * Distinct from `appliedViewRevisionRef`, which is stamped on EVERY selection —
+ * reusing that for the create race also matched a view that had been selected
+ * normally and then deleted, so the delete never cleaned up.
*/
const pendingCreatedViewIdRef = useRef(null)
+ /** View config gestures made before the views query identifies their owner. */
+ const pendingViewConfigRef = useRef(null)
+
+ /**
+ * State deliberately kept over the first view seed. Deep-linked sort remains
+ * authoritative until the user changes it; early filter/column gestures stay
+ * protected until their queued patch succeeds.
+ */
+ const preservedViewStateRef = useRef<{ viewId: string; keep: ViewConfigKeep } | null>(null)
+
/**
* Replaces the filter from OUTSIDE the filter panel — a view switch, or
* "Filter by cell value". Bumps {@link filterSeed} so the panel re-seeds: it
@@ -435,26 +425,26 @@ export function Table({
* this an open panel keeps showing the rules of the filter it replaced.
*
* The remount discards an unapplied draft, which is the point — the rules on
- * screen must be the rules in effect.
+ * screen must be the rules in effect. An incoming filter identical to the
+ * current one is skipped entirely: the resolve effect re-applies the config
+ * after this client's own autosave settles, and letting that echo remount an
+ * open panel would wipe keystrokes typed since the flush and steal focus.
*/
const replaceFilter = useCallback((next: TablePredicate | null) => {
+ if (isEqual(next, filterRef.current)) return
setFilter(next)
setFilterSeed((seed) => seed + 1)
}, [])
/**
* Applies a view's config to the live state. `keep` marks slices the user has
- * already set by hand, which win over the view's stored values on the FIRST
- * resolve only — a deep-linked `?sort=` is more specific than the view's default,
- * and a filter typed while the views query was still in flight shouldn't be
- * thrown away when it lands. Switching views later passes no `keep`, so the
- * incoming view fully replaces the outgoing one.
+ * already set by hand. A deep-linked `?sort=` is more specific than the view's
+ * default, and a filter typed while the views query was still in flight should
+ * not be thrown away when it lands. Switching views later passes no `keep`, so
+ * the incoming view fully replaces the outgoing one.
*/
const applyViewConfig = useCallback(
- (
- config: TableViewConfig | null,
- keep?: { sort?: boolean; filter?: boolean; hiddenColumns?: boolean }
- ) => {
+ (config: TableViewConfig | null, keep?: ViewConfigKeep) => {
if (!keep?.filter) replaceFilter(config?.filter ?? null)
if (!keep?.hiddenColumns) setHiddenColumns(config?.hiddenColumns ?? [])
if (keep?.sort) return
@@ -475,12 +465,8 @@ export function Table({
const layoutSnapshotRef = useRef<(() => TableMetadata) | null>(null)
const readLayout = useCallback((): TableMetadata => layoutSnapshotRef.current?.() ?? {}, [])
- /** Layout KEYS the user changed before the views query settled, when there was
- * no owner to write to. Values aren't recorded — the grid holds them live —
- * but the keys are, so a settle to All persists only what was touched. A full
- * snapshot would also carry keys the grid hasn't seeded yet (e.g. pins while
- * the slower detail query is still in flight) and wipe them in metadata. */
- const pendingLayoutKeysRef = useRef | null>(null)
+ /** Layout patch the user committed before the views query identified its owner. */
+ const pendingLayoutPatchRef = useRef(null)
/** Whether the resolve effect has decided the initial owner — including the
* terminal-error fallback to All. Until then a write that reads "All" might
@@ -490,45 +476,78 @@ export function Table({
/**
* Resolves that pending layout once the resolve effect has picked an owner.
*
- * Settling on All re-seeds nothing — `viewLayoutKey` never changed — so the
- * user's resize is still on screen and has to be persisted or it silently
- * disappears on refresh. Adopting a view instead re-seeds the grid from that
- * view's config, which already replaced the gesture on screen, so it is dropped.
- *
- * Called from the resolve effect rather than keyed on `activeView`: adoption
- * writes the view id through the URL, so for one render the query has settled
- * while `activeView` is still null, and an effect would flush to All in exactly
- * the case that must drop.
+ * Called from the resolve effect rather than keyed on the URL selection:
+ * default adoption is resolved synchronously before that URL catches up.
*/
const resolvePendingLayout = useCallback(
- (adoptedView: boolean) => {
- const keys = pendingLayoutKeysRef.current
- pendingLayoutKeysRef.current = null
- if (!keys || keys.size === 0) return
- if (adoptedView || !userPermissions.canEdit) return
- const live = readLayout()
- const patch: TableMetadata = {}
- if (keys.has('columnWidths') && live.columnWidths) patch.columnWidths = live.columnWidths
- if (keys.has('columnOrder') && live.columnOrder) patch.columnOrder = live.columnOrder
- if (keys.has('pinnedColumns') && live.pinnedColumns) {
- patch.pinnedColumns = live.pinnedColumns
+ (viewId: string | null) => {
+ const patch = pendingLayoutPatchRef.current
+ pendingLayoutPatchRef.current = null
+ if (!patch || !userPermissions.canEdit) return
+ if (viewId) {
+ updateViewMutation.mutate(
+ { viewId, configPatch: patch },
+ { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save layout')) }
+ )
+ return
}
- if (Object.keys(patch).length > 0) updateMetadataMutation.mutate(patch)
+ updateMetadataMutation.mutate(patch)
},
- [userPermissions.canEdit, readLayout]
+ [userPermissions.canEdit]
)
- /** What the user has already set by hand, for the first-resolve `keep`. */
- const localWork = () => ({
- sort: sortColumn !== null,
- filter: filterRef.current !== null,
- hiddenColumns: hiddenColumnsRef.current.length > 0,
- })
+ /** What the user has already set by hand when the first view resolves. */
+ const localWork = () => {
+ const pending = pendingViewConfigRef.current
+ return {
+ sort: sortColumn !== null || Boolean(pending && 'sort' in pending),
+ filter: filterRef.current !== null || Boolean(pending && 'filter' in pending),
+ hiddenColumns:
+ hiddenColumnsRef.current.length > 0 || Boolean(pending && 'hiddenColumns' in pending),
+ }
+ }
+
+ const preserveViewState = useCallback((viewId: string, keep: ViewConfigKeep | undefined) => {
+ if (!keep || (!keep.sort && !keep.filter && !keep.hiddenColumns)) {
+ preservedViewStateRef.current = null
+ return
+ }
+ preservedViewStateRef.current = { viewId, keep }
+ }, [])
+
+ const releasePersistedViewState = useCallback((viewId: string, patch: TableViewConfig) => {
+ const preserved = preservedViewStateRef.current
+ if (!preserved || preserved.viewId !== viewId) return
+ const keep = { ...preserved.keep }
+ if ('sort' in patch) keep.sort = undefined
+ if ('filter' in patch) keep.filter = undefined
+ if ('hiddenColumns' in patch) keep.hiddenColumns = undefined
+ preservedViewStateRef.current =
+ keep.sort || keep.filter || keep.hiddenColumns ? { viewId, keep } : null
+ }, [])
+
+ const flushPendingViewConfig = useCallback(
+ (viewId: string) => {
+ const configPatch = pendingViewConfigRef.current
+ if (!configPatch || !userPermissions.canEdit) return
+ pendingViewConfigRef.current = null
+ updateViewMutation.mutate(
+ { viewId, configPatch },
+ {
+ onSuccess: () => releasePersistedViewState(viewId, configPatch),
+ onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')),
+ }
+ )
+ },
+ [userPermissions.canEdit, releasePersistedViewState]
+ )
/**
* Resolves the active view and seeds the local filter/sort/hidden-column state
- * from it. Runs only when the *selected view id* changes, never on every edit,
- * so ad-hoc changes on top of a view are preserved until the user switches away.
+ * from it. A different view always applies; a newer revision of the same view
+ * applies once this client's autosave queue settles. That lets navigation
+ * rehydrate a freshly saved filter without an intermediate response rewinding
+ * a newer local gesture.
*
* On first load with no `?view=` the table's default view (if any) is selected
* and written into the URL explicitly — a link then keeps resolving to the same
@@ -539,64 +558,74 @@ export function Table({
// Terminal only when the fetch failed WITHOUT ever producing a list — then
// the table settles to All: mark the owner resolved so layout writes flow
// to shared metadata, and flush what was touched during the load. It does
- // NOT stamp `seededViewIdRef` — that would consume the first resolve, and a
+ // NOT stamp `appliedViewRevisionRef` — that would consume the first resolve, and a
// later successful refetch must still run adoption (with `localWork` keep,
// so filters set while errored survive). An error with a cached list falls
// through — the list is still resolvable.
if (viewsErrored && !viewsAvailable) {
ownerResolvedRef.current = true
- resolvePendingLayout(false)
+ resolvePendingLayout(null)
return
}
- if (!viewsAvailable) return
+ if (!viewsAvailable || !tableAvailable) return
ownerResolvedRef.current = true
-
- if (seededViewIdRef.current === undefined) {
+ if (appliedViewRevisionRef.current === undefined) {
// Embedded tables bind these parsers to the HOST page's URL, which the
// mothership panel keeps across resource switches. A view id this table
// can't resolve was left by the previously-open resource — ignore it so
- // this table picks its own default. A param it CAN resolve is honoured,
- // including an explicit All: that is a real bookmark or a remount after
- // switching resources away and back, not leakage.
+ // this table picks its own default. A param it CAN resolve is honoured.
const inheritedParams =
embedded &&
activeViewId !== null &&
activeViewId !== ALL_VIEW_PARAM &&
- !views.some((view) => view.id === activeViewId)
+ selectedView === null
+ // Until the backfill ships, All remains the compatibility state for a
+ // table with no persisted default. Once a default exists, an old All URL
+ // upgrades to that view instead of preserving the synthetic state.
+ const legacyAllWithDefault = activeViewId === ALL_VIEW_PARAM && defaultView !== null
- if (activeViewId === null || inheritedParams) {
- const defaultView = views.find((view) => view.isDefault)
+ if (activeViewId === null || inheritedParams || legacyAllWithDefault) {
// `sort` rides the same host URL, so when the view id is inherited the
// sort beside it is too — not local work, and it must not suppress the
// default view's own sort.
const keep = inheritedParams ? { ...localWork(), sort: false } : localWork()
if (defaultView) {
- seededViewIdRef.current = defaultView.id
+ appliedViewRevisionRef.current = getTableViewRevision(defaultView)
setTableParams({ view: defaultView.id })
- applyViewConfig(defaultView.config, keep)
- resolvePendingLayout(true)
+ preserveViewState(defaultView.id, keep)
+ applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView.config), keep)
+ resolvePendingLayout(defaultView.id)
+ flushPendingViewConfig(defaultView.id)
return
}
// No view to adopt. Deliberately does NOT apply an empty config — that
// would clear a deep-linked `?sort=` on mount. Inherited params are the
// exception: nothing about them refers to this table, so they're cleared.
- seededViewIdRef.current = null
+ appliedViewRevisionRef.current = getTableViewRevision(null)
if (inheritedParams) setTableParams({ view: ALL_VIEW_PARAM, sort: null, dir: null })
- resolvePendingLayout(false)
+ resolvePendingLayout(null)
return
}
if (activeViewId === ALL_VIEW_PARAM) {
- seededViewIdRef.current = null
- resolvePendingLayout(false)
+ appliedViewRevisionRef.current = getTableViewRevision(null)
+ resolvePendingLayout(null)
return
}
- // A `?view=` that resolves to nothing (deleted view, stale bookmark) falls
- // back to "All" without touching state, for the same reason. An explicit
- // `?sort=` alongside `?view=` also wins over the view's stored sort.
- seededViewIdRef.current = activeView?.id ?? null
- resolvePendingLayout(activeView !== null)
- if (activeView) {
- applyViewConfig(activeView.config, localWork())
+ // A `?view=` that resolves to nothing adopts the persisted default when
+ // one exists; tables awaiting backfill retain the legacy All fallback.
+ const viewToAdopt = selectedView ?? defaultView
+ const keep = localWork()
+ appliedViewRevisionRef.current = getTableViewRevision(viewToAdopt)
+ resolvePendingLayout(viewToAdopt?.id ?? null)
+ if (selectedView) {
+ preserveViewState(selectedView.id, keep)
+ applyViewConfig(resolveTableViewConfig(tableData?.metadata, selectedView.config), keep)
+ flushPendingViewConfig(selectedView.id)
+ } else if (defaultView) {
+ setTableParams({ view: defaultView.id })
+ preserveViewState(defaultView.id, keep)
+ applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView.config), keep)
+ flushPendingViewConfig(defaultView.id)
} else {
// Nothing to apply, but the URL still names a view that no longer exists.
// Rewrite it so a stale bookmark can't be copied on, and so the param
@@ -606,54 +635,81 @@ export function Table({
return
}
+ /** Creating a view updates the query cache before nuqs commits its URL id.
+ * Keep the blank view already applied in the success handler during that
+ * gap instead of briefly reapplying the previously selected view. */
+ if (pendingCreatedViewIdRef.current && activeViewId !== pendingCreatedViewIdRef.current) {
+ return
+ }
+
// The id resolved, so any create race for it is over.
- if (activeView && pendingCreatedViewIdRef.current === activeView.id) {
+ if (selectedView && pendingCreatedViewIdRef.current === selectedView.id) {
pendingCreatedViewIdRef.current = null
}
// A selected id that doesn't resolve is one of two things. Ours — creation
// writes the URL before the list refetches, and clearing there would wipe the
// config just saved. Or genuinely dead (deleted by someone else, stale
- // bookmark), where leaving it applied keeps the grid narrowed under an "All"
- // label, since the menu resolves the same missing view to null.
- if (activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !activeView) {
+ // bookmark), where leaving it applied keeps the grid narrowed under the
+ // wrong label because the menu resolves the same missing view to null.
+ if (activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !selectedView) {
if (pendingCreatedViewIdRef.current === activeViewId) return
- seededViewIdRef.current = null
- setTableParams({ view: ALL_VIEW_PARAM })
- applyViewConfig(null)
+ preservedViewStateRef.current = null
+ appliedViewRevisionRef.current = getTableViewRevision(defaultView)
+ setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM })
+ applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView?.config ?? null))
return
}
- const nextViewId = activeView?.id ?? null
- if (seededViewIdRef.current === nextViewId) return
- seededViewIdRef.current = nextViewId
- // Navigating away ends any create race — without this a reconcile on the
- // destination could fall back to the still-pending created id.
- if (pendingCreatedViewIdRef.current && pendingCreatedViewIdRef.current !== nextViewId) {
- pendingCreatedViewIdRef.current = null
+ const nextViewRevision = getTableViewRevision(activeView)
+ if (
+ !shouldApplyTableViewRevision(
+ appliedViewRevisionRef.current,
+ nextViewRevision,
+ updateViewMutation.isPending
+ )
+ ) {
+ return
}
- applyViewConfig(activeView?.config ?? null)
+ appliedViewRevisionRef.current = nextViewRevision
+ const nextViewId = nextViewRevision.id
+ const preserved = preservedViewStateRef.current
+ if (preserved && preserved.viewId !== nextViewId) {
+ preservedViewStateRef.current = null
+ }
+ if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) {
+ setTableParams({ view: activeView.id })
+ }
+ const keep = preserved?.viewId === nextViewId ? preserved.keep : undefined
+ applyViewConfig(activeViewConfig, keep)
+ if (activeView) flushPendingViewConfig(activeView.id)
}, [
viewsEnabled,
viewsAvailable,
viewsErrored,
+ tableAvailable,
views,
+ selectedView,
+ defaultView,
activeView,
+ activeViewConfig,
activeViewId,
embedded,
sortColumn,
+ updateViewMutation.isPending,
applyViewConfig,
setTableParams,
resolvePendingLayout,
+ preserveViewState,
+ flushPendingViewConfig,
+ tableData?.metadata,
])
/**
* Live state pruned the same way `pruneViewConfig` prunes the stored config on
* read. Without this, deleting a hidden or sorted column leaves the local ids
- * behind while the server drops them, so the dirty check never balances again —
- * Save writes the stale id, the response comes back pruned, and the chip is
- * stuck on. Guarded on the schema being loaded so an empty first render doesn't
- * prune everything.
+ * behind while the server drops them. Guarded on the schema being loaded so
+ * an empty first render doesn't prune everything.
*/
const liveColumnIds = useMemo(() => new Set(columns.map(getColumnId)), [columns])
const effectiveHiddenColumns = useMemo(
@@ -662,63 +718,6 @@ export function Table({
[columns.length, hiddenColumns, liveColumnIds]
)
- /**
- * Drops a sort whose column was deleted by clearing the URL, rather than masking
- * it in a derived value: `queryOptions` feeds the query that produces `columns`,
- * so a pruned sort can't flow back into it without a cycle. Clearing keeps one
- * source of truth, so the rows query, the dirty check, and the Save patch can't
- * disagree about whether a sort is active.
- */
- useEffect(() => {
- if (!sortColumn || columns.length === 0) return
- if (liveColumnIds.has(sortColumn)) return
- setTableParams({ sort: null, dir: null })
- }, [sortColumn, columns.length, liveColumnIds, setTableParams])
-
- /** The payload for creating a view, and the left-hand side of the dirty check.
- * Carries the current layout so "Save as view" from "All" captures the widths /
- * order / pins the grid is rendering (they live in the table's shared metadata
- * until a view owns them) instead of creating a layout-less view that then
- * resets the grid. Updates never send this — they send a merge patch. */
- const currentViewConfig = useMemo(
- () => ({
- ...(activeView?.config ?? tableData?.metadata),
- filter: effectiveFilter ?? null,
- sort: sortQuery,
- hiddenColumns: effectiveHiddenColumns,
- }),
- [activeView, tableData?.metadata, effectiveFilter, sortQuery, effectiveHiddenColumns]
- )
-
- /**
- * The active view's stored config, pruned against the live columns exactly as
- * the local state is. The server prunes on read, but the cached copy is not
- * re-pruned when the schema changes here — so without this, deleting a hidden or
- * sorted column makes the two sides disagree and lights Save with no user edit.
- */
- const storedViewConfig = useMemo(() => {
- if (!activeView) return null
- const stored = activeView.config
- if (columns.length === 0) return stored
- return {
- ...stored,
- hiddenColumns: (stored.hiddenColumns ?? []).filter((id) => liveColumnIds.has(id)),
- sort:
- stored.sort && Object.keys(stored.sort).every((id) => liveColumnIds.has(id))
- ? stored.sort
- : null,
- }
- }, [activeView, columns.length, liveColumnIds])
-
- /**
- * Whether the live state diverges from what the active view stores (or, on
- * "All", whether anything is applied at all). Drives the Save button — it is
- * the only affordance that persists, so ad-hoc exploration stays throwaway.
- */
- const isViewDirty = storedViewConfig
- ? !isSameViewConfig(currentViewConfig, storedViewConfig)
- : Boolean(effectiveFilter) || Boolean(sortQuery) || effectiveHiddenColumns.length > 0
-
/** Rename targets a live view rather than a snapshot, so a concurrent rename or
* delete can't leave the modal editing stale data. */
const renamingView =
@@ -726,6 +725,7 @@ export function Table({
const handleSelectView = useCallback(
(viewId: string | null) => {
+ preservedViewStateRef.current = null
setTableParams({ view: viewId ?? ALL_VIEW_PARAM })
},
[setTableParams]
@@ -736,12 +736,65 @@ export function Table({
}, [])
const handleNewView = useCallback(() => {
- setViewModal({ mode: 'create', blank: true })
+ setViewModal({ mode: 'new' })
}, [])
- /** Column order/width/pinning auto-saves into the active view as the user drags,
- * which is why `isSameViewConfig` excludes layout from the dirty check. Sent as
- * a `configPatch` so the server merges it — two overlapping layout writes must
+ /**
+ * Persists one user-committed view change. Filter application, sorting, and
+ * column visibility are discrete gestures, so they can save immediately
+ * without the document-style debounce needed for text editing. The mutation
+ * hook serializes patches for this table, preserving click order when several
+ * visibility changes happen before the first request settles.
+ */
+ const persistActiveViewConfig = useCallback(
+ (configPatch: TableViewConfig) => {
+ if (!viewsEnabled || !userPermissions.canEdit) return
+ const viewId = activeView?.id ?? pendingCreatedViewIdRef.current
+ if (!viewId) {
+ if (!ownerResolvedRef.current) {
+ pendingViewConfigRef.current = {
+ ...pendingViewConfigRef.current,
+ ...configPatch,
+ }
+ }
+ return
+ }
+
+ updateViewMutation.mutate(
+ { viewId, configPatch },
+ {
+ onSuccess: () => releasePersistedViewState(viewId, configPatch),
+ onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')),
+ }
+ )
+ },
+ [viewsEnabled, activeView?.id, userPermissions.canEdit, releasePersistedViewState]
+ )
+
+ /**
+ * Drops a sort whose column was deleted from the URL and, only when the saved
+ * view names that same field, from persistence. A stale deep-link can name a
+ * missing field while the view still owns a different valid sort, which must
+ * not be erased.
+ */
+ useEffect(() => {
+ if (!sortColumn || columns.length === 0) return
+ if (liveColumnIds.has(sortColumn)) return
+ setTableParams({ sort: null, dir: null })
+ if (activeViewConfig?.sort?.[0]?.field === sortColumn) {
+ persistActiveViewConfig({ sort: null })
+ }
+ }, [
+ sortColumn,
+ columns.length,
+ liveColumnIds,
+ activeViewConfig?.sort,
+ setTableParams,
+ persistActiveViewConfig,
+ ])
+
+ /** Column order/width/pinning auto-saves into the active view as the user drags.
+ * Sent as a `configPatch` so the server merges it — two overlapping layout writes must
* not each replace the whole blob from their own snapshot. With All selected
* the sink is unbound and the grid writes the table's shared metadata instead;
* while the views query is still loading the sink IS bound and the write is
@@ -767,12 +820,9 @@ export function Table({
return
}
// Owner reads "All", but the resolve effect hasn't confirmed that yet —
- // record the touched keys; `resolvePendingLayout` decides at settle.
+ // retain the exact gesture so adoption can save it to the selected owner.
if (!ownerResolvedRef.current) {
- pendingLayoutKeysRef.current ??= new Set()
- for (const key of Object.keys(patch) as (keyof TableMetadata)[]) {
- pendingLayoutKeysRef.current.add(key)
- }
+ pendingLayoutPatchRef.current = { ...pendingLayoutPatchRef.current, ...patch }
return
}
updateMetadataMutation.mutate(patch)
@@ -780,28 +830,6 @@ export function Table({
[userPermissions.canEdit]
)
- const handleSaveView = () => {
- if (activeView) {
- // Only the fields Save owns, merged server-side — never a client-built full
- // config. A full replace from a cached snapshot would drop a layout write
- // still in flight (and vice versa). `null`/`[]` merge as explicit values, so
- // clearing a filter or unhiding every column still persists as a removal.
- updateViewMutation.mutate(
- {
- viewId: activeView.id,
- configPatch: {
- filter: effectiveFilter,
- sort: sortQuery,
- hiddenColumns: effectiveHiddenColumns,
- },
- },
- { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')) }
- )
- return
- }
- setViewModal({ mode: 'create' })
- }
-
const handleSubmitViewName = (name: string) => {
if (viewModal?.mode === 'rename') {
updateViewMutation.mutate(
@@ -813,19 +841,15 @@ export function Table({
)
return
}
- // "New view" starts from All and is configured afterwards; "Save as view"
- // captures what is already applied. Both keep the current column layout so
- // creating a view never visually resets the grid.
- const blank = viewModal?.blank === true
- const config: TableViewConfig = blank
- ? {
- ...(activeView?.config ?? tableData?.metadata),
- ...readLayout(),
- filter: null,
- sort: null,
- hiddenColumns: [],
- }
- : { ...currentViewConfig, ...readLayout() }
+ // New views start unfiltered and are configured after naming. They inherit
+ // the live layout so creation never visually resets the grid.
+ const config: TableViewConfig = {
+ ...(activeView?.config ?? tableData?.metadata),
+ ...readLayout(),
+ filter: null,
+ sort: null,
+ hiddenColumns: [],
+ }
createViewMutation.mutate(
{ name, config },
{
@@ -833,12 +857,12 @@ export function Table({
setViewModal(null)
// Stamp before selecting so the resolve effect treats this as already
// seeded — it can't tell a just-created view from a dead id otherwise.
- seededViewIdRef.current = view.id
+ appliedViewRevisionRef.current = getTableViewRevision(view)
pendingCreatedViewIdRef.current = view.id
setTableParams({ view: view.id })
- // Which means the blank config must be applied here; nuqs batches this
- // sort write with the `view` write above into one URL update.
- if (blank) applyViewConfig(view.config)
+ // Apply the clean config immediately; nuqs batches its sort write with
+ // the `view` write above into one URL update.
+ applyViewConfig(view.config)
},
onError: (error) => toast.error(getErrorMessage(error, 'Failed to create view')),
}
@@ -847,14 +871,20 @@ export function Table({
const handleDeleteView = useCallback(
(viewId: string) => {
+ if (views.some((view) => view.id === viewId && view.isDefault)) {
+ toast.error('Set another view as default before deleting this view')
+ return
+ }
deleteViewMutation.mutate(viewId, {
onSuccess: () => {
- if (viewId === activeViewId) setTableParams({ view: ALL_VIEW_PARAM })
+ if (viewId !== activeViewId) return
+ const defaultView = views.find((view) => view.isDefault && view.id !== viewId)
+ setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM })
},
onError: (error) => toast.error(getErrorMessage(error, 'Failed to delete view')),
})
},
- [activeViewId, setTableParams]
+ [activeViewId, views, setTableParams]
)
const runColumnMutation = useRunColumn({ workspaceId, tableId })
@@ -1123,18 +1153,21 @@ export function Table({
)
const handleSortColumn = useCallback(
- (column: string, direction: SortDirection) => setTableParams({ sort: column, dir: direction }),
- [setTableParams]
+ (column: string, direction: SortDirection) => {
+ setTableParams({ sort: column, dir: direction })
+ persistActiveViewConfig({ sort: [{ field: column, direction }] })
+ },
+ [setTableParams, persistActiveViewConfig]
)
/**
* Clearing writes the default direction (stripped by clearOnDefault) and
* drops the column, leaving a clean URL with no active sort.
*/
- const handleClearSort = useCallback(
- () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }),
- [setTableParams]
- )
+ const handleClearSort = useCallback(() => {
+ setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION })
+ persistActiveViewConfig({ sort: null })
+ }, [setTableParams, persistActiveViewConfig])
const sortConfig = useMemo(
() => ({
@@ -1148,6 +1181,12 @@ export function Table({
const handleFilterApply = (next: TablePredicate | null) => {
setFilter(next)
+ persistActiveViewConfig({ filter: next })
+ }
+
+ const handleHiddenColumnsChange = (next: string[]) => {
+ setHiddenColumns(next)
+ persistActiveViewConfig({ hiddenColumns: next })
}
/**
@@ -1157,7 +1196,9 @@ export function Table({
* user no way to see what was applied.
*/
const handleFilterByCellValue = (conditions: readonly Predicate[]) => {
- replaceFilter(withCellValueFilter(effectiveFilter, conditions))
+ const next = withCellValueFilter(effectiveFilter, conditions)
+ replaceFilter(next)
+ persistActiveViewConfig({ filter: next })
setFilterOpen(true)
}
@@ -1403,22 +1444,9 @@ export function Table({
/>
) : null
- const saveViewChip =
- viewsEnabled && isViewDirty && userPermissions.canEdit ? (
-
- {activeView ? 'Save' : 'Save as view'}
-
- ) : null
-
- /** Right-aligned slot. Left `undefined` when both are absent so the options bar
- * doesn't render an empty flex row — a fragment would always read as truthy. */
- const optionsTrailing =
- runStatus || saveViewChip ? (
- <>
- {runStatus}
- {saveViewChip}
- >
- ) : undefined
+ /** Right-aligned slot. Left `undefined` when absent so the options bar
+ * doesn't render an empty flex row. */
+ const optionsTrailing = runStatus || undefined
return (
@@ -1456,13 +1484,13 @@ export function Table({
/>
)}
{/* Sort + filter render in both modes. In embedded (mothership) mode there's no
- Resource.Header, so the run/stop control rides in the options bar — pinned
- right, opposite the menu cluster, next to Save. */}
+ Resource.Header, so the run/stop control rides in the options bar — pinned
+ right, opposite the menu cluster. */}
) : undefined
}
@@ -1496,9 +1524,9 @@ export function Table({
/>
)}
!open && setViewModal(null)}
- mode={viewModal?.mode === 'rename' ? 'rename' : viewModal?.blank ? 'new' : 'create'}
+ mode={viewModal?.mode === 'rename' ? 'rename' : 'new'}
initialName={renamingView?.name ?? ''}
onSubmit={handleSubmitViewName}
isSubmitting={createViewMutation.isPending || updateViewMutation.isPending}
@@ -1534,7 +1562,7 @@ export function Table({
onSelectionChange={onSelectionChange}
queryOptions={queryOptions}
hiddenColumns={effectiveHiddenColumns}
- viewLayout={activeView?.config ?? null}
+ viewLayout={activeViewConfig}
viewLayoutKey={activeView?.id ?? null}
// Always bound while views are enabled: the router reads the owner at
// call time (buffer / view / All-metadata), so no binding gap can send a
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts
new file mode 100644
index 00000000000..7cbc4cc5927
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts
@@ -0,0 +1,127 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import type { TableViewWire } from '@/lib/api/contracts/tables'
+import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params'
+import {
+ getTableViewRevision,
+ resolveTableViewConfig,
+ resolveTableViewSelection,
+ shouldApplyTableViewRevision,
+} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
+
+describe('resolveTableViewConfig', () => {
+ it('inherits layout metadata when an ungated default view is still empty', () => {
+ const metadata = {
+ columnWidths: { 'column-1': 240 },
+ columnOrder: ['column-1'],
+ pinnedColumns: ['column-1'],
+ hiddenColumns: ['column-2'],
+ }
+
+ expect(resolveTableViewConfig(metadata, {})).toEqual(metadata)
+ })
+
+ it('lets explicitly stored view fields override the metadata baseline', () => {
+ expect(
+ resolveTableViewConfig(
+ { columnWidths: { 'column-1': 240 }, pinnedColumns: ['column-1'] },
+ { columnWidths: { 'column-1': 180 }, pinnedColumns: [] }
+ )
+ ).toEqual({ columnWidths: { 'column-1': 180 }, pinnedColumns: [] })
+ })
+})
+
+const DEFAULT_VIEW: TableViewWire = {
+ id: 'view-default',
+ tableId: 'table-1',
+ name: 'Default',
+ config: { filter: { all: [{ field: 'column-1', op: 'eq', value: 'Ada' }] } },
+ isDefault: true,
+ createdBy: 'user-1',
+ createdAt: new Date('2026-08-15T01:00:00.000Z'),
+ updatedAt: new Date('2026-08-15T01:10:00.000Z'),
+}
+
+describe('resolveTableViewSelection', () => {
+ it('makes the persisted default active before its URL id is adopted', () => {
+ expect(resolveTableViewSelection([DEFAULT_VIEW], null)).toEqual({
+ selectedView: null,
+ defaultView: DEFAULT_VIEW,
+ activeView: DEFAULT_VIEW,
+ })
+ })
+
+ it('advances the applied revision when a default arrives after an empty cached list', () => {
+ const emptySelection = resolveTableViewSelection([], null)
+ const loadedSelection = resolveTableViewSelection([DEFAULT_VIEW], null)
+
+ expect(
+ shouldApplyTableViewRevision(
+ getTableViewRevision(emptySelection.activeView),
+ getTableViewRevision(loadedSelection.activeView),
+ false
+ )
+ ).toBe(true)
+ })
+
+ it('does not replace a pending selected id with the default view', () => {
+ expect(resolveTableViewSelection([DEFAULT_VIEW], 'view-pending')).toEqual({
+ selectedView: null,
+ defaultView: DEFAULT_VIEW,
+ activeView: null,
+ })
+ })
+
+ it('upgrades the legacy All sentinel when a persisted default exists', () => {
+ expect(resolveTableViewSelection([DEFAULT_VIEW], ALL_VIEW_PARAM).activeView).toBe(DEFAULT_VIEW)
+ })
+})
+
+describe('shouldApplyTableViewRevision', () => {
+ const cached = {
+ id: 'view-1',
+ updatedAt: new Date('2026-08-15T01:09:29.136Z'),
+ }
+
+ it('reapplies a refreshed config for the same view after autosave settles', () => {
+ const applied = getTableViewRevision(cached)
+ const saved = getTableViewRevision({
+ ...cached,
+ updatedAt: new Date('2026-08-15T01:10:47.737Z'),
+ })
+
+ expect(shouldApplyTableViewRevision(applied, saved, false)).toBe(true)
+ })
+
+ it('does not rewind local state while autosave is still pending', () => {
+ const applied = getTableViewRevision(cached)
+ const saved = getTableViewRevision({
+ ...cached,
+ updatedAt: new Date('2026-08-15T01:10:47.737Z'),
+ })
+
+ expect(shouldApplyTableViewRevision(applied, saved, true)).toBe(false)
+ })
+
+ it('ignores an older response for the same view', () => {
+ const applied = getTableViewRevision(cached)
+ const stale = getTableViewRevision({
+ ...cached,
+ updatedAt: new Date('2026-08-15T01:08:00.000Z'),
+ })
+
+ expect(shouldApplyTableViewRevision(applied, stale, false)).toBe(false)
+ })
+
+ it('applies a different view even while the previous view is saving', () => {
+ const applied = getTableViewRevision(cached)
+ const selected = getTableViewRevision({
+ id: 'view-2',
+ updatedAt: new Date('2026-08-15T01:09:00.000Z'),
+ })
+
+ expect(shouldApplyTableViewRevision(applied, selected, true)).toBe(true)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts
new file mode 100644
index 00000000000..0903d6aa4cd
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts
@@ -0,0 +1,75 @@
+import type { TableViewWire } from '@/lib/api/contracts/tables'
+import type { TableMetadata, TableViewConfig } from '@/lib/table'
+import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params'
+
+export interface TableViewSelection {
+ selectedView: TableViewWire | null
+ defaultView: TableViewWire | null
+ activeView: TableViewWire | null
+}
+
+/**
+ * For fields shared with table metadata, a persisted view owns only what it has
+ * stored. Missing fields inherit values written before table views were enabled.
+ */
+export function resolveTableViewConfig(
+ metadata: TableMetadata | null | undefined,
+ viewConfig: TableViewConfig | null
+): TableViewConfig | null {
+ if (!viewConfig) return null
+ return { ...(metadata ?? {}), ...viewConfig }
+}
+
+/**
+ * Resolves the persisted default synchronously when the URL has not selected a
+ * view yet. The URL effect still records that choice, but render-time consumers
+ * all see the same owner while that update is pending.
+ */
+export function resolveTableViewSelection(
+ views: TableViewWire[],
+ activeViewId: string | null
+): TableViewSelection {
+ let selectedView: TableViewWire | null = null
+ let defaultView: TableViewWire | null = null
+ for (const view of views) {
+ if (view.id === activeViewId) selectedView = view
+ if (view.isDefault) defaultView = view
+ }
+ return {
+ selectedView,
+ defaultView,
+ activeView:
+ selectedView ??
+ (activeViewId === null || activeViewId === ALL_VIEW_PARAM ? defaultView : null),
+ }
+}
+
+export interface TableViewRevision {
+ id: string | null
+ updatedAt: number | null
+}
+
+export function getTableViewRevision(
+ view: Pick | null
+): TableViewRevision {
+ return {
+ id: view?.id ?? null,
+ updatedAt: view?.updatedAt.getTime() ?? null,
+ }
+}
+
+/**
+ * Whether server state should replace the view configuration currently applied
+ * to the grid. A different view always wins. The same view wins only when its
+ * persisted revision advanced and no local autosave is still queued; older
+ * query responses must never rewind a newer applied revision.
+ */
+export function shouldApplyTableViewRevision(
+ applied: TableViewRevision,
+ next: TableViewRevision,
+ autosavePending: boolean
+): boolean {
+ if (applied.id !== next.id) return true
+ if (autosavePending || next.updatedAt === null) return false
+ return applied.updatedAt === null || next.updatedAt > applied.updatedAt
+}
diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts
index 274605ad669..b6fcb8a963c 100644
--- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts
+++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
-import { folder as folderTable } from '@sim/db/schema'
+import { folder as folderTable, tableViews, userTableDefinitions } from '@sim/db/schema'
import { sha256Hex } from '@sim/security/hash'
import {
dbChainMockFns,
@@ -1211,6 +1211,127 @@ describe('copyForkResourceContent', () => {
})
})
+describe('copyForkResourceContainers table views', () => {
+ it('copies saved views and seeds a default for a legacy table', async () => {
+ const now = new Date('2026-08-19T00:00:00.000Z')
+ const definitions = [
+ {
+ id: 'table-with-view',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Configured table',
+ description: null,
+ schema: { columns: [{ id: 'col-name', name: 'Name', type: 'string' }] },
+ metadata: { columnOrder: ['col-name'] },
+ maxRows: 10000,
+ rowCount: 1,
+ rowsVersion: 1,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ },
+ {
+ id: 'legacy-table',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Legacy table',
+ description: null,
+ schema: { columns: [{ id: 'col-email', name: 'Email', type: 'string' }] },
+ metadata: { columnOrder: ['col-email'] },
+ maxRows: 10000,
+ rowCount: 0,
+ rowsVersion: 0,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ },
+ ]
+ const sourceViews = [
+ {
+ id: 'source-view',
+ tableId: 'table-with-view',
+ workspaceId: 'src-ws',
+ name: 'My view',
+ config: { hiddenColumns: ['col-name'] },
+ isDefault: true,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ },
+ ]
+ const inserted = new Map>>()
+ const tx = {
+ select: () => ({
+ from: (table: unknown) => ({
+ where: () =>
+ Promise.resolve(
+ table === userTableDefinitions ? definitions : table === tableViews ? sourceViews : []
+ ),
+ }),
+ }),
+ insert: (table: unknown) => ({
+ values: (values: Array>) => {
+ inserted.set(table, values)
+ return Promise.resolve()
+ },
+ }),
+ }
+
+ const result = await copyForkResourceContainers({
+ tx: tx as unknown as DbOrTx,
+ sourceWorkspaceId: 'src-ws',
+ childWorkspaceId: 'child-ws',
+ userId: 'user-1',
+ now,
+ selection: {
+ customTools: [],
+ skills: [],
+ mcpServers: [],
+ workflowMcpServers: [],
+ tables: definitions.map((definition) => definition.id),
+ knowledgeBases: [],
+ },
+ workflowIdMap: new Map(),
+ documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
+ })
+
+ const copiedTableId = result.idMap.get('table')?.get('table-with-view')
+ const legacyTableId = result.idMap.get('table')?.get('legacy-table')
+ const copiedViews = inserted.get(tableViews)
+ expect(copiedViews).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ tableId: copiedTableId,
+ workspaceId: 'child-ws',
+ name: 'My view',
+ config: { hiddenColumns: ['col-name'] },
+ isDefault: true,
+ createdBy: 'user-1',
+ }),
+ expect.objectContaining({
+ tableId: legacyTableId,
+ workspaceId: 'child-ws',
+ name: 'Default',
+ config: { columnOrder: ['col-email'] },
+ isDefault: true,
+ createdBy: 'user-1',
+ }),
+ ])
+ )
+ expect(copiedViews?.find((view) => view.name === 'My view')?.id).not.toBe('source-view')
+ })
+})
+
describe('copyForkResourceContainers custom-tool code env rewrite', () => {
function makeContainerTx(rows: Array>) {
const inserted: Array> = []
diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
index a50830cebce..54d01bc3b32 100644
--- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
+++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
@@ -10,6 +10,7 @@ import {
permissions,
skill,
skillMember,
+ tableViews,
userTableDefinitions,
userTableRowSecretProvenance,
userTableRows,
@@ -54,6 +55,7 @@ import {
rebindKnowledgeDocumentSecretProvenance,
replaceKnowledgeDocumentSecretProvenanceInTx,
} from '@/lib/knowledge/secret-provenance'
+import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants'
import { nKeysBetween } from '@/lib/table/order-key'
import {
classifyTableRowSecretProvenanceForCopy,
@@ -635,6 +637,27 @@ export async function copyForkResourceContainers(
isNull(userTableDefinitions.archivedAt)
)
)
+ const sourceViews =
+ definitions.length > 0
+ ? await tx
+ .select()
+ .from(tableViews)
+ .where(
+ and(
+ inArray(
+ tableViews.tableId,
+ definitions.map((definition) => definition.id)
+ ),
+ eq(tableViews.workspaceId, sourceWorkspaceId)
+ )
+ )
+ : []
+ const sourceViewsByTable = new Map()
+ for (const view of sourceViews) {
+ const views = sourceViewsByTable.get(view.tableId) ?? []
+ views.push(view)
+ sourceViewsByTable.set(view.tableId, views)
+ }
const tableFolderIdMap = await resolveForkFolderMapping({
tx,
sourceWorkspaceId,
@@ -647,6 +670,7 @@ export async function copyForkResourceContainers(
for (const [source, target] of tableFolderIdMap) folderIdMap.set(source, target)
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
+ const viewInserts: (typeof tableViews.$inferInsert)[] = []
for (const definition of definitions) {
const childTableId = generateId()
const remappedSchema = remapForkTableWorkflowGroups(
@@ -684,11 +708,37 @@ export async function copyForkResourceContainers(
createdAt: now,
updatedAt: now,
})
+ const views = sourceViewsByTable.get(definition.id) ?? []
+ for (const view of views) {
+ viewInserts.push({
+ ...view,
+ id: generateId(),
+ tableId: childTableId,
+ workspaceId: childWorkspaceId,
+ createdBy: userId,
+ createdAt: now,
+ updatedAt: now,
+ })
+ }
+ if (!views.some((view) => view.isDefault)) {
+ viewInserts.push({
+ id: generateId(),
+ tableId: childTableId,
+ workspaceId: childWorkspaceId,
+ name: DEFAULT_TABLE_VIEW_NAME,
+ config: definition.metadata ?? {},
+ isDefault: true,
+ createdBy: userId,
+ createdAt: now,
+ updatedAt: now,
+ })
+ }
record('table', definition.id, childTableId)
contentPlan.tables.push({ sourceId: definition.id, childId: childTableId })
names.tables.push(definition.name)
}
if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts)
+ if (viewInserts.length > 0) await tx.insert(tableViews).values(viewInserts)
}
if (selection.knowledgeBases.length > 0) {
diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts
index b90836494f4..2d4f6eb0b0d 100644
--- a/apps/sim/hooks/queries/tables.test.ts
+++ b/apps/sim/hooks/queries/tables.test.ts
@@ -63,6 +63,7 @@ import {
useDeleteColumn,
useRestoreTable,
useUpdateColumn,
+ useUpdateTableView,
} from '@/hooks/queries/tables'
import { tableKeys } from '@/hooks/queries/utils/table-keys'
@@ -89,6 +90,23 @@ beforeEach(() => {
vi.clearAllMocks()
})
+describe('useUpdateTableView autosave ordering', () => {
+ it('serializes config and layout patches for the same table', () => {
+ const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
+
+ expect(hook.scope).toEqual({ id: `table-view:${TABLE_ID}` })
+ })
+
+ it('does not hold the serial mutation queue open for list reconciliation', () => {
+ const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
+
+ expect(hook.onSettled?.(undefined, null, { viewId: 'view-1' }, undefined)).toBeUndefined()
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: tableKeys.views(TABLE_ID),
+ })
+ })
+})
+
describe('useDeleteColumn optimistic update', () => {
it('removes column from schema cache, strips its width, and clears it from row data', async () => {
setCache(tableKeys.detail(TABLE_ID), {
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 9fcb64d6119..fbe80d0abda 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -1540,8 +1540,8 @@ export function useCreateTableView({ workspaceId, tableId }: RowMutationContext)
prev ? [...prev, view] : [view]
)
},
- // Returned so the mutation stays pending until the refetch settles — otherwise
- // the Save chip re-enables and flashes dirty against a stale cached config.
+ // Keep creation pending until the refetch settles so the newly selected
+ // view does not briefly resolve against an incomplete list.
onSettled: () => queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) }),
})
}
@@ -1549,7 +1549,7 @@ export function useCreateTableView({ workspaceId, tableId }: RowMutationContext)
interface UpdateTableViewParams {
viewId: string
name?: string
- /** Full replace (explicit Save). Mutually exclusive with `configPatch`. */
+ /** Full replacement for API consumers. Mutually exclusive with `configPatch`. */
config?: TableViewConfigInput
/** Server-side shallow merge — used for the grid's incremental layout writes. */
configPatch?: TableViewConfigInput
@@ -1565,6 +1565,10 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext)
const queryClient = useQueryClient()
return useMutation({
+ // View config and layout patches can touch the same top-level JSON keys.
+ // Preserve gesture order so rapid visibility toggles cannot finish out of
+ // order and leave an older snapshot stored last.
+ scope: { id: `table-view:${tableId}` },
mutationFn: async ({ viewId, name, config, configPatch, isDefault }: UpdateTableViewParams) => {
const response = await requestJson(updateTableViewContract, {
params: { tableId, viewId },
@@ -1572,13 +1576,13 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext)
})
return response.data.view
},
- // Without this the edited view's cached config stays stale until the refetch,
- // so `isViewDirty` re-reads true and the Save chip flashes back after a save.
+ // Keep the active view's server baseline current immediately; the refetch
+ // remains the authoritative reconciliation for concurrent collaborators.
onSuccess: (view) => {
queryClient.setQueryData(tableKeys.views(tableId), (prev) =>
prev?.map((existing) => {
if (existing.id !== view.id) return existing
- // Layout auto-saves and an explicit Save fire concurrently, and their
+ // Layout and view controls auto-save concurrently, and their
// responses can arrive out of order. The DB merge is authoritative, so
// only let a row at least as new as the cached one win — otherwise a
// slower response rewinds the cache until the refetch lands.
@@ -1586,7 +1590,12 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext)
})
)
},
- onSettled: () => queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) }),
+ onSettled: () => {
+ // A scoped mutation only needs the database write ahead of the next
+ // patch. Let reconciliation run alongside the queue instead of making
+ // every rapid visibility toggle wait for a full list refetch.
+ void queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) })
+ },
})
}
diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts
index e5eeefac94e..10690687308 100644
--- a/apps/sim/lib/api/contracts/tables.ts
+++ b/apps/sim/lib/api/contracts/tables.ts
@@ -2189,7 +2189,7 @@ export const updateTableViewBodySchema = z
.min(1, 'Workspace ID is required')
.describe('Workspace that owns the table.'),
name: viewNameSchema.optional().describe('Replacement saved-view display name.'),
- /** Full replace. Use for an explicit Save, where dropping a removed filter is the point. */
+ /** Full replacement for callers that own the complete configuration snapshot. */
config: tableViewConfigSchema
.optional()
.describe('Complete replacement saved-view configuration.'),
diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts
index b53a1faecee..a673cd97356 100644
--- a/apps/sim/lib/table/constants.ts
+++ b/apps/sim/lib/table/constants.ts
@@ -12,6 +12,8 @@ import { env, envNumber } from '@/lib/core/config/env'
*/
export const MAX_TABLE_BATCH_ITEMS = 100
+export const DEFAULT_TABLE_VIEW_NAME = 'Default'
+
export const TABLE_LIMITS = {
MAX_TABLES_PER_WORKSPACE: 100,
MAX_ROWS_PER_TABLE: 10000,
diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts
index ea6ade3ea84..3bde50fa497 100644
--- a/apps/sim/lib/table/service.test.ts
+++ b/apps/sim/lib/table/service.test.ts
@@ -93,14 +93,25 @@ describe('createTable schema invariants', () => {
expect(dbChainMockFns.insert).toHaveBeenCalled()
})
- it('creates an ordinary group-free table unchanged', async () => {
+ it('creates an ordinary group-free table with a persisted default view', async () => {
queueTableRows(schemaMock.userTableDefinitions, [{ count: 0 }])
const table = await create({ columns: [{ name: 'email', type: 'string' }] } as TableSchema)
expect(table.name).toBe('contacts')
expect(table.schema.columns[0].id).toEqual(expect.any(String))
- expect(dbChainMockFns.insert).toHaveBeenCalled()
+ expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.userTableDefinitions)
+ expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.tableViews)
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tableId: table.id,
+ workspaceId: WORKSPACE_ID,
+ name: 'Default',
+ config: {},
+ isDefault: true,
+ createdBy: 'user-1',
+ })
+ )
})
})
diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts
index 42aa16fc4a9..d77c71457f1 100644
--- a/apps/sim/lib/table/service.ts
+++ b/apps/sim/lib/table/service.ts
@@ -9,7 +9,7 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema'
+import { tableJobs, tableViews, userTableDefinitions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -36,7 +36,12 @@ import { resolveRestoredFolderId } from '@/lib/folders/queries'
import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify'
import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing'
import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys'
-import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
+import {
+ COLUMN_TYPES,
+ DEFAULT_TABLE_VIEW_NAME,
+ NAME_PATTERN,
+ TABLE_LIMITS,
+} from '@/lib/table/constants'
import { appendTableEvent } from '@/lib/table/events'
import {
EMPTY_JOB_FIELDS,
@@ -644,6 +649,17 @@ export async function createTable(
}
await trx.insert(userTableDefinitions).values(newTable)
+ await trx.insert(tableViews).values({
+ id: generateId(),
+ tableId,
+ workspaceId: data.workspaceId,
+ name: DEFAULT_TABLE_VIEW_NAME,
+ config: {},
+ isDefault: true,
+ createdBy: data.userId,
+ createdAt: now,
+ updatedAt: now,
+ })
if (initialJob) {
await trx.insert(tableJobs).values({
diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts
index a76c568d310..a55386ba1a6 100644
--- a/apps/sim/lib/table/views/service.test.ts
+++ b/apps/sim/lib/table/views/service.test.ts
@@ -157,6 +157,28 @@ describe('table-view mutations signal collaborators', () => {
expect(mockSignalTableViewsChanged).toHaveBeenCalledWith('table-1')
})
+ it.each([
+ { existingTotal: 0, isDefault: true },
+ { existingTotal: 1, isDefault: false },
+ ])(
+ 'creates a view with isDefault=$isDefault when $existingTotal views already exist',
+ async ({ existingTotal, isDefault }) => {
+ queueTableRows(tableViews, [{ total: existingTotal }])
+ dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault }])
+
+ await createTableView({
+ tableId: 'table-1',
+ workspaceId: 'ws-1',
+ name: 'My View',
+ config: {},
+ userId: 'user-1',
+ columns,
+ })
+
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ isDefault }))
+ }
+ )
+
it('updateTableView signals when the target view exists', async () => {
queueTableRows(tableViews, [{ id: 'view-1' }]) // the in-transaction existence pre-check
dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) // the update returning
diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts
index 68a0ddce1e2..79c6df28296 100644
--- a/apps/sim/lib/table/views/service.ts
+++ b/apps/sim/lib/table/views/service.ts
@@ -2,11 +2,12 @@
* Saved views on a user table — named presets of `{ filter, sort, column layout }`.
*
* A view is presentation state, never an access boundary: it narrows what a
- * reader sees by default, but every row it hides is still reachable by switching
- * to "All". Row access is enforced entirely by the caller's workspace permission.
+ * reader sees by default, but every row it hides remains accessible by clearing
+ * the filter or selecting another view. Row access is enforced entirely by the
+ * caller's workspace permission.
*
- * "All" is the *absence* of a view, so no row is seeded per table and a table is
- * always reachable unfiltered even if every saved view is broken or deleted.
+ * New tables are seeded with an empty default view. Legacy tables without one
+ * temporarily use "All" as an unfiltered fallback until they are migrated.
*/
import { db } from '@sim/db'
@@ -193,10 +194,10 @@ function tolerantColumns(
* `carriedForward` names the references that are exempt from that refusal.
* Deleting a column leaves every view that filtered on it dangling —
* `pruneViewConfig` deliberately does not prune a filter — so without the
- * exemption the view becomes unwritable: the Save chip sends the whole
- * `{filter, sort, hiddenColumns}` slice, and a user changing the sort would be
- * refused over a condition they did not touch, with no way to save the removal
- * of anything else first. The v2 surface exempts only what the STORED config
+ * exemption the filter becomes unwritable: changing one of its other conditions
+ * autosaves the whole predicate and would be refused over the dangling condition
+ * the user did not touch, with no way to save its eventual removal. The v2
+ * surface exempts only what the STORED config
* already held, so a reference the caller INTRODUCES is refused; a first-party
* caller exempts its own refs too, which is the behavior the grid has always
* had — see {@link CreateTableViewData.strictRefs}.
@@ -425,9 +426,8 @@ export interface CreateTableViewData {
* Absent — the first-party grid, which does not author these refs so much as
* carry them: a view filtered on a since-deleted column keeps the dangling
* leaf through every read (`pruneViewConfig` spares filters) and hands it
- * straight back on the next save. Refusing it would 400 "Save as view" on a
- * config the Save chip accepts, one menu item apart, over a condition the user
- * never touched.
+ * straight back on the next autosave. Refusing it would reject a config the
+ * first-party grid already accepted, over a condition the user never touched.
*/
strictRefs?: boolean
}
@@ -464,7 +464,8 @@ export async function createTableView(data: CreateTableViewData): Promise= TABLE_LIMITS.MAX_VIEWS_PER_TABLE) {
+ const existingTotal = Number(existing?.total ?? 0)
+ if (existingTotal >= TABLE_LIMITS.MAX_VIEWS_PER_TABLE) {
throw new TableViewValidationError(
`A table cannot have more than ${TABLE_LIMITS.MAX_VIEWS_PER_TABLE} saved views`
)
@@ -478,6 +479,7 @@ export async function createTableView(data: CreateTableViewData): Promise