Skip to content

update() loses component id (and thus all event bindings) after first dispatch under 0.6.0 Idiomorph client #50

Description

@fsecada01

Summary

Under component-client.js's 0.6.0 Idiomorph-based update(), a component's event bindings (_cfHandler, attached by bind()/_bindTrigger()) are lost after its first server round-trip. Any second-or-later interaction with the same live component (a second button click, a multi-step wizard's second form submit, typing a second character into a search box) silently does nothing — or, for a <form data-event="...">, falls through to a native browser form submission, losing all component state.

Discovered while migrating Formana (a Django app using this package) from v0.3.1b0 to v0.6.0 as part of a cf-ui adoption; it broke provider_registration's public 3-step signup wizard at the Step 2 → Step 3 transition, reproducibly, on every attempt.

Root cause

  1. component_framework/core/component.py:

    self.id = params.get("component_id") or self._generate_id()
    # ...
    def _generate_id(self):
        return f"component-{uuid4().hex[:8]}"

    Every Component.__init__ mints a brand-new random id unless the caller explicitly passes params["component_id"].

  2. The JS client's dispatch() (component-client.js) POSTs {event, payload, state} — it never includes params or the live element's current id:

    const body = {
      event,
      payload,
      ...(stateJson ? { state: stateJson } : {}),
    };
  3. The Django adapter's parse_params() (adapters/django_views.py) therefore always receives {}:

    params = self.parse_params(data.get("params", "{}"))

    component_cls(**params) → a fresh random id on every single dispatch, not just the initial mount.

  4. Under v0.3.1b0's innerHTML-replace client, a changing id didn't matter — the whole subtree was replaced and nothing depended on id stability. Under v0.6.0's Idiomorph-based update(), node reconciliation matches by real id. Combined with update() calling this.bind(element) using the pre-morph element reference rather than re-querying the live DOM by id post-morph, the freshly-morphed subtree ends up with zero listeners attached.

Reproduction

Confirmed via direct browser console inspection against a live Django dev server (not just visual/behavioral — checked _cfHandler presence directly):

// Fresh page load — binding is correct:
document.querySelector('[data-component="provider_registration"] form')._cfHandler
// → a real function

// After the FIRST dispatch (e.g. Step 1 → Step 2 of a wizard) succeeds and morphs the DOM:
const root = document.querySelector('[data-component="provider_registration"]');
root.id
// → a DIFFERENT id than before the dispatch (was never passed as params.component_id)

root.querySelector('form')._cfHandler
// → undefined

// Clicking/submitting this "Step 2" form now falls through to a native browser
// form submission (GET, page navigates, all component state lost) because no
// submit listener is attached to the live node.

Suggested fix (either would resolve it)

  • Client-side: have dispatch() include the live element's current id in the POST body's params (e.g. params: { component_id: componentId }), so the server reuses the existing id across the component's lifetime instead of minting a new one per-request.
  • Server-side fallback: if no component_id is supplied but a state["_component_id"] (or similar) round-trips through state, fall back to that.
  • Client-side update(): regardless of the above, re-acquire the live element via document.getElementById(componentId) immediately before bind() inside update()/rollback(), rather than relying on the element reference captured before the morph — this would make binding correct even if node identity isn't preserved by the morph.

Any of these would remove the current requirement that the DOM's id attribute happen to stay the same for bind() to keep working, which currently holds only for a component's very first render.

Workaround in use

Formana is currently working around this at the call-site (no changes to this package): a page-level script wraps componentClient.update() to rewrite the incoming server HTML's root tag so its id always matches the live element's current id before handing it to the original update(), forcing Idiomorph to treat it as the same node. This keeps bind()'s post-morph rebinding correct without needing the id round-trip fixed here. Happy to share the exact patch if useful as a reference.

Environment

  • component-framework v0.6.0 (git tag)
  • Django adapter (adapters/django_views.py, AuthenticatedComponentView-style view)
  • Reproduced in Chrome, confirmed via direct DOM/console inspection of _cfHandler and component id stability across dispatches

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions