Skip to content

feat(plugin-form): an edit save writes only the fields that changed - #10546

Merged
os-litant merged 6 commits into
mainfrom
claude/issue-10156-edit-mode-dirty-diff
Sep 25, 2026
Merged

os-litant merged 6 commits into
mainfrom
claude/issue-10156-edit-mode-dirty-diff

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #10156

Clause-②: no (conditional on the stop above)

The stop did not fire. No exported type, prop or signature changed. submitHandler keeps its declared type, and @object-ui/plugin-form publishes . only, from index.tsx, which re-exports neither sanitize nor masterDetailTx. After pnpm --filter @object-ui/plugin-form run build on this branch, grep -c over dist/index.d.ts prints 0 for each of dirtyEditPayload, snapshotLoadedRecord, advanceLoadedRecord, LoadedRecordSnapshot, EditSaveTarget, isSameStoredValue, changedFields and sanitizeFormData. The positive control, ObjectForm, prints 6. ⚠️ One published behaviour does move: in edit mode, a host submitHandler now receives the payload the form would have written (see "What shipped"). The seat confirmed Clause-②: no in comment 5829229738 on objectui#10156, and ruled that the published JSDoc describing the seam is corrected in this PR. Round 2 below does that.

What shipped

An edit form now diffs its sanitized payload against the record it read with findOne, and writes only the fields that differ.

  • Where the loaded record lives. SimpleObjectForm (in ObjectForm.tsx), ModalForm and DrawerForm each hold the record in a ref, set by their own findOne and read only by the save path. The ref is tagged with the object and record it was read for. A save for any other record therefore finds no baseline and sends everything. initialData / formData are left alone, because they seed the form and supply the OCC token. No exported type or prop was needed.
  • One place, both write routes. In SimpleObjectForm, the diff runs once, before the route is chosen. It covers the host-owned submitHandler seam, which is how a master-detail form builds its parent operation, and the plain OCC-guarded update. ModalForm and DrawerForm compute it in the same writePayload their create path already used.
  • One comparison. isSameStoredValue and changedFields moved from masterDetailTx.ts into sanitize.ts, and both callers import that copy. The function bodies are byte-identical to the originals: a diff of each body, extracted from the base blob and from this branch, is empty. The master-detail child rows and the edit form use the same rule.
  • Empty diff. A save with nothing changed still sends the full sanitized payload, as it did before. It stays a real request, with the same OCC guard and a real server record for onSuccess. Skipping the request instead would report success for a save no server saw. That is the one outcome a wrong baseline must never produce.
  • Baseline advance. After a successful save, the snapshot takes on the fields just written. A form that stays mounted therefore diffs its next save against the record as it now stands. Without this, changing a field back to its first-read value would compare clean and be dropped, while the server kept the first save's value. The row a second save from the same mounted form diffs against what the first save wrote pins it.
  • Untouched: occSave.tsx; the OCC token (baseRecord is still initialData / formData); the 409 dialog; the create path; and the sections branch of ObjectForm.tsx. The save handler is shared by the flat and sectioned layouts, so a sectioned simple form also gets the diff. No line of the sections branch changed.

A one-field edit, on a record that also carries a numeric string, a null, an ownership column and updated_at. The "full payload" line is the same pin under ablation A1 below, which puts the pre-branch full payload back:

full payload  update("deal", "d1", {"amount":10.5,"name":"Mine v2","qty":"5","remark":null,"stage":"open"}, {"ifMatch":"2026-09-25 00:00:00.000"})
this branch   update("deal", "d1", {"name":"Mine v2"}, {"ifMatch":"2026-09-25 00:00:00.000"})

Round 2 (patch): the published submitHandler doc says what an edit-mode handler receives

The seat asked for this in comment 5829229738 on objectui#10156. It is one commit, 13ac7751e, fast-forward, and JSDoc plus changeset only: 20 lines added, 0 removed, no type moves.

  • packages/types/src/objectql.ts: the ObjectFormSchema.submitHandler JSDoc. Its first paragraph is unchanged, so "hands the collected values" stays true for create. A new paragraph says that in edit mode, for a record the form read itself, the simple, modal and drawer layouts hand over what the form would have written: the fields that differ from that read, or the full sanitized payload when nothing changed. It also says a field whose sameness cannot be settled counts as changed, and that the tabbed, wizard and split layouts, and a simple form the mobile stepper routes through the wizard, still hand over every collected value. The BulkActionDef icon example that draft PR objectui#8941 edits is untouched.
  • ModalForm.tsx and DrawerForm.tsx: their exported ModalFormSchema / DrawerFormSchema carry their own copy of the same "hands the collected values" sentence, under a comment that says the copy is declared from the canonical key so the two "can never drift apart". Each gains the same edit-mode clause. ⚠️ The round-2 instruction said "no other source change this round". These two lines are JSDoc only, in files already on this claim's surface. They were published text that round 1 made incomplete in the same way as the canonical sentence. The deviation is named in the report.
  • .changeset/10156-edit-form-writes-only-changed-fields.md: declares @object-ui/types as a patch, and one bullet names the correction.
  • The zod mirror (packages/types/src/zod/objectql.zod.ts, held by objectui#9618) carries no submitHandler at all: 0 hits in the zod directory, against 2 files there that carry onSuccess as the control. There was nothing to report or to leave alone.
  • A search for source-text pins quoting the old sentence found none outside the edited JSDoc blocks. The control is the same search run with nothing excluded, which hits the source line. The two pending changesets that paraphrase "documented as handing the collected values" remain true: that first paragraph is unchanged.

The comparison rule

A field counts as clean only when every case below says "same". Anything this cannot settle is SENT.

pair same?
identical values (===) yes
null / undefined yes, the one blank a round-trip interchanges
null / '', undefined / '' no
1 / '1' (number vs numeric string) no
lookup id / expanded lookup object no
two Dates with the same finite time yes
a Date / a date string; two date strings in different formats no
objects or arrays with identical JSON yes
reordered keys or elements no
NaN / NaN no

Each row is pinned both ways round in the one comparison — every pair the rule states.

Premise checks (the dispatch's mechanism assumptions, measured)

  • A1 — holds, with one correction. The record is read in SimpleObjectForm's fetch effect, and in ModalForm / DrawerForm's fetch effects, and was already kept in component state (initialData / formData). A ref beside it was enough. Correction to the dispatch: the child-row comparison lived in masterDetailTx.ts, not in sanitize.ts. It now lives in sanitize.ts.
  • A2 — holds. There is one rule, stated above. It is the rule objectui#10108's child rows already used, moved and not rewritten.
  • A3 — holds. ifMatch is still the updated_at the form read. Overwrite resends the dirty fields only, pinned in "Overwrite" after a 409 re-sends the changed fields only, re-keyed to the server version.
  • A4 — the full payload, as today. Before this branch, a save with no changes sent a PATCH carrying every sanitized field and reported the server's answer. It still does. Pinned for the plain edit and for the master-detail parent operation.
  • A5 — holds. A field the form moved itself differs from the read record, so it is sent. Pinned with a cascade clear: the user moves region, the form empties tier, and tier: null is on the payload.
  • A6 — holds. Create is untouched. The control row posts a seeded value the user never changed, and update and findOne are never called.

Pins (editDirtyPayload-10156.test.tsx, real components, real sanitizeFormData)

  • plain edit, modal edit and drawer edit: changing one field sends exactly that field, with ifMatch;
  • master-detail parent operation: ops[0] is exactly {ref: 'PO-2'};
  • type drift is sent. Real widgets produce it: retyping 5 into a number input whose stored value is the string '5', and typing into then emptying a textarea whose stored value is null. A unit row covers the lookup id against the expanded object;
  • a cascade clear is sent;
  • Overwrite after a 409 sends dirty fields only;
  • a save with nothing changed sends the full sanitized payload (plain and master-detail);
  • a second save from a still-mounted form;
  • create is unchanged (control).

Reverse verification (head cb00385aa)

The fix was committed first. Each mutation went through ablation-replace.mjs: the anchor had to hit exactly once, the blob change was verified on disk, and the file was trap-restored from HEAD on an absolute path. After every leg, the sanitize.ts blob equals HEAD (9c115af88c2a) and git diff HEAD is empty. No rebuild was needed, because every consumer imports ./sanitize relatively inside the package.

ablation pin file result what went red what stayed green
A1 remove the dirty filter (dirtyEditPayload returns the payload) 8 failed / 25 passed every exact "only that field" row (plain, modal, drawer, master-detail parent, Overwrite, second save) every type-drift row, the cascade row, both empty-diff rows, create
A2 comparison forced to "all clean" 17 failed / 16 passed the 9 "different" rule rows and the same exact rows as A1 type-drift and cascade rows
A3 all clean AND empty-diff fallback removed 24 failed / 9 passed all type-drift rows, the cascade row, both empty-diff rows, everything above the "same" rule rows, create
A4 loosened equality (String(a ?? '') === String(b ?? '')) 9 failed / 24 passed all three component type-drift rows, the unit drift row, 5 rule rows every "only that field" row, the cascade row
A5 no baseline advance 2 failed / 31 passed the second-save row and its unit row the rest
A6 sanitizeFormData returns its input (over the three retriaged files) 14 failed / 15 passed every retriaged filter row (below) the new an edit writes the edited field alone — never the FLS-refused one rows

⚠️ The dispatch expected A2 to turn the type-drift and cascade pins red. It did not. Under an "all clean" comparison the diff comes out empty, and the empty-diff rule then resends the full payload, so nothing can be dropped. That is the fallback working as a second line of defence. A3 removes the fallback as well, and they go red. A4 shows that the comparison's strictness is load-bearing on its own terms: loosen it, and a drifted field is dropped the moment another field is dirty.

Fixtures retriaged, and why

A row that asserts a column ABSENT after a real edit now stays green with its filter deleted, because the dirty diff alone keeps an unchanged column off the wire. The new FLS edit rows show exactly this: they stayed green under A6. So each row whose job is the sanitizer, the roster or the field-level filter now submits with nothing changed. That is the one edit route whose payload is the filter's whole output, and A6 turns every one of them red.

  • ObjectForm.test.tsx strips computed and server-managed fields from the edit-mode update payload: now submits unchanged, and its control is the stored name / budget.
  • systemManagedPayload.test.tsx sends no server-owned column, from a form that declares none of them: same move. This row was still passing, but vacuously.
  • fieldSecurityPayload.test.tsx: the filter row and the CONTROL row submit unchanged in all three containers. A new row per container pins that an edit writes the edited field alone. The file header says why.
  • cascadePruneWire-10291.test.tsx, two EDIT rows: the negative-control select is now asserted ABSENT from the wire body, not present with its stored value. A wrongful null would still show, because it differs from the stored 'gold'.
  • drawerFirstLoadWindow-10190.test.tsx CONTROL: now toEqual({title: 'typed'}). That is stronger than before: note is absent only because the form still holds the value the record landed with.

Checks at the round-2 head (13ac7751e)

check command result
dependency closure build turbo run build over the plugin-form dependency closure, --concurrency=2 (includes @object-ui/types) 11/11 tasks, exit 0
@object-ui/types type-check pnpm --filter @object-ui/types run type-check (tsc --noEmit, tsconfig.examples.json, tsconfig.test.json) exit 0
@object-ui/types suites, including every suite that reads objectql.ts text pnpm exec vitest run packages/types/ 233 files, 5184 passed, exit 0
package tests pnpm exec vitest run packages/plugin-form/ 119 files, 1235 passed, 1 skipped, exit 0
plugin-form type-check pnpm --filter @object-ui/plugin-form run type-check exit 0
lint, touched files eslint on objectql.ts, ModalForm.tsx, DrawerForm.tsx 0 errors
changeset presence / no-major / fixed / overwrite / pending literals check-changeset-*, check:pending-changeset-literals exit 0 all. Presence: 12 source files of 2 released packages, 1 changeset declared
changeset claims check:changeset-claims exit 0 (report-only). Self-contradiction reading: both bodies clean. It now also flags the pending changesets that name objectql.ts. Those paragraphs concern other faces or other ObjectFormSchema keys, and none describes the submitHandler doc
line citations, control bytes, doc expression carriage, handler-key reads check:* exit 0 all, 0 new citations
governed surface check-governed-queue-guard.mjs --test over the round-2 paths NOT GOVERNED

Round 2 changed no executable line. So the round-1 reverse verification below, measured at cb00385aa, still describes this code.

Checks on the round-1 head (cb00385aa)

check command result
dependency closure build turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2 11/11 tasks, exit 0
package build pnpm --filter @object-ui/plugin-form run build exit 0
package tests pnpm exec vitest run packages/plugin-form/ 119 files, 1235 passed, 1 skipped, exit 0
suites outside the package that name sanitizeFormData, ObjectForm, ModalForm, DrawerForm, saveWithOcc, occSave or masterDetailTx (99 files, enumerated with git grep -l) pnpm exec vitest run over them in two batches 40 files / 1169 passed and 59 files / 1359 passed, exit 0 both. Run at e111bfd68; everything after it touches only the changeset, README and docs page
type-check pnpm --filter @object-ui/plugin-form run type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0. --listFiles on the test project lists all six touched test files
lint eslint . in packages/plugin-form (its own lint script), --format json 153 files, 0 errors, 1000 warnings. Per-file rule counts in the four edited source files match the base, except that 4 no-explicit-any moved with the comparison from masterDetailTx.ts to sanitize.ts
changeset presence / no-major / fixed / overwrite check-changeset-* exit 0 all. Overwrite reports the intended body correction of objectui#10120's changeset
changeset claims check:changeset-claims exit 0 (report-only). The three flagged paragraphs (objectui#10108, #6237, #8738) were read and are still true
pending changeset literals, line citations, control bytes check:* exit 0, 0 new citations
test path roots, vi-mock ×3, unreferenced sources, handler-key reads, metadata write doors, type-check coverage, lint coverage check:* / scripts/check-* exit 0 all
doc links, doc fences, doc example ids, doc component types, doc expression carriage, shell escape residue scripts/check-* exit 0 all
governed surface check-governed-queue-guard.mjs --test over the changed paths NOT GOVERNED

NOT MEASURED: check:readme-exports, which answered exit 1 with "population collapsed", and check:doc-snippets, which answered exit 2 PRECONDITION NOT MET. Both need packages outside this closure built, and neither answer is a verdict. Both read fenced code blocks, and this branch's README and docs-page additions contain 0 fences (git diff over the two files, counting added lines that open a fence). Remote CI on 13ac7751e: 43 check-runs completed, 40 success and 3 skipped by design (dependabot, Test (coverage) and the coverage-shard matrix), 0 red.

Declared narrowing on lint. pnpm lint is turbo run lint, i.e. each package's own eslint .. The run above is that command for the one package this branch changes, over its whole tree. eslint.config.js sets neither parserOptions.project nor projectService, so type-aware linting is not enabled, and a change inside this package cannot move the verdict on a file it does not touch. The .changeset, README and docs-page files are not eslint inputs.

File surface

Round 2 added packages/types/src/objectql.ts (the submitHandler JSDoc only) through the seat's amendment, plus the JSDoc copies on ModalFormSchema / DrawerFormSchema (see Round 2). In round 1, the claim named sanitize.ts, ObjectForm.tsx, ModalForm.tsx, DrawerForm.tsx, the tests beside them and one changeset. This branch also touches:

  • masterDetailTx.ts: the comparison's old home. It loses its private copy and imports the shared one; the function bodies are byte-identical;
  • packages/plugin-form/README.md and content/docs/plugins/plugin-form.mdx: the docs AGENTS.md Add automated testing infrastructure and CI/CD workflows #2 requires;
  • .changeset/10120-form-omits-fls-denied-fields.md: body only, frontmatter byte-identical. Its sentence "ObjectForm and ModalForm produce the same payloads they did before" would be false beside this change in the same release. It now says both already withheld the refused field and still do.

Acceptance notes

  • Out-of-scope findings, handed to the seat, which files them at ACCEPT (measured with probes that were not committed):
    • The tabbed, split and wizard edit arms never call sanitizeFormData. Their edit PATCH carries the whole record: id, owner_id, created_by, updated_at and a formula column, on this branch and on main alike. This is objectui#10108's 403 and the "unknown field" rejection, on the arms that fix did not reach. The same arms also skip the field-level filter. A simple form with mobile.stepper routes through the wizard arm too. The docs and changeset name all of these as not covered.
    • A master-detail EDIT form that stays mounted after a save sends stale child rows on the next save. It re-creates every row the first save created, so the second batch carries the same create again. It also drops a child cell changed back to its first-read value, so the second batch carries no child operation and still reports success. The row state's original never advances, and created rows never learn their ids. This is the child-row twin of the baseline advance above. The fix shape (refetch the children, or map the batch results) is not pinned, so it is not fixed in place here.
  • Observations, not findings:
    • A second save from a still-mounted edit form still sends the updated_at first read as ifMatch, so against a real server it would 409 on the user's own first save. OCC was out of scope and unchanged; this was not measured against a server.
    • MasterDetailForm's fallback echo {...parentData, id} is used only when the batch returns no results. It now carries the changed parent fields, not all of them.
    • Two cross-file line addresses into objectql.ts were already false before this PR, and are left as they are: the groupBy sentence in the pending changeset for the kanban group-by control, and the KanbanConditionalFormattingRule comment in the one-authority-per-exported-name script test. At cb00385aa, the changeset's address lands on a map-config comment and the script test's on a View type comment. Round 2's inserted lines sit before both, but neither was true to begin with.
  • The sections branch of ObjectForm.tsx, objectui#10475 and objectui#10476 were not touched.

Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C


Generated by Claude Code

An edit form now diffs its sanitized payload against the record it read
with findOne and writes only the fields that differ, on both write routes:
the host-owned submitHandler seam (how a master-detail form builds its
parent operation) and the plain OCC-guarded update. ObjectForm, ModalForm
and DrawerForm each hold the record they read in a ref tagged with the
object and record it was read for; the save path is its only reader.

The comparison is the one master-detail child rows already used; it moves
from masterDetailTx.ts into sanitize.ts unchanged so there is one rule.
Everything it cannot settle is sent: '1' and 1, null and '', a lookup id
and its expanded object all read as changed. An empty diff sends the full
sanitized payload, as a save with no changes always has. After a save
succeeds the baseline advances by what was written, so a form that stays
mounted cannot drop a change back to the first-read value.

Create, the OCC token and the 409 dialog are untouched.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…vacuous

New pins for objectui#10156: the comparison rule pair by pair, and on real
components the plain edit, the modal and drawer edits and the master-detail
parent operation each send only the changed field; type drift ('5' then a
retyped 5, null then an emptied textarea) and a cascade clear are sent; an
unchanged save sends the full sanitized payload; the 409 Overwrite resends the
changed fields only; a second save from a still-mounted form diffs against
what the first wrote; create is unchanged.

Rows that asserted a column ABSENT after a real edit would now stay green with
their filter deleted, because the dirty diff alone keeps an unchanged column
off the wire. The sanitizer, roster and field-level-security rows now submit
with nothing changed, the one edit route whose payload is the full sanitized
set. Rows that asserted an untouched field PRESENT now assert it absent.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
The README and the plugin-form docs page say what an edit save now writes,
the comparison rule, the saves that still send every field, the unchanged
OCC guard, and what a host submitHandler receives in edit mode. The tabbed,
wizard and split variants are named as not covered.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…pending changeset true

The new snapshot and dirty-payload helpers take Record<string, unknown>, so
sanitize.ts carries only the explicit-any warnings it had plus the four the
moved comparison brought with it.

The pending objectui#10120 changeset said ObjectForm and ModalForm "produce
the same payloads they did before". Released beside this change that is no
longer true of the whole payload; it now says what it meant, that both
already withheld the refused field and still do. Body only; front matter
byte-identical.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…covered

A simple form whose mobile stepper option is on renders through WizardForm,
whose save path is its own, so it still sends every value it holds. The
README, the docs page and the changeset now say so beside the tabbed, wizard
and split variants.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
@github-actions github-actions Bot added documentation Improvements or additions to documentation plugin tests labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 22 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/10108-form-payload-server-owned-fields.md

  • names sanitize.ts → packages/plugin-form/src/sanitize.ts — edited by this change

    What it was. The write-side roster in sanitize.ts and the render-side roster in autoLayout.ts were two hand-written copies of the same list, and they drifted. The render copy had learned to drop owner_id; the write copy never did. A form therefore showed the user business inputs only, and sent back every ownership and audit column it had read — owner_id, owning_business_unit_id, created_by, updated_by — while dropping created_at and organization_id from the same payload, because those two happened to be in the write copy.

.changeset/5903-objectgantt-declared-keys.md

  • names packages/types/src/objectql.ts → packages/types/src/objectql.ts — edited by this change

    Both halves move together. The TS declaration (packages/types/src/objectql.ts) and its zod mirror (src/zod/objectql.zod.ts) gain the same ten keys at the same requiredness — all optional — and no KnownDrift entry is added. navigation is taken from @objectstack/spec's NavigationConfigSchema by reference rather than restated, matching ObjectGridSchema.navigation.

.changeset/6237-wizard-step-config-split.md

  • names content/docs/plugins/plugin-form.mdx → content/docs/plugins/plugin-form.mdx — edited by this change

    Documentation repair in the same stroke: the support table in the README and in content/docs/plugins/plugin-form.mdx still said formType: 'tabbed' sections drop the predicate. That stopped being true when the tabbed arm landed — the row now reads Yes, the surrounding prose no longer claims two inert arms or a diagnostic that fires for tabbed, and the wizard row stays No, which is still exactly true.

.changeset/7322-object-kanban-group-by-limit.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    Breaking for authored metadata: ObjectKanbanSchema.groupField is RETIRED (objectui#7322, ADR-0049 enforce-or-remove), and the two keys the object-kanban renderer actually reads — groupBy and limit — are now DECLARED and validated on both published faces: the TypeScript interface in objectql.ts and the Zod mirror in zod/objectql.zod.ts.

.changeset/7363-objectql-union-arms.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    ObjectGallerySchema and ObjectDataTableSchema are members of ObjectQLComponentSchema on both faces — the TS union in objectql.ts and the zod union in zod/objectql.zod.ts — so AnyComponentSchema, and with it validateSchema / safeValidateSchema / objectui validate, has an arm for object-gallery and object-data-table nodes (objectui#7363).

.changeset/7632-shared-record-source-config.md

  • names packages/types/src/objectql.ts → packages/types/src/objectql.ts — edited by this change

    That ladder is published contract on both faces — packages/types/src/objectql.ts and its zod mirror both ship .describe() strings naming getDataConfig's order (objectui#6939, maintainer ruling 2026-09-02), pinned by objectql-record-source-refinement-6939.test.ts — and it was hand-copied into five plugin components with no gate holding them together. A change to the ruled order had five edit sites and nothing that noticed a missed one; that is the AGENTS.md #0.1 drift class.

.changeset/7804-objectql-handler-key-arms.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    The four plain objectql.ts node faces declare the nine handler keys their registered renderers read (objectui#7804, the objectql.ts slice): ObjectFormSchema.onCancel / .onError / .onOpenChange / .onStepChange / .onSuccess, ObjectGallerySchema.onCardClick / .onRowClick, ObjectGridSchema.onNavigate and ObjectViewSchema.onNavigate.

.changeset/7804-tree-view-handler-slot.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    'runtime-slot' and not 'retired', measured at this key's own channel. 'retired' publishes "no renderer reads this key, so nothing could ever run it" — true of the two siblings already tombstoned on this arm (onSelectChange, onExpandChange) and flatly false here, since the read is live and INVOKED. ⚠️ No in-repo host builds a tree-view node carrying the key: the channel is wired end to end and only the supplier is absent, which is the same shape as ObjectFormSchema.onStepChange in this card's objectql.ts slice and is not evidence of a dead read. The TypeScript declaration is unchanged and still callable, so a programmatic host supplies it exactly as before.

.changeset/7963-alert-dialog-footer-keys-retired.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    Nothing else moves. These spellings are overloaded across the tree and every other owner is a live key on a different declaration — FormSchema.cancelLabel, objectql.ts's confirmLabel, plugin-designer's ConfirmDialog React props, plugin-grid's def.confirmLabel, and plugin-form's ModalForm / DrawerForm, which build a local cancelLabel from schema.cancelText. None is an AlertDialogSchema; none is touched, and a pin asserts it. No fixture, catalog schema, example app or doc fence authored any of the three on an alert-dialog node, so no shipped document is stranded.

.changeset/8738-object-form-fields-description.md

  • names ObjectForm.tsx → packages/plugin-form/src/ObjectForm.tsx — edited by this change

    The registration declared { name: 'fields', type: 'array' } with no description, so an author had nowhere to read that this key's members are bare field names — a different vocabulary from sections[].fields, which also accepts the spec FormFieldSchema object (identity key field, e.g. { field: 'note', colSpan: 2 }). Moving one of those objects to the top-level fields resolves to no name and is skipped by SimpleObjectForm (ObjectForm.tsx) and by buildFlatFields (flatFields.ts, shared by the drawer/modal presentations). Behaviour is unchanged by this entry; it only adds the description text an author would need to avoid the drop before writing it.

.changeset/8767-object-grid-refuses-string-sort.md

  • names packages/types/src/objectql.ts → packages/types/src/objectql.ts — edited by this change

    Migration. Write the array: sort: [{ field: 'name', order: 'desc' }]. Both keys are required. SortConfig.order carries no ? in @object-ui/types (packages/types/src/objectql.ts) and no .optional() in its zod mirror, and the protocol's own reusable SortItemSchema requires order as well — measured: that schema refuses [{ field: 'name' }] with invalid_value at 0.order. Do not omit it: this block's array arm interpolates whatever is present, so an omitted order lowers to $orderby: 'name undefined' today. That is pre-existing behaviour on the arm this change does not touch, and it is filed as a successor card rather than widened into here.

.changeset/8801-object-kanban-allow-collapse-retired.md

  • names packages/types/src/objectql.ts → packages/types/src/objectql.ts — edited by this change

    • the declarations retired here — packages/types/src/objectql.ts and its mirror packages/types/src/zod/objectql.zod.ts; - the pins that assert the retirement — object-kanban-allow-collapse-retired-8801.test.ts and bare-kanban-node-key-retired-8802.test.ts; - a comment in packages/types/src/zod/complex.zod.ts, recording that the deleted retiredZeroReadKanbanKey helper once carried this spelling on the SIBLING arm; - one row of content/docs/api/schema-reference.md; - the .changeset/ release notes that discuss it — this one, the two historical entries covering the sibling arm's own spelling, and objectui#9629's note recording the correction to this paragraph.

.changeset/8885-object-chart-drilldown-title-compareto.md

  • names packages/types/src/objectql.ts → packages/types/src/objectql.ts — edited by this change

    ObjectChart.tsx reads all three off schema, and until now neither published copy declared any of them: not the TS interface (packages/types/src/objectql.ts) and not the zod mirror (packages/types/src/zod/objectql.zod.ts). They rode BaseSchema's index signature / .passthrough() and arrived unvalidated. drillDown was the sharpest case — this component's registry inputs advertise it to the designer palette, and @objectstack/spec publishes ChartDrillDownSchema for exactly this carrier, so an author was offered a key that neither published shape mentioned.

.changeset/8913-object-kanban-columns-declared.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    What moved. ObjectKanbanSchema gains columns on both halves that move together — the TypeScript interface (objectql.ts) and its Zod mirror (zod/objectql.zod.ts). Retiring the bare kanban node type key (objectui#8802) removed the only face that judged a lane, and object-kanban had never declared the key, so it rode BaseSchema's [key: string]: any / .passthrough(): read by the renderer at three sites, named by no published face.

.changeset/8990-object-kanban-groupby-optional.md

  • names packages/types/src/objectql.ts → packages/types/src/objectql.ts — edited by this change

    @objectstack/spec declares the key optional — groupBy: z.string().optional() on ObjectKanbanPropsSchema — while this package required it on the TypeScript declaration (packages/types/src/objectql.ts) and on the Zod mirror (packages/types/src/zod/objectql.zod.ts). objectui was therefore narrower than the protocol on a published key: ObjectKanbanSchema.safeParse and safeValidateSchema refused an object-kanban node the protocol accepts, and such a node could not be annotated with its own type.

.changeset/9092-inline-locale-declared-face.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    • AppComponentSchema.label (app.ts) - ObjectGridSchema.label and .description (objectql.ts) - PageNodeSchema.aria.ariaLabel (layout.ts)

.changeset/9309-object-gallery-filter-destination-typed.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    ObjectGallerySchema.filter is typed as the destination its own docblock names — QueryParams['$filter'] — on both faces, the TS interface in objectql.ts and the zod mirror in zod/objectql.zod.ts (objectui#9309).

.changeset/9511-record-id-is-a-string.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    The three authorable keys, each on BOTH faces. ObjectFormSchema.recordId (objectql.ts + zod/objectql.zod.ts), DetailViewSchema.resourceId (views.ts + zod/views.zod.ts) and DetailSchema.resourceId (crud.ts + zod/crud.zod.ts). ⚠️ The crud pair is DetailSchema, not DetailViewSchema, and it reaches the same renderer — not by symbol but by data flow: plugin-detail registers the 'detail' node type onto DetailView. A read that follows TypeScript symbols alone finds two keys and is incomplete.

.changeset/9549-tree-filter-declared.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    ObjectTreeSchema.filter is declared on both faces, in the shape objectui#9309 settled for ObjectGallerySchema.filter: QueryParams['$filter'] by indexed access on the TS interface in objectql.ts, and the same two-arm union (array first) on the zod mirror in zod/objectql.zod.ts (objectui#9549).

.changeset/9550-object-tree-root-barrel.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    ObjectQLComponentSchema declares the node types an ObjectQL block may be. Every one of its arms was a named export of this package's root barrel except ObjectTreeSchema, which was declared in objectql.ts, applied by the union, and re-exported by the ./zod barrel (objectui#7917) — while no TypeScript consumer could name it. There is no ./objectql subpath to reach around the barrel: the package's exports map is pinned by packages/types/src/__tests__/package-exports-manifest.test.ts, and the root barrel was the only route to this type.

.changeset/9606-object-kanban-card-title.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    Both published faces of the object-kanban arm now name the key: the zod mirror ObjectKanbanSchema in zod/objectql.zod.ts and its TypeScript twin, the ObjectKanbanSchema interface in objectql.ts. Both declare it OPTIONAL, at the same requiredness the other face uses, so the two faces accept and refuse the same documents. (Located and cited by SYMBOL: line addresses in zod/objectql.zod.ts have drifted before, and this change is itself about a drifted mirror.)

.changeset/9628-kanban-column-collapsed-honoured.md

  • names objectql.ts → packages/types/src/objectql.ts — edited by this change

    The key was declared on both published faces of the object-kanban arm — the lane element of ObjectKanbanSchema (objectql.ts and its Zod mirror) and the runtime lane KanbanColumn (complex.ts and its mirror) — and read by KanbanEnhanced alone, a module no production source imports. An authored { "id": "todo", "title": "To Do", "collapsed": true } therefore parsed green on both faces and reached a board that did nothing with it: KanbanImpl's only collapse is the SWIMLANE row's, held in viewer state under objectui:kanban-collapsed:ANGLE-BRACKETS(swimlaneField) and never keyed to a lane's declared value. That is the ADR-0049 declared-but-unhonoured shape.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Angle-bracketed names in the quoted prose above are rewritten as ANGLE-BRACKETS(name): GitHub deletes tag-shaped fragments from a stored body, and a quote that silently loses the identifier it is about is worse than a visible repair.

Compared the checked-out tree with 39bf246ed (merge-base with origin/main): 14 file(s) changed outside .changeset/, read against 1437 pending declaration(s) that publish a body (2019 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3046.6 KB 3104.5 KB
Main entry chunk (gzip) 147.8 KB 350 KB
Entry file index-KtI7w4T8.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
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.68KB 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) 544.97KB 130.34KB
core (index.js) 9.22KB 3.71KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 223.86KB 62.25KB
fields (index.js) 257.16KB 65.08KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.38KB 14.60KB
plugin-charts (index.js) 74.95KB 20.88KB
plugin-chatbot (index.js) 198.36KB 47.20KB
plugin-dashboard (index.js) 133.50KB 35.37KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 232.25KB 61.40KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 147.58KB 37.78KB
plugin-gantt (index.js) 169.64KB 41.92KB
plugin-grid (index.js) 215.49KB 58.91KB
plugin-kanban (index.js) 49.30KB 15.39KB
plugin-list (index.js) 114.55KB 28.29KB
plugin-map (index.js) 23.82KB 7.42KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.64KB 8.94KB
plugin-tree (index.js) 10.52KB 3.69KB
plugin-view (index.js) 88.00KB 21.97KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 114.58KB 37.60KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

ObjectFormSchema.submitHandler's JSDoc said the form hands the collected
values to the handler. Since objectui#10156, in edit mode, for a record the
form read itself, the simple, modal and drawer layouts hand over what the
form would have written: the fields that differ from that read, or the full
sanitized payload when nothing changed. A field whose sameness cannot be
settled counts as changed. The tabbed, wizard and split layouts still hand
over every collected value. The JSDoc now says so, and the copies on
ModalFormSchema and DrawerFormSchema gain the same clause so they keep
matching the key they are declared from. The changeset declares
@object-ui/types and names the correction. JSDoc only; no type moves.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3046.5 KB 3104.5 KB
Main entry chunk (gzip) 147.8 KB 350 KB
Entry file index-D7HFwpXs.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
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.68KB 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) 545.50KB 130.49KB
core (index.js) 9.22KB 3.71KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 223.91KB 62.28KB
fields (index.js) 257.41KB 65.16KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.38KB 14.60KB
plugin-charts (index.js) 74.95KB 20.88KB
plugin-chatbot (index.js) 198.36KB 47.20KB
plugin-dashboard (index.js) 133.50KB 35.37KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 232.25KB 61.40KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 147.58KB 37.78KB
plugin-gantt (index.js) 169.64KB 41.92KB
plugin-grid (index.js) 215.37KB 58.92KB
plugin-kanban (index.js) 49.30KB 15.39KB
plugin-list (index.js) 114.55KB 28.29KB
plugin-map (index.js) 23.82KB 7.42KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.64KB 8.94KB
plugin-tree (index.js) 10.52KB 3.69KB
plugin-view (index.js) 87.31KB 21.78KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 114.58KB 37.60KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 13ac7751ea893a1d8ac72f3c9546fc8fa3b4ca88

Rendered by an isolated review subagent spawned by the domain:ui#4 seat; its served tier was checked against its transcript stamps (163 of 163 model stamps at the review tier). Adopted by this seat.

① Derived judgments

  • Inputs read. Card objectui#10156; triage 5817822344; claim 5828523731; dev reports 5829200476 and 5829400615; seat amendment 5829229738; PR objectui#10154's merge commit e026e15f9 (the child-row rule: const data = before ? changedFields(writable, before) : writable, i.e. no snapshot means the whole payload). PR diff read against the merge-base 6881e9e47 (the PR is behind, so the base-tip two-dot diff drags in main's own drift; the true surface is 16 files, +890/−92). proposed-body.md is byte-identical to the report's pr_body_replacement.

  • (a) Loaded-record baseline — holds. In SimpleObjectForm, ModalForm and DrawerForm the ref is written in exactly one success path, the findOne try-block, from the same data that seeds initialData / formData; it is nulled in the create / no-recordId branch of all three and in the no-dataSource branch of modal and drawer; its only readers are dirtyEditPayload and advanceLoadedRecord inside the save handler. snapshotLoadedRecord tags objectName and String(recordId) at fetch time; loadedRecordFor demands mode === 'edit', a non-empty recordId and both tags equal, else the full payload. Paths traced:

    • recordId change while mounted: modal and drawer set cancelled and skip both writes for a superseded response, and loading hides the form during the swap. The simple form has no cancel flag (pre-existing), but a late response for the old id writes a snapshot tagged with the OLD id, so a save for the new id finds no baseline and sends everything; snapshot and seed always come from one response.
    • Refetch (effect deps include schema.initialData / initialValues identity, dataSource, objectSchema): findOne runs again and resets snapshot and seed together; a failing refetch touches neither (the catch sets error only).
    • OCC 409: Keep editing returns cancelled and the handler returns before advanceLoadedRecord, so the baseline is untouched. Overwrite resends the same writePayload re-keyed to currentVersion, then the baseline advances by exactly what was written. Fields the other user changed and this user never touched compare clean against the first read and are not resent, which is the Overwrite semantics the triage asked for.
    • Second save: advanceLoadedRecord lays the written fields over the loaded record, for the matching tag only. Reverting a field to its first-read value is sent (pinned).
    • initialData without findOne: simple form with no recordId or mode: 'create' nulls the ref; with customFields the fetch is skipped and the ref is never set; MasterDetailForm's row editor has no recordId and gets every value. Modal and drawer in edit mode with a dataSource always findOne; without one they null the ref.
    • A host submitHandler that throws: the catch runs, no advance.
    • Load-bearing invariant: the baseline is always the record the form was seeded from, or that record plus what this form itself wrote, so any value the user can see change differs from the baseline and is sent. The only gap is theoretical: SimpleObjectForm's fetch effect does not null the ref when it skips the fetch for hasInlineFields, so a MOUNTED edit form flipped from schema fields to customFields for the SAME record would keep the earlier read as baseline while re-seeding from the host's initialData. A false clean then also needs the user to type exactly the old-read value into a field the host seeded differently. Not reachable by ordinary use; a one-line hardening (null the ref when the fetch is skipped) for a follow-up, not a blocker.
  • (b) The comparison — holds. isSameStoredValue (575 bytes) and changedFields (264 bytes) are byte-identical from the function keyword to the closing brace between the base masterDetailTx.ts and head sanitize.ts; the only textual difference is the added export. Every table row follows from the branches: ===; both == null; one == null is different (so null/'' and undefined/'' differ); any Date operand compares by finite getTime() and a string operand yields NaN, so Date/string and two differently formatted date strings differ; both objects compare by JSON.stringify (reorder differs, a throw is caught to false); everything else including 1/'1', id/expanded-object and NaN/NaN is false. No "same" verdict is unsafe: null/undefined merges only the two blanks; a form clearing to undefined over a stored null is a server no-op, and a null over a key ABSENT from a findOne issued without $select is likewise a no-op, since absent means null on the server; the one residual case, a projection that hides a stored non-null value the user could not see, now errs toward not clearing an unseen value where the old full payload would have nulled it. JSON-equal pairs are exactly the pairs a JSON transport would send identically. Date/date-string pairs on date and datetime fields are judged different and SENT; a picker that normalizes an untouched value on load costs one redundant column, never a drop.

  • (c) Both routes and the empty diff — holds. In SimpleObjectForm, writePayload = dirtyEditPayload(payload, loadedRecordRef.current, schema) is computed once before the try and is what both schema.submitHandler(writePayload) and saveWithOcc({ payload: writePayload }) receive; dataSource.create still receives payload. dirtyEditPayload returns changed only when it has at least one key, else payload unchanged, so a save with nothing changed is the same full sanitized request as before, with the same OCC guard and a real result for onSuccess (pinned for plain and master-detail). The A2 result follows: forcing "all clean" empties the diff, the fallback resends everything, and every presence row (type drift, cascade) stays green while every exact row goes red; A3, which also removes the fallback, is what turns them red.

  • (d) OCC unchanged — holds. occSave.tsx is not in the diff; ifMatch = occVersionOf(baseRecord) with baseRecord still initialData / formData; the 409 dialog is byte-unchanged; the Overwrite retry resends its payload argument, which is now the dirty set. The dev's observation is pre-existing: neither initialData nor formData was refreshed after a save at base or head, so a second save from a still-mounted form always sent the first-read updated_at. This PR does not worsen it; on Overwrite it now writes only the new edits instead of the whole record.

  • (e) Create untouched — holds. Simple form: create(objectName, payload); dirtyEditPayload is invoked for every mode but returns payload untouched whenever mode !== 'edit' (and the ref is null in create). Modal and drawer keep the create arm of the writePayload ternary as it was. No create payload is ever diffed; pinned by the control row (update and findOne never called) and the unit row.

  • (f) Out-of-claim files — each justified and correct. masterDetailTx.ts: loses its private copy, imports the shared one, identical behaviour. packages/plugin-form/README.md and content/docs/plugins/plugin-form.mdx: AGENTS.md Add automated testing infrastructure and CI/CD workflows #2 literally names content/docs/guide/*.md, but the plugin's own page is where prior plugin-form features (feat(plugin-form): one divider-row rule, group semantics on every arm, and the modal collapse pair (objectui#9849 step two) #10284, feat(types): refuse the function-slot mirror keys by name (objectui#7759 group E) #10294) documented themselves; every sentence checked true, including the not-covered arms and the stepper route. .changeset/10120-form-omits-fls-denied-fields.md: frontmatter byte-identical (computed), one body sentence changed, and the new sentence is true (ObjectForm and ModalForm already withheld the FLS-refused field and still do, since canEdit runs inside sanitizeFormData before the diff). The round-2 JSDoc copies on ModalFormSchema / DrawerFormSchema: true, point at the canonical key, and without them the copies would contradict the key they are declared from.

  • (g) The pins are real. editDirtyPayload-10156.test.tsx: 33 rows = 14 rule pairs (each asserted both ways) + 6 unit + 6 plain ObjectForm + 2×2 modal/drawer + 2 master-detail + 1 cascade; zero vi.mock; real ObjectForm and MasterDetailForm, registerAllFields(), real sanitizeFormData. The ablation table reproduces from the case list: A1 = the 8 exact rows; A2 = 9 different-verdict rule rows + the same 8; A3 = 24 with the 9 survivors being the 5 same-verdict rule rows, no-snapshot, create-unit, after-save and the create control; A4 = 5 rule rows (null/'', undefined/'', 1/'1', reordered object, NaN) + the unit drift row + 3 component drift rows; A5 = the second-save row and its unit row; A6's denominator 29 = 7 + 7 + (4×3 + 3) and 14 red is consistent (filter rows, the readonly-asserting CONTROL rows, and the schema-dependent unit rows red; render rows, the new exact edit rows and the no-predicate row green). Retriages: each row still reads the filter, because the unchanged submit is the one edit route whose payload is the filter's whole output; cascadePruneWire absence still catches a wrongful null (it differs from the stored 'gold'); the drawer CONTROL's note is a rendered input in both shapes, so an unseeded form yields '', which differs from 'kept' and is sent, so the exact toEqual still catches it. Not a weakening.

  • (h) Changeset truth — holds. Every sentence of .changeset/10156-edit-form-writes-only-changed-fields.md is true at head; no line addresses; frontmatter declares '@object-ui/plugin-form': patch and '@object-ui/types': patch; both are in the 40-package fixed group. Verified: package.json exports . only; index.tsx re-exports neither sanitize nor masterDetailTx; the only in-repo submitHandler hosts are MasterDetailForm's two sites (packages/*/src and apps, non-test). The dist/index.d.ts sentence was not rebuilt here; it is consistent with the source. Pending changesets re-read: 10108 (still true: the comparison moved, behaviour identical), 10120 (its "Deliberately not here… objectui#10156" scope sentence still true), 8738 and 4783 (file names only), the 21 changesets naming objectql.ts (none about submitHandler), simple-form-consults-declared-submit-handler.md and submithandler-variant-forms.md ("documented as handing the collected values", still true since the first paragraph is unchanged), 10291 ("the master-detail dirty diff… treat null and undefined as the same blank", still true).

  • (i) The JSDoc — every clause true at head. Simple, modal and drawer: yes. "For a record the form read itself": the snapshot only ever comes from findOne. Changed fields or the full sanitized payload when nothing changed: the empty-diff rule. The gloss of "sanitized" (server-owned, computed, read-only, FLS-refused, unknown to the object) matches sanitizeFormData's five strips exactly. "Cannot be settled counts as changed": the comparison's failure direction. Tabbed, wizard and split: their edit writePayload is the raw data, handed to submitHandler and saveWithOcc alike. The stepper: wantsStepper (no formType, no sections, stepper on) renders WizardForm. "Every collected value" is exact; those arms hand raw values.

  • (j) See ② below.

  • (k) The proposed PR body. Fixes #10156 is the only closing keyword; no angle brackets; identifier scan clean over title and body; the footer carries the product-attribution name and the session link only. Flags: (1) "Remote CI has not run on this head." (under NOT MEASURED) is now FALSE: 43 check-runs have completed on 13ac7751e (40 success, 3 skipped). Remove or update before posting. (2) The two-address sentence reads swapped: at cb00385aa, objectql.ts line 2765 is the map-config comment and line 2202 the View type comment; the body lists View type first. Both remain false citations, so the substantive point holds. (3) The body itself carries the two literal path:line strings it is quoting; AGENTS.md [WIP] Update documentation for project #11 bans a cross-file path:line in a PR body, so name them by content (the 7772 changeset's groupBy sentence; the one-authority script test's KanbanConditionalFormattingRule comment) instead. The PR diff adds no line-address citation (scan of every added line). (4) "That is stronger than before" for the drawer CONTROL is defensible (exact on title), though the note proof became indirect; note only.

  • (l) The two findings — both confirmed by reading. TabbedForm, SplitForm and WizardForm import neither sanitizeFormData nor fieldWriteGate; in edit their writePayload is data, so the host seam and the OCC update both receive the whole record. MasterDetailForm: rowState[].original is written only by the children-fetch effect; setRows preserves original; handleSaved resets row state only when not isEdit; the effect's deps do not change after a save, so a still-mounted edit form diffs its next save against the first-read children and re-creates id-less rows.

② Semver level

  • Rule quoted (objectui AGENTS.md, 版本号策略): "推论:changeset 里不要声明 major … objectui 自身的破坏性变更也标 minor(在正文里写清 breaking 语义即可);唯一例外是跟随 objectstack 跨 major 的那一次同步升级。" The fleet clause the seat ruled on: "The declaration is Clause-②: yes|no … yes takes at least minor, (narrowing) is BREAKING." Clause-② turns on declared surfaces (exports, types, props, spec keys, accept sets); the seat confirmed no, and I find nothing that moves it: no export, signature, prop or accept set changes; the submitHandler type is unchanged.
  • The risk, stated plainly. An EXTERNAL edit-mode host that relied on the handler receiving every sanitized business column now receives a subset: a replace-semantics write would null the untouched business columns; a cross-field check on the payload may see an absent key; a last-payload-wins store loses save-1's fields on save-2. The risk is narrow: the payload was never the whole record (id, timestamps, computed and server-owned columns were already stripped), the platform update is patch-semantics, the seam is documented as "the host owns persistence", and the changeset already carries the host notice ("A host that needs the whole record must read it itself"). In-repo, only MasterDetailForm hosts an edit-mode submitHandler; it is the intended beneficiary and its row editor (no recordId) is unaffected.
  • Repo practice is mixed: behaviour-change changesets went minor in 6110, 6111, 9760, 9778 and 10166, and patch in 10108 and 10120 (wire-payload changes, both seat-accepted). This PR differs from 10108/10120 in that the host-facing seam's payload changes, not only the server-facing one.
  • Judgment: under the operative clause, patch on both packages is right; @object-ui/types is JSDoc-only. I would not require minor. If the seat weighs the seam's narrowed payload as a documented-behaviour break, minor on @object-ui/plugin-form is the conservative alternative and costs nothing inside the fixed group. ⛔ Never major, and none is declared.

③ Boundary flags

  • Identifiers. No model or version identifier in the six commit messages, their trailers, the PR title or the proposed body. Trailers carry the product-attribution name, a session link and a session trailer only.
  • File surface (16, all accounted for). Claim: packages/plugin-form/src/sanitize.ts (M), ObjectForm.tsx (M), ModalForm.tsx (M), DrawerForm.tsx (M); tests beside them: ObjectForm.test.tsx (M), editDirtyPayload-10156.test.tsx (A), fieldSecurityPayload.test.tsx (M), systemManagedPayload.test.tsx (M), cascadePruneWire-10291.test.tsx (M), __tests__/drawerFirstLoadWindow-10190.test.tsx (M); one changeset .changeset/10156-edit-form-writes-only-changed-fields.md (A). Amendment 5829229738: packages/types/src/objectql.ts (M, +10/−0, JSDoc only). Out-of-claim per (f): masterDetailTx.ts (M), packages/plugin-form/README.md (M), content/docs/plugins/plugin-form.mdx (M), .changeset/10120-form-omits-fls-denied-fields.md (M, body only). Nothing else.
  • sections branch untouched. The ObjectForm.tsx hunks are at new lines 45, 629, 714, 735 and 1124 to 1170; the sections branch begins at line 1457. No line of it changed.
  • PR objectui#8941's line untouched. 'user-check', 'trash-2' sits at objectql.ts line 524, identical at merge-base and head; this PR's only objectql.ts hunk is at 1633.
  • Serial. 14 open PRs read with their file lists. Draft PR fix(plugin-form): the default object-form arm builds sections through the shared section builder (objectui#10475) #10556 touches ObjectForm.tsx at hunks 27, 1443 and 1511 (imports and the sections branch), disjoint from this PR's hunks. PRs feat(plugin-charts): the dataKey arm of ChartRendererProps.series declares type (objectui#8086) #10554, docs(types,core): name resolveRecordSourceConfig, not getDataConfig, for the gantt and calendar record-source ladder (objectui#9618) #10550, feat(plugin-kanban)!: retire the unwired useColumnWidths hook and its storage key (objectui#8522) #10549 and chore(deps): lucide-react 1.31.0 -> 1.43.0, with the one retired spelling repaired #8941 touch objectql.ts at 4351, 2938–3325, 3727 and 518, all disjoint from 1633. No open PR touches the other 14 paths or anything else under packages/plugin-form/. Main's own drift since the merge-base touches only objectql.ts among the 16 (hunks 846 and 4645); a textual three-way merge shows "changed in both" for that file and zero conflict markers.
  • mergeable_state. First read behind (mergeable: true, base tip c2c4372c); after main advanced to fee1da59 mid-review, six re-reads returned unknown (mergeable: null) while GitHub recomputed. No conflict per the merge probe above.
  • Check-runs on the head. 43 total, all completed: 40 success, 3 skipped: Test (coverage), Test (coverage shard ${{ matrix.shard }}/4), dependabot. 0 failure, 0 neutral, 0 cancelled, 0 in_progress or queued at the first read (08:35Z) and at the final poll (08:54Z); the 60-second loop exited on its first iteration because nothing was pending. Combined commit status: success.
  • Required before posting the body: the three body edits in (k): drop or refresh the stale "Remote CI has not run on this head" sentence; put the two cited lines in the right order; replace the two literal path:line strings with content citations.

Implemented-by: claude/issue-10156-edit-mode-dirty-diff
Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33C

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 25, 2026 09:07
@os-litant
os-litant added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 2dd4d3f Sep 25, 2026
45 checks passed
@os-litant
os-litant deleted the claude/issue-10156-edit-mode-dirty-diff branch September 25, 2026 09:19
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
… storage key (objectui#8522) (objectstack-ai#10549)

Fixes objectstack-ai#8522
Clause-②: yes — a published export (`useColumnWidths` and its two types)
is removed.

Executes ruling `5810224713` (remove: reading 2 of the card, superseded
and left behind). Dispatched by the `domain:ui` seat 2 PM; implemented
by its `os-dev` in session
`https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN`.

## What changed

- `packages/plugin-kanban/src/useColumnWidths.ts` deleted: the hook,
`UseColumnWidthsOptions`, `UseColumnWidthsReturn`, and its storage key
`objectui:kanban-column-widths`.
- `packages/plugin-kanban/src/index.tsx`: the three entry exports
removed (the `useColumnWidths` value export and the two-type `export
type` line).
- `@object-ui/types` docblocks, comment-only hunks:
- `complex.ts`, the bare-`kanban` retirement docblock: the "NOT retired"
sentence no longer names the hook as a consumer. `KanbanCard` /
`KanbanColumn` / `CardTemplate` keep the renderer and `CardTemplates` as
consumers, and `ColumnWidthConfig` is recorded as having lost its one
`plugin-kanban` reader to this card, which did not rule on the type.
- `objectql.ts`, the `ObjectKanbanSchema` lane "member set is the READ
set" paragraph: the hook is dropped from the list of read sites. The
hook destructured `columns` as `_columns` and read no lane member, so
the counted member set does not move.
- `kanban-plugin-dialect-authoritative-7664.test.ts`: the pin-1 comment
stops naming the hook. No assertion changed.
- New pin
`packages/plugin-kanban/src/__tests__/useColumnWidthsRetired-8522.test.ts`,
following the `plugin-form` `entrySurface-6059` barrel-pin pattern
(`import * as entry` + `Object.keys(entry)`) and the `spec-symbol-4650`
compile-time `import('..').X` + `@ts-expect-error` pattern.
- `.changeset/8522-retire-use-column-widths.md`:
`'@object-ui/plugin-kanban': minor`, with a BREAKING body in migration
terms. There is no `@object-ui/types` entry: `check-changeset-presence`
asks for presence only, and the types hunks are comment-only. Its last
migration bullet names objectui#7742.
- `.changeset/7742-kanban-arm-batch70.md`, a pending changeset that this
change falsifies, is reconciled here in prose only:
- The sentences naming the `useColumnWidths` widths channel, and saying
the prop and the hook consume the types, are scoped to "at this change".
- A dated note (2026-09-25) records that the hook is retired
(objectui#8522), that `ColumnWidthConfig` has no `plugin-kanban` reader,
and that `CardTemplate` is still consumed by the `CardTemplates`
component's `templates` prop.
- The frontmatter is byte-identical: md5 of the frontmatter block is
`b62313df8fb2af929fbe2c21c5ae3873` before and after.

No tests were deleted. No test existed only to test the hook: the
tracked tree had zero test importers of it.

## Premise, measured on `origin/main` `d461b0de8`

- Readers. `git grep` over `packages apps examples content skills` found
`useColumnWidths` 7 hits in 5 files, `UseColumnWidths` 4 hits in 2
files, and `kanban-column-widths` 1 hit in 1 file. Every hit was the
hook's own file, the entry, the two docblocks, or the 7664 comment. On
the same instrument, the control `useCrossSwimlaneMove` fired with 5
hits in 3 files. Zero readers holds.
- Published surface. H3 answers yes. `@object-ui/plugin-kanban@17.6.0`
from the npm registry exports the hook in its `dist/index.js` export
list as `Ue as useColumnWidths`, and `dist/index.d.ts` re-exports it and
both types. The storage-key literal is present in `dist/index.js` and
`dist/index.umd.cjs`. That is why the changeset is written as a
migration.
- A tree-wide grep found one more carrier outside the file surface: the
pending changeset `.changeset/7742-kanban-arm-batch70.md`. It names "a
`useColumnWidths` hook option for widths" and says "the prop and the
hook still consume the types". Both go false when this merges, and both
would publish into the same release, so this PR reconciles it (see
above).
- A re-grep of every pending `.changeset/*.md` for `useColumnWidths`,
`UseColumnWidths` and `kanban-column-widths` hits only 7742 and this
PR's own changeset.
  - The control `CardTemplate` hits 3 files on the same instrument.
- The two `7664-*` changesets that name `ColumnWidthConfig` claim only
that it is declared and re-exported, which remains true.

## Verification

The code legs below ran on `e62e390f4`. The only later commit,
`8be30d7fc`, edits changeset prose only; the changeset gates were re-run
on it.

- Build, under the verify lock: `pnpm --workspace-concurrency=2 --filter
'@object-ui/plugin-kanban...' build` printed `VERDICT command-exit 0`.
The rebuilt `plugin-kanban/dist` has `useColumnWidths` 0 and
`kanban-column-widths` 0 in `index.js`, `index.d.ts` and
`index.umd.cjs`, and no `useColumnWidths.d.ts`. The control
`useCrossSwimlaneMove` is 2 / 2 / 1.
- `pnpm --filter @object-ui/plugin-kanban type-check` (`tsc --noEmit &&
tsc -p tsconfig.test.json`) exits 0. `tsc -p tsconfig.test.json
--listFilesOnly` lists the new pin, so the compile-time half is
compiled.
- `pnpm --filter @object-ui/types type-check` (including
`tsconfig.examples.json` and `tsconfig.test.json`) exits 0.
- `pnpm exec vitest run --maxWorkers=2 packages/plugin-kanban/` reports
`Test Files 61 passed (61)` and `Tests 412 passed (412)`. That is 61
tracked test files: 60 on base plus the new pin.
- `pnpm exec vitest run --maxWorkers=2 packages/types/` reports `Test
Files 233 passed (233)` and `Tests 5184 passed (5184)`.
- Red on base. The base `index.tsx` and `useColumnWidths.ts` were
written back onto disk. The landing was checked on disk: the entry had
`useColumnWidths` twice and the hook file was present with its key.
Under a `trap` restore:
- vitest: `Tests 1 failed | 1 passed (2)`, with `AssertionError:
expected [ 'KANBAN_UNCOLUMNED_ID', …(10) ] to not include
'useColumnWidths'`. The failure is the runtime pin; the type-only test
is erased at runtime.
- `tsc -p tsconfig.test.json` exits 2 with `error TS2578: Unused
'@ts-expect-error' directive.` twice, once on each retired type query.
- After restore, the `index.tsx` blob matches HEAD `7e09f2d92`, the hook
file is absent, and `git diff HEAD` and `git status --porcelain` are
both empty.
- Changeset and prose gates, on `8be30d7fc`:
- `node scripts/check-changeset-presence.mjs` printed `✅ 6 source
file(s) of 2 released package(s) changed, and this change declares 1
changeset(s)`.
- `node scripts/check-changeset-overwrite.mjs` exited 0 (report-only).
It printed `1 changeset(s) added, 1 modified, 0 deleted`. For 7742 it
printed `declared at base: @object-ui/types: minor,
@object-ui/plugin-kanban: minor` and `declares now: @object-ui/types:
minor, @object-ui/plugin-kanban: minor`.
- `node scripts/check-changeset-claims.mjs` exited 0 (report-only),
reading `Corpus: 2 changeset(s) this change adds or modifies` with no
self-contradiction. It listed 27 pending changesets that name
`complex.ts` or `objectql.ts`; none of them names the hook.
- `node scripts/check-pending-changeset-literals.mjs` printed `✅ No test
source names a pending changeset`.
- `node scripts/check-changeset-no-major.mjs` printed `✅ No changeset
declares a major bump`.
- `node scripts/check-new-cross-file-line-citations.mjs` printed
`VERDICT new-cross-file-line-citations: 0 new citation(s)`.
  - `node scripts/check-control-bytes.mjs` passed.
- Source gates, on `e62e390f4`. These exited 0:
`check:unreferenced-sources`, `check:self-import`,
`check:side-effects-array`, `check:handler-key-reads`,
`check:entry-guard`, `check:phantom-deps`, `check:unused-deps`,
`check:test-path-roots`, `check:vi-mock-specifiers`,
`check:spec-symbols` and `check:esm-specifiers`.
- `check:readme-exports` passed after the build. No README names any of
the three removed exports.
- ESLint on the 5 touched text files (`--no-inline-config --format
json`): 0 errors. The 66 warnings are pre-existing `no-explicit-any` /
`react-refresh` warnings in untouched lines, and there are none in the
new pin.
- NOT MEASURED:
- `check:sdui-registration-pins`: it needs an `apps/console` build and
exits 2 without one. No registration changed here.
- `check:published-dist`: its turbo build of the whole workspace,
`apps/console` included, was killed by my 300s timeout (exit 124). It
was run outside the verify lock by mistake. That is declared to CI.

## Overlap

The one `types/src/objectql.ts` hunk here is the one-line docblock edit
in the `ObjectKanbanSchema` lane paragraph. objectui#10533 and objectstack-ai#10522,
which also edited that file while this PR was being written, have since
merged, and this hunk still applies on `main`. At landing, the open PRs
that edit `objectql.ts` are objectui#10550, objectstack-ai#10546 and objectstack-ai#8941, and all
three are hunk-disjoint from this edit.

## Acceptance notes

- After this change, `ColumnWidthConfig` (in `@object-ui/types`,
re-exported by `plugin-kanban`, and mirrored by
`ColumnWidthConfigSchema`) has no reader in `plugin-kanban`. The ruling
named the hook, its exports and its key, not the type, so the type
stays. Recorded here only: there is no reproducible defect, and nobody
is known to own it next.
- The 7664 pin-1 comment still says `KanbanColumn` / `KanbanCard` are
consumed by `CardTemplates`. The `CardTemplates` component consumes
`CardTemplate`, not those two. That wording predates this change and is
kept, because the dispatch limits this file to removing the hook's name.

---

_Generated by [Claude
Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation package: types plugin tests

Projects

None yet

2 participants