Skip to content

feat: add reflect option to useProperty for property-attribute reflection#100

Draft
cristinecula wants to merge 2 commits into
mainfrom
feat/use-property-reflect
Draft

feat: add reflect option to useProperty for property-attribute reflection#100
cristinecula wants to merge 2 commits into
mainfrom
feat/use-property-reflect

Conversation

@cristinecula

@cristinecula cristinecula commented Feb 16, 2026

Copy link
Copy Markdown
Contributor

Note: This is one of three alternative implementations for NEO-1055. See also:

Summary

Adds a reflect option to useProperty that enables bidirectional property-attribute synchronization with built-in loop prevention.

  • Extend useProperty with optional { reflect: true } third argument
  • Add reflectingSymbol to symbols.ts for loop guard
  • Modify attributeChangedCallback in component.ts to respect the guard and convert null (attribute removal) to false

Closes NEO-1055

API

const [opened, setOpened] = useProperty<boolean>('opened', false, { reflect: true });

Behavior:

  • Property → attribute: when the property changes, reflect to the HTML attribute (setAttribute/removeAttribute)
  • Attribute → property: when the attribute changes (via attributeChangedCallback), sync to the property
  • Loop guard: a Symbol("haunted.reflecting") flag on the host prevents attributeChangedCallback from re-setting the property during reflection
  • Boolean coercion: false/null/undefined → remove attribute; true → set empty string attribute; other values → setAttribute(name, String(value))

Changes

1. src/symbols.ts — New reflectingSymbol

const reflectingSymbol = Symbol("haunted.reflecting");

Follows existing Symbol("haunted.xyz") convention. Symbol keys are handled by the proxy as non-reactive plain properties.

2. src/component.tsattributeChangedCallback changes

attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;
    if (this[reflectingSymbol]) return;   // skip self-initiated reflection
    let val = newValue === "" ? true : newValue;
    if (val === null) val = false;        // attribute removal → false
    Reflect.set(this, toCamelCase(name), val);
}
  • reflectingSymbol guard: When useProperty reflects a property to an attribute, it sets host[reflectingSymbol] = true. The callback checks this and skips processing, preventing the feedback loop.
  • null → false: Attribute removal now produces false instead of null. Verified safe across all existing consumers.

3. src/use-property.tsreflect option

  • New UsePropertyOptions interface with optional reflect: boolean
  • New attrName, reflect, and lastReflected fields on the Hook class
  • New reflectToAttribute(value) method wrapping setAttribute/removeAttribute in reflectingSymbol guard
  • In constructor: if reflecting and host has the attribute, read initial value from attribute
  • In updateProp(): after setting property, call reflectToAttribute() if reflecting
  • In update(): reflect on re-render when value changed externally (e.g. parent sets property via binding)
  • Updated TypeScript overloads for the third parameter

4. Tests

10 new test cases:

  • Reflects boolean property to/from attribute
  • Reflects string property to/from attribute
  • No feedback loop (setting property reflects without infinite recursion)
  • Attribute → property sync (requires observedAttributes)
  • Attribute removal syncs property to false
  • Initial attribute value is honored
  • Does not reflect when event is prevented
  • Backward compatible (existing useProperty unchanged)
  • Works with lift() integration
  • null → false in attributeChangedCallback (in attrs.test.ts)

Consumer Migration

Before (manual reflection with feedback loop risk):

const [opened, setOpened] = useProperty<boolean>('opened', false);
useEffect(() => { host.toggleAttribute('opened', !!opened); }, [opened]);
// 'opened' excluded from observedAttributes to avoid loop

After:

const [opened, setOpened] = useProperty<boolean>('opened', false, { reflect: true });
// 'opened' can now safely be in observedAttributes

Notes

  • Consumers must still declare reflected attributes in observedAttributes for attribute → property sync (web platform limitation: observedAttributes is static)
  • Property → attribute reflection works regardless of observedAttributes
  • The reflectingSymbol is set synchronously around setAttribute/removeAttribute calls, so there's no async timing concern

…tion

- Add reflectingSymbol to symbols.ts for loop guard
- Modify attributeChangedCallback in component.ts to check reflectingSymbol
  guard and convert null (attribute removal) to false
- Extend useProperty with optional { reflect: true } third argument that
  enables bidirectional property-attribute sync
- Add 9 new tests for reflect behavior (boolean/string reflection, loop
  prevention, attribute-to-property sync, initial attribute, preventDefault,
  backward compat, lift integration)
- Add 1 new test for attribute removal producing false
@netlify

netlify Bot commented Feb 16, 2026

Copy link
Copy Markdown

Deploy Preview for pionjs ready!

Name Link
🔨 Latest commit 7004a53
🔍 Latest deploy log https://app.netlify.com/projects/pionjs/deploys/6992d0b4724dbd00084fa4e4
😎 Deploy Preview https://deploy-preview-100--pionjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented Feb 16, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 7004a53

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

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