diff --git a/CHANGELOG.md b/CHANGELOG.md index a65777ad..d3f86abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **DataBind:** sync late-mounted `immediate` keyed subscribers with the current scoped value ([#626](https://github.com/studiometa/ui/pull/626)) - **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)) +- **Fetch:** announce content updates with the bubbling `dom-update` protocol event whose `wrap()` lets a listener or transitioner run the swap ([#632](https://github.com/studiometa/ui/pull/632)) +- **Fetch:** route the default view transition through the shared `viewTransition` scheduler so it batches and serializes with every other view transition ([#632](https://github.com/studiometa/ui/pull/632)) ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index 7e0bbe35..c431f956 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', @@ -294,6 +310,14 @@ export const publicContractSymbols = [ href: '/reference/items/Fetch/js-api', status: 'stable', }, + { + name: 'FetchUpdateWrapper', + kind: 'type', + package: 'npm:@studiometa/ui', + importPath: '@studiometa/ui', + href: '/reference/items/Fetch/js-api', + status: 'stable', + }, { name: 'FetchShopifyPartialConstructor', kind: 'type', diff --git a/packages/docs/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md index 47adf4d7..0258a36c 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -61,7 +61,7 @@ Adds custom headers to the fetch request. - Type: `boolean` - Default: `true` -Disables support for the [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API). +Wrap the content update in a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API), through the same [`viewTransition` scheduler](/reference/items/ViewTransition/) as every other component — updates requested in the same tick are batched into one transition and batches are serialized, so a `Fetch` swap never fights a [`Toaster`](/reference/items/Toaster/) or [`ViewTransition`](/reference/items/ViewTransition/) animation over the one-transition-per-document limit. Falls back to a direct update when the API is unavailable. Disable it with `data-option-no-view-transition`. ```html Fetch @@ -112,10 +112,7 @@ Defines the URL to fetch. This makes it possible to drive the `Fetch` component The value is resolved against the current location, so both absolute and relative URLs are supported. When set, `src` **takes precedence** over the element's own destination: it overrides a ``'s `href` and a `
`'s `action`. For a GET ``, the live form data is still folded onto the `src` URL, so a fixed query in `src` (e.g. `?section_id=…`) survives alongside the form fields, with form fields winning on conflict. ```html -
+
``` @@ -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 +
+
    + … +
+
Next page +
+``` 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(); + } + } +}