Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:** 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)
Expand Down
16 changes: 16 additions & 0 deletions packages/docs/.vitepress/reference/public-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 40 additions & 0 deletions packages/docs/reference/items/DataBind/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,46 @@ 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.<name>` or `data-bind:style.display` bindings, which keep the element and its state.

### Wrapping the DOM change with the `dom-update` event

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
interface DomUpdateTransitioner {
update(mutate: () => void | Promise<void>): void | Promise<unknown>;
}

type DomUpdateRunner = ((apply: () => void) => void | Promise<unknown>) | 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 enclosing `MotionView` wraps any `dom-update` announced in its subtree with no wiring at all:

```html
<div data-component="MotionView">
<template data-component="DataBind" data-option-key="query" data-bind:if="value !== ''">
</template>
</div>
```

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:

<!-- prettier-ignore-start -->
```html {4}
<template
data-component="Action DataBind"
data-option-key="query"
data-on:dom-update="MotionView(#panel)->event.detail.wrap(target)"
data-bind:if="value !== ''">
</template>
```
<!-- prettier-ignore-end -->

## Properties

### `value`
Expand Down
176 changes: 169 additions & 7 deletions packages/tests/Data/DataBind.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -783,4 +779,170 @@ describe('The DataBind component', () => {
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});

it('should emit a bubbling dom-update event whose wrap runner defers the insertion', () => {
const template = h('template', { 'data-bind:if': '' });
template.innerHTML = '<p>Hello</p>';
const root = h('div', [template]);
const instance = new DataBind(template);

let detail: DomUpdateDetail;
let apply: () => void;
root.addEventListener('dom-update', (event) => {
detail = (event as CustomEvent<DomUpdateDetail>).detail;
detail.wrap((run) => {
apply = run;
});
});

instance.set(true);

expect(detail.isPresent).toBe(true);
expect(typeof detail.wrap).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 wrap runner applies', () => {
const template = h('template', { 'data-bind:if': '' });
template.innerHTML = '<p>Bye</p>';
const root = h('div', [template]);
const instance = new DataBind(template);

instance.set(true);
expect(root.querySelector('p')).not.toBeNull();

let detail: DomUpdateDetail;
let apply: () => void;
root.addEventListener('dom-update', (event) => {
detail = (event as CustomEvent<DomUpdateDetail>).detail;
detail.wrap((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 defer the DOM change to a duck-typed transitioner registered through wrap', () => {
const template = h('template', { 'data-bind:if': '' });
template.innerHTML = '<p>Hello</p>';
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<DomUpdateDetail>).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 = '<p>Hello</p>';
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 wrap: DomUpdateDetail['wrap'];
root.addEventListener('dom-update', (event) => {
({ wrap } = (event as CustomEvent<DomUpdateDetail>).detail);
});

instance.set(true);
expect(root.querySelector('p')).not.toBeNull();

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);
});

it('should warn and still apply the DOM change when the wrap runner rejects', async () => {
const template = h('template', { 'data-bind:if': '' });
template.innerHTML = '<p>Hello</p>';
const root = h('div', [template]);
const instance = new DataBind(template);
const warn = vi.fn();
Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn });

root.addEventListener('dom-update', (event) => {
(event as CustomEvent<DomUpdateDetail>).detail.wrap(() =>
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 = '<p>Flash</p>';
const root = h('div', [template]);
const instance = new DataBind(template);

const applies: Array<() => void> = [];
const states: boolean[] = [];
root.addEventListener('dom-update', (event) => {
const { detail } = event as CustomEvent<DomUpdateDetail>;
states.push(detail.isPresent);
detail.wrap((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 DomUpdateDetail = {
isPresent: boolean;
wrap(
runner:
| ((apply: () => void) => void | Promise<unknown>)
| { update(mutate: () => void | Promise<void>): void | Promise<unknown> },
): void;
};
2 changes: 2 additions & 0 deletions packages/tests/barrel-exports/barrel-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
Loading
Loading