From 87d2baa35e125ffd2980375dc3eaea97f4c22c82 Mon Sep 17 00:00:00 2001 From: phillipc Date: Thu, 16 Jul 2026 19:27:39 +0200 Subject: [PATCH 1/2] docs: update descendantsComplete behavior and migration guidance for TKO --- tko.io/src/content/docs/3to4.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tko.io/src/content/docs/3to4.md b/tko.io/src/content/docs/3to4.md index 024cd6c10..51e35fe98 100644 --- a/tko.io/src/content/docs/3to4.md +++ b/tko.io/src/content/docs/3to4.md @@ -54,6 +54,30 @@ TKO is designed for stricter Content Security Policies and avoids the older `eva `ko.applyBindings` returns a `Promise`. If you chain bootstrap work after binding or need to wait for async bindings to finish, account for that in your startup flow. +### `descendantsComplete` no longer waits for async descendants + +In Knockout 3, `descendantsComplete` fired only after every descendant — including content rendered asynchronously by a nested `if`, `template`, `foreach`, or component — had finished binding. Knockout tracked this with an ancestor-linked async-completion context and, on control-flow bindings, a `completeOn: 'render'` option. + +TKO drops that ancestor-tracking machinery in favor of a simpler per-node model. As a result, `descendantsComplete` is decided **once**, synchronously, when the node itself binds, and waits only for async bindings **on that same node** — not for subtrees that appear later. If a descendant `if`/`template` renders its content after the fact (for example when its condition flips to `true`), the `descendantsComplete` callback is not re-triggered and will not fire. + +**Why:** the async-completion context was cross-cutting, hard to reason about, and a common source of disposal and ordering bugs. The per-node lifecycle is smaller and predictable, at the cost of this one edge case. + +**Migration:** use `childrenComplete` instead, which is notified every time a node's descendants are (re-)bound and therefore still fires when async content renders: + +```html + +
+
+
+``` + +```html + +
+
+
+``` + ## What is still familiar - `ko.observable`, `ko.observableArray`, and `ko.computed` From 138631b20a39d90ba39c0d4c8fac5906e16698ce Mon Sep 17 00:00:00 2001 From: phillipc Date: Sat, 18 Jul 2026 08:51:24 +0200 Subject: [PATCH 2/2] Restore Knockout 3.5 binding-lifecycle semantics for `descendantsComplete` and add `completeOn: "render"`. --- .changeset/binding-lifecycle-parity.md | 37 +++ .../components/componentBindingBehaviors.js | 12 +- packages/bind/src/applyBindings.ts | 104 ++++---- packages/bind/src/bindingEvent.ts | 127 +++++++++- packages/bind/verified-behaviors.json | 3 +- .../binding.component/src/componentBinding.ts | 37 ++- .../binding.component/verified-behaviors.json | 8 + .../spec/descendantsCompleteBehaviors.ts | 79 +++++- .../binding.core/src/descendantsComplete.ts | 11 +- packages/binding.core/verified-behaviors.json | 5 +- .../src/ConditionalBindingHandler.ts | 26 +- packages/binding.if/src/ifUnless.ts | 4 +- packages/binding.if/src/with.ts | 6 +- packages/binding.if/verified-behaviors.json | 8 + .../2026-07-16-ko-binding-lifecycle-parity.md | 234 ++++++++++++++++++ .../2026-07-18-template-completeon-parity.md | 69 ++++++ tko.io/public/agents/guide.md | 1 + .../public/agents/verified-behaviors/bind.md | 2 +- .../verified-behaviors/binding-component.md | 3 + .../agents/verified-behaviors/binding-core.md | 2 +- .../verified-behaviors/binding-foreach.md | 2 + .../agents/verified-behaviors/binding-if.md | 3 + tko.io/src/content/docs/3to4.md | 26 +- .../docs/components/component-binding.md | 2 + 24 files changed, 700 insertions(+), 111 deletions(-) create mode 100644 .changeset/binding-lifecycle-parity.md create mode 100644 plans/2026-07-16-ko-binding-lifecycle-parity.md create mode 100644 plans/2026-07-18-template-completeon-parity.md diff --git a/.changeset/binding-lifecycle-parity.md b/.changeset/binding-lifecycle-parity.md new file mode 100644 index 000000000..6b89323be --- /dev/null +++ b/.changeset/binding-lifecycle-parity.md @@ -0,0 +1,37 @@ +--- +"@tko/bind": minor +"@tko/binding.if": minor +"@tko/binding.component": minor +"@tko/binding.core": minor +--- + +Restore Knockout 3.5 binding-lifecycle semantics for `descendantsComplete` and +add `completeOn: "render"`. + +`@tko/bind` now owns the KO 3.5 async-completion bookkeeping +(`bindingEvent.startPossiblyAsyncContentBinding` / `AsyncCompleteContext`). +`childrenComplete` drives per-node tracking of pending async descendants, and +`descendantsComplete` fires once a node's children **and** every +asynchronously-completing descendant (components, conditionals) have bound. + +Behavior changes to be aware of: + +- **`descendantsComplete` re-arms per content cycle.** Previously it fired at + most once (when the initial binding pass settled), and never fired for a + conditional that started false. It now fires each time a node renders content + — a nested `if` becoming true re-fires the ancestor's `descendantsComplete`, + and toggling a conditional off then on fires it again. Consumers who attached + one-shot `descendantsComplete` callbacks may see additional calls. +- **`completeOn: "render"`** is now supported on `if`/`ifnot`/`with`. It defers + the node's `childrenComplete` (and any ancestor `descendantsComplete`, or a + component's `koDescendantsComplete`) until the binding actually renders + content. `completeOn` is a reserved binding key (as in KO 3.5). +- A component viewmodel's **`koDescendantsComplete`** now fires via the + element's `descendantsComplete` event, so inner components complete before + their outer component (KO 3.5 ordering), including across intermediate + bindings and several nesting layers. + +Fixes the root cause behind +[#414](https://github.com/knockout/tko/issues/414): `koDescendantsComplete` is +no longer suppressed when a component template contains a conditional that +starts false. diff --git a/builds/knockout/spec/components/componentBindingBehaviors.js b/builds/knockout/spec/components/componentBindingBehaviors.js index a3ef45967..065dd926d 100644 --- a/builds/knockout/spec/components/componentBindingBehaviors.js +++ b/builds/knockout/spec/components/componentBindingBehaviors.js @@ -295,9 +295,7 @@ describe('Components: Component binding', function () { expect(renderedCount).to.equal(1) }) - // @mbest - This fails b/c `test-component`'s koDescendantsComplete is called - // after the test function completes. Otherwise the result is correct. - it.skip("Inner components' koDescendantsComplete occurs before the outer component's", function () { + it("Inner components' koDescendantsComplete occurs before the outer component's", function () { after(function () { ko.components.unregister('sub-component') }) @@ -331,7 +329,7 @@ describe('Components: Component binding', function () { expect(renderedComponents).to.deep.equal(['sub-component1', 'sub-component2', 'test-component']) }) - it.skip('koDescendantsComplete occurs after all inner components even if outer component is rendered synchronously', function () { + it('koDescendantsComplete occurs after all inner components even if outer component is rendered synchronously', function () { after(function () { ko.components.unregister('sub-component') }) @@ -400,7 +398,7 @@ describe('Components: Component binding', function () { expect(renderedComponents).to.deep.equal(['sub-component1', 'sub-component2', 'test-component']) }) - it.skip('koDescendantsComplete waits for inner component to complete even if it is several layers down', function () { + it('koDescendantsComplete waits for inner component to complete even if it is several layers down', function () { after(function () { ko.components.unregister('sub-component') }) @@ -431,7 +429,7 @@ describe('Components: Component binding', function () { expect(renderedComponents).to.deep.equal(['sub-component1', 'test-component']) }) - it.skip('koDescendantsComplete waits for inner components that are not yet loaded', function () { + it('koDescendantsComplete waits for inner components that are not yet loaded', function () { restoreAfter(window, 'require') after(function () { ko.components.unregister('sub-component') @@ -997,7 +995,7 @@ describe('Components: Component binding', function () { expect(callbacks).to.deep.equal(1) }) - it.skip("Does not call outer component's koDescendantsComplete function if an inner component is re-rendered", function () { + it("Does not call outer component's koDescendantsComplete function if an inner component is re-rendered", function () { after(function () { ko.components.unregister('sub-component') }) diff --git a/packages/bind/src/applyBindings.ts b/packages/bind/src/applyBindings.ts index f469eb015..011e2d239 100644 --- a/packages/bind/src/applyBindings.ts +++ b/packages/bind/src/applyBindings.ts @@ -98,34 +98,35 @@ function applyBindingsToDescendantsInternal( ) { let nextInQueue: ChildNode | null = virtualElements.firstChild(elementOrVirtualElement) - if (!nextInQueue) { - return - } + if (nextInQueue) { + let currentChild: Node | null + const provider = getBindingProvider() + const preprocessNode = provider.preprocessNode + + // Preprocessing allows a binding provider to mutate a node before bindings are applied to it. For example it's + // possible to insert new siblings after it, and/or replace the node with a different one. This can be used to + // implement custom binding syntaxes, such as {{ value }} for string interpolation, or custom element types that + // trigger insertion of