fix: guard binding updates against stale notifications after view unbind#7435
Merged
fix: guard binding updates against stale notifications after view unbind#7435
Conversation
When a parent `when` directive tears down and recreates a child custom element with a property binding, the child's template bindings could evaluate with a null source, causing a crash. This occurred because the coupled source lifetime optimization left expression observers subscribed to the child element's observable properties after the child's view was unbound. Added `isBound` guards to `HTMLBindingDirective.handleChange()` and `RenderBehavior.handleChange()` to skip updates when the controller's view is no longer active. Fixes #7422 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Collaborator
Author
|
Converting to draft to consider a more holistic approach on disposal of coupled lifetime observers. This could create some performance issues either way (from notifications that are silently ignore or from re-creating observables). |
janechu
added a commit
that referenced
this pull request
Apr 14, 2026
Remove the coupled-lifetime optimization in ExpressionNotifierImplementation that skipped unbind registration for observers with a single subscription on the controller source. All expression observers now register for disposal when their controller unbinds, ensuring stale subscriptions are eliminated when views are torn down (e.g., by a parent when directive). The isBound guards in HTMLBindingDirective.handleChange and RenderBehavior.handleChange are retained as defence-in-depth against any notification already queued before disposal completes. Follows up on #7435 which added subscriber-level guards as a defensive fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
5 tasks
radium-v
reviewed
Apr 15, 2026
| }; | ||
|
|
||
| behavior.bind(controller); | ||
| controller.isBound = true; |
Collaborator
There was a problem hiding this comment.
just commenting here to point out that this is the main change in the sea of comma linting adjustments.
Rename custom element tags from issue-number-based names (e.g., test-child-7422a) to descriptive names (e.g., when-child-a). Break the monolithic try/catch test into six focused tests, each with its own page.evaluate and assertions verifying the expected DOM state at each step of the when-directive toggle lifecycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Link to #7444 above each isBound guard to note this behavior will be reconsidered in the next major version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
chrisdholt
approved these changes
Apr 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request
📖 Description
When a parent
whendirective tears down and recreates a child custom element with a property binding (e.g.,:data), the child's template bindings could evaluate with a null source, causing a crash. This occurred because the coupled source lifetime optimization inExpressionNotifierImplementationleft expression observers subscribed to the child element's observable properties after the child's view was unbound.Added
isBoundguards toHTMLBindingDirective.handleChange()andRenderBehavior.handleChange()to skip binding re-evaluation when the controller's view is no longer active. This prevents stale notifications from reaching expressions that would crash with a null source.🎫 Issues
whendirective: child bindings may evaluate with no controller after teardown/recreate #7422👩💻 Reviewer Notes
The root cause is that
ExpressionNotifierImplementation.requiresUnbind()returnsfalseforSourceLifetime.coupledbindings that only depend on the controller source. This means those observers are never disposed when the view is unbound, under the assumption that they will be garbage collected with the element. However, when awhendirective reuses a cached view (same template), the element survives the unbind and the stale observers can fire.The guards in
handleChange()are a defensive fix at the subscriber level. A deeper fix would be to revisit the coupled lifetime optimization so that observers are always disposed on unbind for views that can be reused. That could be addressed as a follow-up.The render test fixtures that used raw controller objects with
isBound: falsehave been updated to setisBound = trueafterbehavior.bind()to match realHTMLViewlifecycle behavior.📑 Test Plan
Two new Playwright tests added to
binding.pw.spec.ts:Crash regression test — Reproduces the exact issue fix:
whendirective: child bindings may evaluate with no controller after teardown/recreate #7422 scenario: creates parent/child elements, toggles thewhencondition through multiple cycles (on → off → on → data change → off → on), and verifies no crash occurs.Rendering correctness test — Verifies that the child element's template correctly renders the pushed data label after toggling the
whendirective off and back on with new data.All 1423 tests pass (1421 existing + 2 new).
✅ Checklist
General
$ npm run change⏭ Next Steps
ExpressionNotifierImplementation.requiresUnbind()to properly dispose coupled-lifetime observers when the view is unbound, rather than relying on the subscriber-level guard. This would eliminate stale subscriptions entirely rather than silently skipping their notifications.