Skip to content
Open
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
58 changes: 58 additions & 0 deletions src/plugins/props/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,61 @@ The `reflect` property takes the following values:
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 (via `el.prop = undefined` or `removeAttribute`) clears 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
34 changes: 15 additions & 19 deletions src/plugins/props/util/Prop.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ 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;
Expand Down Expand Up @@ -130,8 +131,8 @@ let Self = class Prop {
this.changed(element, {
element,
source,
parsedValue: newValue,
oldInternalValue: oldValue,
value: newValue,
oldValue,
});
}

Expand Down Expand Up @@ -220,9 +221,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 @@ -259,14 +258,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 @@ -288,7 +286,7 @@ let Self = class Prop {
parsedValue = undefined;
}

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

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

if (source === "property") {
Expand All @@ -319,7 +315,7 @@ let Self = class Prop {
let oldAttributeValue = element.getAttribute(attributeName);

if (oldAttributeValue !== attributeValue) {
element.ignoredAttributes.add(this.toAttribute);
element.ignoredAttributes.add(attributeName);

Object.assign(change, { attributeName, attributeValue, oldAttributeValue });
this.applyChange(element, { ...change, source: "attribute" });
Expand All @@ -330,9 +326,9 @@ let Self = class Prop {
}
else if (source === "attribute") {
Object.assign(change, {
attributeName,
attributeName: name,
attributeValue: value,
oldAttributeValue: oldValue,
oldAttributeValue,
});
}

Expand Down
142 changes: 111 additions & 31 deletions src/plugins/props/util/Props.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
import Prop from "./Prop.js";
import PropChangeEvent from "./PropChangeEvent.js";
import PropsChangeEvent from "./PropsChangeEvent.js";

export default class Props extends Map {
/**
* Per-element coalescing buffer. Sync writes to the same prop overwrite the entry's
* `value`/`source` (latest wins) while pinning `oldValue`/`oldAttributeValue` to the
* first write — so the drained payload spans the full first→last delta. Drained and
* cleared in `#drainFor`.
* @type {WeakMap<HTMLElement, Map<string, {name: string, prop: Prop, detail: object}>>}
*/
#eventDispatchQueue = new WeakMap();

/**
* Elements with queued events awaiting drain. Snapshotted and cleared at the top of
* `#drain` so re-entrant writes (from handlers) accumulate for the *next* drain.
* @type {Set<HTMLElement>}
*/
#pendingElements = new Set();

/**
* Microtask-schedule guard. Set when a drain is queued, cleared when it runs —
* collapses many `propChanged` calls in the same tick into one `queueMicrotask`.
* @type {boolean}
*/
#drainScheduled = false;
Comment thread
DmitrySharabin marked this conversation as resolved.

/**
*
* @param {HTMLElement} Class The class to define props for
Expand Down Expand Up @@ -46,39 +70,103 @@ export default class Props extends Map {
let propsFromAttribute = [...this.values()].filter(spec => spec.fromAttribute === name);

for (let prop of propsFromAttribute) {
prop.set(element, element.getAttribute(name), { source: "attribute", name, oldValue });
prop.set(element, element.getAttribute(name), {
source: "attribute",
name,
oldAttributeValue: oldValue,
});
}
}

eventDispatchQueue = new WeakMap();

/**
* Called when a prop value changes. Fires propchange events.
* Dependency propagation is handled automatically by signals.
* Called from Prop#changed when a value settles. Coalesces into the
* dispatch queue; dispatch happens in #drainFor on the next microtask.
*/
propChanged (element, prop, change) {
// Fire propchange event
let eventNames = ["propchange", ...(prop.eventNames ?? [])];
for (let eventName of eventNames) {
this.firePropChangeEvent(element, eventName, {
name: prop.name,
prop,
detail: change,
});
let map = this.#eventDispatchQueue.get(element);
if (!map) {
map = new Map();
this.#eventDispatchQueue.set(element, map);
}

let existing = map.get(prop.name);
if (existing) {
// Coalesce: latest value/source wins, but old values stay pinned
// to the first write so the payload spans the full first→last delta.
let { oldValue, oldAttributeValue } = existing.detail;
Object.assign(existing.detail, change, { oldValue, oldAttributeValue });
}
else {
map.set(prop.name, { name: prop.name, prop, detail: { ...change } });
}

this.#pendingElements.add(element);
this.#scheduleDrain();
}

#scheduleDrain () {
if (this.#drainScheduled) {
return;
}

this.#drainScheduled = true;
queueMicrotask(() => {
this.#drainScheduled = false;
this.#drain();
});
}

firePropChangeEvent (element, eventName, eventProps) {
let event = new PropChangeEvent(eventName, eventProps);
#drain () {
// Snapshot and clear: events queued by handlers (incl. on other
// elements) run on the next microtask, not in this drain.
let elements = [...this.#pendingElements];
this.#pendingElements.clear();

if (element.isConnected && eventProps.prop.initialized) {
element.dispatchEvent?.(event);
for (let element of elements) {
this.#drainFor(element);
}
else {
let queue = this.eventDispatchQueue.get(element) ?? [];
queue.push(event);
this.eventDispatchQueue.set(element, queue);
}

#drainFor (element) {
if (!element.isConnected) {
// Queue stays intact; `connected()` drains it on (re)connect.
return;
}

let map = this.#eventDispatchQueue.get(element);
if (!map) {
return;
}

// Detach the queue before dispatch: re-entrant writes from event
// handlers must accumulate for the next drain, not this one.
let entries = [...map];
this.#eventDispatchQueue.delete(element);

let changedProps = new Map();
for (let [, payload] of entries) {
// Plain Signals don't dedupe coalesced round-trips on their own;
// mirror Signal equality here.
let { prop, detail } = payload;
if (prop.equals(detail.value, detail.oldValue)) {
continue;
}

changedProps.set(prop.name, detail.oldValue);

// EventTarget isolates listener throws — siblings stay safe without try/catch.
for (let name of ["propchange", ...(prop.eventNames ?? [])]) {
element.dispatchEvent(new PropChangeEvent(name, payload));
}
}

if (changedProps.size > 0) {
element.dispatchEvent(new PropsChangeEvent("propschange", { changedProps }));
}
}

connected (element) {
this.#drainFor(element);
}

initializeFor (element) {
Expand All @@ -97,15 +185,7 @@ export default class Props extends Map {
prop.initializeFor(element);
}

// Dispatch any events that were queued
let queue = this.eventDispatchQueue.get(element);

if (queue) {
for (let event of queue) {
element.dispatchEvent?.(event);
}

this.eventDispatchQueue.delete(element);
}
// Drain synchronously so callers see initial state without waiting for the microtask.
this.#drainFor(element);
}
}
Loading