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..b583ae03cce
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx
@@ -0,0 +1,48 @@
+/**
+ * @vitest-environment jsdom
+ */
+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'),
+}
+
+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<')
+ })
+})
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..335bea769ef 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) => (
)
- .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)
-}
+/** New views are named before configuration; rename targets an existing view. */
+type ViewModalState = { mode: 'new' } | { mode: 'rename'; viewId: string } | null
/**
* Page-level wrapper for the table detail view. Mirrors the shape of
@@ -410,21 +378,20 @@ export function Table({
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 [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)
@@ -495,10 +462,9 @@ export function Table({
* 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, and
+ * an independent effect could flush to All in exactly the case that must drop.
*/
const resolvePendingLayout = useCallback(
(adoptedView: boolean) => {
@@ -527,8 +493,10 @@ export function Table({
/**
* 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,7 +507,7 @@ 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.
@@ -550,28 +518,32 @@ export function Table({
}
if (!viewsAvailable) 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()
+ const keep = inheritedParams
+ ? { ...localWork(), sort: false }
+ : legacyAllWithDefault
+ ? undefined
+ : localWork()
if (defaultView) {
- seededViewIdRef.current = defaultView.id
+ appliedViewRevisionRef.current = getTableViewRevision(defaultView)
setTableParams({ view: defaultView.id })
applyViewConfig(defaultView.config, keep)
resolvePendingLayout(true)
@@ -580,23 +552,25 @@ export function Table({
// 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)
return
}
if (activeViewId === ALL_VIEW_PARAM) {
- seededViewIdRef.current = null
+ appliedViewRevisionRef.current = getTableViewRevision(null)
resolvePendingLayout(false)
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
+ // A `?view=` that resolves to nothing adopts the persisted default when
+ // one exists; tables awaiting backfill retain the legacy All fallback.
+ appliedViewRevisionRef.current = getTableViewRevision(activeView)
resolvePendingLayout(activeView !== null)
- if (activeView) {
- applyViewConfig(activeView.config, localWork())
+ if (selectedView) {
+ applyViewConfig(selectedView.config, localWork())
+ } else if (defaultView) {
+ setTableParams({ view: defaultView.id })
+ applyViewConfig(defaultView.config)
} 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
@@ -607,26 +581,38 @@ export function Table({
}
// 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)
+ appliedViewRevisionRef.current = getTableViewRevision(defaultView)
+ setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM })
+ applyViewConfig(defaultView?.config ?? null)
return
}
- const nextViewId = activeView?.id ?? null
- if (seededViewIdRef.current === nextViewId) return
- seededViewIdRef.current = nextViewId
+ const nextViewRevision = getTableViewRevision(activeView)
+ if (
+ !shouldApplyTableViewRevision(
+ appliedViewRevisionRef.current,
+ nextViewRevision,
+ updateViewMutation.isPending
+ )
+ ) {
+ return
+ }
+ appliedViewRevisionRef.current = nextViewRevision
+ const nextViewId = nextViewRevision.id
+ if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) {
+ setTableParams({ view: activeView.id })
+ }
// 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) {
@@ -638,10 +624,13 @@ export function Table({
viewsAvailable,
viewsErrored,
views,
+ selectedView,
+ defaultView,
activeView,
activeViewId,
embedded,
sortColumn,
+ updateViewMutation.isPending,
applyViewConfig,
setTableParams,
resolvePendingLayout,
@@ -650,10 +639,8 @@ export function Table({
/**
* 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(
@@ -666,7 +653,7 @@ export function Table({
* 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
+ * source of truth, so the rows query and the active-view autosave cannot
* disagree about whether a sort is active.
*/
useEffect(() => {
@@ -675,50 +662,6 @@ export function Table({
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 =
@@ -736,12 +679,33 @@ 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) => {
+ const viewId = activeView?.id
+ if (!viewId || !userPermissions.canEdit) return
+
+ updateViewMutation.mutate(
+ { viewId, configPatch },
+ {
+ onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')),
+ }
+ )
+ },
+ [activeView?.id, userPermissions.canEdit]
+ )
+
+ /** 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
@@ -780,28 +744,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 +755,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 +771,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')),
}
@@ -849,12 +787,14 @@ export function Table({
(viewId: string) => {
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 +1063,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 +1091,12 @@ export function Table({
const handleFilterApply = (next: TablePredicate | null) => {
setFilter(next)
+ persistActiveViewConfig({ filter: next })
+ }
+
+ const handleHiddenColumnsChange = (next: string[]) => {
+ setHiddenColumns(next)
+ persistActiveViewConfig({ hiddenColumns: next })
}
/**
@@ -1403,22 +1352,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
+ const optionsTrailing = runStatus || undefined
return (
@@ -1462,7 +1398,7 @@ export function Table({
sort={sortConfig}
filter={filterConfig}
aside={
- viewsEnabled ? (
+ viewsEnabled && viewsAvailable ? (
) : undefined
}
@@ -1496,9 +1432,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}
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..2c2545191fc
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts
@@ -0,0 +1,104 @@
+/**
+ * @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,
+ resolveTableViewSelection,
+ shouldApplyTableViewRevision,
+} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
+
+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..03cca8936bd
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts
@@ -0,0 +1,62 @@
+import type { TableViewWire } from '@/lib/api/contracts/tables'
+import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params'
+
+export interface TableViewSelection {
+ selectedView: TableViewWire | null
+ defaultView: TableViewWire | null
+ activeView: TableViewWire | null
+}
+
+/**
+ * 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/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 7423d6b09de..c2c9c4651d8 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -1524,8 +1524,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) }),
})
}
@@ -1533,7 +1533,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
@@ -1549,6 +1549,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 },
@@ -1556,13 +1560,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.
@@ -1570,7 +1574,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 f93a6d5438a..d5cf45ac53f 100644
--- a/apps/sim/lib/api/contracts/tables.ts
+++ b/apps/sim/lib/api/contracts/tables.ts
@@ -2153,7 +2153,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 7ab2e7aa098..81a681b4bc5 100644
--- a/apps/sim/lib/table/constants.ts
+++ b/apps/sim/lib/table/constants.ts
@@ -5,6 +5,8 @@
import { randomInt, randomItem } from '@sim/utils/random'
import { env, envNumber } from '@/lib/core/config/env'
+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 5774caea7ab..979681451bd 100644
--- a/apps/sim/lib/table/service.test.ts
+++ b/apps/sim/lib/table/service.test.ts
@@ -86,13 +86,24 @@ 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 842a0a84d75..d907d445c28 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, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service'
import { assertSchemaMutable, TableLockedError } from '@/lib/table/mutation-locks'
@@ -632,6 +637,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.ts b/apps/sim/lib/table/views/service.ts
index 68a0ddce1e2..f53c2d424f2 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
}
@@ -495,7 +495,7 @@ export interface UpdateTableViewData {
tableId: string
workspaceId?: string
name?: string
- /** Full replace — an explicit Save, where removing a filter must persist. */
+ /** Full replacement for callers that own the complete configuration snapshot. */
config?: TableViewConfig
/** Shallow-merged into the stored config. Mutually exclusive with `config`. */
configPatch?: TableViewConfig
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 6132d2f513e..aa6a14aece8 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -4373,10 +4373,10 @@ export const userTableRowSecretProvenance = pgTable(
)
/**
- * Saved presets for a user-defined table — a named filter + sort + column layout.
+ * Saved views for a user-defined table — a named filter + sort + column layout.
* Workspace-shared: anyone who can read the table sees every view, and `write` is
- * required to create, update, or delete one. The absence of a view is the built-in
- * "All" state, so a table is always reachable unfiltered without a seeded row.
+ * required to create, update, or delete one. New tables are seeded with one default
+ * view; legacy tables without one temporarily retain the built-in "All" fallback.
*
* A dedicated table rather than a key on `user_table_definitions.metadata`: that
* column is written read-modify-write with a shallow merge, so a stale snapshot