Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
50 changes: 15 additions & 35 deletions src/plugins/events/onprops.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Add on* props for UI events, just like native UI events
*/
import propsPlugin from "../props/index.js";
import propsPlugin from "../props/base.js";
import base from "./base.js";
import { symbols } from "xtensible";
import { defineOwnProperty } from "xtensible/util";
Expand Down Expand Up @@ -39,6 +39,12 @@ const hooks = {
continue;
}

// Capture the event name in the closure so each on* prop knows
// which event to attach/detach when its value changes. The
// `changed` callback runs synchronously inside the write — before
// `propchange` fires — so handlers set during prop init catch
// the mount-time propchanges that follow.
let eventName = name;
newProps[eventDef.onprop] = {
type: {
is: Function,
Expand All @@ -47,6 +53,14 @@ const hooks = {
reflect: {
from: true,
},
changed ({ oldValue, value }) {
if (oldValue) {
this.removeEventListener(eventName, oldValue);
}
if (value) {
this.addEventListener(eventName, value);
}
},
};

this[eventProps] ??= {};
Expand All @@ -57,40 +71,6 @@ const hooks = {
this.defineProps(newProps);
}
},

constructed () {
// Deal with existing values
if (!this.constructor[eventProps]) {
return;
}

for (let name in this.constructor[eventProps]) {
// Read any existing on* prop value
let value = this[name];

if (typeof value === "function") {
let eventName = this.constructor[eventProps][name];
this.addEventListener(eventName, value);
}
}

// Listen for changes
this.addEventListener("propchange", event => {
let eventName = this.constructor[eventProps][event.name];
if (eventName) {
// Implement onEventName attributes/properties
let change = event.detail;

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

if (change.value) {
this.addEventListener(eventName, change.value);
}
}
});
},
};

const providesStatic = {};
Expand Down
69 changes: 41 additions & 28 deletions src/plugins/events/propchange.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { symbols } from "xtensible";
import base, { events } from "./base.js";
import { props } from "../props/index.js";
import { props } from "../props/base.js";
import PropChangeEvent from "../props/util/PropChangeEvent.js";

const { propchange } = symbols.new;
Expand All @@ -18,42 +18,55 @@ const hooks = {
return;
}

let propchangeEvents = Object.entries(this[events])
.filter(([name, options]) => options.propchange)
.map(([eventName, options]) => [eventName, options.propchange]);
// Invert the user's `{eventName: {propchange: propName}}` declaration
// into `{propName: [eventName, ...]}` — the shape we need at dispatch
// time, when we have the prop name and want every alias that fires for it.
let aliases = {};
for (let [eventName, options] of Object.entries(this[events])) {
if (!options.propchange) {
continue;
}

if (propchangeEvents.length > 0) {
// Shortcut for events that fire when a specific prop changes
this[propchange] = Object.fromEntries(propchangeEvents);
let propName = options.propchange;
if (!this[props].get(propName)) {
throw new TypeError(`No prop named ${propName} in ${this.name}`);
}

for (let eventName in this[propchange]) {
let propName = this[propchange][eventName];
let prop = this[props].get(propName);
(aliases[propName] ??= []).push(eventName);
}

if (prop) {
(prop.eventNames ??= []).push(eventName);
}
else {
throw new TypeError(`No prop named ${propName} in ${this.name}`);
}
}
if (Object.keys(aliases).length > 0) {
this[propchange] = aliases;
}
},

first_connected () {
// Often propchange events have already fired by the time the event handlers are added
for (let eventName in this.constructor[propchange]) {
let propName = this.constructor[propchange][eventName];
let value = this[propName];
constructor () {
let aliases = this.constructor[propchange];
if (!aliases) {
return;
}

if (value === undefined) {
continue;
// Re-dispatch every propchange as its declared alias event(s). The
// canonical event already inherits coalescing / pause-resume from
// ElementProps, so the alias rides along for free. Attaching in the
// `constructor` hook means we catch mount propchanges too — no
// synthesized catch-up needed.
this.addEventListener("propchange", event => {
let aliasNames = aliases[event.name];
if (!aliasNames) {
return;
}

let prop = this.props.get(propName);
let detail = { source: "initial", value };
this.dispatchEvent(new PropChangeEvent(eventName, { name: propName, prop, detail }));
}
for (let aliasName of aliasNames) {
this.dispatchEvent(new PropChangeEvent(aliasName, {
name: event.name,
prop: event.prop,
source: event.source,
value: event.value,
oldValue: event.oldValue,
}));
}
Comment on lines +60 to +68

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should mount-drain propchanges be re-dispatched as semantic alias events?

This re-broadcasts every propchange — including the mount-time default fire — as its alias (spacechange, valuechange, …). So a consumer listening for the alias gets a "change" for a value that never changed (the initial default).

Real case that bit me: a parent listens to a nested child's spacechange and writes the child's value back into its own reflected prop:

// child: events = { spacechange: { propchange: "selectedSpace" } }   // selectedSpace is a computed (get) prop
spacePicker.addEventListener("spacechange", () => {
  this.value = spacePicker.value + "." + channelSelect.value; // ← also runs at mount
});

At mount the child's computed selectedSpace fires with its default (a98rgb), this listener re-fires it as spacechange, and the parent's authored value="oklab.a" gets overwritten (and reflected back to the attribute, destroying the markup). Because it races the parent's own value-sync, it's intermittent.

The catch is that for a computed aliased prop you can't filter it by source: both the mount fire and a genuine post-mount cascade arrive with source: undefined (ElementProp.update() clears it) — only oldValue === undefined separates them. And PropChangeEvent.applyTo keys off source, so it would also refuse to mirror real computed-prop changes.

So: should aliases fire during the mount drain for initial/default values at all — and if they do, what's the intended signal to tell "initial" from "changed"? (Pre-#106 these carried source: "initial", which at least gave consumers a marker to filter on.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the whole idea of applyTo() is fundamentally broken TBH. And for most callsites in color-elements replacing it would be a one line fix, I think. So I wonder if we should just get rid of it…

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the adoption of updated({change}), none of the color elements use it. So, yes, we can get rid of it.

});
},
};

Expand Down
56 changes: 56 additions & 0 deletions src/plugins/props/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,59 @@ 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`.

## Reacting to changes

Two events fire when props change. They have different timings and different shapes — pick the one that matches your use case.

### `propchange` — fine-grained, synchronous

Fires once per individual property change, synchronously inside the assignment. Best for per-write side effects (logging, validation, syncing to another store).

```js
element.addEventListener("propchange", e => {
e.name; // prop name
e.value; // new stored value
e.oldValue; // previous stored value
e.source; // "property" | "attribute" | undefined
});
```

Subclasses that define a `propChangedCallback(event)` method are auto-wired to `propchange`.

### `propschange` — coalesced, microtask-deferred

Fires once per microtask after a burst of `propchange` events settles. Best for "re-render once after a batch of changes" work — the typical use case for a settled snapshot.

```js
element.addEventListener("propschange", e => {
e.changed; // Map<name, oldValue> — net first→last delta across the burst
for (let [name, oldValue] of e.changed) {
let currentValue = this[name];
// …
}
});
```

Subclasses that define an `updated(event)` method are auto-wired to `propschange`, mirroring Lit's `updated(changedProperties)`.

```js
class MyElement extends NudeElement {
static props = { /* … */ };

updated (event) {
// event.changed is a Map<name, oldValue>
this.render();
}
}
```

#### Semantics

- **Mount fires a `propschange`** with every prop in `changed` — initial values arrive as a single settled snapshot. `oldValue` is `undefined` for the mount drain.
- **Round-trips drop out.** Setting a prop and then setting it back to its previous value within the burst produces no entry in `changed`, even though `propchange` fired for each assignment.
- **`oldValue` is the stored previous value**, matching `propchange`'s `e.oldValue`. Resolved defaults are cached on first access, so for a prop sitting at its default, `oldValue` will be that resolved default (not `undefined`). Read `this[name]` inside the handler for the current value.

### Pausing dispatch

`element.props.paused = true` holds both `propchange` and `propschange` dispatch. Writes during the paused window are coalesced per property and dispatched as a single rebased `propchange` per (event, prop) on `paused = false`, followed by one `propschange` for the net delta. Used internally for the disconnect/reconnect lifecycle.
98 changes: 98 additions & 0 deletions src/plugins/props/base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Props from "./util/Props.js";
import Prop from "./util/Prop.js";
import ElementProps from "./util/ElementProps.js";
import ElementProp from "./util/ElementProp.js";
import { symbols } from "xtensible";
import { defineOwnProperty, getSuperMethod } from "xtensible/util";
import { defineLazyProperty } from "../../util/lazy.js";
import PropType from "./util/PropType.js";
import "./types/index.js";

export const { props } = symbols.known;

export { PropType, Props, Prop, ElementProps, ElementProp };

const hooks = {
setup () {
if (Object.hasOwn(this, "props")) {
this.defineProps();
}
},

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

if (this.propChangedCallback) {
this.addEventListener("propchange", this.propChangedCallback);
}
},

connected () {
this.props.paused = false;
},

disconnected () {
this.props.paused = true;
},

"attribute-changed" ({ name, oldValue }) {
this.props.attributeChanged(name, oldValue);
},
};

const provides = {
props: undefined, // see below

// Must be on the prototype before customElements.define runs — spec reads observedAttributes only if ACB is non-null.
attributeChangedCallback (name, oldValue, value) {
// super.attributeChangedCallback()
getSuperMethod(this, provides.attributeChangedCallback)?.call(this, name, oldValue, value);

this.$hook("attribute-changed", { name, oldValue, value });
},

constructor: {
defineProps (def = this.props) {
if (def instanceof Props && def.Class === this) {
// Already defined
return null;
}

let env = { props: def };
this.$hook("define-props", env);

this[props].add(env.props);
},

get observedAttributes () {
// FIXME how to combine with existing observedAttributes?
let attributes = [...this[props].allValues()]
.map(prop => prop.reflect.from)
.filter(Boolean);
return [...new Set(attributes)];
},
},
};

/**
* Per-element collection of {@link ElementProp} wrappers, materialized on
* first access. The {@link ElementProps} constructor self-installs as the
* element's own `props` data property, shadowing this accessor from then on.
*/
defineLazyProperty(provides, "props", {
get () {
if (this.constructor.props) {
return new ElementProps(this);
}
},
configurable: true,
writable: true,
});

defineOwnProperty(provides.constructor, props, function () {
return new Props(this, this.props);
});

export default { hooks, provides };
Loading