Skip to content

Claim the props a component declares as its own, and add the working agreement - #11

Merged
pathscale merged 4 commits into
masterfrom
fix/layout-declared-props-passthrough
Aug 31, 2026
Merged

Claim the props a component declares as its own, and add the working agreement#11
pathscale merged 4 commits into
masterfrom
fix/layout-declared-props-passthrough

Conversation

@pathscale

@pathscale pathscale commented Aug 31, 2026

Copy link
Copy Markdown
Owner

A compiled component's own props were also being spread onto its root element, so a prop the layout wires up itself was bound twice.

What happens today

The runtime already has the machinery to prevent this. behaviour names the props a component declares, routedKeys excludes them from passthrough, and the root slot spreads only what is left over. The compiler never populated behaviour, so for every compiled component the list was empty and each of its own props landed on the root as well.

For most props that is invisible, just a stray attribute on a wrapper <div>. For a prop named after a DOM event it is not. Given

onInput?: (value: string) => void;

the layout wires that to an inner <input> and calls it with the string, which is what the signature promises. But the same prop is also on the wrapper, and input bubbles. So the inner event travels up and calls the caller's handler a second time with the raw InputEvent. The bubbled call lands last, so that is the one the caller actually observes.

onChange is declared with a translated signature by eight layouts in @pathscale/ui, plus onInput and onSubmit. Consumers had already started working around it without knowing what they were working around:

honey.id/src/features/admin/components/tables/AdminLogsTable.tsx:161
honey.id/src/features/admin/components/tables/AdminEndUsersTable.tsx:211
honey.id/src/features/admin/components/tables/AdminApplicationsTable.tsx:305
nofilter.io/src/features/studio/pages/StudioSettingsPage.tsx:162
nofilter.io/src/features/admin/pages/AdminBackendSettingsPage.tsx:114

All five are typeof value !== "string" guards on Select's onChange.

The rule

Which props are a component's own is a question its props type already answers, and the two halves of the intersection mean different things:

export type PasswordFieldProps = UIBaseProps & {   // referenced: inherited HTML
  value?: string;                                   // literal: this component's own
  onInput?: (value: string) => void;
};

A referenced member (UIBaseProps, JSX.ButtonHTMLAttributes, IconSlotProps) is inherited surface that belongs on the element. An inline object literal is what this component itself accepts and is responsible for placing. So the literal's keys, and only those, are emitted as behaviour.

That keeps ButtonProps, which extends JSX.ButtonHTMLAttributes, passing onClick and the rest through to the element exactly as before.

A props type that cannot be read locally, because it is imported, emits nothing and keeps today's behaviour. That is the safe direction to fail in: it leaves a prop on the element rather than dropping one.

Blast radius

Checked by regenerating a 98-component library and comparing each emitted behaviour list against what its layout actually reads. Of 661 declared keys, the only ones a layout does not place itself are:

  • recipe keys, which presentationOwn claims before behaviourOwn (first claim wins), so naming them again is a no-op;
  • children, which layouts read through _stable.children;
  • a few read through an aliased binding, such as Grid's as via merge({ as: "div" }, props).

No HTML attribute stops reaching an element.

Tests

Three in the transform crate: declared props are claimed, inherited HTML props are not, and a non-local props type emits nothing. One in the runtime covering the actual defect, that a declared prop stays off the root slot while an undeclared handler still reaches the element.

cargo test 66 pass, cargo clippy clean, cargo fmt clean, runtime suite 149 pass.


Also in this PR

The repository working agreement (AGENTS.md plus the CLAUDE.md import shim), previously opened as #12 and folded in here so this repository has one pull request rather than two. It is documentation only and touches nothing the compiler change does.

The runtime already routes declared props away from the root element:
`behaviour` names them, `routedKeys` excludes them from `passthrough`, and
the root slot spreads only what is left. The compiler never populated it, so
for every compiled component the list was empty and each of its own props was
also spread onto the root.

For most props that is invisible: a stray attribute on a wrapper. For a prop
whose name is a DOM event it is not. A component declaring

    onInput?: (value: string) => void

wires that to an inner input and calls it with the string. With the same prop
on the wrapper too, the inner event bubbles up and calls the caller's handler
a second time with the raw InputEvent. The bubbled call lands last, so that
is the one the caller sees, and the signature it was written against is a
lie. `onChange` is declared this way by eight layouts, and consumers had
already started writing `typeof value !== "string"` guards without knowing
why they were needed.

Which props are the component's own is a question the props type answers. It
is an intersection, and the two halves differ: a referenced member --
`UIBaseProps`, `JSX.ButtonHTMLAttributes` -- is inherited HTML that belongs
on the element, while an inline object literal is what this component itself
accepts and places. So the literal's keys, and only those, are emitted as
`behaviour`.

A props type that cannot be read locally, because it is imported, emits
nothing and keeps the previous behaviour. That is the safe direction: it
leaves a prop on the element rather than dropping it.

Checked against a 98-component library: of 661 declared keys the only ones a
layout does not itself place are recipe keys, which presentation claims
first, and `children`, which the layout reads through `_stable`. No HTML
attribute stops reaching an element.
@pathscale

Copy link
Copy Markdown
Owner Author

Added chore(oxc): 0.2.3, following the convention the previous compiler fix used (fix(lint): validate compound recipe slots together bumped 0.2.1 to 0.2.2 in the same PR). Release tag is therefore solid-layouts-oxc-v0.2.3.

Only solid-layouts-oxc needs a version. The change to solid-layouts is a test, and that package publishes dist only, so nothing about it changes on npm.

Worth calling out for review: this is a patch bump but it does change emitted behaviour. A component's own props stop being spread onto its root element, so anything that was accidentally relying on that placement moves. The audit in the PR description found nothing in a 98-component library that does, but it is the kind of change worth a second opinion on the version.

No Python in any form: reaching for it is the tell that a step is
being solved by parsing when the tool that owns the answer could
just be asked. The near substitutes are ruled out too, and jq is
not on macOS to begin with.

CLAUDE.md imports AGENTS.md rather than copying it, so there is one
source of truth and no per-vendor fork to keep in step.
@pathscale pathscale changed the title Claim the props a component declares as its own Claim the props a component declares as its own, and add the working agreement Aug 31, 2026
`SlotAttrs` types its values as `string`, so comparing the handler by
identity had no matching `toBe` overload and `tsc --noEmit` failed while
`bun test` passed. `toHaveProperty(name, value)` still asserts it is the
same function, and is the idiom the rest of the file already uses.
@pathscale
pathscale merged commit 4f5ad17 into master Aug 31, 2026
5 checks passed
@pathscale
pathscale deleted the fix/layout-declared-props-passthrough branch August 31, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant