Skip to content

propchange events queued while disconnected are not dispatched on reconnect #100

@DmitrySharabin

Description

@DmitrySharabin

Symptom

For a prop on an already-mounted element, mutating the prop while the element is disconnected drops the resulting propchange event — it is queued internally but never dispatched when the element reconnects.

class C extends NudeElement {
  static props = { v: { type: Number, default: 0 } };
}
customElements.define("x-c", C);

let el = new C();
document.body.append(el);
el.addEventListener("propchange", e => console.log(e.name));

el.remove();             // disconnect
el.v = 5;                // mutation lands in the queueing branch
await Promise.resolve(); // let the Computed microtask flush while disconnected
document.body.append(el); // reconnect

// expected: console logged "v"
// actual:   nothing — queued event never drained

Root cause

firePropChangeEvent enters its queueing branch when the element is disconnected (plugins/props/util/Props.js#L74-L81):

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

The queue is drained exactly once, inside Props.initializeFor (plugins/props/util/Props.js#L100-L109), and initializeFor is invoked only from the constructed lifecycle hook (plugins/props/index.js#L40-L42) — i.e. once per element, at construction time. The plugin registers no connected hook, so there is nothing to drain the queue when isConnected later flips back to true. Events that landed in the queueing branch after construction are permanently stuck in the eventDispatchQueue WeakMap.

Reproducible on

main and the props-runtime-tests branch. Surfaced as a skipped JS-first test in test/Props.js ("Queued propchange events drain on reconnect"). Pre-existing — not introduced by #79 / #91 / #97; the test was added in #99 specifically to track this case.

Suggested fix direction

Split a drainEventQueue(element) helper out of initializeFor and call it from a connected lifecycle hook (or connectedCallback, whichever fits the plugin contract — see how attributeChangedCallback is wired in plugins/props/index.js#L10-L14). Calling initializeFor itself on every reconnect would over-fire — it walks observed attributes and re-runs prop.initializeFor for every prop, which is mount-time work, not reconnect-time work.

Once fixed, un-skip the corresponding test in test/Props.js.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions