pyreon

@pyreon/attrs wraps a Pyreon component in an immutable, chainable builder that accumulates default props (.attrs()), reconfigures the base component (.config()), composes additional higher-order components (.compose()), and attaches static metadata (.statics()). Every chain method returns a new component — the original is never mutated — and the TypeScript generics accumulate so prop types stay correct after each .attrs<P>({...}) call.

It is the lower-level HOC factory that @pyreon/rocketstyle builds on. You'll also reach for it directly whenever you want default-prop composition and base-swapping without the multi-dimensional styling layer.

@pyreon/attrsstable

Installation

npm install @pyreon/attrs @pyreon/core @pyreon/ui-core
bun add @pyreon/attrs @pyreon/core @pyreon/ui-core
pnpm add @pyreon/attrs @pyreon/core @pyreon/ui-core
yarn add @pyreon/attrs @pyreon/core @pyreon/ui-core

@pyreon/core and @pyreon/ui-core are peer dependencies — attrs uses mergeProps / removeUndefinedProps from core for descriptor-safe prop forwarding, and small helpers (compose, omit, pick, isEmpty, hoistNonReactStatics) from ui-core.

Quick Start

import attrs from '@pyreon/attrs'
import { Element } from '@pyreon/elements'

// The factory takes { name, component } — NOT a bare component.
const Button = attrs({ name: 'Button', component: Element })
  .attrs({ tag: 'button', alignX: 'center', alignY: 'center' })
  .attrs<{ primary?: boolean }>(({ primary }) => ({
    backgroundColor: primary ? 'blue' : 'gray',
  }))

// Renders Element with the accumulated defaults
<Button label="Click me" />

// Explicit props override .attrs() defaults
<Button tag="a" href="/x" label="Link button" />

Why attrs?

In a design system you frequently want pre-configured variants of a base primitive without writing a one-off wrapper component for each. attrs lets you layer defaults declaratively:

// ❌ One-off wrapper components — boilerplate, no shared type accumulation
function PrimaryButton(props) {
  return <Element tag="button" backgroundColor="blue" {...props} />
}
function LargePrimaryButton(props) {
  return <Element tag="button" backgroundColor="blue" size="lg" {...props} />
}

// ✅ A chain — immutable, type-accumulating, base is never mutated
const Button = attrs({ name: 'Button', component: Element }).attrs({ tag: 'button' })
const PrimaryButton = Button.attrs({ backgroundColor: 'blue' })
const LargePrimaryButton = PrimaryButton.attrs({ size: 'lg' })

Each call returns a brand-new component (cloneAndEnhance clones the accumulated configuration and rebuilds the component). The original Button keeps its own defaults — PrimaryButton does not affect it. Pyreon components are plain functions that run once per mount, so there is no forwardRef: a ref flows through the chain as an ordinary prop.

The Chain

attrs() returns an AttrsComponent — a callable Pyreon component with four chain methods plus introspection helpers. The methods can be called in any order; each produces a fresh component.

MethodPurpose
.attrs(props | cb, opts?)Layer default props (static object or callback)
.config({ name?, component?, DEBUG? })Rename, swap the base component, or toggle debug
.compose({ name: hoc })Attach named higher-order components
.statics({ key: value })Attach metadata, readable at Component.meta

.attrs() — Default Props

Layer default props onto the component. Accepts either a static object or a callback that receives the current resolved props and returns a partial override. Call it as many times as you like — the defaults stack left-to-right across the chain.

// Object form — static defaults
const Button = attrs({ name: 'Button', component: Element }).attrs({ tag: 'button' })

// Callback form — receives the current resolved props
const Input = attrs({ name: 'Input', component: Element }).attrs<{ error?: boolean }>(
  (props) => ({
    'aria-invalid': props.error ? 'true' : undefined,
  }),
)

// Multiple calls stack — later wins for the same key
const Base = attrs({ name: 'Base', component: Element }).attrs({ variant: 'default', size: 'md' })
const Small = Base.attrs({ size: 'sm' }) // → { variant: 'default', size: 'sm' }
const SmallPrimary = Small.attrs({ variant: 'primary' }) // → { variant: 'primary', size: 'sm' }

The generic on .attrs<P>() widens the component's prop type so the new keys are typed at the call site and inside later callbacks.

Merge order at render time

When the component mounts, the accumulated chains resolve and merge in this exact order (later wins):

priorityAttrs  →  attrs  →  explicit (call-site) props   →   filter strips names   →   base component
  • priorityAttrs resolve first and act as a base. They have the lowest precedence in the final merge — both attrs and explicit props override them.

  • attrs callbacks resolve next, receiving priorityAttrs + explicit props as their input.

  • Explicit (call-site) props always win.

So in the common case, a value you pass at the call site overrides any default the chain provides.

const Button = attrs({ name: 'Button', component: Element }).attrs({ tag: 'button' })

<Button />            // tag = 'button' (default applies)
<Button tag="a" />    // tag = 'a'      (explicit wins)

Options: priority

Pass { priority: true } as the second argument to add the props to the priorityAttrs chain instead of the normal attrs chain. These resolve first and are visible to later attrs callbacks as input, but they sit at the lowest precedence in the final merge.

const Component = attrs({ name: 'Box', component: Element })
  .attrs(() => ({ label: 'Normal' }))
  .attrs(() => ({ label: 'Priority' }), { priority: true })

// Result: label === 'Normal' — normal attrs override priority attrs

Options: filter

Pass { filter: [...] } to strip prop names from the props before they reach the base component. This is how you keep internal/control props (variant flags, behavioral switches) from leaking onto the DOM.

const Component = attrs({ name: 'Box', component: Element }).attrs(
  () => ({ label: 'Visible' }),
  { filter: ['data-internal', 'variant'] },
)

<Component data-internal="secret" variant="primary" label="x" />
// → 'data-internal' and 'variant' are omitted before forwarding to Element

filter lists accumulate across the chain — every name listed in any .attrs(..., { filter }) call along the chain is stripped.

.config() — Rename, Swap, Debug

Reconfigure the builder. All three keys are optional; the method returns a new component.

type ConfigAttrs = Partial<{
  name: string
  component: ElementType
  DEBUG: boolean
}>
const Button = attrs({ name: 'Button', component: Element }).attrs({ tag: 'button' })

// Rename
const Renamed = Button.config({ name: 'PrimaryButton' })
Renamed.displayName // 'PrimaryButton'
Button.displayName  // 'Button' — original untouched

// Swap the underlying component
const AnchorButton = Button.config({ component: 'a' })

.compose() — Higher-Order Components

Attach named HOCs to the chain. The argument is a record of { name: hoc }, where each HOC has the shape (component) => component. Setting a name to null / undefined / false removes a previously composed HOC of that name.

const withTheme = (Component) => (props) => Component({ ...props, themed: true })
const withTracking = (Component) => (props) => {
  /* … */
  return Component(props)
}

const Enhanced = attrs({ name: 'Button', component: Element }).compose({
  withTheme,
  withTracking,
})

// Remove a previously composed HOC by name
const NoTracking = Enhanced.compose({ withTracking: false })

HOCs are applied in registration order with the last-defined wrapping innermost: .compose({ withOuter, withInner }) runs withInner first, then withOuter (the chain reverses the record values so compose(withOuter, withInner)(Component) evaluates as withOuter(withInner(Component))). The built-in attrs HOC (which resolves the .attrs() chain) is always the outermost wrapper, so default props are computed before any user HOC runs.

.statics() — Attached Metadata

Attach arbitrary metadata to the component. The values land on Component.meta (not directly on the component function), and successive .statics() calls merge.

const Button = attrs({ name: 'Button', component: Element }).statics({
  category: 'action',
  sizes: ['sm', 'md', 'lg'],
})

Button.meta.category // 'action'
Button.meta.sizes    // ['sm', 'md', 'lg']

// Merges across calls
const Extended = Button.statics({ variant: 'primary' })
Extended.meta // { category: 'action', sizes: [...], variant: 'primary' }

This is the mechanism @pyreon/document-primitives uses to mark a component's _documentType so the document tree extractor can discover it after the HOC chain has run. Any system that needs post-construction component introspection can read Component.meta.

Introspection

Every attrs component exposes a few runtime properties:

const Button = attrs({ name: 'Button', component: Element }).attrs({ tag: 'button' })

Button.IS_ATTRS    // true — the marker used by isAttrsComponent()
Button.displayName // 'Button'
Button.meta        // {} (or whatever .statics() attached)

// Resolve the accumulated default props for a given input
Button.getDefaultAttrs({}) // { tag: 'button' }

getDefaultAttrs(props)

Runs the whole .attrs() callback chain against the given props object and returns the merged result. Useful for testing, snapshotting effective defaults, or building tooling. Pass the props the callbacks should see ({} for the static defaults).

const Component = attrs({ name: 'Box', component: Element })
  .attrs(() => ({ color: 'blue' }))
  .attrs((props) => ({ computed: props.variant === 'primary' ? 'on' : 'off' }))

Component.getDefaultAttrs({}) // { color: 'blue', computed: 'off' }
Component.getDefaultAttrs({ variant: 'primary' }) // { color: 'blue', computed: 'on' }

isAttrsComponent(value)

Runtime type guard — returns true for any value carrying the IS_ATTRS marker (i.e. produced by attrs()).

import { isAttrsComponent } from '@pyreon/attrs'

isAttrsComponent(Button) // true
isAttrsComponent('div')  // false
isAttrsComponent(() => null) // false

Reactive Prop Forwarding (descriptor-safe)

Pyreon's reactive-prop contract is the reason attrs cannot use a naïve object spread anywhere props flow in from the consumer. The compiler emits a reactive prop like <Comp value={count()} /> as a getter-shaped property (makeReactiveProps converts a _rp-branded thunk into a property getter). Reading that property inside a tracking scope subscribes to the underlying signal.

A plain spread ({ ...props }) or value-copy (target[key] = props[key]) fires the getter at copy time — outside any tracking scope — collapsing the live signal into a one-shot snapshot. Downstream reads then see the captured value forever, and the prop silently stops updating.

attrs avoids this on the inbound path:

  • The HOC strips undefined values via removeUndefinedProps from @pyreon/core (a descriptor-copy that preserves getters), so a { x: undefined } from the consumer doesn't shadow a real default — without firing any getters.

  • The final merge uses the canonical mergeProps from @pyreon/core (also descriptor-safe), so getter-shaped explicit props stay live all the way to the wrapped component.

const count = signal(0)

// The reactive `value` survives the attrs chain — it's still live in Element,
// not frozen at the value it had when the chain merged.
<Button value={count()} />

Relationship to @pyreon/rocketstyle

@pyreon/rocketstyle is built on top of @pyreon/attrs. Rocketstyle is the multi-dimensional styling engine (state, size, variant, theme, dark mode); attrs is the underlying prop-composition + HOC + statics machinery. Where rocketstyle's .attrs() targets a component's inner layout and .theme() targets the styled wrapper, the bare attrs chain just layers props and HOCs onto any component.

Use @pyreon/attrs directly when you want default-prop composition and base-swapping but do not need rocketstyle's dimension/theme resolution. Reach for @pyreon/rocketstyle when you need the multi-state styling layer on top.

TypeScript

Each .attrs<P>() generic accumulates into the component's prop type. Three type-only properties expose the accumulated shapes (these exist for type inspection — they have no runtime value):

type AllProps = typeof Button.$types // origin + extended
type OriginProps = typeof Button.$originTypes // base component's props
type ExtendedProps = typeof Button.$extendedTypes // everything added via .attrs<P>()

ExtractProps (also exported from @pyreon/attrs) recovers a component's props union when forwarding through another HOC. It is multi-overload-aware (matches up to 4 call signatures and unions their first-argument types) so wrapping a 3-overload primitive like Element / Iterator / List doesn't silently collapse its prop surface to the loosest overload.

Gotchas

  • Factory takes { name, component }, not a bare component. attrs(Element) is wrong; attrs({ name: 'X', component: Element }) is correct.

  • .statics() lands on .meta, not the component itself. Read Component.meta.foo, not Component.foo.

  • .compose() takes a record of named HOCs. Pass { name: hoc }; set a name to null/false to remove it.

  • Defaults are merged, not deep-merged. An object-valued prop (style={{ color: 'red' }}) is replaced wholesale by a later default, not combined.

  • priorityAttrs is the lowest-precedence layer — the name is about resolution order, not final precedence.

  • The dev data-attrs attribute is added in dev builds for debugging and tree-shaken in production (gated on process.env.NODE_ENV !== 'production').

  • hoistNonReactStatics copies non-React statics from the base onto the wrapper, so Base.someStaticMethod survives the HOC chain.

API Reference

attrs({ name, component })

The default export. Creates an attrs-enhanced component.

ParameterTypeRequiredDescription
namestringyesBecomes displayName + dev data-attrs attribute
componentElementTypeyesThe base component or intrinsic tag ('a', 'button', …) to wrap

Returns an AttrsComponent — a callable component with chain methods and introspection properties.

AttrsComponent methods

MemberSignatureDescription
(callable)(props) => VNode | nullRender the component (Pyreon components are plain functions)
.attrs()(props | (props) => Partial<props>, opts?: { priority?: boolean; filter?: string[] }) => AttrsComponentLayer default props; returns a new component
.config()({ name?, component?, DEBUG? }) => AttrsComponentRename / swap base / toggle debug; returns a new component
.compose()(Record<string, (c) => c | null | undefined | false>) => AttrsComponentAttach/remove named HOCs; returns a new component
.statics()(Record<string, unknown>) => AttrsComponentAttach metadata onto .meta; returns a new component
.getDefaultAttrs()(props) => Record<string, unknown>Resolve the accumulated .attrs() chain against props

AttrsComponent properties

PropertyTypeDescription
IS_ATTRStrueMarker checked by isAttrsComponent
displayNamestringThe resolved name (from name, or the base's displayName / name)
metaRecord<string, unknown>Statics attached via .statics()
$typestype-onlyAccumulated origin + extended props
$originTypestype-onlyBase component's props
$extendedTypestype-onlyProps added via .attrs<P>()

Exports

ExportTypeDescription
attrs (default + named)FunctionThe factory — attrs({ name, component })
isAttrsComponentFunctionRuntime guard — true for attrs()-produced components

Exported types

TypeDescription
AttrsThe factory function type
AttrsComponentThe component produced by the chain (with chain methods + introspection)
AttrsComponentTypeAn ElementType carrying the IS_ATTRS: true marker
AttrsCbCallback form of .attrs(): (props) => Partial<props>
ConfigAttrsParameters for .config(): { name?, component?, DEBUG? }
ComposeParamThe .compose() argument: Record<string, GenericHoc | null | undefined | false>
GenericHocA HOC: (component: ElementType) => ElementType
ComponentFnA Pyreon component function with optional static props
ElementTypeA ComponentFn or intrinsic tag string
IsAttrsComponentType of the isAttrsComponent guard
TObjRecord<string, unknown>
Attrs