Skip to content

SDUI element:button executes an action prop the spec does not declare #2997

Description

@os-zhuang

element:button renders an action and dispatches it, but ElementButtonPropsSchema (packages/spec/src/ui/component.zod.ts:298) has no action property at all.

What runs today

packages/components/src/__tests__/element-button-action.test.tsx:33 exercises the live path:

<ElementButton schema={{ type: 'element:button', properties: {
  label: 'Upgrade',
  action: { type: 'navigation', to: '/apps/cloud_control/sys_environment' },
} }} />

The click reaches the navigation handler. So both the action prop and the navigation action type are implemented and reachable, and neither is named in a spec schema:

  • ElementButtonPropsSchema declares label, variant, size, icon, iconPosition, disabled, aria — no action;
  • ActionType (ui/action.zod.ts:226) is script | url | modal | flow | api | form — no navigation.

Why nothing breaks, and why that is the problem

Page block properties are z.record(z.string(), z.unknown()) (ui/page.zod.ts:86), so an authored action is neither validated nor stripped. Nothing is losing data. What is lost is everything a declaration buys:

  • Studio's inspector cannot offer the prop, because it derives fields from the props schema;
    Corrected (see the analysis comment): block-config.ts is hand-written, so the inspector
    needs its own edit either way. Declaring the prop buys validation and discoverability, not a field.
  • no validation, so a typo in action.type or a missing to fails silently at click time rather than at save;
  • the type vocabulary is undocumented, so an author cannot discover that navigation exists, or that it is not url.

The navigation / url question comes with it

ActionRunner.executeNavigation and executeUrl (packages/core/src/actions/ActionRunner.ts) both navigate. executeUrl is strictly richer — ${param.X} / ${ctx.X} interpolation, apiBase promotion for /api/... paths, openIn — while executeNavigation adds only replace (and reads to where url reads target).

So declaring the prop means deciding one of:

  1. action accepts the spec's ActionSchema, and navigation becomes an alias executeUrl absorbs (replace moving onto ActionSchema if it is worth keeping) — one spelling of navigate;
  2. element:button.action gets its own narrow schema that names navigation + to, accepting a second vocabulary for this surface;
  3. navigation is promoted into ActionType as-is.

(3) was considered and rejected while widening the action vocabularies for #2945 (objectstack-ai/objectstack#4070): adding a synonym to a closed vocabulary is a one-way door — members cannot be removed later — and it would not have declared the prop, which is the actual gap. (1) looks right, but it changes what an authored element:button may say, so it wants a decision rather than a drive-by.

Cross-repo: the schema lives in framework packages/spec, the renderer in objectui packages/components.

Refs #2945, #2901, objectstack-ai/objectstack#4070

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions