From 3f3f71c369270b0dcbf7a0ffce857bbd0f36f619 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:20:07 +0200 Subject: [PATCH 01/10] Emit a wrappable bind-if event around the data-bind:if change Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- packages/ui/src/Data/DataBind.ts | 77 ++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/Data/DataBind.ts b/packages/ui/src/Data/DataBind.ts index 985ac0c6..3683f040 100644 --- a/packages/ui/src/Data/DataBind.ts +++ b/packages/ui/src/Data/DataBind.ts @@ -31,6 +31,8 @@ type VirtualBinding = | { type: 'text' | 'if'; expression: string } | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; +type BindIfRunner = (apply: () => void) => void | Promise; + /** * DataBind class. * @@ -58,6 +60,7 @@ export class DataBind extends withGroup { static config: BaseConfig = { name: 'DataBind', + emits: ['bind-if'], options: { prop: String, immediate: Boolean, @@ -72,6 +75,7 @@ export class DataBind extends withGroup extends withGroup` content in the DOM. The * content is cloned and inserted after the template element when the value * is truthy, and removed when the value is falsy. Each insertion is a fresh - * clone, so any state held by the content is reset on every toggle. + * clone, so any state held by the content is reset on every toggle. Before + * the change runs, a bubbling `bind-if` event exposes + * `event.detail.through(runner)` so any listener can substitute the + * function that runs the DOM change — to wrap it in a view transition, for + * example, and give removed content an exit animation. Registration is only + * valid while the event dispatches — later calls warn and are ignored — and + * the last registered runner wins. A rejected runner is reported with a + * warning and never loses the change: the insert or removal runs anyway if + * the runner did not call `apply()`. * @private */ __applyIfBinding(isPresent: boolean) { @@ -354,16 +366,63 @@ export class DataBind extends withGroup { + if (this.__ifNodes) { + return; + } + const fragment = target.content.cloneNode(true) as DocumentFragment; + this.__ifNodes = [...fragment.childNodes]; + target.after(fragment); + } + : () => { + if (!this.__ifNodes) { + return; + } + for (const node of this.__ifNodes) { + node.remove(); + } + this.__ifNodes = undefined; + }; + + let dispatching = true; + let runner: BindIfRunner | undefined; + const through = (fn: BindIfRunner) => { + if (!dispatching) { + this.$warn('`through` must be called synchronously while the `bind-if` event dispatches.'); + return; } - this.__ifNodes = undefined; + runner = fn; + }; + + this.$emit(new CustomEvent('bind-if', { detail: { isPresent, through }, bubbles: true })); + dispatching = false; + + if (!runner) { + apply(); + return; } + + let applied = false; + function applyOnce() { + applied = true; + apply(); + } + Promise.resolve(runner(applyOnce)).catch((error) => { + this.$warn('The runner of the `bind-if` event rejected.', error); + if (!applied) { + applyOnce(); + } + }); } /** From 68f2c1d6b3d4a9e4cad6281acfe0dbb36fc1207f Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:20:14 +0200 Subject: [PATCH 02/10] Add tests for the bind-if through runner Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- packages/tests/Data/DataBind.spec.ts | 136 +++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index ef2142f8..57fbb98f 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -783,4 +783,140 @@ describe('The DataBind component', () => { expect(warnSpy).toHaveBeenCalledTimes(1); warnSpy.mockRestore(); }); + + it('should emit a bubbling bind-if event whose through runner defers the insertion', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + let detail: BindIfDetail; + let apply: () => void; + root.addEventListener('bind-if', (event) => { + detail = (event as CustomEvent).detail; + detail.through((run) => { + apply = run; + }); + }); + + instance.set(true); + + expect(detail.isPresent).toBe(true); + expect(typeof detail.through).toBe('function'); + expect(root.querySelector('p')).toBeNull(); + + apply(); + expect(root.querySelector('p')).not.toBeNull(); + expect(template.nextElementSibling).toBe(root.querySelector('p')); + }); + + it('should keep removed template content in the DOM until the through runner applies', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Bye

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + instance.set(true); + expect(root.querySelector('p')).not.toBeNull(); + + let detail: BindIfDetail; + let apply: () => void; + root.addEventListener('bind-if', (event) => { + detail = (event as CustomEvent).detail; + detail.through((run) => { + apply = run; + }); + }); + + instance.set(false); + + // The exit-animation enabler: the content stays until the runner applies. + expect(detail.isPresent).toBe(false); + expect(root.querySelector('p')).not.toBeNull(); + + apply(); + expect(root.querySelector('p')).toBeNull(); + }); + + it('should ignore and warn on through calls after the bind-if event dispatched', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + // `$warn` is a prototype getter: shadow it on the instance to observe calls. + const warn = vi.fn(); + Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); + + let through: BindIfDetail['through']; + root.addEventListener('bind-if', (event) => { + ({ through } = (event as CustomEvent).detail); + }); + + instance.set(true); + expect(root.querySelector('p')).not.toBeNull(); + + through(() => {}); + expect(warn).toHaveBeenCalledTimes(1); + expect(root.querySelectorAll('p')).toHaveLength(1); + }); + + it('should warn and still apply the DOM change when the through runner rejects', async () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + const warn = vi.fn(); + Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); + + root.addEventListener('bind-if', (event) => { + (event as CustomEvent).detail.through(() => + Promise.reject(new Error('runner failed')), + ); + }); + + instance.set(true); + expect(root.querySelector('p')).toBeNull(); + + await nextTick(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(root.querySelectorAll('p')).toHaveLength(1); + }); + + it('should keep the if bookkeeping consistent on rapid toggles with a deferring runner', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Flash

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + const applies: Array<() => void> = []; + const states: boolean[] = []; + root.addEventListener('bind-if', (event) => { + const { detail } = event as CustomEvent; + states.push(detail.isPresent); + detail.through((run) => { + applies.push(run); + }); + }); + + instance.set(true); + instance.set(false); + expect(states).toEqual([true, false]); + + // Toggling to the same logical state emits nothing. + instance.set(false); + expect(states).toEqual([true, false]); + + for (const apply of applies) { + apply(); + } + + expect(root.querySelectorAll('p')).toHaveLength(0); + expect(instance.__ifNodes).toBeUndefined(); + }); }); + +type BindIfDetail = { + isPresent: boolean; + through(runner: (apply: () => void) => void | Promise): void; +}; From c156c1e5911fd4240c1de7244405115a5e420a65 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:20:19 +0200 Subject: [PATCH 03/10] Document the bind-if through runner Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- .../docs/reference/items/DataBind/js-api.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/docs/reference/items/DataBind/js-api.md b/packages/docs/reference/items/DataBind/js-api.md index 21be309a..2dabb0fa 100644 --- a/packages/docs/reference/items/DataBind/js-api.md +++ b/packages/docs/reference/items/DataBind/js-api.md @@ -93,6 +93,32 @@ Each insertion is a fresh clone of the template content, so components inside ar Use `data-bind:if` when the element must not exist in the DOM — a form control that must not submit, an expensive subtree, or content that must be absent from the accessibility tree. To show or hide an element in place, prefer the cheaper `data-bind:attr.hidden`, `data-bind:class.` or `data-bind:style.display` bindings, which keep the element and its state. +### Wrapping the DOM change with the `bind-if` event + +Before `data-bind:if` inserts or removes the template content, the component emits a bubbling `bind-if` event. Its `detail` carries the new logical state as `isPresent` and a `through(runner)` function: a listener can call `through()` to substitute the function that runs the DOM change, with the runner receiving an `apply()` callback that performs the actual insertion or removal. + +```ts +type BindIfRunner = (apply: () => void) => void | Promise; +``` + +- `through()` is only valid synchronously, while the event dispatches — later calls warn and are ignored. +- A single runner runs the change: the last `through()` call wins. +- The DOM change is never lost: without a runner it runs synchronously as before, and a rejected runner is reported with a warning before the change is applied anyway if the runner did not call `apply()`. + +Because the removal also goes through the runner, the removed nodes stay in the DOM until the runner calls `apply()` — this is what enables exit animations for removed template content. And because the event bubbles, an ancestor [`Action`](../Action/index.md) can catch it and route it across the page, the same pattern as the [`Timer`](../Timer/index.md) events. For example, a `MotionView` component (from `@studiometa/ui-motion`) can wrap both the insertion and the removal in a view transition: + + +```html {4} + +``` + + ## Properties ### `value` From 60180d297b9583018c61397cd9dfdd33db5ac37f Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:20:27 +0200 Subject: [PATCH 04/10] Add a changelog entry for the bind-if through runner Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a65777ad..fdb22ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **DataBind:** sync late-mounted `immediate` keyed subscribers with the current scoped value ([#626](https://github.com/studiometa/ui/pull/626)) +- **DataBind:** emit a bubbling `bind-if` event whose `through()` lets listeners wrap the `data-bind:if` DOM change (e.g. in a view transition) - **Dialog:** make the `open` and `close` events extendable with `event.detail.waitUntil()` so any component can join the open and close choreography ([#627](https://github.com/studiometa/ui/pull/627)) ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) From b0464b6d3de055e5df150fb8e60733f9b6bea5d4 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:21:16 +0200 Subject: [PATCH 05/10] Link the changelog entry to the pull request Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb22ebb..eece397a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **DataBind:** sync late-mounted `immediate` keyed subscribers with the current scoped value ([#626](https://github.com/studiometa/ui/pull/626)) -- **DataBind:** emit a bubbling `bind-if` event whose `through()` lets listeners wrap the `data-bind:if` DOM change (e.g. in a view transition) +- **DataBind:** emit a bubbling `bind-if` event whose `through()` lets listeners wrap the `data-bind:if` DOM change (e.g. in a view transition) ([#634](https://github.com/studiometa/ui/pull/634)) - **Dialog:** make the `open` and `close` events extendable with `event.detail.waitUntil()` so any component can join the open and close choreography ([#627](https://github.com/studiometa/ui/pull/627)) ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) From e91aa86276172a4f8ef10343c00e0fbe421a169f Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 10:16:52 +0200 Subject: [PATCH 06/10] Rename the through function to wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrap((apply) => ...) says what the listener does — wrap the DOM change in its own transition runner — where through only described the data path. Mirrors the same rename on the Fetch seam. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 2 +- .../docs/reference/items/DataBind/js-api.md | 8 +++--- packages/tests/Data/DataBind.spec.ts | 26 +++++++++---------- packages/ui/src/Data/DataBind.ts | 8 +++--- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eece397a..bac1231f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **DataBind:** sync late-mounted `immediate` keyed subscribers with the current scoped value ([#626](https://github.com/studiometa/ui/pull/626)) -- **DataBind:** emit a bubbling `bind-if` event whose `through()` lets listeners wrap the `data-bind:if` DOM change (e.g. in a view transition) ([#634](https://github.com/studiometa/ui/pull/634)) +- **DataBind:** emit a bubbling `bind-if` event whose `wrap()` lets listeners wrap the `data-bind:if` DOM change (e.g. in a view transition) ([#634](https://github.com/studiometa/ui/pull/634)) - **Dialog:** make the `open` and `close` events extendable with `event.detail.waitUntil()` so any component can join the open and close choreography ([#627](https://github.com/studiometa/ui/pull/627)) ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) diff --git a/packages/docs/reference/items/DataBind/js-api.md b/packages/docs/reference/items/DataBind/js-api.md index 2dabb0fa..80801833 100644 --- a/packages/docs/reference/items/DataBind/js-api.md +++ b/packages/docs/reference/items/DataBind/js-api.md @@ -95,14 +95,14 @@ Use `data-bind:if` when the element must not exist in the DOM — a form control ### Wrapping the DOM change with the `bind-if` event -Before `data-bind:if` inserts or removes the template content, the component emits a bubbling `bind-if` event. Its `detail` carries the new logical state as `isPresent` and a `through(runner)` function: a listener can call `through()` to substitute the function that runs the DOM change, with the runner receiving an `apply()` callback that performs the actual insertion or removal. +Before `data-bind:if` inserts or removes the template content, the component emits a bubbling `bind-if` event. Its `detail` carries the new logical state as `isPresent` and a `wrap(runner)` function: a listener can call `wrap()` to substitute the function that runs the DOM change, with the runner receiving an `apply()` callback that performs the actual insertion or removal. ```ts type BindIfRunner = (apply: () => void) => void | Promise; ``` -- `through()` is only valid synchronously, while the event dispatches — later calls warn and are ignored. -- A single runner runs the change: the last `through()` call wins. +- `wrap()` is only valid synchronously, while the event dispatches — later calls warn and are ignored. +- A single runner runs the change: the last `wrap()` call wins. - The DOM change is never lost: without a runner it runs synchronously as before, and a rejected runner is reported with a warning before the change is applied anyway if the runner did not call `apply()`. Because the removal also goes through the runner, the removed nodes stay in the DOM until the runner calls `apply()` — this is what enables exit animations for removed template content. And because the event bubbles, an ancestor [`Action`](../Action/index.md) can catch it and route it across the page, the same pattern as the [`Timer`](../Timer/index.md) events. For example, a `MotionView` component (from `@studiometa/ui-motion`) can wrap both the insertion and the removal in a view transition: @@ -112,7 +112,7 @@ Because the removal also goes through the runner, the removed nodes stay in the diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index 57fbb98f..ee12d034 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -784,7 +784,7 @@ describe('The DataBind component', () => { warnSpy.mockRestore(); }); - it('should emit a bubbling bind-if event whose through runner defers the insertion', () => { + it('should emit a bubbling bind-if event whose wrap runner defers the insertion', () => { const template = h('template', { 'data-bind:if': '' }); template.innerHTML = '

Hello

'; const root = h('div', [template]); @@ -794,7 +794,7 @@ describe('The DataBind component', () => { let apply: () => void; root.addEventListener('bind-if', (event) => { detail = (event as CustomEvent).detail; - detail.through((run) => { + detail.wrap((run) => { apply = run; }); }); @@ -802,7 +802,7 @@ describe('The DataBind component', () => { instance.set(true); expect(detail.isPresent).toBe(true); - expect(typeof detail.through).toBe('function'); + expect(typeof detail.wrap).toBe('function'); expect(root.querySelector('p')).toBeNull(); apply(); @@ -810,7 +810,7 @@ describe('The DataBind component', () => { expect(template.nextElementSibling).toBe(root.querySelector('p')); }); - it('should keep removed template content in the DOM until the through runner applies', () => { + it('should keep removed template content in the DOM until the wrap runner applies', () => { const template = h('template', { 'data-bind:if': '' }); template.innerHTML = '

Bye

'; const root = h('div', [template]); @@ -823,7 +823,7 @@ describe('The DataBind component', () => { let apply: () => void; root.addEventListener('bind-if', (event) => { detail = (event as CustomEvent).detail; - detail.through((run) => { + detail.wrap((run) => { apply = run; }); }); @@ -838,7 +838,7 @@ describe('The DataBind component', () => { expect(root.querySelector('p')).toBeNull(); }); - it('should ignore and warn on through calls after the bind-if event dispatched', () => { + it('should ignore and warn on wrap calls after the bind-if event dispatched', () => { const template = h('template', { 'data-bind:if': '' }); template.innerHTML = '

Hello

'; const root = h('div', [template]); @@ -847,20 +847,20 @@ describe('The DataBind component', () => { const warn = vi.fn(); Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); - let through: BindIfDetail['through']; + let wrap: BindIfDetail['wrap']; root.addEventListener('bind-if', (event) => { - ({ through } = (event as CustomEvent).detail); + ({ wrap } = (event as CustomEvent).detail); }); instance.set(true); expect(root.querySelector('p')).not.toBeNull(); - through(() => {}); + wrap(() => {}); expect(warn).toHaveBeenCalledTimes(1); expect(root.querySelectorAll('p')).toHaveLength(1); }); - it('should warn and still apply the DOM change when the through runner rejects', async () => { + it('should warn and still apply the DOM change when the wrap runner rejects', async () => { const template = h('template', { 'data-bind:if': '' }); template.innerHTML = '

Hello

'; const root = h('div', [template]); @@ -869,7 +869,7 @@ describe('The DataBind component', () => { Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); root.addEventListener('bind-if', (event) => { - (event as CustomEvent).detail.through(() => + (event as CustomEvent).detail.wrap(() => Promise.reject(new Error('runner failed')), ); }); @@ -894,7 +894,7 @@ describe('The DataBind component', () => { root.addEventListener('bind-if', (event) => { const { detail } = event as CustomEvent; states.push(detail.isPresent); - detail.through((run) => { + detail.wrap((run) => { applies.push(run); }); }); @@ -918,5 +918,5 @@ describe('The DataBind component', () => { type BindIfDetail = { isPresent: boolean; - through(runner: (apply: () => void) => void | Promise): void; + wrap(runner: (apply: () => void) => void | Promise): void; }; diff --git a/packages/ui/src/Data/DataBind.ts b/packages/ui/src/Data/DataBind.ts index 3683f040..6b557b8e 100644 --- a/packages/ui/src/Data/DataBind.ts +++ b/packages/ui/src/Data/DataBind.ts @@ -347,7 +347,7 @@ export class DataBind extends withGroup extends withGroup { + const wrap = (fn: BindIfRunner) => { if (!dispatching) { - this.$warn('`through` must be called synchronously while the `bind-if` event dispatches.'); + this.$warn('`wrap` must be called synchronously while the `bind-if` event dispatches.'); return; } runner = fn; }; - this.$emit(new CustomEvent('bind-if', { detail: { isPresent, through }, bubbles: true })); + this.$emit(new CustomEvent('bind-if', { detail: { isPresent, wrap }, bubbles: true })); dispatching = false; if (!runner) { From 2a3b2958ddf4b18c7b1b4c9e09df9c1888e55543 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 14:04:19 +0200 Subject: [PATCH 07/10] Move the bind-if wrap seam to the dom-update protocol event Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- .../.vitepress/reference/public-contracts.ts | 16 ++++ packages/tests/Data/DataBind.spec.ts | 74 ++++++++++------ .../barrel-exports/barrel-exports.spec.ts | 2 + packages/ui/src/Data/DataBind.ts | 55 ++++-------- packages/ui/src/index.ts | 1 + packages/ui/src/utils/dom-update.ts | 86 +++++++++++++++++++ 6 files changed, 171 insertions(+), 63 deletions(-) create mode 100644 packages/ui/src/utils/dom-update.ts diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index 7e0bbe35..92317694 100644 --- a/packages/docs/.vitepress/reference/public-contracts.ts +++ b/packages/docs/.vitepress/reference/public-contracts.ts @@ -270,6 +270,22 @@ export const publicContractSymbols = [ href: '/reference/items/Disclosure/js-api', status: 'stable', }, + { + name: 'DomUpdateRunner', + kind: 'type', + package: 'npm:@studiometa/ui', + importPath: '@studiometa/ui', + href: '/reference/items/Fetch/js-api', + status: 'stable', + }, + { + name: 'DomUpdateTransitioner', + kind: 'type', + package: 'npm:@studiometa/ui', + importPath: '@studiometa/ui', + href: '/reference/items/Fetch/js-api', + status: 'stable', + }, { name: 'DraggableProps', kind: 'type', diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index ee12d034..f76d3752 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -284,9 +284,7 @@ describe('The DataBind component', () => { }); it('should reject mutation helpers on computed values and effects', () => { - const computed = new DataComputed( - h('div', { dataOptionCompute: 'value' }, ['current']), - ); + const computed = new DataComputed(h('div', { dataOptionCompute: 'value' }, ['current'])); computed.toggle('next', 'current'); expect(computed.value).toBe('current'); @@ -381,8 +379,7 @@ describe('The DataBind component', () => { }); const immediateElement = h('div', { dataOptionGroup: 'immediate-effect', - dataOptionEffect: - 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', + dataOptionEffect: 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', dataOptionImmediate: true, }); const passive = new DataEffect(passiveElement); @@ -403,8 +400,7 @@ describe('The DataBind component', () => { const source = new DataBind(h('div', { dataOptionGroup: 'lifecycle' })); const effectElement = h('div', { dataOptionGroup: 'lifecycle', - dataOptionEffect: - 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', + dataOptionEffect: 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', }); const effect = new DataEffect(effectElement); await mount(source, effect); @@ -784,16 +780,16 @@ describe('The DataBind component', () => { warnSpy.mockRestore(); }); - it('should emit a bubbling bind-if event whose wrap runner defers the insertion', () => { + it('should emit a bubbling dom-update event whose wrap runner defers the insertion', () => { const template = h('template', { 'data-bind:if': '' }); template.innerHTML = '

Hello

'; const root = h('div', [template]); const instance = new DataBind(template); - let detail: BindIfDetail; + let detail: DomUpdateDetail; let apply: () => void; - root.addEventListener('bind-if', (event) => { - detail = (event as CustomEvent).detail; + root.addEventListener('dom-update', (event) => { + detail = (event as CustomEvent).detail; detail.wrap((run) => { apply = run; }); @@ -819,10 +815,10 @@ describe('The DataBind component', () => { instance.set(true); expect(root.querySelector('p')).not.toBeNull(); - let detail: BindIfDetail; + let detail: DomUpdateDetail; let apply: () => void; - root.addEventListener('bind-if', (event) => { - detail = (event as CustomEvent).detail; + root.addEventListener('dom-update', (event) => { + detail = (event as CustomEvent).detail; detail.wrap((run) => { apply = run; }); @@ -838,7 +834,30 @@ describe('The DataBind component', () => { expect(root.querySelector('p')).toBeNull(); }); - it('should ignore and warn on wrap calls after the bind-if event dispatched', () => { + it('should defer the DOM change to a duck-typed transitioner registered through wrap', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + let mutate: () => void; + const update = vi.fn((fn: () => void) => { + mutate = fn; + }); + root.addEventListener('dom-update', (event) => { + (event as CustomEvent).detail.wrap({ update }); + }); + + instance.set(true); + + expect(update).toHaveBeenCalledTimes(1); + expect(root.querySelector('p')).toBeNull(); + + mutate(); + expect(root.querySelector('p')).not.toBeNull(); + }); + + it('should ignore and warn on wrap calls after the dom-update event dispatched', () => { const template = h('template', { 'data-bind:if': '' }); template.innerHTML = '

Hello

'; const root = h('div', [template]); @@ -847,9 +866,9 @@ describe('The DataBind component', () => { const warn = vi.fn(); Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); - let wrap: BindIfDetail['wrap']; - root.addEventListener('bind-if', (event) => { - ({ wrap } = (event as CustomEvent).detail); + let wrap: DomUpdateDetail['wrap']; + root.addEventListener('dom-update', (event) => { + ({ wrap } = (event as CustomEvent).detail); }); instance.set(true); @@ -857,6 +876,9 @@ describe('The DataBind component', () => { wrap(() => {}); expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '`wrap` must be called synchronously while the `dom-update` event dispatches.', + ); expect(root.querySelectorAll('p')).toHaveLength(1); }); @@ -868,8 +890,8 @@ describe('The DataBind component', () => { const warn = vi.fn(); Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); - root.addEventListener('bind-if', (event) => { - (event as CustomEvent).detail.wrap(() => + root.addEventListener('dom-update', (event) => { + (event as CustomEvent).detail.wrap(() => Promise.reject(new Error('runner failed')), ); }); @@ -891,8 +913,8 @@ describe('The DataBind component', () => { const applies: Array<() => void> = []; const states: boolean[] = []; - root.addEventListener('bind-if', (event) => { - const { detail } = event as CustomEvent; + root.addEventListener('dom-update', (event) => { + const { detail } = event as CustomEvent; states.push(detail.isPresent); detail.wrap((run) => { applies.push(run); @@ -916,7 +938,11 @@ describe('The DataBind component', () => { }); }); -type BindIfDetail = { +type DomUpdateDetail = { isPresent: boolean; - wrap(runner: (apply: () => void) => void | Promise): void; + wrap( + runner: + | ((apply: () => void) => void | Promise) + | { update(mutate: () => void | Promise): void | Promise }, + ): void; }; diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index 599100dc..4e48f8ba 100644 --- a/packages/tests/barrel-exports/barrel-exports.spec.ts +++ b/packages/tests/barrel-exports/barrel-exports.spec.ts @@ -116,6 +116,8 @@ test('@studiometa/ui barrel export surface', () => { "DisclosureGroup [value]", "DisclosureGroupProps [type]", "DisclosureProps [type]", + "DomUpdateRunner [type]", + "DomUpdateTransitioner [type]", "Draggable [value]", "DraggableProps [type]", "Fetch [value]", diff --git a/packages/ui/src/Data/DataBind.ts b/packages/ui/src/Data/DataBind.ts index 6b557b8e..f2565091 100644 --- a/packages/ui/src/Data/DataBind.ts +++ b/packages/ui/src/Data/DataBind.ts @@ -16,6 +16,7 @@ import { writeControlValue, } from './formControl.js'; import { getCallback } from './utils.js'; +import { emitDomUpdate, runWrapped } from '../utils/dom-update.js'; export interface DataBindProps extends BaseProps { $options: { @@ -31,8 +32,6 @@ type VirtualBinding = | { type: 'text' | 'if'; expression: string } | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; -type BindIfRunner = (apply: () => void) => void | Promise; - /** * DataBind class. * @@ -60,7 +59,7 @@ export class DataBind extends withGroup { static config: BaseConfig = { name: 'DataBind', - emits: ['bind-if'], + emits: ['dom-update'], options: { prop: String, immediate: Boolean, @@ -174,8 +173,7 @@ export class DataBind extends withGroup) + this.dataScope?.getChannel(this.group) ?? getDataChannel(this.$group as Set) ); } @@ -346,12 +344,12 @@ export class DataBind extends withGroup extends withGroup { - if (!dispatching) { - this.$warn('`wrap` must be called synchronously while the `bind-if` event dispatches.'); - return; - } - runner = fn; - }; - - this.$emit(new CustomEvent('bind-if', { detail: { isPresent, wrap }, bubbles: true })); - dispatching = false; + const runner = emitDomUpdate(this, { isPresent }); - if (!runner) { + if (runner) { + // Intentionally not awaited: the reactive pipeline stays synchronous + // while the runner defers the DOM change. + runWrapped(this, runner, apply); + } else { apply(); - return; } - - let applied = false; - function applyOnce() { - applied = true; - apply(); - } - Promise.resolve(runner(applyOnce)).catch((error) => { - this.$warn('The runner of the `bind-if` event rejected.', error); - if (!applied) { - applyOnce(); - } - }); } /** @@ -456,8 +434,7 @@ export class DataBind extends withGroup void | Promise): void | Promise; +} + +/** + * What `wrap()` accepts: a function receiving the `apply` callback, or a + * transitioner whose `update()` receives it. + */ +export type DomUpdateRunner = + | ((apply: () => void) => void | Promise) + | DomUpdateTransitioner; + +/** + * Emit the bubbling `dom-update` protocol event announcing an imminent DOM + * change, and return the runner registered by a listener through + * `detail.wrap()`, normalized to a function — or `null` when nobody wrapped. + * + * `wrap()` only accepts registrations synchronously while the event + * dispatches — later calls warn and are ignored — and keeps a single runner: + * the last call wins. + * + * The event is dispatched directly on the element instead of `$emit` because + * `Fetch` overrides `$emit` with a string-only signature that would mangle a + * `CustomEvent` instance. + */ +export function emitDomUpdate( + instance: Base, + detail: Record = {}, +): ((apply: () => void) => void | Promise) | null { + let runner: DomUpdateRunner | null = null; + let dispatching = true; + + const wrap = (newRunner: DomUpdateRunner) => { + if (!dispatching) { + instance.$warn( + '`wrap` must be called synchronously while the `dom-update` event dispatches.', + ); + return; + } + runner = newRunner; + }; + + instance.$el.dispatchEvent( + new CustomEvent('dom-update', { detail: { ...detail, wrap }, bubbles: true }), + ); + dispatching = false; + + if (runner && typeof (runner as DomUpdateTransitioner).update === 'function') { + const transitioner = runner as DomUpdateTransitioner; + return (apply) => transitioner.update(apply); + } + + return runner as ((apply: () => void) => void | Promise) | null; +} + +/** + * Run a DOM change through a registered runner without ever losing it: when + * the runner throws or rejects, the change is applied directly and the error + * is reported through `$warn`. + */ +export async function runWrapped( + instance: Base, + runner: (apply: () => void) => void | Promise, + applyChange: () => void, +): Promise { + let applied = false; + const apply = () => { + applied = true; + applyChange(); + }; + try { + await runner(apply); + } catch (error) { + instance.$warn('The `dom-update` runner rejected.', error); + if (!applied) { + apply(); + } + } +} From b3b5b6d62ee0554c0433482e17f9b35665262cb6 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 14:04:27 +0200 Subject: [PATCH 08/10] Document the dom-update protocol event Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- .../docs/reference/items/DataBind/js-api.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/docs/reference/items/DataBind/js-api.md b/packages/docs/reference/items/DataBind/js-api.md index 80801833..5c0aaf58 100644 --- a/packages/docs/reference/items/DataBind/js-api.md +++ b/packages/docs/reference/items/DataBind/js-api.md @@ -93,26 +93,40 @@ Each insertion is a fresh clone of the template content, so components inside ar Use `data-bind:if` when the element must not exist in the DOM — a form control that must not submit, an expensive subtree, or content that must be absent from the accessibility tree. To show or hide an element in place, prefer the cheaper `data-bind:attr.hidden`, `data-bind:class.` or `data-bind:style.display` bindings, which keep the element and its state. -### Wrapping the DOM change with the `bind-if` event +### Wrapping the DOM change with the `dom-update` event -Before `data-bind:if` inserts or removes the template content, the component emits a bubbling `bind-if` event. Its `detail` carries the new logical state as `isPresent` and a `wrap(runner)` function: a listener can call `wrap()` to substitute the function that runs the DOM change, with the runner receiving an `apply()` callback that performs the actual insertion or removal. +Before `data-bind:if` inserts or removes the template content, the component emits the bubbling `dom-update` protocol event — the shared announcement components use before an imminent DOM change. Its `detail` carries the new logical state as `isPresent` and a `wrap(runner)` function: a listener can call `wrap()` to substitute what runs the DOM change. The runner is either a function receiving an `apply()` callback that performs the actual insertion or removal, or a duck-typed transitioner exposing an `update(mutate)` method — like `MotionView` from `@studiometa/ui-motion` — whose `update()` receives the callback. ```ts -type BindIfRunner = (apply: () => void) => void | Promise; +interface DomUpdateTransitioner { + update(mutate: () => void | Promise): void | Promise; +} + +type DomUpdateRunner = ((apply: () => void) => void | Promise) | DomUpdateTransitioner; ``` - `wrap()` is only valid synchronously, while the event dispatches — later calls warn and are ignored. - A single runner runs the change: the last `wrap()` call wins. - The DOM change is never lost: without a runner it runs synchronously as before, and a rejected runner is reported with a warning before the change is applied anyway if the runner did not call `apply()`. -Because the removal also goes through the runner, the removed nodes stay in the DOM until the runner calls `apply()` — this is what enables exit animations for removed template content. And because the event bubbles, an ancestor [`Action`](../Action/index.md) can catch it and route it across the page, the same pattern as the [`Timer`](../Timer/index.md) events. For example, a `MotionView` component (from `@studiometa/ui-motion`) can wrap both the insertion and the removal in a view transition: +Because the removal also goes through the runner, the removed nodes stay in the DOM until the runner calls `apply()` — this is what enables exit animations for removed template content. And because the event bubbles, an enclosing `MotionView` wraps any `dom-update` announced in its subtree with no wiring at all: + +```html +
+ +
+``` + +For cross-subtree topologies — when the transitioner does not enclose the template — an ancestor [`Action`](../Action/index.md) can catch the event and route it across the page, the same pattern as the [`Timer`](../Timer/index.md) events: ```html {4} From 63c5958af9a926ca0e5dc5f149eea55218f454e9 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 14:04:27 +0200 Subject: [PATCH 09/10] Reword the changelog entry for the dom-update protocol event Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bac1231f..50e0ce14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **DataBind:** sync late-mounted `immediate` keyed subscribers with the current scoped value ([#626](https://github.com/studiometa/ui/pull/626)) -- **DataBind:** emit a bubbling `bind-if` event whose `wrap()` lets listeners wrap the `data-bind:if` DOM change (e.g. in a view transition) ([#634](https://github.com/studiometa/ui/pull/634)) +- **DataBind:** announce the `data-bind:if` DOM change with the bubbling `dom-update` protocol event whose `wrap()` lets a listener or transitioner run it ([#634](https://github.com/studiometa/ui/pull/634)) - **Dialog:** make the `open` and `close` events extendable with `event.detail.waitUntil()` so any component can join the open and close choreography ([#627](https://github.com/studiometa/ui/pull/627)) ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) From ac6c5417da3d6dbcc625d86c9bcb3fae31fc251e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 14:09:53 +0200 Subject: [PATCH 10/10] Use function declarations in the dom-update helper Satisfies the repo's func-style lint rule; identical commit on both branches carrying the shared helper so the file stays byte-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- packages/ui/src/utils/dom-update.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/utils/dom-update.ts b/packages/ui/src/utils/dom-update.ts index 2b2eeaaa..ff1e5339 100644 --- a/packages/ui/src/utils/dom-update.ts +++ b/packages/ui/src/utils/dom-update.ts @@ -37,7 +37,7 @@ export function emitDomUpdate( let runner: DomUpdateRunner | null = null; let dispatching = true; - const wrap = (newRunner: DomUpdateRunner) => { + function wrap(newRunner: DomUpdateRunner) { if (!dispatching) { instance.$warn( '`wrap` must be called synchronously while the `dom-update` event dispatches.', @@ -45,7 +45,7 @@ export function emitDomUpdate( return; } runner = newRunner; - }; + } instance.$el.dispatchEvent( new CustomEvent('dom-update', { detail: { ...detail, wrap }, bubbles: true }), @@ -71,10 +71,10 @@ export async function runWrapped( applyChange: () => void, ): Promise { let applied = false; - const apply = () => { + function apply() { applied = true; applyChange(); - }; + } try { await runner(apply); } catch (error) {