8.8 KiB
New Property Not In View Implementation 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: When a user creates a new property, it lands in the grid's local column state immediately (appended to the end, visible by default) so the grid can size and scroll to reveal it.
Architecture: Single-file fix in apps/client/src/features/base/hooks/use-base-table.ts. Extend the existing gated re-seed effect so that on property add / remove (within the same view) it reconciles local columnOrder and columnVisibility instead of ignoring the change. Existing per-column user toggles (hide, reorder) are preserved; new columns are appended; deleted columns are dropped.
Tech Stack: React 18, TanStack Table v8, TanStack Query v5.
Background
Earlier in this branch (commit c6f993b6) the sync effect that copied derivedColumnOrder / derivedColumnVisibility into local state was gated behind a view-id ref to stop ws-driven base refetches from stomping the user's pending hide-column toggles. Current code:
// apps/client/src/features/base/hooks/use-base-table.ts:267-281
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [activeView?.id, derivedColumnOrder, derivedColumnVisibility]);
What goes wrong with a new property in the same view:
- User opens
CreatePropertyPopover, submits →useCreatePropertyMutation.onSuccessappends the new property to["bases", baseId].properties. base.propertieshas a new reference →propertiesuseMemo re-runs → new array reference.derivedColumnOrderrecomputes — includes the new property id.- The gated effect sees
currentViewId === lastSyncedViewIdRef.current, so it does nothing. LocalcolumnOrderstate still lists the OLD column ids. columnsprop touseReactTableis rebuilt (it memos on[properties]), so react-table does know the new column def exists.- But state passed via
state={{columnOrder}}still references only the old columns →table.getState().columnOrder→ stale →gridTemplateColumns(which depends ontable.getVisibleLeafColumns()+table.getState().columnOrderingrid-container.tsx:149) doesn't get a track for the new column. - Result: the new column renders in the DOM (react-table still yields it as visible), but the grid wrapper's
scrollWidthdoesn't extend to contain it, sohandlePropertyCreated'sscrollRef.current.scrollTo({left: scrollWidth})ends at the OLD scrollWidth — the new column stays clipped at the edge.
The same mechanism also breaks property deletion within the same view (local state keeps a dead id) — not the filed symptom, but worth fixing in the same patch.
The same mechanism does NOT break rename, because rename changes property.name but not property IDs; order + visibility maps key on id, so they stay correct. Rename is already working after the earlier memo-prop-threading fix.
File Structure
Modified files:
apps/client/src/features/base/hooks/use-base-table.ts— extend the sync effect.
No new files, no new deps, nothing server-side.
Task 1: Reconcile local column state on property add / remove
Files:
-
Modify:
apps/client/src/features/base/hooks/use-base-table.ts:260-281 -
Step 1: Replace the effect
Before (current):
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [activeView?.id, derivedColumnOrder, derivedColumnVisibility]);
After:
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
// View switch → full re-seed from the server's stored config.
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
return;
}
// Same view — preserve user toggles, but reconcile the id set:
// append properties that were just created, drop properties that
// were deleted. Without this, creating a new column leaves it
// invisible to `table.getState().columnOrder` / `gridTemplateColumns`,
// and the grid's scrollWidth never grows to include it.
const validIds = new Set<string>(["__row_number"]);
for (const p of properties) validIds.add(p.id);
setColumnOrder((prev) => {
const prevSet = new Set(prev);
const kept = prev.filter((id) => validIds.has(id));
const appended = derivedColumnOrder.filter(
(id) => !prevSet.has(id) && validIds.has(id),
);
if (appended.length === 0 && kept.length === prev.length) return prev;
return [...kept, ...appended];
});
setColumnVisibility((prev) => {
let changed = false;
const next: VisibilityState = {};
for (const [id, visible] of Object.entries(prev)) {
if (validIds.has(id)) {
next[id] = visible;
} else {
changed = true;
}
}
for (const id of derivedColumnOrder) {
if (!(id in next)) {
next[id] = derivedColumnVisibility[id] ?? true;
changed = true;
}
}
return changed ? next : prev;
});
}, [
activeView?.id,
derivedColumnOrder,
derivedColumnVisibility,
properties,
]);
Notes for the implementer:
-
VisibilityStateis already imported from@tanstack/react-table— no new import needed. -
propertiesis already declared as a memoized value at the top of this hook, so adding it to the dep list is safe. -
The two
setX((prev) => ...)updaters both short-circuit (returnprev) when nothing actually changed, which matters becausederivedColumnOrder/derivedColumnVisibilityhave a new identity every timepropertiesdoes — without the short-circuit the set would fire every render in the same view and blow away user toggles. -
kept.length === prev.lengthis a proxy for "no deletions" and is safe becauseprevcan't contain duplicates (react-table enforces id uniqueness, and our ownderivedColumnOrderis also unique). -
Step 2: Build
pnpm nx run client:build
Expected: success.
- Step 3: Commit
git add apps/client/src/features/base/hooks/use-base-table.ts
git commit -m "fix(base): include new properties in local column state so the grid can scroll to them"
Task 2: USER smoke test
⚠️ Do not run
pnpm devas an agent. Hand off to the user.
After a hard reload:
-
Create a new property — grid sizes for it and scroll reaches it.
- Open a base with enough columns that horizontal scrolling is already active.
- Click the "+" / create-property button, pick a type, submit.
- The grid should scroll right automatically; the new column is fully visible; the horizontal scrollbar extends.
-
New property is visible in the Hide fields popover.
- Open the eye icon (Hide fields).
- The new property appears in the list, toggle ON.
-
Existing toggles are preserved.
- Hide column X.
- Create a new column Y. Column X stays hidden; Y appears at the end, visible.
-
Delete a property.
- From a property's menu, click Delete.
- Column disappears from the grid; grid scrollWidth contracts. No stale column left.
-
View switch still works cleanly.
- Switch to a different view; then switch back.
- Hidden / reordered state for that view loads correctly.
-
Rename still works (regression check).
- Rename a property; the header text updates without reload.
-
Hide + concurrent sort mutation (regression for the original hide bug).
- Hide a column, then add a sort within 300 ms. Column stays hidden; sort applies.
If any step fails, report back with the specific case.
Out of scope
- Scrolling behavior on row add (orthogonal, not broken).
- The two
rAFdelay inhandlePropertyCreated— it already waits long enough once state reconciliation happens in the same render cycle. columnSizingreconciliation — a new column uses its defined size automatically via react-table'sinitialState, and a deleted column's entry in the sizing state is harmless.