Skip to content

fix(list): the filter panel no longer persists a view overlay over the source-defined filter - #4214

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4155-filter-panel-overlay
Aug 11, 2026
Merged

fix(list): the filter panel no longer persists a view overlay over the source-defined filter#4214
yinlianghui merged 1 commit into
mainfrom
claude/issue-4155-filter-panel-overlay

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4155

Diagnosis

The reported symptom — a list permanently empty, the filter panel showing no conditions, recovery only via a sys_metadata delete plus a service restart — comes from one automatic write meeting one merge rule.

The write. ObjectView's list toolbar bound onFilterChange to persistViewFilter, which folded the panel's group and handed it to persistViewPatch. So every emission of the filter panel wrote a view-customization overlay. Add filter inserts a row that is incomplete by construction — a real field, operator: 'equals', and value: '' — and it emits immediately, before the user types a value. Merely opening the panel and clicking the button was therefore enough to write.

The merge rule. A view override is applied over the source-declared view key-wise ({ ...source, ...override }). An override carrying a filter key does not add to the declared filter — it replaces it wholesale. So the one stray condition became the view's entire filter.

Why the live screen never warned anyone. ListView.convertFilterGroupToAST skips conditions whose value is null / '' / [], because [field, '=', ''] is a silently-wrong filter (it matches only the empty string) rather than "no filter". The grid therefore ignored the very row that was being persisted. The one condition the screen refused to apply was the one condition that reached storage — which is why nothing looked wrong until the next load, when it came back as the whole filter and returned total: 0.

Why Clear all could not undo it. Clearing emitted an empty group, which folded to [] and persisted filter: []. That is not a poisoned filter, but it is still an override with an opinion about filter, so it kept winning the spread and kept the declaration deleted. The panel showed no conditions while the request still carried the stale filter — exactly as reported.

Why the empty state pointed triage away from the view layer. ListView distinguishes "filtered to empty" from "truly empty (first run)", but the view's own declared filter did not count toward that decision. A list emptied by its filter rendered the first-run copy, so the report read as data loss or a permission problem.

What changed, per ruling

Ruling 1 — an end-user filter-panel interaction must not persist an overlay. persistViewFilter is deleted, along with both onFilterChange persist bindings. Filter-panel state is transient: it belongs to the session and to writeListFilterState's per-browser restore. Overlay writes now require an explicit save — handleViewConfigSave, and ObjectDataPage's "Save as view". ObjectDataPage had already made this exact call for the sibling surface ("Deliberately NO onSortChange/onFilterChange persistence hooks", #2251); this surface was the outlier. foldFilterGroupToSpecRules (objectstack#5159) is untouched and still the one dialect for the explicit paths, including the Studio inspector — a caller was removed, not the fold.

Ruling 2 — empty-value conditions are never persisted nor applied, and dropping them lets the source filter win again. Two layers. In foldFilterGroupToSpecRules, a row that wants a value and has none is now dropped, matching the predicate the live query already used — what is not applied is not persisted. On the read path, sanitizeViewOverride runs on both branches of loadViewOverrides and strips the same conditions from a stored overlay, in both at-rest shapes (spec ViewFilterRule objects and the legacy runtime triple). When nothing effective survives it drops the filter key rather than writing [], which is what lets the merge fall through to the source declaration. Poisoned installs self-heal on the next read — no sys_metadata surgery, no restart.

A value-less operator (isEmpty / isNull and friends) is complete without a value and is kept. The set is pinned against the FilterBuilder's own needsValueInput list so the two cannot drift apart unnoticed.

Ruling 3 — Clear all clears any panel-written overlay. Reached from both ends. Going forward the panel writes no overlay at all, so there is nothing for Clear all to leave behind; its clear is session state, which it still writes. For installs already carrying one, the filter: [] that Clear all previously wrote is now dropped on read by the same sanitizer, so the source filter returns without the user having to do anything.

Ruling 4 — filtered-empty copy. The view's own filter now counts as an active query, in both the array and Mongo-style object shapes, so a filtered-to-empty list says "no records match" instead of inviting the user to create a first record. No new strings: this routes to list.noMatches / list.noMatchesMessage, which already exist in all ten locales. An author-supplied emptyState.title / emptyState.message still wins over both branches.

A deliberate consequence

An overlay can no longer express "this view has NO filter" over a source view that declares one. That is the strictly safer side of the trade: the shape that expressed it is the same shape that silently erased source declarations. An author who genuinely wants no filter edits the view, which writes the view body rather than an overlay.

Verification

Reverse verification (red-first). With the three source files reverted to origin/main and the tests kept: 24 failed, 13 passed. Restored: 63 passed. Representative signatures, each naming a value rather than a crash:

AssertionError: a filter persist reappeared on the toolbar path — see #4155:
  expected [ 'folded.rules' ] to deeply equal []
AssertionError: expected [ { field: 'status', …(2) } ] to deeply equal []
AssertionError: operator equals persisted an empty value: expected [ { field: 'status', …(2) } ] to deeply equal []

One prediction was wrong, and the correction is committed. The suite header predicted that the what must NOT change control block would stay green in both worlds. It does not: reverting removes the sanitizeViewOverride export, so the module fails at import and every case in that file goes red, controls included. That bounds what the file can prove against origin/main — a missing export cannot tell "kept the real conditions" apart from "module did not load" — so those cases are controls in the forward direction only. The header now records the measured result instead of the prediction, and names the value-quoting failures as the genuinely discriminating ones.

Gates (repo root, scoped):

Gate Result
vitest run packages/app-shell/ packages/plugin-list/ 357 files, 3517 passed, 1 skipped
type-check (both packages) Done, no errors
lint (both packages) 0 errors (warnings are the repo baseline)
check-control-bytes OK, 3909 files scanned

Build closure ('@object-ui/app-shell^...', '@object-ui/plugin-list^...') built first, so the type-check read fresh .d.ts rather than stale artifacts.

Changesets: patch for @object-ui/app-shell and @object-ui/plugin-list.

Not fixed here — the cross-user half is platform-side

The card's cross-user angle is real and is not closed by this PR. updateViewConfig writes meta.saveItem('view', viewId, merged) — a single org-wide metadata item keyed by view id, with no per-user scoping. Every key the toolbar still persists by design (sort, hiddenFields, columnState, rowHeight) is therefore written org-wide and applies to every user of the view, even though ObjectView describes them as "Airtable-style per-view personal config". Removing the filter write takes the destructive key out of that path, but the scoping gap remains for the others, and the client has nowhere to put a per-user overlay. Handed to the seat as evidence per the dispatch lane split; this lane lands no platform code.


Generated by Claude Code

…e source filter (#4155)

Opening a Console list's filter panel and clicking "Add filter" wrote a
view-customization overlay into sys_metadata. The row that button inserts is
incomplete by construction ({ field, operator: 'equals', value: '' }), and an
override is merged key-wise over the source view, so that one stray condition
became the view's ENTIRE filter — total: 0 for every user of the view, with the
panel's own Clear all unable to undo it (it wrote filter: [], still an override
that deletes the declaration).

Write half: persistViewFilter and both onFilterChange persist bindings are
removed. Filter-panel state is transient — session plus writeListFilterState's
per-browser restore. Overlay writes belong to an explicit save only
(handleViewConfigSave, ObjectDataPage's "Save as view"); ObjectDataPage had
already made this call for the sibling surface (#2251). The fold
(foldFilterGroupToSpecRules, objectstack#5159) survives for those explicit
paths — a caller was removed, not the fold.

Read half: sanitizeViewOverride runs on both loadViewOverrides branches,
strips empty-valued conditions in both the spec-rule and legacy-triple shapes,
and drops the filter KEY when nothing effective survives, so the source filter
wins again. Poisoned installs self-heal on read — no sys_metadata delete, no
restart.

Empty-state copy: the view's own filter now counts as an active query, so a
filtered-to-empty list says "no records match" rather than inviting the user to
create their first record — the misread that sent this report's triage to the
data and permission layers.

The objectstack#5159 ratchet is retargeted, not deleted: no filter reaches the
persist path, the persistViewFilter seam does not exist, no onFilterChange
handler reaches any persist call, with the explicit-save path as a control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 11, 2026 2:42am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-Nn028rNQ.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 486.47KB 107.53KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 140.66KB 36.25KB
fields (index.js) 226.96KB 56.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.87KB 10.80KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.52KB 30.68KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.97KB 49.90KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.18KB 26.70KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console: opening a list filter panel persists a view overlay that overrides the source-defined view filter (empties the list)

2 participants