ReviewOS

stacks/stx

An @event on a nested component tag clobbers that component's own scope, so its bindings stop resolving

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

Forwarding an event to a nested component — <Child @change="handler($event)" /> — stops the child's own bindings from resolving. The child keeps its scope id and its scope object, but an expression inside it reports the name as undefined at hydration.

This is the documented composition pattern. CLAUDE.md: "@event attributes on component tags are forwarded to the component root element."

Reproduction

Three wrappers around the same <Calendar />, differing only in the attribute, through the repo's own SPA harness:

run('<div id="w"><Calendar /></div>', 'no client script')
run('<script client>\nconst n = state(0)\n</script>\n<div id="w"><Calendar /></div>', 'client script, no @change')
run('<script client>\nconst n = state(0)\nfunction f(e) { n.set(1) }\n</script>\n<div id="w"><Calendar @change="f($event)" /></div>', 'client script + @change')
no client script                  []
client script, no @change         []
client script + @change           [stx] hydration invariant failed after DOMContentLoaded:
                                  1 expression(s) never evaluated —
                                  monthYearLabel() → monthYearLabel is not defined.

monthYearLabel is Calendar's own derived(). Only the third case fails, and the only difference is the @change attribute on the child tag.

What it affects

Any wrapper that listens to a child component's events, which is how defineEmits is meant to be consumed. Concretely it blocks composing <DateRangePicker> out of <Calendar> the obvious way — the shape stacksjs/stx#1981 asks for.

The child's scope is registered (window.stx._scopes has it, with every function and signal on it), so this is not a missing scope. It looks like the element's scope resolution picks the parent's once __stx_parent_scope is set on it for the forwarded listener, leaving the child's own bindings looking up names in the wrong place.

Workaround

Listen one element out. defineEmits dispatches a CustomEvent with bubbles: true, so a wrapper receives the same event and the child is untouched:

<!-- instead of <Calendar @change="onChange($event)" /> -->
<div @change="onChange($event)">
  <Calendar mode="range" />
</div>

Verified clean: no hydration complaint, and monthYearLabel() resolves to "October 2026". <DateRangePicker> (ae430c2604) is arranged this way deliberately, with the reason in the source, and one of its tests asserts the nested Calendar produces no hydration errors.

Note on the harness

Event delivery is not observable in spa-harness — very-happy-dom stops attribute parsing at an @-prefixed name, so @change is absent from the harness DOM either way and the component tests here drive handlers through the scope instead. So this report is about the scope damage, which is observable, not about whether the event arrives. A browser check would be worth adding to confirm the workaround delivers as well as it hydrates.

Found while building stacksjs/stx#1981.

Sign in to comment on this issue.