fix(webui-framework): @attr property reflection#304
Merged
Conversation
The bug was that `@attr` looked reactive from the component template, but it was not fully the same reactive primitive as `@observable`. Property writes updated the backing field and could refresh template bindings, but they did not reflect back to the host DOM attribute. `@attr` also was not registered in the observable-name registry, so targeted updates, SSR state seeding, and `setState()` treated it differently from `@observable`. ```ts host.label = "bob"; ``` Before this fix, that assignment left the host markup stale: ```html <test-attr label="Status"></test-attr> ``` This broke attribute selectors, external DOM reads, serialization, and parent code that expects `@attr` to be observable state plus DOM attribute synchronization. The fix makes `@attr` reuse the observable registration path and adds guarded property-to-attribute reflection. The reflection guard prevents a DOM write from re-entering `attributeChangedCallback` and converting non-string backing values into strings. ```ts host.count = 5; // stays number-backed while reflecting count="5" ``` It also syncs pre-connect `@attr` property values after mount so client-created elements preserve values assigned before `appendChild()`. Tests now cover default and custom attr names, boolean attr reflection, DOM-to-property updates, `setState()`, type-preserving reflection, and pre-connect assignment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The review found that subclassed WebUI elements did not inherit decorator metadata from their base classes. That meant inherited `@attr` properties could appear in `observedAttributes` while `attributeChangedCallback`, `setState()`, SSR state seeding, targeted updates, and mount-time attribute sync only saw metadata registered directly on the subclass.
```ts
class BaseCard extends WebUIElement {
@attr baseLabel = "";
}
class ProductCard extends BaseCard {
@attr label = "";
}
```
Before this fix, `base-label` changes on `<product-card>` were not routed to `baseLabel`, and inherited observable names were missing from the subclass registry.
The fix resolves observable and attr metadata through the constructor chain and copies inherited maps when a subclass registers its own decorators. That keeps lookup cost bounded to subclass setup or rare inheritance fallback, while direct classes still use the same WeakMap-backed hot path.
Tests now cover inherited `@observable` names and inherited `@attr` metadata for DOM-to-property and property-to-attribute synchronization.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mcritzjam
approved these changes
May 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug was that
@attrlooked reactive from the component template, but it was not fully the same reactive primitive as@observable.Property writes updated the backing field and could refresh template bindings, but they did not reflect back to the host DOM attribute.
@attralso was not registered in the observable-name registry, so targeted updates, SSR state seeding, andsetState()treated it differently from@observable.Before this fix, that assignment left the host markup stale:
This broke attribute selectors, external DOM reads, serialization, and parent code that expects
@attrto be observable state plus DOM attribute synchronization.The fix makes
@attrreuse the observable registration path and adds guarded property-to-attribute reflection. The reflection guard prevents a DOM write from re-enteringattributeChangedCallbackand converting non-string backing values into strings.It also syncs pre-connect
@attrproperty values after mount so client-created elements preserve values assigned beforeappendChild().Tests now cover default and custom attr names, boolean attr reflection, DOM-to-property updates,
setState(), type-preserving reflection, and pre-connect assignment.