Uh oh!
There was an error while loading. Please reload this page.
fix(types): the three view handler keys are EVENT NAMES, not callbacks (part of #6124) - #6899
Conversation
…in them live
`ViewSwitcherSchema.onViewChange`, `FilterUISchema.onChange` and
`SortUISchema.onChange` were described as "change callback" on both the zod
mirror and the TS interface. Measured, they are none of the three things that
wording suggests: the authored string is the NAME of a `CustomEvent` the
renderer dispatches on `window` —
ViewSwitcher.tsx:249-255 new CustomEvent(schema.onViewChange, { detail: { view } })
FilterUI.tsx:99-105 new CustomEvent(schema.onChange, { detail: { values } })
SortUI.tsx:93-99 new CustomEvent(schema.onChange, { detail: { sort } })
i.e. live, JSON-authorable capabilities. The mislabel is not cosmetic: a
handler-key census that buckets BY ZOD TYPE cannot tell an event NAME from the
handler-EXPRESSION dialect this repo does not support, and on that reading all
three were swept in for retirement — which would have deleted working
behaviour. The dual channel is what hides it: `onViewChange?.(next)` one line
above is the REACT PROP, while `schema.onViewChange` is the authored string.
Same type (`z.string()` / `string`), so no accept set moves. What changes is
that the declaration now says which channel it is, and a pin holds it:
half 1 the key is in the mirror's `shape`. Deliberately NOT a `safeParse`:
`BaseSchema` is `.passthrough()`, so a retired key still parses GREEN
and the output still CARRIES the value — a parse-based pin would stay
green through the very deletion it exists to catch.
half 2 the authored string reaches `new CustomEvent(...)` on `window`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB… `any`) The pin landed with seven `@typescript-eslint/no-explicit-any` warnings. Typed against the real contracts instead: the TS `ViewSwitcherSchema` / `FilterUISchema` / `SortUISchema` for the authored nodes (imported `as type`, aliasing the zod mirrors to `*Mirror` so the two faces stay distinguishable), and a generic `captureWindowEvents<D>` that returns typed `CustomEvent` details. Typing the node literals is not cosmetic here: it means the pin now also fails if one of these keys leaves the TS face, which is the other half of the surface a retirement would touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
Required by `changeset-presence.yml`: published source of `@object-ui/types` changed. Patch, never major (AGENTS.md — a `major` in the fixed group pushes all 39 packages off objectstack's cadence). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
✅ 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-sam
commented
Aug 30, 2026
Clause ② review — |
…felt on The changeset claimed the correction is felt in "the generated JSON Schema description". Measured, and it is not: every `z.toJSONSchema` call in this repo generates from `@objectstack/spec` — view-schema.ts / page-schema.ts / dashboard-schema.ts / report-schema.ts -> @objectstack/spec/ui package-schema.ts -> @objectstack/spec/kernel — and none from `@object-ui/types/zod`. The only importers of the three schemas are `import type` (the TS face) plus this PR's own pin. No baselined artifact carried the old `describe()` text either: a repo-wide grep for the three old strings returns nothing but this changeset's own quotation of one, with the new text as the hit-control. So the claim named a consumer that does not exist. Replaced with the two surfaces that do change and that an author can actually read: the mirror's `describe()` text and the TS JSDoc. This matters more than its size: the changeset is the only artifact here that ships to people who never read the PR thread, and the only one written before review is complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…lables-to-json-conversion
Follow-up round addressed — head |
| call site | generates from |
|---|---|
metadata-admin/view-schema.ts · page-schema.ts · dashboard-schema.ts · report-schema.ts | @objectstack/spec/ui |
metadata-admin/package-schema.ts | @objectstack/spec/kernel |
Complete set of toJSONSchema inputs: DashboardSchema, ListColumnSchema, ManifestSchema, PageSchema, ReportSchema, ViewSchema. The only importers of the three schemas are import type plus this PR's own pin. Repo-wide grep for the three old describe() strings: no carrier anywhere except this changeset's own quotation of one, with the new text as the hit-control.
⇒ the claim named a consumer that does not exist. Changeset and body now state only the two surfaces that do change — the mirror's describe() text and the TS JSDoc. Taking the point that the changeset is the artifact that ships to people who never read this thread, that is where the fix landed first, in its own commit.
3. Branch update
Merged origin/mainfab4802e3 (repo convention for a branch I created — no rebase, no force-push). Clean, no conflicts; mergeable: true, and 0 commits in origin/main are missing from the branch. My three-dot diff vs merged main is still exactly 4 files, +232/−6.
Verification on the merged head
- Suites, repo-root invocation
pnpm exec vitest run packages/types/ packages/plugin-view/— 102 files / 1122 tests, all passed.VERDICT command-exit 0. The+1 file / +6 testsversus this PR's pre-merge 101/1116 is main's, attributed not assumed: main's newreport-schema-authoring-face.test.tsplus this PR's pin run as 2 files / 14 tests, and the pin is 8 of them. - Type-check
pnpm --filter @object-ui/types type-check, echoed astsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json.VERDICT command-exit 0. check:control-bytesOK (5752 tracked text files) ·check:vi-mock-inheritOK (108 call sites, 108 inherit, 0 auto-mocked) ·check:vi-mock-specifiersOK.check-changeset-presenceOK against the new merge-basefab4802e3— "3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)".check-changeset-no-majorOK.- Lint population 3 files, 0 errors; my two authored files 0 warnings;
views.ts's 8 warnings pre-existing. - This round changed no source file. I authored exactly one commit (
017613ecd), touching only the changeset (+3/−2); non-changeset files touched by me this round = 0. The other commits in the range arrived with the merge.
Generated by Claude Code
Generated by Claude Code
✅ 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-sam
commented
Aug 30, 2026
Clause ② reviewer — follow-up discharge confirmedBoth follow-ups from my ACCEPT WITH FOLLOW-UP verdict are discharged, verified at head Follow-up 1 (census erratum) — discharged. The body now states 29 keys / 60 anchored named-key call sites / 9 files, with an explicit erratum block: 61 was the raw Follow-up 2 (JSON Schema wording) — discharged, and upgraded from softening to removal. Verified in the commit diff: the changeset now names only the two surfaces that change — the mirror's Re-verified at the merged head, first-hand: the PR diff vs current main Drift re-check at the new base: Taken on report: the terminal CI reading at Nothing is outstanding from the clause-② review. The PR stays draft — not mine to close or mark ready; card #6124 remains Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Part of #6124 — this lands the executable half and escalates the rest. It is deliberately NOT a closing PR: the ruled conversion sweep is not executable as ruled, and the measurement is below.
@object-ui/types' zod mirrors are objectui's own published surface. This PR stays draft, its review goes atCONTRACT_REVIEW_TIER, and it is not the PM's to close.What this PR changes
Three
on*mirrors inviews.zod.tswere described as"... change callback". Measured, the authored string is the name of aCustomEventthe renderer dispatches onwindow:ViewSwitcherSchema.onViewChangeplugin-view/src/ViewSwitcher.tsx:249-255new CustomEvent(schema.onViewChange, { detail: { view } })FilterUISchema.onChangeplugin-view/src/FilterUI.tsx:99-105new CustomEvent(schema.onChange, { detail: { values } })SortUISchema.onChangeplugin-view/src/SortUI.tsx:93-99new CustomEvent(schema.onChange, { detail: { sort } })These are live, JSON-authorable capabilities. The type is unchanged (
z.string()/string), so no accept set moves. What changes is the two surfaces this contract is published on — the mirror'sdescribe()text and the TS JSDoc — which now say which channel the string is, plus a pin that holds it.The mislabel was load-bearing. A handler-key census that buckets by declared TYPE cannot tell an event name from the handler-expression dialect this repo does not support, and on that reading all three were swept in for retirement — which would have deleted working behaviour. The dual channel is what hides it:
onViewChange?.(next)one line above is the React prop, whileschema.onViewChangeis the authored string. Same name, two channels.The pin, and why it has two halves
packages/plugin-view/src/__tests__/handlerEventNameLiveness.6124.test.tsxshape.safeParseassertion:BaseSchemais.passthrough(), so a retired key still parses green and the parsed output still carries the value. A parse-based pin would stay green through the very deletion it exists to catch.new CustomEvent(...)onwindow.Ablation proving both halves are load-bearing (mutation and restore both verified on disk). Retiring
onViewChangefrom the mirror:⭐ The three runtime tests stayed GREEN through that retirement — the runtime keeps working while the authorable surface silently loses the key. That is precisely why half 1 exists, and it is the passthrough hazard demonstrated on the pin itself.
Restore leg:
git diff HEADempty, blob60940616matched against the HEAD blob,git statusclean.The test resolves the package from source, not
dist: the wording assertion went red before the edit and green after it with no build run, which is the control that the ablation was reading the mutated file.⛔ Why the ruled sweep is NOT in this PR
The 2026-08-29 ruling adopts conversion on the server-side
lowerCallablesprecedent, in three census-first buckets. All three are non-executable as ruled, measured on today'smain(merge-base05b832407).Census moved — 28 distinct keys is now 29
z.function()inpackages/types/src/zod/, measured on the merged tree: 29 distinct keys / 60 anchored named-key call sites / 9 files. Thez.string()andz.any()halves are unchanged at 11 and 3 (anchored recount; the unanchored pattern from #6182 over-reports by matching mid-identifier —buttonLabel,actionUrl,conversationId,locationField,actionName,descriptionField).z.function(occurrence count, not a count of named keys. The two differ by exactly one, and the residue is:a record-valued declaration, not a named key, so it does not belong in a named-key call-site count. Anchored on
key: z.function(the figure is 60.29and9were correct and are unchanged. ⛔EventHandlersSchemais deliberately not in this PR's scope — it does not bear on the threez.string()keys changed here; it is recorded on the card as a separate uncounted surface.The one key present today that is absent from the card's own enumerated list of 28 is
onSend(complex.zod.ts:327). That is a set difference against the list the card body prints, not an archaeology claim: this checkout is shallow (19 commits), sogit log -Scannot date an introduction and none is asserted.Bucket 1 (declarative action object) — measured EMPTY
Zero of the keys is consumed as a declarative object. Every read is a function call, a React-prop forward, or an authored string used as a
CustomEventname. Exhaustive per-key census; representative reads:schema.onCancel()—plugin-form/src/SplitForm.tsx:308-309schema.onColumnsReorder(newColumns)—components/src/renderers/complex/data-table.tsx:1429-1430onCardMove={schema.onCardMove}—plugin-kanban/src/index.tsx:193column.cell(value, row)—plugin-grid/src/VirtualGrid.tsx:153And the plumbing itself cannot be reached from a handler key:
ActionRunner.builtinExecutorsisscript | url | modal | flow | api | form | navigation(core/src/actions/ActionRunner.ts:845-856) — dispatch is keyed on anActionDef'stype, reached fromaction:*node types, never from a handler key.ButtonRenderer(components/src/renderers/form/button.tsx) does not readschema.onClickat all.schema.events— the ActionDef channel AGENTS.md section 4 documents — is consumed nowhere.⭐ Two landed facts point the opposite way from bucket 1:
examples/schema-catalog/test/component-fixture-declared-keys.test.ts:270pins "every handler value in the corpus is a string expression, never an action object" (expect(objectValued).toEqual([])). Converting a corpus fixture to the declarative object form turns that green pin red.onClick, which is declared as a function and read by no dispatcher #6250 / PR fix(examples,docs): correct the toast demos to spellings the engine executes #6498 already ruled this exact shape and landed the correction the other way: the authorable spelling is a node type ({"type":"toast"}), not a handler-key action object. Bucket 1 would re-introduce the spelling that PR removed.Bucket 2 (hooks-style ref form) — no consumer exists
No resolver anywhere turns a string on
cell/validate/custominto a function.registerHandleris keyed to action names dispatched byActionRunner, not to handler keys. Declaring the ref arm anyway mints an inert declaration — which this ruling itself forbids ("a declared form nothing reads is the disease, not the cure"). Building the resolver is a new capability, and AGENTS.md #6 bans runtime dynamic imports /eval()for exactly this.Bucket 3 (ADR-0049 narrow) — the stop condition, re-verified end to end
Everything falls into bucket 3, and there the narrow is a deletion, and the deletion is SILENT. Measured on today's
main, with controls:ButtonSchema.safeParseof the card's payloadsuccess:false,invalid_type,expected:'function', path['onClick']ButtonSchema.omit({onClick:true})success:TRUE, anddata.onClickdeep-equals the objecttoFormControlDomPropsforwards the object-valuedonClick(and drops a sibling key — control that it really filters)Expected `onClick` listener to be a function, instead got a value of `object` type.onClickis inSDUI_DOM_PASS_THROUGH_KEYS(core/src/utils/dom-props.ts:88-99). So bucket 3 converts a clear parse-time error into no error plus an uncaught runtime throw at click — strictly worse than today, on the axis the ruling called decisive. The triage word was 「响亮移除」 — loud. Underpassthrough()the removal is silent.Two further blockers
main: 433 documents, 362 green, 71 red, and reds whose failure isexpected: function= 0. PR fix(examples,docs): correct the toast demos to spellings the engine executes #6498 already rewrote the toast/sonner fixtures. The 71 are a pre-existing family (text / sidebar / carousel / button-group), not this card's.cell/custom/validatecannot be filed.CallbackShapedKeyison[A-Z]..., soRuntimeOnlyDeclaredrefuses them (assertionRuntimeOnlyIsCallbackShapedOnly, the ledger's "load-bearing half") whileUnmirroredDeclaredrecords "ordinary mirroring debt" — a remedy 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152's ruling rejects. The ledger vocabulary cannot express a genuine non-on*runtime-only face, and relaxing that pin is a governed change the file itself says happens by ruling, never by quiet refiling.⛔ Not addressed in this PR, and none of the referenced cards change state here: #6182 remains open, #6152 remains open, #5250 remains open.
Verification
All heavy runs through
scripts/pm/os-verify-lock.sh; verdict lines quoted, never a bare exit code. Union re-run on the merged headabd73c18f(branch updated by mergingorigin/mainfab4802e3— the repo's convention for a branch I created; no rebase, no force-push).pnpm exec vitest run packages/types/ packages/plugin-view/(repo-root invocation, notpnpm --filter) — 102 files / 1122 tests, all passed on the merged head.os-verify-lock: VERDICT command-exit 0packages/types/src/__tests__/report-schema-authoring-face.test.tstogether with this PR's pin gives 2 files / 14 tests, and the pin is 8 of them, so main's file is the 6.pnpm --filter @object-ui/types type-check→tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json.tsconfig.jsonexcludes tests, sotsconfig.test.jsonis the leg that judges the type-level ledger pins.VERDICT command-exit 0check:control-bytesOK (5752 tracked text files) ·check:vi-mock-inheritOK (108 call sites on@object-ui/react, 108 inherit, 0 auto-mocked) ·check:vi-mock-specifiersOK ·check-changeset-presenceOK (now against merge-basefab4802e3: "3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)") ·check-changeset-no-majorOK.--format jsonoutput = 3 files, each a changed file; 0 errors; my files contribute 0 warnings;views.ts's 8 warnings are proven pre-existing (identical count on the same file at merge-base05b832407). Config invariance: type-aware linting is not enabled (noprojectService/projectineslint.config.js), so this diff cannot move the verdict of any file it does not contain.Provenance
Authored by the
domain:uiexecution seat, sessionsession_013hfmP9hoMd3dJwTh85J4yB(https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB) — recorded here in prose because editing a PR body rewrites the footer's link form.Changeset
.changeset/6124-view-handler-keys-are-event-names.md—@object-ui/types: patch. What an author feels: nothing they wrote breaks, and the declaration now tells them the string is an event name and what to listen for.z.toJSONSchemacall in this repo generates from@objectstack/spec, never from@object-ui/types/zod:metadata-admin/view-schema.ts·page-schema.ts·dashboard-schema.ts·report-schema.ts@objectstack/spec/uimetadata-admin/package-schema.ts@objectstack/spec/kernelThe complete set of
toJSONSchemainputs isDashboardSchema,ListColumnSchema,ManifestSchema,PageSchema,ReportSchema,ViewSchema— none from these mirrors; the only importers of the three schemas areimport typeplus this PR's own pin. No baselined artifact carried the old text either: a repo-wide grep for the three olddescribe()strings returns nothing but this changeset's own quotation of one, with the new text as the hit-control. The claim named a consumer that does not exist, so it is gone. The changeset now states only the two surfaces that do change.Generated by Claude Code
Generated by Claude Code