Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/plugins/events/onprops.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ const hooks = {
// Implement onEventName attributes/properties
let change = event.detail;

if (change.oldInternalValue) {
this.removeEventListener(eventName, change.oldInternalValue);
if (change.oldValue) {
this.removeEventListener(eventName, change.oldValue);
}

if (change.parsedValue) {
this.addEventListener(eventName, change.parsedValue);
if (change.value) {
this.addEventListener(eventName, change.value);
}
}
});
Expand Down
5 changes: 4 additions & 1 deletion src/plugins/events/propchange.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ const hooks = {
},

first_connected () {
// Often propchange events have already fired by the time the event handlers are added
// Don't remove: re-fires initial propchange for on*= attribute handlers,
// which onprops attaches *after* the initial dispatch. Without this,
// shortcut handlers declared in HTML never see the initial event.
// Pre-connect imperative listeners receive the event twice.
for (let eventName in this.constructor[propchange]) {
let propName = this.constructor[propchange][eventName];
let value = this[propName];
Expand Down
62 changes: 62 additions & 0 deletions src/plugins/props/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,65 @@ The `reflect` property takes the following values:
- `to`: If `true`, reflect to the attribute with the same name as the prop. If a string, reflect to the attribute with the given name.

By default, `reflect` is `true` **unless** `get` is also specified, in which case it defaults to `false`.

**Defaults are not reflected to attributes** — only user-set values are.

**Restoring the default after a prior write:** both `el.prop = undefined` and `el.removeAttribute(name)` restore the default value **and** clear any previously-reflected attribute.

## Observing changes

There are two layers of observability for prop changes, both first-class:

| Granularity | Event | Auto-wired callback |
| ---------------- | ------------- | ---------------------------- |
| Per prop | `propchange` | `propChangedCallback(event)` |
| Per drain (bulk) | `propschange` | `updated(event)` |

Sync writes are coalesced into a single drain on the next microtask, so `el.x = 1; el.x = 2; el.x = 3` produces one `propchange` event with `value: 3`. `oldValue` is pinned to the pre-write value, so the event reports the full first→last delta.

### Per-prop: `propchange` event and `propChangedCallback`

Fires once per changed prop after the value settles.

```js
class MyElement extends NudeElement {
propChangedCallback (event) {
console.log(event.name, event.detail.value);
}
}

// External listeners work the same way:
el.addEventListener("propchange", e => { /* … */ });
```

The event detail includes `source` (`"property"`, `"attribute"`, `"default"`, `"convert"`, `"get"`, or `"initial"` — for shortcut events re-fired on first connect to catch late-bound listeners), `value`, `oldValue`, and (when applicable) `attributeName`, `attributeValue`, `oldAttributeValue`.

### Per-drain: `propschange` event and `updated()` callback

Fires once at the end of every drain cycle, after every per-prop `propchange`. `event.changedProps` is `Map<name, oldValue>` — keys are the names of props that changed in this cycle, values are the previous value of each. Read the current value via `this[name]`.

```js
class MyElement extends NudeElement {
updated (event) {
for (let [name, oldValue] of event.changedProps) {
console.log(name, oldValue, "→", this[name]);
}
}
}

// External listeners receive the same event:
el.addEventListener("propschange", e => {
for (let [name, oldValue] of e.changedProps) { /* … */ }
});
```

Use this for work you only want to run once per cycle (re-rendering a sub-tree, persisting to storage, computing derived state across multiple props).

### Cycle ordering

Attribute reflection is synchronous with property writes, so the DOM is up to date before the drain runs. Within a single drain:

1. **`propchange` events** — one per changed prop. Custom shortcut events (registered via the `events` plugin's `propchange:` option) also fire here from the same payload.
2. **`propschange` event** + `updated()` callback — last, with the full Map.

Handlers in step 2 see the fully-settled state and can read any prop's post-cascade value via `this[name]`.
16 changes: 15 additions & 1 deletion src/plugins/props/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,30 @@ const hooks = {
},

constructor () {
if (this.propChangedCallback && this.constructor[props]) {
if (!this.constructor[props]) {
return;
}

// Per-prop callback: auto-wire to propchange events.
if (this.propChangedCallback) {
this.addEventListener("propchange", this.propChangedCallback);
}

// Bulk callback: auto-wire to the propschange event.
if (this.updated) {
this.addEventListener("propschange", this.updated);
}
},

first_constructor_static,

constructed () {
this.constructor[props].initializeFor(this);
},

connected () {
this.constructor[props].connected(this);
},
};

const provides = {
Expand Down
65 changes: 41 additions & 24 deletions src/plugins/props/util/Prop.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,18 @@ let Self = class Prop {
}

/**
* Subscriber for Computed signals (spec.get, spec.convert, spec.default).
* Updates element.props cache, reflects to attributes if opted in,
* and fires propchange events.
* Side-effect handler for Computed-backed props (spec.get, spec.convert, spec.default).
* Fires on first compute, user write, and tracked dep change. Updates the
* element.props cache, reflects to the attribute if opted in, and dispatches
* the propchange event.
*/
#onComputedChange (element, source, newValue, oldValue) {
element.props[this.name] = newValue;

// Reflect to attribute if this prop opts in
// `null` for defaults: don't synthesize an attribute (would clobber pre-mount
// writes — #105) and clear any stale attribute left over from a prior write.
if (this.toAttribute) {
let attributeValue = this.stringify(newValue);
let attributeValue = source === "default" ? null : this.stringify(newValue);
let oldAttributeValue = element.getAttribute(this.toAttribute);
if (oldAttributeValue !== attributeValue) {
element.ignoredAttributes.add(this.toAttribute);
Expand All @@ -120,11 +122,16 @@ let Self = class Prop {
}
}

// Gate event on value change (the Computed uses force).
if (this.equals(newValue, oldValue)) {
return;
}

this.changed(element, {
element,
source,
parsedValue: newValue,
oldInternalValue: oldValue,
value: newValue,
oldValue,
});
}

Expand All @@ -143,7 +150,9 @@ let Self = class Prop {

if (!signal) {
// Delegate equality to the prop's type-aware equality.
let options = { equals: (a, b) => this.equals(a, b) };
// Explicit user writes still reach the subscriber when
// the Computed dedupes against a cached default-resolved value (#105).
let options = { equals: (a, b) => this.equals(a, b), force: true };

if (this.spec.get) {
signal = new Computed(() => this.spec.get.call(element), options);
Expand All @@ -157,7 +166,7 @@ let Self = class Prop {
let rawSignal = new Signal(undefined, options);
this.#rawSignals.set(element, rawSignal);

let source = this.spec.convert ? "convert" : "default";
let source = this.spec.convert ? "convert" : "property";
signal = new Computed(() => {
let value = rawSignal.value;
if (value === undefined && this.spec.default !== undefined) {
Expand All @@ -181,7 +190,8 @@ let Self = class Prop {
return value;
}, options);
signal.subscribe((newValue, oldValue) => {
this.#onComputedChange(element, source, newValue, oldValue);
let newSource = rawSignal.value === undefined ? "default" : source;
this.#onComputedChange(element, newSource, newValue, oldValue);
});
}
else {
Expand Down Expand Up @@ -210,9 +220,7 @@ let Self = class Prop {
// Force first compute so the subscriber emits the initial propchange
signal.value;
}
else {
this.changed(element, { source: "default", element });
}
// Plain Signals start at undefined: nothing to fire about at mount.
}

this.#initialized = true;
Expand Down Expand Up @@ -249,14 +257,13 @@ let Self = class Prop {
return signal.value;
}

set (element, value, { source, name, oldValue } = {}) {
set (element, value, { source, name, oldAttributeValue } = {}) {
let signal = this.getSignal(element);
let rawSignal = this.#rawSignals.get(element);

// For Computed-backed props, compare against the raw user-set value
let oldInternalValue = (rawSignal ?? signal).value;
let oldValue = (rawSignal ?? signal).value;

let attributeName = name;
let parsedValue;

try {
Expand All @@ -271,7 +278,14 @@ let Self = class Prop {
return;
}

if (this.equals(parsedValue, oldInternalValue)) {
// removeAttribute() arrives as null; collapse to undefined so the prop
// reverts to its natural empty state (default, if any, otherwise just
// undefined). Property writes of null remain a legitimate user value.
if (source === "attribute" && parsedValue === null) {
parsedValue = undefined;
}

if (this.equals(parsedValue, oldValue)) {
return;
}

Expand All @@ -289,10 +303,8 @@ let Self = class Prop {
let change = {
element,
source,
value,
parsedValue,
oldInternalValue,
attributeName: name,
value: parsedValue,
oldValue,
};

if (source === "property") {
Expand All @@ -305,17 +317,22 @@ let Self = class Prop {
element.ignoredAttributes.add(this.toAttribute);

Object.assign(change, { attributeName, attributeValue, oldAttributeValue });
this.applyChange(element, { ...change, source: "attribute" });
if (attributeValue === null) {
element.removeAttribute(attributeName);
}
else {
element.setAttribute(attributeName, attributeValue);
}

element.ignoredAttributes.delete(attributeName);
}
}
}
else if (source === "attribute") {
Object.assign(change, {
attributeName,
attributeName: name,
attributeValue: value,
oldAttributeValue: oldValue,
oldAttributeValue,
});
}

Expand Down
Loading