ReviewOS

stacks/stx

buildComponentLibrary: defineProps is never parsed into properties, and the one form it reads produces broken names

#2009
Closed glennmichael123 opened this 9 days ago ·
9 days ago

buildComponentLibrary discovers a component's public surface from the build config, never from the component. Every defineProps form I tried produces an empty properties map — except one, which produces names that cannot work.

Measured on @stacksjs/stx 0.2.343, with each component built alone.

sourceemitted properties
const props = defineProps<{ label?: string }>(){}
const props = withDefaults(defineProps<{ label?: string }>(), { label: 'Hello' }){}
const props = defineProps({ label: { type: String, default: 'Hello' } }){}
const { label } = defineProps(['label']){}
const { label = 'Hello', count = 0 } = defineProps<{ label?: string, count?: number }>()see below

The last one is the problem, because it looks like it works:

"properties": {
  "label = 'Hello'": { "type": "object", "reflect": true },
  "count = 0":       { "type": "object", "reflect": true }
}

The whole initialiser is taken as the property name. custom-elements.json then documents the attributes as label-hello and count-0, every type is object rather than string/number, and nothing a page writes as label="x" ever reaches the element.

What it costs

The public surface has to be restated in the build config:

components: [{
  file: 'activity-card.stx',
  properties: { activity: { type: 'string', default: '', reflect: true } },
}]

That half works well — declared properties produce a correct manifest and correct attribute handling. But it leaves two lists to keep in step, and the failure when they drift is silent: the generated render reads an undefined identifier and the component renders an empty attribute.

A sharp edge that follows from it

When properties are declared, the compiler emits its own prologue:

const { activity, format } = this._props();

So a component that also destructures defineProps redeclares those names and the build fails with a bare failed to compile render (Parse error) naming the file but not the line or the identifier. It took a while to find, because the same component compiles fine with no properties declared — the error only appears once you declare the surface, which is the opposite of where you look.

Two things would help on their own:

  • say which identifier collided, rather than Parse error
  • read defineProps (any form) and treat an explicit properties entry as an override

Why it matters here

The point of authoring a web component in stx rather than in hand-written JS is that the component is the single description of itself. Having to declare the surface somewhere else gives up a good part of that.

Happy to send a PR for the destructure-with-defaults parse if the shape above looks right.

9 days ago

Fixed in 162a0ce6fb, with the collision case pinned separately in b2d40887c5.

Reproduced first

Every row of the table, before changing anything:

type-only, not destructured        {}
withDefaults, not destructured     {}
object form                        {}
array form                         {}
destructure with defaults          {"label = 'Hello'":…, "count = 0":…}
destructure, no defaults           {"label":{"type":"Record<string, unknown>"}, …}

That last row is a sixth case the issue does not list, and it matters: the form that did produce usable names typed every one of them object, because the inline <{ label?: string }> literal was never read either. So even the working spelling made label="Revenue" arrive as [object Object].

What changed

The declaration is now found by balanced scanning rather than one regex, and names, defaults and types are read from whichever part of the declaration states them. All six forms work, plus defineProps<Props>() against a named interface, and a renaming pattern ({ label: shown }) correctly yields the property label rather than the local name.

Two traps the old regex was hiding, both of which produced "no props at all" rather than an error:

  • a brace inside the destructuring pattern ended the pattern early, so { make = () => ({ a: 1 }) } read as nothing
  • the > of an arrow inside a type argument closed the type argument, so <{ make?: () => object }> broke the declaration after it

Types come from the declaration or the object form's type:, never inferred from a default, which is what fixes the [object Object] case.

The sharp edge

Gone, and not by improving the message. Reading defineProps means removing it, so there is exactly one const { … } = this._props() prologue and nothing left to collide with. A component can now declare properties in its config and destructure defineProps for the same names. Pinned in b2d40887c5.

One correction on that: you reported a bare failed to compile render (Parse error). On current main the pre-fix build actually says

failed to compile render ("activity" has already been declared)

so either Bun's message improved since you filed, or the report came from an older one. Either way the collision can no longer happen, so the "say which identifier collided" item is moot rather than done.

Not changed

items?: Array<string> still reports as object. typeFromDeclaration has four property types and an array arrives as a JSON attribute, so that is the design rather than a gap.

Verification

13 tests added, 10 of which fail without the source change. The two that pass either way pin behaviour that already worked and is easy to break here. tsc --noEmit clean, lint clean, 34 web-components tests and the 19 tests across the four suites that consume this module all pass.

Sign in to comment on this issue.