Repository navigation
fix(plugin-list): a URL-restored quick-filter value is typed once a late object definition types its field (objectui#12008) - #12022
Merged
objectstack-fleet[bot] merged 3 commits intoOct 9, 2026
Conversation
…finition types its field (objectui#12008) A dropdown field declared without its type takes its type and options from the object definition, which loads after the list mounts. The bar coerced URL-restored strings to the option value types only at mount or on a field's arrival, so a restored 'true' stayed a string: no box ticked and the query filtered on "true". The arrival effect becomes one settle effect keyed on the field names and each field's typing (resolved type plus option values). The first time a field's typing moves, its still-starting value is coerced once more; a value the user changed or cleared is left alone, and nothing is emitted when the coercion moves nothing. The new suite enumerates every read the bar makes of the definition (recorded through a Proxy) and probes each against a late definition. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…inition is not coerced (objectui#12008) The user-change guard in the typing settle had no pin that could go red: a cleared value coerces to itself. Authored options let the user pick before the definition types the field, which makes the guard observable. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…ectui#12008) A declared select with no authored options does get its typing moved when the definition's options arrive; the coercion then moves nothing and emits nothing. The comment now says "type and options known at mount". Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
objectstack-fleet
Bot
deleted the
claude/issue-12008-late-type-coercion
branch
October 9, 2026 02:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12008
Clause-②: no
What was wrong
URL-restored quick-filter values (
initialSelections, whichObjectView,ObjectDataPageandInterfaceListPagefill fromuf_*params since objectui#11915) arrive as strings.DropdownFiltersconverts them to the field's option value types withcoerceToOptionTypes, which reads two things of a resolved field: its type and its option values. It did that once per field: in the mount effect, or in objectui#12001's arrival effect when the field first appears.A field declared without its type (
fields: [{ field: 'is_active' }]) takes its type and options from the object definition, whichListViewfetches after it mounts. So the mount coerced'true'against an untyped field with no options, and nothing coerced it again when the definition arrived. The lastfindsent["is_active","=","true"], and no box was ticked while the chip counted 1.Premise measured on
maine616327abbefore any source edit. Five of the new suite's cases were red: UNTYPED BOOLEAN, NUMERIC OPTION, LATEfieldDef.type, LATEfieldDef.optionsand ONE COMMIT. Each failed on the string where the typed value belongs ('true'fortrue,'2'for2). The other 12 were green.The change (
packages/plugin-list/src/UserFilters.tsx,DropdownFiltersonly)typingOf(field)returns, as one string, whatcoerceToOptionTypesreads of a field: its resolved type and its option values, JSON-encoded so that2and'2'differ. It is module-private.typingRefmap holds, per field, the typing its starting selection was last coerced against. The settle effect records it the first time it sees a field: in the mount commit for the mount's fields, and in the same pass that coerces an arriving field.fieldNamesKeyand a newtypingKey, both primitives (AGENTS.md [WIP] Enhance every detail of the designer #10).null).selectedValues.handleChangemarks the field done, so a value the user has picked or cleared is never coerced afterwards.onFilterChangeonly, never throughonSelectionsChange.No export, prop, type or language-pack key changes.
The enumeration (triage's closing pass for the
UserFiltersfamily)The table lists every read
UserFiltersmakes of the object definition. The reads are recorded at runtime through a Proxy over the definition (the COMPLETENESS pin). The last two columns say how the state each read feeds follows a definition that loads after mount; the LATE pins test this, one per read.objectDef.fieldsobjectDef.namefieldDef.typefieldDef.optionsfieldDef.labelfieldDef.referencefieldDef.displayFielddefaultValues) are authored, not read from the definition. But their coercion is the same mount-only read of type and options.defaultValues: ['true']on an untyped boolean field was measured red together with the restored value, and the same settle fixes it. LATEfieldDef.typepins both.resolveFieldsruns inside theresolvedFieldsmemo, which depends onobjectDef.controlKindsreads only the authored type, never the definition. That is by design: an inferred type keeps the multi-check UX (objectui#2941). It is not a definition read and is unchanged.UserFilterspassesobjectDeftoDropdownFiltersalone, soTabFiltersandToggleFiltersnever receive the definition. The SIBLINGS pins render both modes with the recording Proxy and read zero keys.ToggleFiltersrenders its label from the config only, so a declared toggle field with no label never shows the definition's label, early or late. That was read in source, not probed; the mode is spec-deprecated, andListView's derived toggle fields carry labels.resolveFieldsor theobjectNameline, and both are on the render path.Pins
New file
packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx, 18 cases.List level.
getObjectSchemais gated as in objectui#12001's suite, and each case first asserts that nofindhas gone out:fields: [{ field: 'is_active' }]plus restored{ is_active: ['true'] }. The True box is ticked, and the lastfindcarries["is_active","=",true].type: 'boolean'. Everyfindcarries["is_active","=",true], and the box is ticked.'2'against the definition option2. The option is ticked, and the lastfindcarries["points","=",2].findcarries a$filter.The enumeration: COMPLETENESS, SIBLINGS (tabs, toggle), and a LATE case for each read except
objectDef.fields.LATEis typed as a record over the enumerated reads, so a read added toDEFINITION_READSwithout a probe failstype-check.Settles once, and only moves a starting value (bar level):
Reverse verification
Each leg ran once from the committed fix
d87114b86, through objectstack'sscripts/ablation-replace.mjs. The tool counts the anchor's hits and checks the write and the restore on disk. Each leg ran this suite and objectui#12001's, 24 cases. I wrote down the expected direction before each leg, and every leg went red as predicted.else if (was !== null && was !== typingOf(f))branch becameelse if (false): anchor 1 to 0, blobbbf849fb2c78tof04173af8267. Result: 5 failed, 19 passed. The red cases are UNTYPED BOOLEAN, NUMERIC OPTION, LATEfieldDef.type, LATEfieldDef.optionsand ONE COMMIT, the same five as the premise run. Restored: the blob equals HEAD'sbbf849fb2c78, andgit diff HEADis empty.typingRef.current.set(field, null);was deleted (blob tob7e1ce9d9f4d). Result: 1 failed, 23 passed, exactly A USER CHOICE SURVIVES. The clear pins stayed green, as predicted, because a cleared[]coerces to itself. Restored: the blob equals HEAD's, andgit diff HEADis empty.objectLabel = fieldDef.label;becameobjectLabel = (void fieldDef.defaultValue, fieldDef.label);(blob tob73ceb426586). Result: 1 failed, 23 passed, exactly COMPLETENESS, whose diff namesfieldDef.defaultValue. Restored: the blob equals HEAD's, andgit diff HEADis empty.Local gates
All at head
633f6ddbf, run from the worktree root.pnpm exec vitest run packages/plugin-list/Test Files 122 passed (122),Tests 1303 passed (1303)pnpm --filter @object-ui/plugin-list type-check(after building its 13-package dependency closure)tsc --noEmit && tsc -p tsconfig.test.json, with no diagnostics;--listFilesreads the new test file andUserFilters.tsxpnpm exec eslinton the two touched source files0 errors, 38 warnings(2 files, counted from--format json); onUserFilters.tsxthe per-rule counts equal the base's (30 warnings, the same four rules)pnpm check:control-bytescheck-control-bytes: OKpnpm check:test-path-rootscheck-test-path-roots: OKpnpm check:changeset-claimsNo pending changeset names a file this change touches.pnpm check:pending-changeset-literalsNo test source names a pending changeset.pnpm check:new-line-citations0 new citation(s)pnpm check:phantom-deps,check:self-import,check:unreferenced-sources,check:vi-mock-specifiers,check:handler-key-reads,check:metadata-write-doors(re-derived: each reads test or package source this diff touches)node scripts/check-changeset-presence.mjs2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)node scripts/check-changeset-no-major.mjs,-fixed,-overwriteThe lint run is a declared narrowing. It linted 2 files (counted from
--format json), and the config ignores neither.eslint.config.jsenables no type-aware linting (noparserOptionsorprojectService), and none of the 23 files undereslint-rules/reads the filesystem. So this diff cannot change the verdict on a file it did not touch.Left to CI: the full
pnpm test,pnpm lint, the console build andBundle Analysis. No locale pack or package export changed, so the i18n and readme-exports gates are not owed.First-load bytes
@object-ui/plugin-listloads eagerly in the console, so this change adds first-load bytes. Measured locally on the module alone (esbuild minify ofUserFilters.tsx, basee616327abagainst head, gzip -9): +322 B minified, +136 B gzip. That is a module-level reading, not the chunk's.The PR's
Bundle Analysisgives the eager-closure total against the ceiling re-pinned under ruling6070754914. That ruling's standing rule is "a fix that adds first-load bytes within the margin lands without a ruling". Nothing was trimmed to fit, and this PR does not touch the ceiling.Acceptance notes
findon the untyped list path, carrying the string.["is_active","=","true"]and then["is_active","=",true]. The typed field issues 1.ListView's fetch effect runs once with the conditions it held before the bar re-emits. The last request always carries the typed value.ListView.tsx, which is off this claim's surface. Noted, not filed.FieldSchema's select optionvalueis aSystemIdentifierSchemastring (lowercase, starting with a letter), so a definition cannot legally carry option2. Only the user-filter field's ownoptions[].valueadmits numbers, and those are authored, so they are present at mount.UserFilters.test.tsx("coerces URL-restored string values to typed option values"), whose definition fixture already carries numeric options. It keeps the late path at parity with the mount path and adds no tolerance the mount path did not already have.coerceToOptionTypesprefers the boolean conversion over an exact string option.'true'and'false', the definition makes it boolean, and the restored value is'true'. The bar emits[true]and leaves Yes unticked. That happens with the definition present at mount and, after this change, with the definition late too.'true'(ticked) only because nothing coerced it.coerceToOptionTypes"). Noted, not filed.showCount's snapshot is taken only while a field has no selection, so a field restored at mount never takes one. That depends on the data, not the definition, and is the same when the definition is present at mount. Read in source, not probed. Noted, not filed.Changeset:
.changeset/12008-late-type-coercion.md,'@object-ui/plugin-list': patch.Session:
https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8Generated by Claude Code