Skip to content

feat(lint): require an explicit type on plain button elements; fix the 114-site population (#4045) - #4450

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4045-button-type-lint
Aug 12, 2026
Merged

feat(lint): require an explicit type on plain button elements; fix the 114-site population (#4045)#4450
yinlianghui merged 2 commits into
mainfrom
claude/issue-4045-button-type-lint

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4045

Ends the missing-type="button" defect class mechanically, and fixes the whole
population in the same PR.

An HTML button element with no type defaults to type="submit", so it submits any
form it is composed into instead of running its own handler. In an SDUI renderer that
composition is a JSON metadata decision made far from the button's own file — so "it is
not in a form today" is the dormancy, not a defence. Per-instance patching ran three
rounds (objectui#3344, objectstack#5236, objectstack#6952) and nothing rejected the next
one at write time, because type is optional in React's ButtonHTMLAttributes.

Per the ruling on #4045: option A, the in-repo custom rule (not the
eslint-plugin-react dependency), population fixed in the same PR.

Re-measured population — the card's table was a ~3x undercount

The card's table (39 sites, measured at ebb579dbb with a regex scanner) is not the
population. Re-measured at this branch point with the rule itself — an AST measurement,
not a text scan — the population is 114 sites in 29 files.

This is not drift. Re-running a scanner against ebb579dbbitself finds 113 there,
and the card's 39 is a strict per-file subset of it: the card's scan missed whole files,
most visibly all four plugin-designer surfaces (PageDesigner 15, ProcessDesigner 15,
DataModelDesigner 14, ReportDesigner 12 = 56 sites, none of them listed on the card).
The card's conclusion is unaffected — a bigger population argues harder for option A —
but the number quoted for triage was wrong, and the class-closing gate is what makes the
count stop mattering.

Real drift since the card is small and in the expected direction:
objectDetailWidgets.tsx (4 sites) was deleted by #4365, and 2 of the 3 UserFilters
sites the card counted were fixed by #3948.

filesites
packages/plugin-designer/src/PageDesigner.tsx15
packages/plugin-designer/src/ProcessDesigner.tsx15
packages/plugin-designer/src/DataModelDesigner.tsx14
packages/plugin-designer/src/ReportDesigner.tsx12
apps/console/src/pages/developer/ApiConsolePage.tsx11
packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx8
packages/runner/src/LayoutRenderer.tsx5
packages/plugin-map/src/ObjectMap.tsx4
apps/console/src/components/PerformanceDashboard.tsx3
apps/site/app/playground/page.tsx3
packages/app-shell/src/views/MetadataInspector.tsx2
packages/components/src/debug/DebugPanel.tsx2
packages/plugin-ai/src/NLQueryInput.tsx2
packages/plugin-designer/src/components/ConfirmDialog.tsx2
packages/plugin-view/src/ViewTabBar.tsx2
14 further files, 1 site each14
total114

All 114 became type="button". No site was a genuine submit button — not one
population file contains a form element at all, so the "expect ~zero" in the card's
step 4 is confirmed by measurement rather than by inspection.

Zero behaviour change beyond the attribute, proved mechanically: every changed
.tsx is byte-identical to its parent once the inserted attribute string is removed.

The rule

eslint-rules/button-has-type.js + eslint-rules/button-has-type.test.js, following
no-synthetic-event-trigger.js's shape exactly (default-exported rule module, meta /
messages / create, RuleTester suite in plain JS, registered in
eslint-rules/index.js). Wired in eslint.config.js as error in its own config
block, like the other scoped custom rules.

Grading notes, each decided by measurement rather than taste:

  • Spreads do not satisfy the rule. A {...spread} may or may not carry type at
    runtime and no checker can tell which, so exempting it would reopen the hole for
    exactly the buttons whose props come from elsewhere. Measured cost of the strict
    reading: zero — the whole repo has one button with a real JSX spread attribute
    (plugin-view/ManageViewsDialog.tsx) and it already declares type="button". The
    spread case gets its own messageId (spreadType) so the report says why the spread
    did not save it. The sibling rules gave no precedent here: none of them look at JSX.
  • Exclusions are config, not rule logicfiles: ['**/*.tsx'],
    ignores: ['**/src/ui/**', '**/*.test.tsx', '**/__tests__/**'], mirroring the
    population's counting rules. The src/ui ignore is load-bearing, not decorative:
    packages/components/src/ui/sidebar.tsx:314 (SidebarRail) is an untyped button, and
    without the ignore the rule would demand an edit in a zone AGENTS.md 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7 forbids
    touching and the Shadcn sync script overwrites. See "Known residual" below.
  • Only the lowercase intrinsic is flagged. A Buttoncomponent is a different
    contract — its own implementation owns the DOM attribute.
  • No autofixer, on purpose (same reasoning no-dynamic-import-in-test-hook.js
    records for itself): type="button" is right for nearly every site, and "nearly" is
    the problem — a genuine submit button silently rewritten stops submitting its form,
    invisibly to any test that does not click it.

Radix asChild children — a declared deviation from the ruling

The ruling put the card's 2 Radix-trigger children out of scope
(ApiConsolePage.tsx:274 CollapsibleTrigger, AppSwitcher.tsx:49 DropdownMenuTrigger),
on the card's own correct finding that Radix's Primitive.button already supplies
type="button" through its Slot. Both are fixed here anyway, because the rule
cannot see the difference: the alternative was leaving two permanent lint errors or
writing two eslint-disable comments, and a disable comment is a hole that reads as
sanctioned. This is runtime-neutral — Radix merges the identical value onto a child that
declares none, so the rendered DOM is unchanged either way — and it is exactly what
packages/components/src/custom/combobox.tsx:74-80 already prescribes and the card
quotes approvingly: "that is an upstream implementation detail — declare the contract
locally"
.

Pre-fix red (#4118) — the non-vacuity proof

The rule was run against the unfixed tree first: 114 firings, all missingType,
0 spreadType. Two independent methods agree on the population — the AST rule and a
standalone regex scanner — with zero disagreements in the direction that would mean
a false positive (every site the scanner flagged, the rule flagged). The rule found 2
sites the scanner missed (ApiConsolePage.tsx:323, DataModelDesigner.tsx:751), where
an apostrophe in JSX text swallowed a region during string masking; both are genuine.
Post-fix the same run reports 0.

Verification

Reverse verification (direction predicted before running)

  1. Predicted: removing type from one fixed site reds the rule naming exactly that
    site. Observed:drawer.tsx:38:56 object-ui/button-has-type missingType, exactly
    one firing. Restored.
  2. Predicted: with that violation still in place, deleting the rule from
    eslint.config.js makes lint green again — i.e. the wiring is load-bearing, not
    just the rule file. Observed: 0 button-has-type messages. Restored.

Known residual

packages/components/src/ui/sidebar.tsx:314 (SidebarRail) is still an untyped button.
It sits in the upstream Shadcn zone that the sync script overwrites and AGENTS.md #7
forbids editing, so it is out of the rule's reach by the card's own counting rules. It
is upstream's to fix; filed separately as an observation rather than patched here.

What is NOT absorbed

PR #3948's scan-style assertion in plugin-list/src/__tests__/UserFilters.test.tsx and
the family prototype components/src/__tests__/combobox-trigger-type.test.tsxstay.
They assert the rendered DOM — including buttons produced by dependencies, which a
source-static rule cannot see — and that clicking does not submit an enclosing form,
which lint cannot assert at all. Complementary, not superseded.

objectstack#7074 thread compliance

The source thread was read before acting, per the card's mandate. It carries the
finding-triage promotion (finding to pm:queue, 2026-08-10, spot-checked live) and
the migration note closing it as moved, not rejected under objectstack#7167 — no hold
or restart condition applies. Its substantive ruling, the Radix-mechanism correction, is
honoured above and its reasoning is recorded in the rule's own header so the next reader
does not re-derive it.


Generated by Claude Code

…e 114-site population (#4045)
An HTML `button` with no `type` defaults to `type="submit"`, so it submits any
form it is composed into instead of running its own handler. In an SDUI
renderer that composition is a JSON metadata decision made far from the
button's own file, so "not in a form today" is the dormancy, not a defence.
The same defect class was patched one instance at a time three times
(objectui#3344, objectstack#5236, objectstack#6952) and nothing rejected the
next one at write time, because `type` is optional in React's
ButtonHTMLAttributes. This adds the mechanical gate instead:
- `eslint-rules/button-has-type.js` (+ RuleTester suite), wired as an ERROR
over `**/*.tsx` with the population's own exclusions — `src/ui/**` (upstream
Shadcn, no-touch per AGENTS.md #7) and test files.
- The whole population it fires on, 114 sites in 29 files, converted to
`type="button"`. No site was a genuine submit button: not one population
file contains a form element.
Every changed .tsx is byte-identical to its parent once the inserted attribute
is removed — zero behaviour change beyond the attribute.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 12, 2026 8:51am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-B_SSdweH.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.32KB108.45KB
core (index.js)2.99KB1.14KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)153.42KB41.19KB
fields (index.js)228.99KB56.82KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.01KB17.63KB
plugin-chatbot (index.js)181.17KB43.03KB
plugin-dashboard (index.js)120.75KB31.38KB
plugin-designer (index.js)212.58KB42.83KB
plugin-detail (index.js)239.03KB59.77KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.99KB49.92KB
plugin-kanban (index.js)48.62KB13.42KB
plugin-list (index.js)110.21KB26.79KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.99KB10.74KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.08KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.73KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3.

  • Premise correction accepted and it strengthens the ruling: the card's 39-site table was a per-file-subset undercount (real population 114/29 files, re-measured at the card's own commit with the AST rule AND an independent scanner, zero false-positive disagreements). Option A (in-repo rule, error-level) was the right call at 39 and more so at 114.
  • Red-first honest: 114 missingType pre-fix → 0 post-fix; zero-behaviour-change proven mechanically (every file byte-identical minus the inserted attribute); no form element in any population file, so the submit-grading question is closed by measurement.
  • Reverse verification: both directions predicted first — single-site red names exactly the site; rule-unwired green proves the eslint.config.js wiring is load-bearing, not just the rule file; and the src/ui exclusion pin (sidebar.tsx:314 fires 0) shows the ignore is doing real work where AGENTS.md 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7 forbids edits.
  • Radix-child deviation ACCEPTED as ruled-better-by-measurement: the rule cannot see triggers-with-asChild, so the literal ruling would have meant two permanent errors or two disable comments — both worse for long-term stability than the local type="button" contract combobox.tsx already prescribes. The ruling is updated to match; do not revert.
  • Strict spread grading accepted on its measurement (one real JSX spread button in the repo, already typed — zero cost, closes the statically-unknowable hole).
  • objectui: SidebarRail is the one untyped button the #4045 lint rule cannot reach (Shadcn no-touch zone) — observation #4451 filing (sidebar rail, no-touch zone) is the correct disposition; fix(plugin-list): UserFilters buttons declare type="button" (objectstack#6952) #3948's DOM-level assertion correctly retained as complementary.
  • Changeset patch across the 10 gate-named packages, never major — correct. CI 18/18 success (2 skipped by design).

Flipping ready + arming auto-merge. The population race risk is understood: if the merge-queue rebuild reds on a newly-landed untyped button, the fix is one attribute on that site.

The e2e/ lint-coverage gap (repo-root eslint . sees 3 errors under e2e/ that no package lint script covers) is being filed as its own observation card — it is a real coverage hole, not noise.


Generated by Claude Code


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude