# Hide-Property Persistence Bug Fix Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Stop hidden column toggles from silently reappearing when another view-config mutation or a websocket-driven refetch lands between the toggle and the 300 ms debounced persist. **Architecture:** Two narrow fixes in the client — nothing server-side. (1) The blind `setColumnVisibility(derivedColumnVisibility)` sync effect in [`use-base-table.ts`](apps/client/src/features/base/hooks/use-base-table.ts) is scoped to run only when the user switches views, so refetches of the same view can't overwrite pending local toggles. (2) The sort/filter mutations in [`base-toolbar.tsx`](apps/client/src/features/base/components/base-toolbar.tsx) merge in the live table state (visibility / order / widths) instead of spreading stale `activeView.config` — so saving a sort no longer clobbers a not-yet-persisted hide. **Tech Stack:** React 18, TanStack Table v8, TanStack Query v5, Mantine, TypeScript. --- ## Background — why this happens `useBaseTable` keeps `columnVisibility` as local React state, seeded from the persisted view config via a `useMemo`-derived value (`derivedColumnVisibility`). Two effects currently do an unconditional re-sync: ```ts // apps/client/src/features/base/hooks/use-base-table.ts:223-229 useEffect(() => { setColumnOrder(derivedColumnOrder); }, [derivedColumnOrder]); useEffect(() => { setColumnVisibility(derivedColumnVisibility); }, [derivedColumnVisibility]); ``` Any mutation that calls `queryClient.setQueryData(["bases", baseId], ...)` — or `queryClient.invalidateQueries` at [`use-base-socket.ts:254`](apps/client/src/features/base/hooks/use-base-socket.ts:254) on ws `base:property:*` / `base:view:*` events — minted a new `activeView.config` reference. That recomputes `derivedColumnVisibility`, and the effect slams the local state back to whatever the server currently has. Persistence is debounced 300 ms by `persistViewConfig`. During that window, any of these triggers a stomp: 1. **A concurrent sort / filter mutation from the same user.** `handleSortsChange` / `handleFiltersChange` in [`base-toolbar.tsx:93-120`](apps/client/src/features/base/components/base-toolbar.tsx:93) spread `activeView.config` (stale — still has old `hiddenPropertyIds`) and `mutate` immediately, without going through the debounced path. The `onMutate` optimistic write, the `onSuccess` server write, and the ws echo's `invalidateQueries` each re-trigger the effect. 2. **Another client sending any `base:property:*` / `base:view:*` ws event.** 3. **The optimistic / success writes from our OWN `persistViewConfig` mutation when the server-side state hasn't persisted yet** (e.g., response races). Symptom: user toggles a column hidden → column disappears → column reappears within ~instant to a few hundred ms → reload confirms the toggle was never saved. **Deterministic repro (no second client needed):** 1. Open a base with ≥ 3 non-primary columns and no sorts. 2. In the toolbar, open "Hide fields" and toggle column X off. Column vanishes. 3. Immediately (well under 300 ms) open the Sort popover and add a sort. 4. Column X reappears. 5. Reload: X is visible; sort is saved; hide was lost. `hiddenPropertyIds` wins semantics check: [`use-base-table.ts:117-143`](apps/client/src/features/base/hooks/use-base-table.ts:117) — correct (hidden list is checked first, then legacy `visiblePropertyIds`, then default all-visible). That isn't the bug. --- ## File Structure **Modified files:** - `apps/client/src/features/base/hooks/use-base-table.ts` — gate the re-sync effect behind a view-id ref; export a new `buildViewConfigFromTable` helper used by both the debounced persist and the toolbar's direct mutations. - `apps/client/src/features/base/components/base-toolbar.tsx` — `handleSortsChange` / `handleFiltersChange` build the full config from live table state (not `activeView.config`) before `mutate`. No new files. No server-side changes. No new deps. --- ## Task 1: Verify the repro against the current build - [ ] **Step 1: Build the client** to make sure the branch is clean before changing anything. ```bash pnpm nx run client:build ``` Expected: build succeeds. - [ ] **Step 2: Ask the user to run the deterministic repro above.** (Per `CLAUDE.md` the agent must not run `pnpm dev`.) User should confirm: - Column X disappears, then reappears within ~300 ms of adding the sort. - Reload shows X visible, hide lost. If the repro does NOT reproduce for the user, stop the plan here and ask for actual repro steps — the fixes below target this specific chain. --- ## Task 2: Extract `buildViewConfigFromTable` helper **Files:** - Modify: `apps/client/src/features/base/hooks/use-base-table.ts` - [ ] **Step 1: Add the helper near the other build functions** (above `useBaseTable`, after `buildColumnPinning`) ```ts // Serializes the live react-table state into a persisted ViewConfig. // Sort/filter toolbar mutations and the debounced `persistViewConfig` // both go through this so a direct mutation (e.g. adding a sort) can't // clobber a pending hide/reorder/resize by reading stale `activeView.config`. export function buildViewConfigFromTable( table: Table, base: ViewConfig | undefined, overrides: Partial = {}, ): ViewConfig { const state = table.getState(); const sorts = state.sorting.map((s) => ({ propertyId: s.id, direction: (s.desc ? "desc" : "asc") as "asc" | "desc", })); const propertyWidths: Record = {}; Object.entries(state.columnSizing).forEach(([id, width]) => { if (id !== "__row_number") propertyWidths[id] = width; }); const propertyOrder = state.columnOrder.filter((id) => id !== "__row_number"); const hiddenPropertyIds = Object.entries(state.columnVisibility) .filter(([id, visible]) => id !== "__row_number" && !visible) .map(([id]) => id); return { ...base, sorts, propertyWidths, propertyOrder, hiddenPropertyIds, visiblePropertyIds: undefined, ...overrides, }; } ``` - [ ] **Step 2: Replace the inline serialization inside `persistViewConfig`** (lines roughly 267-297 of the current file) with a call to the helper. Before: ```ts persistTimerRef.current = setTimeout(() => { const state = table.getState(); const sorts = state.sorting.map((s) => ({ ... })); // ...lots of inline serialization... const config: ViewConfig = { ...activeView.config, sorts, propertyWidths, propertyOrder, hiddenPropertyIds, visiblePropertyIds: undefined, }; updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config }); }, 300); ``` After: ```ts persistTimerRef.current = setTimeout(() => { const config = buildViewConfigFromTable(table, activeView.config); updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config }); }, 300); ``` - [ ] **Step 3: Build** ```bash pnpm nx run client:build ``` Expected: success. - [ ] **Step 4: Commit** ```bash git add apps/client/src/features/base/hooks/use-base-table.ts git commit -m "refactor(base): extract buildViewConfigFromTable helper" ``` --- ## Task 3: Route sort/filter mutations through the helper **Files:** - Modify: `apps/client/src/features/base/components/base-toolbar.tsx` `handleSortsChange` and `handleFiltersChange` currently read `activeView.config` directly, which reflects the server-persisted state, not the user's pending local changes. They must build the new config from `table.getState()` + the new sort or filter. - [ ] **Step 1: Import the helper and accept the full view config for merging filters** Add at the top of the file: ```ts import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table"; ``` - [ ] **Step 2: Rewrite `handleSortsChange`** Before: ```ts const handleSortsChange = useCallback( (newSorts: ViewSortConfig[]) => { if (!activeView) return; updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config: { ...activeView.config, sorts: newSorts }, }); }, [activeView, base.id, updateViewMutation], ); ``` After: ```ts const handleSortsChange = useCallback( (newSorts: ViewSortConfig[]) => { if (!activeView) return; const config = buildViewConfigFromTable(table, activeView.config, { sorts: newSorts, }); updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config, }); }, [activeView, base.id, table, updateViewMutation], ); ``` - [ ] **Step 3: Rewrite `handleFiltersChange` the same way** Before: ```ts const handleFiltersChange = useCallback( (newConditions: FilterCondition[]) => { if (!activeView) return; const filter: FilterGroup | undefined = newConditions.length > 0 ? { op: "and", children: newConditions } : undefined; const { filter: _drop, ...rest } = activeView.config ?? {}; updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config: filter ? { ...rest, filter } : rest, }); }, [activeView, base.id, updateViewMutation], ); ``` After: ```ts const handleFiltersChange = useCallback( (newConditions: FilterCondition[]) => { if (!activeView) return; const filter: FilterGroup | undefined = newConditions.length > 0 ? { op: "and", children: newConditions } : undefined; // `filter: undefined` in overrides removes the filter key; the helper's // spread-then-overrides order means `undefined` wins over any base filter. const config = buildViewConfigFromTable(table, activeView.config, { filter, }); updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config, }); }, [activeView, base.id, table, updateViewMutation], ); ``` - [ ] **Step 4: Build** ```bash pnpm nx run client:build ``` Expected: success. - [ ] **Step 5: Commit** ```bash git add apps/client/src/features/base/components/base-toolbar.tsx git commit -m "fix(base): merge live table state into sort and filter mutations" ``` --- ## Task 4: Gate the sync effect behind view-id change **Files:** - Modify: `apps/client/src/features/base/hooks/use-base-table.ts` Within the same view, local state is the source of truth and the debounced persist flushes it. The sync effect must only run when the user switches to a different view (where the server's config is authoritative). - [ ] **Step 1: Replace both sync effects** Before (around lines 220-229): ```ts const [columnOrder, setColumnOrder] = useState(derivedColumnOrder); const [columnVisibility, setColumnVisibility] = useState(derivedColumnVisibility); useEffect(() => { setColumnOrder(derivedColumnOrder); }, [derivedColumnOrder]); useEffect(() => { setColumnVisibility(derivedColumnVisibility); }, [derivedColumnVisibility]); ``` After: ```ts const [columnOrder, setColumnOrder] = useState(derivedColumnOrder); const [columnVisibility, setColumnVisibility] = useState(derivedColumnVisibility); // Re-seed from server only when the user switches views. Within the same // view, local state is the source of truth — the debounced persist flushes // it. Without this guard, any ws-driven `invalidateQueries(["bases", baseId])` // or concurrent view mutation lands a new `derivedColumnVisibility` // reference and the effect would overwrite a pending hide/reorder toggle // before `persistViewConfig` has a chance to flush it. const lastSyncedViewIdRef = useRef(activeView?.id); useEffect(() => { const currentViewId = activeView?.id; if (currentViewId !== lastSyncedViewIdRef.current) { lastSyncedViewIdRef.current = currentViewId; setColumnOrder(derivedColumnOrder); setColumnVisibility(derivedColumnVisibility); } }, [activeView?.id, derivedColumnOrder, derivedColumnVisibility]); ``` Note: `derivedColumnOrder` and `derivedColumnVisibility` remain in the dependency array so the effect reads the current derived values when a view switch happens. The ref-guarded branch only fires on id change. - [ ] **Step 2: Build** ```bash pnpm nx run client:build ``` Expected: success. - [ ] **Step 3: Commit** ```bash git add apps/client/src/features/base/hooks/use-base-table.ts git commit -m "fix(base): only re-seed column state when view identity changes" ``` --- ## Task 5: USER manual verification — scripted steps > ⚠️ **Do not run `pnpm dev` as an agent.** Hand off to the user with the cases below. Ask the user to run through these. Each should now behave correctly: - [ ] **Repro from Task 1 — hide then add sort** 1. Open a base with ≥ 3 non-primary columns. 2. Hide column X via the popover. 3. Within 300 ms, add a sort via the Sort popover. 4. Expected: column X stays hidden. Sort indicator shows. Reload: both persisted. - [ ] **Hide then change filter** 1. Hide column Y. 2. Immediately add a filter condition. 3. Expected: column Y stays hidden, filter applied. Reload: both persisted. - [ ] **Hide all / show all** 1. Click "Hide all" in the popover. All non-primary columns disappear. 2. Reload. Expected: same state. 3. Click "Show all". Reload. Expected: all columns visible. - [ ] **View switch** 1. Hide column Z on view A. 2. Switch to view B (no hide). Expected: Z visible in view B. 3. Switch back to view A. Expected: Z hidden. - [ ] **WebSocket reconcile doesn't stomp** 1. Hide column W. 2. From another browser / incognito session on the same base, add a new property. 3. In the first window, the new property appears visible, W stays hidden. - [ ] **Primary property can't be hidden** (regression check) 1. Open the popover — the primary column's switch is disabled (already enforced by `enableHiding: !property.isPrimary` at `use-base-table.ts:87`). Confirm. --- ## Task 6: Final commit check + handoff - [ ] **Step 1: Confirm branch state** ```bash git status git log --oneline main..HEAD ``` Expected: clean tree, 3 new commits atop the CSV-export branch (refactor, fix sort/filter merge, fix sync effect). - [ ] **Step 2: Trigger `superpowers:finishing-a-development-branch`** to choose merge/PR. --- ## Out of scope (explicitly not fixing here) - **`hiddenFieldCount` badge dep** in `base-toolbar.tsx:88-91` — works by accident today (re-renders produce fresh `getState()` references). Low-impact cosmetic risk; leave alone unless it manifests. - **Legacy `visiblePropertyIds` migration.** Views created before `hiddenPropertyIds` existed may show new properties as hidden by default in `buildColumnVisibility`. No reports of this; migration would be a separate plan. - **Batching `handleHideAll`/`handleShowAll`** into a single `table.setColumnVisibility(map)` call instead of iterating `toggleVisibility`. React 18 batches these anyway; not a bug. - **Server-side zod schema.** `viewConfigSchema` already accepts `hiddenPropertyIds: []` correctly; no change needed.