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
-
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"].
-
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 } : {}),
};
-
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.
-
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
Summary
Under
component-client.js's 0.6.0 Idiomorph-basedupdate(), a component's event bindings (_cfHandler, attached bybind()/_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.1b0tov0.6.0as part of acf-uiadoption; it brokeprovider_registration's public 3-step signup wizard at the Step 2 → Step 3 transition, reproducibly, on every attempt.Root cause
component_framework/core/component.py:Every
Component.__init__mints a brand-new random id unless the caller explicitly passesparams["component_id"].The JS client's
dispatch()(component-client.js) POSTs{event, payload, state}— it never includesparamsor the live element's currentid:The Django adapter's
parse_params()(adapters/django_views.py) therefore always receives{}:→
component_cls(**params)→ a fresh random id on every single dispatch, not just the initial mount.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. Underv0.6.0's Idiomorph-basedupdate(), node reconciliation matches by realid. Combined withupdate()callingthis.bind(element)using the pre-morphelementreference 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
_cfHandlerpresence directly):Suggested fix (either would resolve it)
dispatch()include the live element's currentidin the POST body'sparams(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.component_idis supplied but astate["_component_id"](or similar) round-trips throughstate, fall back to that.update(): regardless of the above, re-acquire the live element viadocument.getElementById(componentId)immediately beforebind()insideupdate()/rollback(), rather than relying on theelementreference 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
idattribute happen to stay the same forbind()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 itsidalways matches the live element's currentidbefore handing it to the originalupdate(), forcing Idiomorph to treat it as the same node. This keepsbind()'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-frameworkv0.6.0(git tag)adapters/django_views.py,AuthenticatedComponentView-style view)_cfHandlerand componentidstability across dispatches