Skip to content

webui-framework: parent attribute bindings overwrite child writes from connectedCallback #324

@Qusic

Description

@Qusic

Summary

When a webui-framework parent is materialized client-side, each child element is appended to the DOM (firing its connectedCallback) before the parent pushes its bound attributes/properties. The parent's $patchAttr pass then writes the bound value onto the child, overwriting anything the child set in its own connectedCallback.

The net effect: a child cannot self-resolve a value from a browser/runtime API in connectedCallback and also be the target of any parent :prop binding — whatever the child set is wiped out by the parent's subsequent write.

Minimal repro

Three source files; one command; open a browser.

child/my-child.html — child displays its current value:

<div>child.value = "{{value}}"</div>

parent/my-parent.html — parent binds its (undefined) val to the child:

<my-child :value="{{val}}"></my-child>

index.html — defines both classes, mounts <my-parent> client-side. The child's connectedCallback tries to resolve value itself:

<!DOCTYPE html>
<html><body style="font:14px sans-serif;padding:20px">
  <!-- A hidden SSR instance just so `webui serve` registers the templates. -->
  <my-parent style="display:none"></my-parent>

  <h2>Expected</h2>
  <div style="border:1px dashed #888;padding:8px">child.value = "set-by-child"</div>

  <h2>Actual (CSR-mounted my-parent)</h2>
  <div style="border:1px dashed #888;padding:8px" id="mount"></div>

  <script type="module">
    import { WebUIElement, observable } from "https://esm.sh/@microsoft/webui-framework";

    class MyChild extends WebUIElement {
      connectedCallback() {
        super.connectedCallback();
        // Resolve value from a "browser API" (here: a literal).
        this.value = "set-by-child";
      }
    }
    observable(MyChild.prototype, "value");
    MyChild.define("my-child");

    class MyParent extends WebUIElement {}
    // Parent declares the bound prop. (Whether it has a value or not
    // doesn't matter — see notes below.)
    observable(MyParent.prototype, "val");
    MyParent.define("my-parent");

    document.getElementById("mount").appendChild(document.createElement("my-parent"));
  </script>
</body></html>

Serve and open in any modern browser:

npx webui serve <repro-dir> --port 3000 --plugin webui
# → open http://localhost:3000/

What you see

Expected vs Actual: child.value after CSR mount
  • Expectedchild.value = "set-by-child". The child's connectedCallback ran and assigned a value; nothing else writes to value afterwards.
  • Actualchild.value = "". The child's assignment is overwritten by the parent's attribute-binding pass, which runs after the child's connectedCallback. (The overwriting value happens to be empty here because the parent never set val; in the general case whatever the parent's binding evaluates to wins, regardless of what the child did in its callback.)

The bound prop and the child's self-resolved value cannot coexist. Every other web-component framework in common use (Lit, FAST, React, Vue, Stencil) applies bound props before invoking connectedCallback / mount lifecycle, so the child sees the real value in its callback and any assignment it makes is final.

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions