+
…
```
@@ -188,10 +185,7 @@ The declarative click, submit and popstate flows call this method for you, but i
- `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit), optional): extra options merged into the [`requestInit` getter](#requestinit-1) for this call.
```html
-
+
…
```
@@ -282,6 +276,16 @@ Emitted when the DOM is updated.
- `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
- `document` (`Document`): the content of the response, parsed with a [DOMParse](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser)
+### `dom-update`
+
+Emitted after the [`fetch-update` event](#fetch-update), right before the fetched content is applied to the DOM. Unlike the `fetch-*` events, this is a shared protocol event announcing an imminent DOM change — see [the `dom-update` protocol event](#the-dom-update-protocol-event).
+
+**Detail**
+
+The event `detail` is a bare object (not an argument array) with the following property:
+
+- `wrap` (`(runner: DomUpdateRunner) => void`): registers a runner or transitioner that substitutes the default update path
+
### `fetch-update-after`
Emitted when the DOM has been updated.
@@ -317,3 +321,29 @@ Emitted when the fetch request has been aborted.
- `url` (`URL`): the URL that was fetched
- `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
- `reason` (`any`): the reason the request was aborted
+
+## The `dom-update` protocol event
+
+Before applying the fetched content, `Fetch` dispatches the bubbling [`dom-update` event](#dom-update) announcing the imminent DOM change. Because it bubbles, any ancestor can listen for it and call `event.detail.wrap(runnerOrTransitioner)` to substitute the runner that applies the fetched content — instead of the default [View Transition](#viewtransition) or direct update — and drive the swap with its own choreography, similar to Turbo's `turbo:before-render` render substitution.
+
+`wrap()` accepts a `DomUpdateRunner`, which is either form:
+
+- a **function** with the signature `(apply: () => void) => void | Promise
`: it receives an `apply` function that injects the fetched content into the DOM, and its return value is awaited before the [`fetch-update-after` event](#fetch-update-after) is emitted
+- a **transitioner**: any duck-typed object with an `update(mutate)` method (the `DomUpdateTransitioner` interface), e.g. `MotionView` from `@studiometa/ui-motion` — its `update()` method receives the apply function and its return value is awaited the same way
+
+The protocol enforces three rules:
+
+- **Synchronous registration only**: `wrap` must be called synchronously while the event dispatches — later calls warn and are ignored.
+- **Last call wins**: a single runner is kept, the last `wrap` call during dispatch replaces any previous one.
+- **The content is never lost**: if the runner throws or rejects, the error is logged with a warning and the content is applied directly when it has not been applied yet. The `fetch-update-after` event is always emitted.
+
+With the upcoming ambient `MotionView` from `@studiometa/ui-motion`, the common case is pure nesting: a `MotionView` wrapping the updated content picks up the bubbling event by itself, with no attributes to write. When the transitioner lives elsewhere in the tree, an [Action](/reference/items/Action/) is the explicit escape hatch to route the event to it:
+
+```html
+
+```
diff --git a/packages/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts
index 8e97ce15..79759c12 100644
--- a/packages/tests/Fetch/Fetch.spec.ts
+++ b/packages/tests/Fetch/Fetch.spec.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { Fetch } from '@studiometa/ui';
import { Window } from 'happy-dom';
-import { h, mount } from '#test-utils';
+import { h, mount, wait } from '#test-utils';
describe('The Fetch class', () => {
describe('getters', () => {
@@ -907,6 +907,7 @@ describe('The Fetch class', () => {
callback();
return {
ready: Promise.resolve(),
+ finished: Promise.resolve(),
};
});
Object.defineProperty(document, 'startViewTransition', {
@@ -938,6 +939,7 @@ describe('The Fetch class', () => {
callback();
return {
ready: Promise.resolve(),
+ finished: Promise.resolve(),
};
});
Object.defineProperty(document, 'startViewTransition', {
@@ -954,6 +956,256 @@ describe('The Fetch class', () => {
delete (document as any).startViewTransition;
container.remove();
});
+
+ it('should batch simultaneous updates into a single view transition', async () => {
+ const container = h('div', { id: 'container' }, [
+ h('div', { id: 'test' }, ['old content']),
+ h('div', { id: 'other' }, ['old other']),
+ ]);
+ document.body.appendChild(container);
+
+ const fetchA = new Fetch(h('a', { href: 'https://example.com' }));
+ const fetchB = new Fetch(h('a', { href: 'https://example.com' }));
+ await mount(fetchA, fetchB);
+
+ const transitionSpy = vi.fn((callback: () => void | Promise) => {
+ callback();
+ return {
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ };
+ });
+ Object.defineProperty(document, 'startViewTransition', {
+ value: transitionSpy,
+ configurable: true,
+ });
+
+ await Promise.all([
+ fetchA.update(new URL('https://example.com'), {}, 'new content
'),
+ fetchB.update(new URL('https://example.com'), {}, 'new other
'),
+ ]);
+
+ // The shared scheduler flushed both updates in ONE view transition.
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+ expect(document.getElementById('other')?.textContent).toBe('new other');
+
+ // Clean up
+ delete (document as any).startViewTransition;
+ container.remove();
+ });
+
+ it('should let a `dom-update` runner substitute the default transition runner', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+
+ await mount(fetch);
+
+ const transitionSpy = vi.fn((callback: () => void) => {
+ callback();
+ return {
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ };
+ });
+ Object.defineProperty(document, 'startViewTransition', {
+ value: transitionSpy,
+ configurable: true,
+ });
+
+ let contentBeforeApply: string | undefined;
+ let contentAfterApply: string | undefined;
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ event.detail.wrap((apply: () => void) => {
+ contentBeforeApply = document.getElementById('test')?.textContent;
+ apply();
+ contentAfterApply = document.getElementById('test')?.textContent;
+ });
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ expect(contentBeforeApply).toBe('old content');
+ expect(contentAfterApply).toBe('new content');
+ expect(transitionSpy).not.toHaveBeenCalled();
+
+ // Clean up
+ delete (document as any).startViewTransition;
+ container.remove();
+ });
+
+ it('should let a `dom-update` transitioner run the update through its `update` method', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+
+ await mount(fetch);
+
+ let contentAfterMutate: string | undefined;
+ const update = vi.fn((mutate: () => void) => {
+ mutate();
+ contentAfterMutate = document.getElementById('test')?.textContent;
+ });
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ event.detail.wrap({ update });
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ expect(update).toHaveBeenCalledOnce();
+ expect(update).toHaveBeenCalledWith(expect.any(Function));
+ expect(contentAfterMutate).toBe('new content');
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+
+ container.remove();
+ });
+
+ it('should keep the last `wrap` runner registered during dispatch', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+
+ await mount(fetch);
+
+ const firstRunner = vi.fn((apply: () => void) => apply());
+ const lastRunner = vi.fn((apply: () => void) => apply());
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ event.detail.wrap(firstRunner);
+ event.detail.wrap(lastRunner);
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ expect(firstRunner).not.toHaveBeenCalled();
+ expect(lastRunner).toHaveBeenCalledOnce();
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+
+ container.remove();
+ });
+
+ it('should ignore and warn on `wrap` calls after the `dom-update` event dispatched', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+ const warnFn = vi.fn();
+ Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn });
+
+ await mount(fetch);
+
+ let wrap: (runner: (apply: () => void) => void) => void;
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ wrap = event.detail.wrap;
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ // The default path ran since no runner was registered during dispatch.
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+
+ const lateRunner = vi.fn();
+ wrap(lateRunner);
+
+ expect(lateRunner).not.toHaveBeenCalled();
+ expect(warnFn).toHaveBeenCalledWith(
+ '`wrap` must be called synchronously while the `dom-update` event dispatches.',
+ );
+
+ container.remove();
+ });
+
+ it('should apply the content and warn when a `wrap` runner rejects', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+ const warnFn = vi.fn();
+ Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn });
+ const fn = vi.fn();
+ fetch.$on('fetch-update-after', () => fn());
+
+ await mount(fetch);
+
+ const error = new Error('Runner failed');
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ event.detail.wrap(() => Promise.reject(error));
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+ expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error);
+ expect(fn).toHaveBeenCalled();
+
+ container.remove();
+ });
+
+ it('should apply the content and warn when a `wrap` runner throws synchronously', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+ const warnFn = vi.fn();
+ Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn });
+ const fn = vi.fn();
+ fetch.$on('fetch-update-after', () => fn());
+
+ await mount(fetch);
+
+ const error = new Error('Runner failed');
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ event.detail.wrap(() => {
+ throw error;
+ });
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+ expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error);
+ expect(fn).toHaveBeenCalled();
+
+ container.remove();
+ });
+
+ it('should not apply the content twice when a `wrap` runner rejects after applying', async () => {
+ const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]);
+ document.body.appendChild(container);
+
+ const anchor = h('a', { href: 'https://example.com' });
+ const fetch = new Fetch(anchor);
+ const warnFn = vi.fn();
+ Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn });
+ const updateDOMSpy = vi.spyOn(fetch, '__updateDOM');
+
+ await mount(fetch);
+
+ const error = new Error('Runner failed');
+ fetch.$on('dom-update', (event: CustomEvent) => {
+ event.detail.wrap((apply: () => void) => {
+ apply();
+ return Promise.reject(error);
+ });
+ });
+
+ await fetch.update(new URL('https://example.com'), {}, 'new content
');
+
+ expect(document.getElementById('test')?.textContent).toBe('new content');
+ expect(updateDOMSpy).toHaveBeenCalledOnce();
+ expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error);
+
+ container.remove();
+ });
});
describe('error handling', () => {
@@ -1069,6 +1321,9 @@ describe('The Fetch class', () => {
await mount(fetch);
await fetch.fetch(new URL('https://example.com'));
+ // `fetch()` does not await `update()`, and the scheduled view transition
+ // resolves in a later microtask: flush before asserting.
+ await wait(0);
expect(eventLog).toContain('fetch-before');
expect(eventLog).toContain('fetch-fetch');
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/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts
index 0ebd07ae..35c19bec 100644
--- a/packages/ui/src/Fetch/Fetch.ts
+++ b/packages/ui/src/Fetch/Fetch.ts
@@ -2,8 +2,9 @@ import { Base } from '@studiometa/js-toolkit/Base';
import type { BaseConfig, BaseProps, BaseInterface } from '@studiometa/js-toolkit';
import { domScheduler } from '@studiometa/js-toolkit/utils/domScheduler';
import { historyPush } from '@studiometa/js-toolkit/utils/historyPush';
-import { isFunction } from '@studiometa/js-toolkit/utils/isFunction';
import morphdom from 'morphdom';
+import { viewTransition as scheduleViewTransition } from '../ViewTransition/scheduler.js';
+import { emitDomUpdate, runWrapped } from '../utils/dom-update.js';
import { adoptNewScripts, getScripts } from './utils.js';
export interface FetchProps extends BaseProps {
@@ -80,7 +81,7 @@ export class Fetch
*/
static config: BaseConfig = {
name: 'Fetch',
- emits: Object.values(this.FETCH_EVENTS),
+ emits: [...Object.values(this.FETCH_EVENTS), 'dom-update'],
refs: ['headers[]'],
options: {
history: Boolean,
@@ -354,8 +355,18 @@ export class Fetch
* a declarative option string.
* @protected
*/
- __parseResponse(response: Response, url: URL, requestInit: RequestInit): Promise | string {
- const fn = new Function('response', 'url', 'requestInit', 'self', `return ${this.$options.response}`);
+ __parseResponse(
+ response: Response,
+ url: URL,
+ requestInit: RequestInit,
+ ): Promise | string {
+ const fn = new Function(
+ 'response',
+ 'url',
+ 'requestInit',
+ 'self',
+ `return ${this.$options.response}`,
+ );
return fn.call(this, response, url, requestInit, self);
}
@@ -400,6 +411,8 @@ export class Fetch
/**
* Dispatch the contents to update to their matching FrameTarget.
+ *
+ * After the `fetch-update` event, the bubbling `dom-update` protocol event announces the imminent DOM change. Its `detail.wrap()` lets a listener register a runner — a function receiving the `apply` callback, or a transitioner object exposing `update(mutate)` — that substitutes the default update path and is awaited before the `fetch-update-after` event. Registration is only valid synchronously while the event dispatches and the last `wrap` call wins. With no registered runner and the `viewTransition` option enabled, the update runs through the shared `viewTransition` scheduler — batched and serialized with every other scheduled view transition, falling back to a direct update when the API is unavailable.
*/
async update(url: URL, requestInit: RequestInit, content: string) {
const { FETCH_EVENTS } = this.constructor;
@@ -423,10 +436,14 @@ export class Fetch
this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment });
- if (viewTransition && isFunction(document.startViewTransition)) {
- await document.startViewTransition(() => {
+ const runner = emitDomUpdate(this);
+
+ if (runner) {
+ await runWrapped(this, runner, () => this.__updateDOM(fragment));
+ } else if (viewTransition) {
+ await scheduleViewTransition(() => {
this.__updateDOM(fragment);
- }).ready;
+ });
} else {
this.__updateDOM(fragment);
}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 670cf3a4..ee1af124 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -188,6 +188,7 @@ export {
type TrackShopifyProps,
} from './Track/index.js';
export { Transition, type TransitionConstructor } from './Transition/index.js';
+export type { DomUpdateRunner, DomUpdateTransitioner } from './utils/dom-update.js';
export {
viewTransition,
ViewTransition,
diff --git a/packages/ui/src/utils/dom-update.ts b/packages/ui/src/utils/dom-update.ts
new file mode 100644
index 00000000..ff1e5339
--- /dev/null
+++ b/packages/ui/src/utils/dom-update.ts
@@ -0,0 +1,86 @@
+import type { Base } from '@studiometa/js-toolkit';
+
+/**
+ * A component able to run a DOM change inside its own transition — the
+ * duck-typed handshake of the `dom-update` protocol. `MotionView` from
+ * `@studiometa/ui-motion` is one; any object exposing `update(mutate)` works.
+ */
+export interface DomUpdateTransitioner {
+ update(mutate: () => 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;
+
+ function 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;
+ function apply() {
+ applied = true;
+ applyChange();
+ }
+ try {
+ await runner(apply);
+ } catch (error) {
+ instance.$warn('The `dom-update` runner rejected.', error);
+ if (!applied) {
+ apply();
+ }
+ }
+}