Skip to content
Merged
43 changes: 43 additions & 0 deletions .changeset/21464-component-props-form-family-typed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/spec': minor
---

feat(spec)!: four members of an `object-form` page block take the shape the form reads instead of any value — `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` (#21464)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-form-members-typed -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.

**`@objectstack/spec`**

- **Four members are typed.** `ComponentPropsMap['object-form']` declared `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` as `z.unknown()`, although the form reads each with one shape. Any value passed, and an off-shape one was answered with a silent default: a `submitBehavior` whose `kind` the form does not know showed the thank-you panel; a misspelled `contentLayout` stacked the modal's sections; a `navigateOnSuccess` that is not a string failed the submit after the record had been written; a misspelled `mobile` member was ignored.
- **`submitBehavior` is the form view's own block, by reference** — `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }`, `{ kind: 'continue' }` or `{ kind: 'next-record' }` — with the same rule on a `redirect` `url` a form view carries: a relative path, interpolating declared record fields as `{{record.field_name}}`.
- **The measured shape, where no form view declares the member:** `contentLayout` is `'simple'` or `'tabbed'`; `navigateOnSuccess` is a relative path string (`{id}` / `{recordId}` interpolate the saved record's id); `mobile` is `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `'auto'` and the two counts positive integers.
- **`ObjectFormProps`** carries these types on the four members instead of `unknown`.
- **The form's `fields` and `sections`, and the master-detail form's `fields` and `sections`, are not narrowed** and still accept any value. The form draws a top-level `fields` entry written as `{ name }` by that name, and it draws an inline runtime field (`{ name, type, … }`) written inside a section's `fields` as it stands — two shapes the typed members (field-name strings; the form view's section, whose field entry is keyed by `field`) would refuse. Each is held until that read is ruled. The master-detail form hands both members to its form unchanged, so they are held with the form's.
- **`customFields` is not narrowed either.** Its entries are the console's runtime form field (keyed by `name`), which the spec has not declared; it is typed once the spec declares it.

## FROM → TO

| you wrote on an `object-form` | write instead |
|:--|:--|
| `submitBehavior: 'thank-you'` | `submitBehavior: { kind: 'thank-you' }` |
| `submitBehavior: { kind: 'toast' }` (any `kind` outside the four) | one of `thank-you`, `redirect`, `continue`, `next-record` |
| `submitBehavior: { kind: 'thank-you', heading: 'Done' }` | `{ kind: 'thank-you', title: 'Done' }` |
| `submitBehavior: { kind: 'redirect', url: 'https://app.example.com/done' }` | a relative path: `url: '/done'` |
| `contentLayout: 'tabs'` | `contentLayout: 'tabbed'` |
| `navigateOnSuccess: { url: '/orders/{id}' }` | `navigateOnSuccess: '/orders/{id}'`, or `submitBehavior: { kind: 'redirect', url: '/orders/{{record.id}}' }` |
| `mobile: { stepper: 'yes' }` | `mobile: { stepper: true }`, or `'auto'` for phone-width viewports only |
| `mobile: { stepperFieldsPerStep: 0 }` | delete the key (one field a step is the default), or a positive integer |

The one-line fix: write each member as the table above shows. No conversion is registered, because an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote; the D3 entry `ui-object-form-members-typed` carries that judgment.

## Who is affected, measured

A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, a call into a local helper that builds the node, or a direct parse through the row. Each member's value is read through same-file constants and local helpers. The control is `objectName` on the same nodes.

- **objectstack** at `e909aa0a23`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 16 `object-form` nodes (the control on 13). Three values among the four members: the showcase's new-project wizard `submitBehavior` (a thank-you panel) and two copies of it in the lint and spec tests. All three parse.
- **objectui** at the `.objectui-sha` pin `89cad75d55`: 539 `object-form` nodes (the control on 522). Across the four members there are 73 values: 60 are static, and 56 of them parse. The 4 that do not are test fixtures of a protocol-relative redirect (`//example.com/thanks`), each asserting that the form refuses it and navigates nowhere. Of the 13 values that are not static, 9 are relative redirects that parse by inspection, and 4 are redirect fixtures the form refuses (three same-origin absolute URLs and one protocol-relative one). No refused value is one the form draws.
- **Deployed metadata** was not measured.
26 changes: 22 additions & 4 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ Sort field and direction pair
| **drawerWidth** | `string \| number` | optional | Drawer width (drawer) |
| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal) |
| **modalCloseButton** | `boolean` | optional | Show the modal close button (modal) |
| **contentLayout** | `any` | optional | Modal content layout config (modal) |
| **contentLayout** | `Enum<'simple' \| 'tabbed'>` | optional | How the modal presentation lays out its sections (modal) — 'simple' stacks them (the default); 'tabbed' puts each section on its own tab once more than one section has a field to show |
| **confirmOnDiscard** | `boolean` | optional | Confirm before discarding edits (drawer/modal) |
| **submitText** | `string \| Record<string, string>` | optional | Submit button label |
| **cancelText** | `string \| Record<string, string>` | optional | Cancel button label |
Expand All @@ -548,14 +548,32 @@ Sort field and direction pair
| **showSubmit** | `boolean` | optional | Show the submit button |
| **showCancel** | `boolean` | optional | Show the cancel button |
| **showReset** | `boolean` | optional | Show the reset button |
| **submitBehavior** | `any` | optional | What happens after a successful submit (`{ kind: 'thank-you' \| …, title?, message? }`) |
| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | What happens after a successful submit — the same block a form view's `submitBehavior` declares: `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` (a relative `url`, interpolating declared record fields as `{{record.field_name}}`), `{ kind: 'continue' }` or `{ kind: 'next-record' }`. Takes precedence over `navigateOnSuccess` and `resetOnSuccess` |
| **successMessage** | `string \| Record<string, string>` | optional | Toast message on successful submit |
| **resetOnSuccess** | `boolean` | optional | Reset the form after a successful submit |
| **navigateOnSuccess** | `any` | optional | Navigate after a successful submit |
| **navigateOnSuccess** | `string` | optional | Relative path to navigate to after a successful create/update — `{id}` / `{recordId}` are replaced with the saved record's id, URL-escaped; an absolute URL is refused at submit and reported on the success toast. Ignored when `submitBehavior` is set — prefer `submitBehavior` |
| **readOnly** | `boolean` | optional | Render every field read-only |
| **initialValues** | `Record<string, any>` | optional | Prefill values (create mode) |
| **initialData** | `Record<string, any>` | optional | Alternate spelling of `initialValues` the renderer also reads |
| **mobile** | `any` | optional | Mobile presentation overrides |
| **mobile** | `{ stickyActions?: boolean; stepper?: boolean \| 'auto'; stepperMinFields?: integer; stepperFieldsPerStep?: integer; … }` | optional | Phone presentation options, each opt-in — `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`. Read by the flat (simple, sectionless) form |

### Nested Shape: `ObjectFormProps.submitBehavior[kind='redirect']`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **kind** | `'redirect'` | ✅ | |
| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11: (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. |
| **delayMs** | `integer` | optional | |

### Nested Shape: `ObjectFormProps.mobile`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **stickyActions** | `boolean` | optional | Pin the submit / cancel bar to the bottom of a phone-width viewport |
| **stepper** | `boolean \| 'auto'` | optional | One-step-at-a-time wizard for a flat form — `true` always, `'auto'` only on a phone-width viewport once the form has `stepperMinFields` fields, `false` (the default) never |
| **stepperMinFields** | `integer` | optional | The field count at which `stepper: 'auto'` steps up (default 8) |
| **stepperFieldsPerStep** | `integer` | optional | Fields shown per stepper step (default 1) |
| **fullscreenLongText** | `boolean` | optional | Offer a fullscreen editor on textarea and rich-text fields |


---
Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 191 | 180 | 4 | 0 | 7 |
| `ui/` | 192 | 181 | 4 | 0 | 7 |

## `ui/` — sites

Expand All @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `app.zod.ts` | 19 |
| `bulk-action.zod.ts` | 4 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 61 |
| `component.zod.ts` | 62 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `i18n.zod.ts` | 1 |
Expand All @@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 60 |
| `widget.zod.ts` | 1 |
| **total** | **191** |
| **total** | **192** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**7 strip of 191**, in 4 file(s).
**7 strip of 192**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 60 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **191** |
| **total** | **7** | **192** |

| Bucket | Sites |
|---|---|
Expand Down
9 changes: 7 additions & 2 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 217,
"droppedRefinementSites": 653,
"publishedSchemasWithDroppedRefinements": 218,
"droppedRefinementSites": 654,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -1364,6 +1364,11 @@
"filter.element"
]
},
"ui/ObjectFormProps": {
"sites": [
"submitBehavior.options[1].url"
]
},
"ui/ObjectGanttProps": {
"sites": [
"filter.element",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #21464 — four members of the `object-form` page block were `z.unknown()`
// although the form reads each with a fixed shape, so an off-shape value passed
// the component-props gate and the form fell back or ignored it in silence. The
// row now takes the form view's own `submitBehavior` by reference and the
// measured shape for `contentLayout`, `navigateOnSuccess` and `mobile`. The
// form's `fields` and `sections` and the master-detail form's two are held at
// `z.unknown()` (the form draws a `{ name }` field entry and an inline runtime
// field inside a section, which the typed shapes would refuse), and
// `customFields` waits for the spec to declare objectui's runtime form field.
// D3 only: page-component `properties` is not parsed on the metadata save or
// load path, so a stored page is never refused; an off-shape value has no
// rewrite that says what the author meant; and the authored census found no
// authored value to respell — the refused values are fixtures probing that the
// form refuses them.
export const entry: SemanticMigration = {
id: 'ui-object-form-members-typed',
surface: 'page `object-form` components — `properties.contentLayout`, `.submitBehavior`, '
+ '`.navigateOnSuccess` and `.mobile` (which used to accept any value)',
replacement: 'the shape the form reads: `contentLayout` `\'simple\'` or `\'tabbed\'`; `submitBehavior` the '
+ 'form view\'s own block — `{ kind: \'thank-you\', title?, message? }`, `{ kind: \'redirect\', url, '
+ 'delayMs? }` with a relative `url`, `{ kind: \'continue\' }` or `{ kind: \'next-record\' }`; '
+ '`navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, '
+ 'stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `\'auto\'` and the two '
+ 'counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` '
+ 'destination to a relative path; write `heading` as `title`.',
reason: 'The form reads these members with one shape, and the page-component row declared them '
+ '`z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one '
+ 'with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; '
+ 'a misspelled `contentLayout` such as `\'tabs\'` stacked the sections; a `navigateOnSuccess` that is not a '
+ 'string threw after the record was written, so the submit reported a failure; and a `mobile` member it '
+ 'does not read, or a `stepper` outside `true` / `false` / `\'auto\'`, was ignored. The row now takes '
+ 'the form view\'s own `submitBehavior` by reference — the block the renderers already judge a redirect '
+ '`url` through — so one value is judged the same way on the form view and the block, and the measured '
+ 'shape for the other three. The form\'s `fields` and `sections` and the master-detail form\'s two stay '
+ 'open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, '
+ 'which the typed shapes would refuse; and `customFields` stays open until the spec declares the '
+ 'runtime form field its entries are. It is read where every page component\'s props are: the '
+ 'component-props gate reports a refused value as an advisory `component-props-invalid` / '
+ '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and '
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s '
+ '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing '
+ 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the '
+ 'form shows today and honours what the author wrote — which is the judgment this entry leaves to the '
+ 'upgrader. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no '
+ '`component-props-invalid` / `component-props-unknown-key` finding under the four members\' paths. '
+ 'Each form that set one of them now shows it: the post-submit behaviour it names, the modal\'s tabbed '
+ 'sections, the navigation after a save, and the phone presentation.',
};
Loading
Loading