Measured on claude/issue-4736-list-exists-operator-gap (branched from origin/main at 69251bfb5). Found while implementing #4736; a different axis from that card and not addressed by it.
What happens
convertFilterGroupToAST (packages/plugin-list/src/ListView.tsx) drops any condition whose value is null / '' / [] as an unfinished row, and exempts exactly two operators:
.filter(c => {
// isEmpty/isNotEmpty carry no value input — always keep them.
if (c.operator === 'isEmpty' || c.operator === 'isNotEmpty') return true;
const v = c.value;
return !(v == null || v === '' || (Array.isArray(v) && v.length === 0));
})
But the FilterBuilder renders no value input for six operators, not two — needsValueInput in packages/components/src/custom/filter-builder.tsx excludes isEmpty, isNotEmpty, isNull, isNotNull, exists, notExists. So for the four it does not exempt, "no value" is the row's finished state and it is treated as unfinished.
Measured, one condition per operator with value: '':
FRESH-ROW isEmpty -> ["name","=",null]
FRESH-ROW isNotEmpty -> ["name","!=",null]
FRESH-ROW isNull -> []
FRESH-ROW isNotNull -> []
FRESH-ROW exists -> []
FRESH-ROW notExists -> []
[] is "no filter". Nothing is sent, nothing errors, the grid returns every row.
Why it is reachable in the normal flow
addCondition inserts { field: firstColumn, operator: 'equals', value: '' }, and the operator dropdown's onValueChange updates operator alone — value is preserved, so on a fresh row it stays ''. Picking Is null as the first thing you do therefore always produces the dropped shape. The only way to reach a non-empty value is to type one under a value-taking operator and then switch, which is the uncommon path.
isNull / isNotNull are fully expressible on this dialect — mapOperator maps them to isnull / isnotnull, both members of VALID_AST_OPERATORS. The bridge is fine; the row never reaches it.
Why nothing catches it
The saved-view side already solved this and the live-grid side never got the same treatment:
packages/app-shell/src/views/viewFilterFold.ts keeps VALUELESS_FILTER_OPERATORS, which lists all six, and viewFilterFold.emptyValue.test.ts pins that set against the builder's own needsValueInput list by reading the source, so the two cannot drift.convertFilterGroupToAST has the same concept written inline as a two-operator literal, with no parity assertion against needsValueInput at all.
The two paths are documented as agreeing — the fold's header says "what is not applied is not persisted" and cites this very function — and on these four operators they do not.
Scope note
exists / notExists appear in the measurement above but stop being reachable from this toolbar once #4736 lands (they move behind OPT_IN_OPERATORS). The live surface of this bug after that is isNull / isNotNull, which stay offered because both dialects genuinely express them. #4736 is a separate concern: it is about operators the dialect has no word for, not about value-completeness.
Suggested shape (not a decision)
Give the live-grid path the same shared notion of a value-less operator the fold has, and pin it against needsValueInput the same way — one set, two consumers, one assertion. Where that set should live is the open question: VALUELESS_FILTER_OPERATORS is currently exported from app-shell, which depends on plugin-list, so plugin-list cannot import it as-is.
Filed unassigned for triage.
Measured on
claude/issue-4736-list-exists-operator-gap(branched fromorigin/mainat69251bfb5). Found while implementing #4736; a different axis from that card and not addressed by it.What happens
convertFilterGroupToAST(packages/plugin-list/src/ListView.tsx) drops any condition whose value isnull/''/[]as an unfinished row, and exempts exactly two operators:But the FilterBuilder renders no value input for six operators, not two —
needsValueInputinpackages/components/src/custom/filter-builder.tsxexcludesisEmpty,isNotEmpty,isNull,isNotNull,exists,notExists. So for the four it does not exempt, "no value" is the row's finished state and it is treated as unfinished.Measured, one condition per operator with
value: '':[]is "no filter". Nothing is sent, nothing errors, the grid returns every row.Why it is reachable in the normal flow
addConditioninserts{ field: firstColumn, operator: 'equals', value: '' }, and the operator dropdown'sonValueChangeupdatesoperatoralone —valueis preserved, so on a fresh row it stays''. Picking Is null as the first thing you do therefore always produces the dropped shape. The only way to reach a non-empty value is to type one under a value-taking operator and then switch, which is the uncommon path.isNull/isNotNullare fully expressible on this dialect —mapOperatormaps them toisnull/isnotnull, both members ofVALID_AST_OPERATORS. The bridge is fine; the row never reaches it.Why nothing catches it
The saved-view side already solved this and the live-grid side never got the same treatment:
packages/app-shell/src/views/viewFilterFold.tskeepsVALUELESS_FILTER_OPERATORS, which lists all six, andviewFilterFold.emptyValue.test.tspins that set against the builder's ownneedsValueInputlist by reading the source, so the two cannot drift.convertFilterGroupToASThas the same concept written inline as a two-operator literal, with no parity assertion againstneedsValueInputat all.The two paths are documented as agreeing — the fold's header says "what is not applied is not persisted" and cites this very function — and on these four operators they do not.
Scope note
exists/notExistsappear in the measurement above but stop being reachable from this toolbar once #4736 lands (they move behindOPT_IN_OPERATORS). The live surface of this bug after that isisNull/isNotNull, which stay offered because both dialects genuinely express them. #4736 is a separate concern: it is about operators the dialect has no word for, not about value-completeness.Suggested shape (not a decision)
Give the live-grid path the same shared notion of a value-less operator the fold has, and pin it against
needsValueInputthe same way — one set, two consumers, one assertion. Where that set should live is the open question:VALUELESS_FILTER_OPERATORSis currently exported fromapp-shell, which depends onplugin-list, soplugin-listcannot import it as-is.Filed unassigned for triage.