fix(react): carry the 18 form-view spec keys the bridge silently dropped - #6365
Merged
os-support-ai merged 1 commit intoAug 25, 2026
Merged
Conversation
`spec-bridge/bridges/form-view.ts` states in its own docblock that "every serializable spec key is either mapped onto the `object-form` node or listed here with an explicit reason for being ignored — the bridge must never silently drop spec configuration (#2545)". Measured against `@objectstack/spec` 17.2.0 that was false for 18 keys: 2 on `FormViewSchema`, 2 on `FormSectionSchema`, 14 on `FormFieldSchema`. Seventeen have a real destination the receiving layer already reads, so they are mapped; the eighteenth is an explained refusal, which is what "not silently" asks for: buttons / defaults -> `ObjectFormSchema` declares both slots and `ObjectForm` folds them at render. section.pane -> `ObjectFormSection.pane`, read by `SplitForm`'s `paneOf`; dropped, every authored placement fell back to the positional rule. section.visibleOn -> folded onto `visibleWhen`, reproducing the contract's own `normalizeVisibleWhen` for the never-parsed input class this bridge serves. 13 field keys -> same-name copies onto the runtime FormField, at the destinations `normalizeSectionField` already pins. field.publicPicker -> NOT carried: a server-side public-lookup authorization opt-in with no client destination. The conformance test is why it stayed green: its completeness loop iterated `Object.keys(FIXTURE)`, so a key nobody remembered to write into the fixture was a key it never asked about. Its key set is now derived from the contract's own shape at all three levels, every key must be claimed by a behavioral row or a reasoned ignore entry, and the fixture is asserted spec-valid before any row runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
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
|
os-support-ai
marked this pull request as ready for review
August 25, 2026 16:20
os-support-ai
deleted the
claude/issue-5898-form-view-bridge-dropped-keys
branch
August 25, 2026 16:32
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 #5898
The form-view bridge's docblock promises that "every serializable spec key is either mapped onto the
object-formnode or listed here with an explicit reason for being ignored — the bridge must never silently drop spec configuration (#2545)". Measured against the installed@objectstack/spec17.2.0, that promise was false. Seventeen keys now reach the node; the eighteenth is an explained refusal, which is what "not silently" asks for.1. The key census, re-derived — both directions
Method. The contract's key set is read from the runtime Zod shape of the three schemas, not from a hand-list and not from
keyof(which cannot distinguish a live key from aretiredKey()tombstone —z.never().optional()still shows up as a key in the type).FormSectionSchemaandFormFieldSchemaclose with.transform(), so they areZodPipes whose authoring shape lives on.in.shape;FormViewSchemacloses with.superRefine()and stays aZodObjectwith.shape. Reading.shapealone answersundefinedfor two of the three — silently — so the helper handles both and throws rather than returning an empty set. Tombstones are filtered by unwrappingoptional → never, and that filter is itself pinned in both directions (below).The comparison side is the bridge's actual output, not its declaration: a spec-valid fixture carrying every live key at every level is fed to
SpecBridge.transformFormView, and each key is asked where it landed. Declarations were the proxy the old check used; output is the thing the promise is about.Forward direction — contract → bridge (spec 17.2.0):
FormViewSchemaaria,defaultSort)buttons,defaultsFormSectionSchemapane,visibleOnFormFieldSchemapublicPicker,maxLength,minLength,min,max,precision,scale,multiple,immutable,span,language,keyField,disclosure,fieldsReverse direction — bridge → contract: 0. Every key the bridge reads (21 on the view, 8 on the section, 15 on the field, before this change) is a key the contract declares. There is no invented vocabulary here and nothing stale: the "no stale rows" half of the new gate asserts it mechanically, at all three levels, and it passes.
Where the card was wrong, and it changed the repair. The card suspected
buttons/defaultsmight have no node slot, in which case "the honest fix is an entry in the ignore list". Measured, the opposite is true:ObjectFormSchemadeclares both slots,ObjectForm'sfoldButtonsAndDefaultsreads them at render, and the spec's own descriptions name ObjectUI'sObjectFormas the consumer the keys exist for (framework#1894 / #2998). An ignore entry would have documented a drop that had a live destination three lines away.2. Which existing test could have caught this, and why it did not — measured
FormViewSpecConformance.test.tsis the file that exists to enforce #2545, and it is the one that missed it. Not because an assertion was weak, but because the key set came from the wrong source:A key absent from the fixture is a key the loop never asks about, so the check could only ever confirm what its author already remembered. This is exactly the degenerate case the card describes: the fixture was assembled from keys the bridge already carried, so it passed identically before and after.
Measured, not asserted: the pre-existing pair (origin/main's test + origin/main's bridge) was run as a control —
Test Files 7 passed (7) / Tests 125 passed (125), exit 0 — while 18 contract keys were being dropped.The other two neighbours are innocent and were left untouched:
FormViewRetiredKeys.test.tspins the absence ofaria/defaultSort(a different promise, and it holds), andFormViewWidenedArms.test.tspins three key types end-to-end (columns,dependsOn,visibleWhen) — none of the 18 is one of them. Nothing was rewritten to match the code; the completeness loop was re-sourced so it is structurally unable to skip a key, and every pre-existing behavioural assertion in the file survives.3. Per-key decisions — map it, or explain it
Nothing was mapped to a destination that had to be guessed.
FormView.buttonsObjectFormSchema.buttons;ObjectFormfolds it ontoshowSubmit/submitText/showCancel/cancelText/showResetat renderFormView.defaultsObjectFormSchema.defaults;ObjectFormfolds it into create-modeinitialValuesFormSection.paneObjectFormSection.pane→ObjectForm's split branch →SplitForm'spaneOf. Dropped, every spec-authored placement fell back to the legacy positional rule, so reordering sections moved them across the divider — the exact failurepanewas added to preventFormSection.visibleOnvisibleWhen, reproducing the contract's ownFormSectionSchema.transform(normalizeVisibleWhen). Not a new lenient arm: it is a declared (deprecated) spec key, and the fold matches what the field path directly above already does — for the same never-parsed input class the bridge still readsgroupsfor. Canonical wins when both are authored, same precedence as the contractFormField—maxLength,minLength,min,max,precision,scale,multiple,immutable,span,language,keyField,disclosure,fieldsFormField, at the destinationsnormalizeSectionFieldalready pins by name inplugin-form/src/sectionFields.spec-parity.test.ts.fieldstravels verbatim in the spec vocabulary (field, notname) because the runtime slot is a pass-through whose pinned row asserts{ field: 'inner' }survives unrewritten — recursing it throughmapFieldwould rewrite the sub-field identity keyFormField.publicPickerGET /forms/:slug/lookup/:field(403LOOKUP_NOT_PUBLICwithout it) and the public-form resolve route strips undeclared lookup fields before any renderer sees them. Zero read points in this repo. Carrying it would invent a client-side meaning for a capability only the server enforces — the same reasoned exemption the downstream chokepoint already records, on the same delegated ruling (objectui#4648 item 5, 2026-08-15)Two details that would otherwise be silent bugs of their own: every new copy tests
!= nullrather than truthiness (min: 0,precision: 0,multiple: false,immutable: falseare all authored decisions, and a truthiness test would drop them exactly as the missing declaration did), and every new declaration binds its type to the contract (FormFieldInput['maxLength'],FormView['buttons'], …) rather than restating it, per this file's derivation policy.No accept set moves. Every key here is already declared by
@objectstack/specand already read by a declared slot onObjectFormSchema/ObjectFormSection/ the runtimeFormField. This is declared-≠-enforced repair; no new public surface was minted.4. The new gate
The completeness loop now derives its key set from the contract's own shape at all three levels. Every key must be claimed by exactly one registry — a behavioural row (an assertion that the authored value arrives at its documented destination) or a reasoned ignore entry — and both directions are asserted: a key the spec adds fails as unclaimed, a key the spec retires fails as stale. The tombstone filter is pinned in both directions too, so it can neither shrink the census silently nor demand a mapping for a key the contract refuses.
The fixture is asserted spec-valid (
FormViewSchema.safeParse) before any row runs — without that control a row could pass against metadata no author could publish.type: 'split'is load-bearing:section.paneis split-only vocabulary and the contract rejects it on any other form type, so a wizard fixture could not carry the key at all, which is the degenerate-fixture failure in its purest form. The deprecated visibility spellings get their own fixture, because the full one authors the canonical spelling beside them and that wins — a row asserted against it would pass whether or not the fallback exists.5. Ablation — prediction first, then the measurement
Predicted before running: reverting
bridges/form-view.tstoorigin/mainwhile keeping the new test turns exactly 17 behavioural rows red — 2 view (buttons,defaults), 2 section (pane,visibleOn), 13 field — and 0 census assertions, because the registries live in the test and are complete either way. The 18th key,publicPicker, is the explained refusal, so its row stays green.Actual:
Tests 17 failed | 181 passed (198), vitest exit 1. The 17 failing rows are exactly the predicted list, key for key. Zero census failures.publicPickergreen. Prediction and measurement agreed in direction, count and identity; nothing was wrong.Ablation hygiene, since a green ablation is the failure mode that reads like success:
21d47691vs post-mutation blob5a70ee02, plus zero-hit greps for each injected marker (objectui#5898,'buttons',,mapped.pane,mapped.maxLength). The script aborts if the file comes back byte-identical to HEAD.@object-ui/*specifier to the package'ssrc/, and this suite imports the bridge by relative path, so the run compiles the mutated source directly — there is nodistto go stale. (Thetschalf is different and does read built.d.ts; its dependency closure was built first —pnpm --filter '@object-ui/react^...' build, exit 0.)HEADrather than a bare checkout (which reads from the index):git diff HEADempty,git statusclean, worktree blob21d47691==HEAD:…/form-view.ts. The script carries atrap … EXIT INT TERMwith absolute paths as the crash-path convenience; the blob comparison is the proof.6. Gates — verdict lines as printed, exit codes captured before any pipe
Gate set derived from the CI job step lists under
.github/workflows/(ci.yml,lint.yml,changeset-presence.yml,changeset-guard.yml,control-bytes.yml) and narrowed to what a TypeScript-only change underpackages/react/src/spec-bridge/can reach. All run at216e1357, the final commit, on a tree verified clean (git status --porcelainempty).pnpm --filter @object-ui/react run type-check(tsc --noEmit && tsc -p tsconfig.test.json)tsc -p tsconfig.test.json --listFilesFormViewSpecConformance.test.tsin program: 1 ·bridges/form-view.tsin program: 1pnpm exec vitest run packages/react/Test Files 55 passed (55) / Tests 817 passed (817)pnpm exec vitest run packages/react/src/spec-bridge/Test Files 7 passed (7) / Tests 198 passed (198)pnpm exec eslint(changed files)files linted = 2 | errors = 0 | warnings = 16pnpm check:spec-symbols✅ spec symbol derivation: 1306 files scanned against 4959 spec export names/✅ spec alignment claims: 2 declared deliberate copiesnode scripts/check-changeset-presence.mjs✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)node scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.node scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 5238 tracked text file(s); skipped 85 binary).node scripts/check-lint-coverage.mjs✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).node scripts/check-type-check-coverage.mjs✅ type-check coverage: 45/46 via 'type-check'/✅ test type-check coverage: 41/41 packages compile their testspnpm check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.pnpm check:self-import✅ No package names itself inside its own src/.pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK (3754 tracked source file(s) …)pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2824 keys) …Two things stated rather than left to inference:
type-checkis measured, not merely silent. The package'stsconfig.jsonexcludes**/*.test.ts, so "typecheck clean" would have said nothing about the new test file.tsconfig.test.json(chained from the same script) is what compiles it, and--listFilesconfirms both edited files are program inputs — 1 hit each, not 0.pnpm lintisturbo run lint→ per-packageeslint .; onlypackages/reactis touched, and eslint here is not type-aware (noparserOptions.project/projectServiceanywhere ineslint.config.js), so this diff cannot move the verdict on any file it does not contain. Population and count come from eslint's own--format jsonoutput: 2 files, 0 errors, 16 warnings — against a 13-warning baseline measured on the same two files atorigin/main. All 16 are@typescript-eslint/no-explicit-any, a rule configured as a warning; the package script is a bareeslint .with no--max-warnings, so it is not gated. The full-farm run is CI's.Out-of-scope observations from this sweep are recorded in the report to PM rather than fixed here; nothing outside
packages/react/src/spec-bridge/is touched.Generated by Claude Code