From f86a94f8802dc085e27b62f9026ddf84b3c68751 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:18:45 +0200 Subject: [PATCH 1/9] Let listeners substitute the Fetch content transition runner Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- packages/ui/src/Fetch/Fetch.ts | 47 ++++++++++++++++++++++++++++++++-- packages/ui/src/Fetch/index.ts | 7 ++++- packages/ui/src/index.ts | 1 + 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 0ebd07ae..9a2902b8 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -28,6 +28,11 @@ export type FetchConstructor = { prototype: Fetch; } & Pick; +/** + * Transition runner registered with `through()` on the `fetch-update` event payload. It receives an `apply` function that injects the fetched content into the DOM and is awaited before the `fetch-update-after` event is emitted. + */ +export type FetchThroughRunner = (apply: () => void) => void | Promise; + /** * Fetch class. * @@ -400,6 +405,8 @@ export class Fetch /** * Dispatch the contents to update to their matching FrameTarget. + * + * The `fetch-update` event payload exposes a `through(runner)` function letting listeners substitute the transition runner that applies the fetched content. The runner receives an `apply` function that injects the content into the DOM and is awaited before the `fetch-update-after` event. Registration is only valid synchronously while the event dispatches and the last `through` call wins. With no registered runner, the default paths run: a View Transition when the `viewTransition` option is enabled and supported, a direct update otherwise. */ async update(url: URL, requestInit: RequestInit, content: string) { const { FETCH_EVENTS } = this.constructor; @@ -421,9 +428,23 @@ export class Fetch }); } - this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment }); + const runner = this.__emitUpdate(url, requestInit, fragment); - if (viewTransition && isFunction(document.startViewTransition)) { + if (runner) { + let applied = false; + const apply = () => { + applied = true; + this.__updateDOM(fragment); + }; + try { + await runner(apply); + } catch (error) { + this.$warn(`The \`${FETCH_EVENTS.UPDATE}\` event runner rejected.`, error); + if (!applied) { + apply(); + } + } + } else if (viewTransition && isFunction(document.startViewTransition)) { await document.startViewTransition(() => { this.__updateDOM(fragment); }).ready; @@ -434,6 +455,28 @@ export class Fetch this.$emit(FETCH_EVENTS.AFTER_UPDATE, { instance: this, url, requestInit, fragment }); } + /** + * Emit the `fetch-update` event with a `through(runner)` function on its payload and return the registered transition runner, if any. Registration is only valid while the event dispatches: later calls warn and are ignored. A single runner is kept — the last `through` call during dispatch wins. + * @private + */ + __emitUpdate(url: URL, requestInit: RequestInit, fragment: Document): FetchThroughRunner | null { + const { FETCH_EVENTS } = this.constructor; + let dispatching = true; + let runner: FetchThroughRunner | null = null; + const through = (newRunner: FetchThroughRunner) => { + if (!dispatching) { + this.$warn( + `\`through\` must be called synchronously while the \`${FETCH_EVENTS.UPDATE}\` event dispatches.`, + ); + return; + } + runner = newRunner; + }; + this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment, through }); + dispatching = false; + return runner; + } + /** * Handle errors. */ diff --git a/packages/ui/src/Fetch/index.ts b/packages/ui/src/Fetch/index.ts index efa561ed..6c2a8f8e 100644 --- a/packages/ui/src/Fetch/index.ts +++ b/packages/ui/src/Fetch/index.ts @@ -1,4 +1,9 @@ -export { Fetch, type FetchConstructor, type FetchProps } from './Fetch.js'; +export { + Fetch, + type FetchConstructor, + type FetchProps, + type FetchThroughRunner, +} from './Fetch.js'; export { FetchShopifyPartial, type FetchShopifyPartialConstructor, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 670cf3a4..9eb62c12 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,6 +72,7 @@ export { type FetchShopifyPartialProps, type FetchShopifySectionConstructor, type FetchShopifySectionProps, + type FetchThroughRunner, } from './Fetch/index.js'; export { Figure, From aa5d0951300bf6a2608dfe35ac09a34e8fa8cc6a Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:18:45 +0200 Subject: [PATCH 2/9] Add tests for the Fetch through runner Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- packages/tests/Fetch/Fetch.spec.ts | 183 ++++++++++++++++++ .../barrel-exports/barrel-exports.spec.ts | 1 + 2 files changed, 184 insertions(+) diff --git a/packages/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts index 8e97ce15..ea8c12c6 100644 --- a/packages/tests/Fetch/Fetch.spec.ts +++ b/packages/tests/Fetch/Fetch.spec.ts @@ -954,6 +954,189 @@ describe('The Fetch class', () => { delete (document as any).startViewTransition; container.remove(); }); + + it('should let a `through` 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(), + }; + }); + Object.defineProperty(document, 'startViewTransition', { + value: transitionSpy, + configurable: true, + }); + + let contentBeforeApply: string | undefined; + let contentAfterApply: string | undefined; + fetch.$on('fetch-update', (event: CustomEvent) => { + event.detail[0].through((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 keep the last `through` 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('fetch-update', (event: CustomEvent) => { + event.detail[0].through(firstRunner); + event.detail[0].through(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 `through` calls after the `fetch-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 through: (runner: (apply: () => void) => void) => void; + fetch.$on('fetch-update', (event: CustomEvent) => { + through = event.detail[0].through; + }); + + 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(); + through(lateRunner); + + expect(lateRunner).not.toHaveBeenCalled(); + expect(warnFn).toHaveBeenCalledWith( + '`through` must be called synchronously while the `fetch-update` event dispatches.', + ); + + container.remove(); + }); + + it('should apply the content and warn when a `through` 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('fetch-update', (event: CustomEvent) => { + event.detail[0].through(() => 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 `fetch-update` event runner rejected.', error); + expect(fn).toHaveBeenCalled(); + + container.remove(); + }); + + it('should apply the content and warn when a `through` 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('fetch-update', (event: CustomEvent) => { + event.detail[0].through(() => { + throw error; + }); + }); + + await fetch.update(new URL('https://example.com'), {}, '
new content
'); + + expect(document.getElementById('test')?.textContent).toBe('new content'); + expect(warnFn).toHaveBeenCalledWith('The `fetch-update` event runner rejected.', error); + expect(fn).toHaveBeenCalled(); + + container.remove(); + }); + + it('should not apply the content twice when a `through` 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('fetch-update', (event: CustomEvent) => { + event.detail[0].through((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 `fetch-update` event runner rejected.', error); + + container.remove(); + }); }); describe('error handling', () => { diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index 599100dc..d3707651 100644 --- a/packages/tests/barrel-exports/barrel-exports.spec.ts +++ b/packages/tests/barrel-exports/barrel-exports.spec.ts @@ -127,6 +127,7 @@ test('@studiometa/ui barrel export surface', () => { "FetchShopifySection [value]", "FetchShopifySectionConstructor [type]", "FetchShopifySectionProps [type]", + "FetchThroughRunner [type]", "Figure [value]", "FigureProps [type]", "FigureShopify [value]", From 07f17b60b28e4a4965876dbf3ea8d72c447ecb46 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:18:45 +0200 Subject: [PATCH 3/9] Document the Fetch through runner Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- .../.vitepress/reference/public-contracts.ts | 8 +++++ packages/docs/reference/items/Fetch/js-api.md | 34 ++++++++++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index 7e0bbe35..7a5f7b7c 100644 --- a/packages/docs/.vitepress/reference/public-contracts.ts +++ b/packages/docs/.vitepress/reference/public-contracts.ts @@ -294,6 +294,14 @@ export const publicContractSymbols = [ href: '/reference/items/Fetch/js-api', status: 'stable', }, + { + name: 'FetchThroughRunner', + 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..e76e1be0 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -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 -
+
``` @@ -281,6 +275,7 @@ Emitted when the DOM is updated. - `url` (`URL`): the URL that was fetched - `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) + - `through` (`(runner: FetchThroughRunner) => void`): registers a [transition runner](#substituting-the-transition-runner-with-through) that substitutes the default update path ### `fetch-update-after` @@ -317,3 +312,26 @@ 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 + +## Substituting the transition runner with `through` + +The [`fetch-update` event](#fetch-update) exposes a `through(runner)` function on its payload. Any listener can substitute the transition 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. + +The runner has the `FetchThroughRunner` 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. + +- **Synchronous registration only**: `through` must be called synchronously while the event dispatches — later calls warn and are ignored. +- **Last call wins**: a single runner is kept, the last `through` 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 `apply` has not run yet. The `fetch-update-after` event is always emitted. + +This is the seam used by the upcoming `MotionView` component from `@studiometa/ui-motion` to animate fetched-content swaps, wired declaratively through an [Action](/reference/items/Action/): + +```html +
+``` From cb1b0ffe3eb270d087a8895fa5e6b1dd2bec866e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:18:45 +0200 Subject: [PATCH 4/9] Add a changelog entry for the Fetch 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..4a2a79a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ 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:** let listeners substitute the content transition runner with `event.detail.through()` on the `fetch-update` event ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) From c828cf140ce8b2d3b19b98dcca7126a9163b3893 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 09:19:29 +0200 Subject: [PATCH 5/9] 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 4a2a79a2..55cf3689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ 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:** let listeners substitute the content transition runner with `event.detail.through()` on the `fetch-update` event +- **Fetch:** let listeners substitute the content transition runner with `event.detail.through()` on the `fetch-update` event ([#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) From 11db51892d6947ab71f6fdd6cfe4244d2909ba1e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 10:15:21 +0200 Subject: [PATCH 6/9] 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. The exported runner type follows: FetchThroughRunner becomes FetchUpdateWrapper. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 2 +- .../.vitepress/reference/public-contracts.ts | 2 +- packages/docs/reference/items/Fetch/js-api.md | 14 ++++---- packages/tests/Fetch/Fetch.spec.ts | 32 +++++++++---------- .../barrel-exports/barrel-exports.spec.ts | 2 +- packages/ui/src/Fetch/Fetch.ts | 18 +++++------ packages/ui/src/Fetch/index.ts | 2 +- packages/ui/src/index.ts | 2 +- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cf3689..701ceb5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ 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:** let listeners substitute the content transition runner with `event.detail.through()` on the `fetch-update` event ([#632](https://github.com/studiometa/ui/pull/632)) +- **Fetch:** let listeners substitute the content transition runner with `event.detail.wrap()` on the `fetch-update` event ([#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 7a5f7b7c..106764d7 100644 --- a/packages/docs/.vitepress/reference/public-contracts.ts +++ b/packages/docs/.vitepress/reference/public-contracts.ts @@ -295,7 +295,7 @@ export const publicContractSymbols = [ status: 'stable', }, { - name: 'FetchThroughRunner', + name: 'FetchUpdateWrapper', kind: 'type', package: 'npm:@studiometa/ui', importPath: '@studiometa/ui', diff --git a/packages/docs/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md index e76e1be0..63aa16e5 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -275,7 +275,7 @@ Emitted when the DOM is updated. - `url` (`URL`): the URL that was fetched - `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) - - `through` (`(runner: FetchThroughRunner) => void`): registers a [transition runner](#substituting-the-transition-runner-with-through) that substitutes the default update path + - `wrap` (`(runner: FetchUpdateWrapper) => void`): registers a [transition runner](#substituting-the-transition-runner-with-wrap) that substitutes the default update path ### `fetch-update-after` @@ -313,14 +313,14 @@ Emitted when the fetch request has been aborted. - `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 -## Substituting the transition runner with `through` +## Substituting the transition runner with `wrap` -The [`fetch-update` event](#fetch-update) exposes a `through(runner)` function on its payload. Any listener can substitute the transition 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. +The [`fetch-update` event](#fetch-update) exposes a `wrap(runner)` function on its payload. Any listener can substitute the transition 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. -The runner has the `FetchThroughRunner` 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. +The runner has the `FetchUpdateWrapper` 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. -- **Synchronous registration only**: `through` must be called synchronously while the event dispatches — later calls warn and are ignored. -- **Last call wins**: a single runner is kept, the last `through` call during dispatch replaces any previous one. +- **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 `apply` has not run yet. The `fetch-update-after` event is always emitted. This is the seam used by the upcoming `MotionView` component from `@studiometa/ui-motion` to animate fetched-content swaps, wired declaratively through an [Action](/reference/items/Action/): @@ -328,7 +328,7 @@ This is the seam used by the upcoming `MotionView` component from `@studiometa/u ```html
+ data-on:fetch-update="MotionView(#list)->event.detail[0].wrap((apply) => target.update(apply))">
diff --git a/packages/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts index ea8c12c6..e35f7743 100644 --- a/packages/tests/Fetch/Fetch.spec.ts +++ b/packages/tests/Fetch/Fetch.spec.ts @@ -955,7 +955,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should let a `through` runner substitute the default transition runner', async () => { + it('should let a `wrap` runner substitute the default transition runner', async () => { const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); document.body.appendChild(container); @@ -978,7 +978,7 @@ describe('The Fetch class', () => { let contentBeforeApply: string | undefined; let contentAfterApply: string | undefined; fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].through((apply: () => void) => { + event.detail[0].wrap((apply: () => void) => { contentBeforeApply = document.getElementById('test')?.textContent; apply(); contentAfterApply = document.getElementById('test')?.textContent; @@ -996,7 +996,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should keep the last `through` runner registered during dispatch', async () => { + 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); @@ -1008,8 +1008,8 @@ describe('The Fetch class', () => { const firstRunner = vi.fn((apply: () => void) => apply()); const lastRunner = vi.fn((apply: () => void) => apply()); fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].through(firstRunner); - event.detail[0].through(lastRunner); + event.detail[0].wrap(firstRunner); + event.detail[0].wrap(lastRunner); }); await fetch.update(new URL('https://example.com'), {}, '
new content
'); @@ -1021,7 +1021,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should ignore and warn on `through` calls after the `fetch-update` event dispatched', async () => { + it('should ignore and warn on `wrap` calls after the `fetch-update` event dispatched', async () => { const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); document.body.appendChild(container); @@ -1032,9 +1032,9 @@ describe('The Fetch class', () => { await mount(fetch); - let through: (runner: (apply: () => void) => void) => void; + let wrap: (runner: (apply: () => void) => void) => void; fetch.$on('fetch-update', (event: CustomEvent) => { - through = event.detail[0].through; + wrap = event.detail[0].wrap; }); await fetch.update(new URL('https://example.com'), {}, '
new content
'); @@ -1043,17 +1043,17 @@ describe('The Fetch class', () => { expect(document.getElementById('test')?.textContent).toBe('new content'); const lateRunner = vi.fn(); - through(lateRunner); + wrap(lateRunner); expect(lateRunner).not.toHaveBeenCalled(); expect(warnFn).toHaveBeenCalledWith( - '`through` must be called synchronously while the `fetch-update` event dispatches.', + '`wrap` must be called synchronously while the `fetch-update` event dispatches.', ); container.remove(); }); - it('should apply the content and warn when a `through` runner rejects', async () => { + 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); @@ -1068,7 +1068,7 @@ describe('The Fetch class', () => { const error = new Error('Runner failed'); fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].through(() => Promise.reject(error)); + event.detail[0].wrap(() => Promise.reject(error)); }); await fetch.update(new URL('https://example.com'), {}, '
new content
'); @@ -1080,7 +1080,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should apply the content and warn when a `through` runner throws synchronously', async () => { + 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); @@ -1095,7 +1095,7 @@ describe('The Fetch class', () => { const error = new Error('Runner failed'); fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].through(() => { + event.detail[0].wrap(() => { throw error; }); }); @@ -1109,7 +1109,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should not apply the content twice when a `through` runner rejects after applying', async () => { + 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); @@ -1123,7 +1123,7 @@ describe('The Fetch class', () => { const error = new Error('Runner failed'); fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].through((apply: () => void) => { + event.detail[0].wrap((apply: () => void) => { apply(); return Promise.reject(error); }); diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index d3707651..93774141 100644 --- a/packages/tests/barrel-exports/barrel-exports.spec.ts +++ b/packages/tests/barrel-exports/barrel-exports.spec.ts @@ -127,7 +127,7 @@ test('@studiometa/ui barrel export surface', () => { "FetchShopifySection [value]", "FetchShopifySectionConstructor [type]", "FetchShopifySectionProps [type]", - "FetchThroughRunner [type]", + "FetchUpdateWrapper [type]", "Figure [value]", "FigureProps [type]", "FigureShopify [value]", diff --git a/packages/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 9a2902b8..8135cf6e 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -29,9 +29,9 @@ export type FetchConstructor = { } & Pick; /** - * Transition runner registered with `through()` on the `fetch-update` event payload. It receives an `apply` function that injects the fetched content into the DOM and is awaited before the `fetch-update-after` event is emitted. + * Transition runner registered with `wrap()` on the `fetch-update` event payload. It receives an `apply` function that injects the fetched content into the DOM and is awaited before the `fetch-update-after` event is emitted. */ -export type FetchThroughRunner = (apply: () => void) => void | Promise; +export type FetchUpdateWrapper = (apply: () => void) => void | Promise; /** * Fetch class. @@ -406,7 +406,7 @@ export class Fetch /** * Dispatch the contents to update to their matching FrameTarget. * - * The `fetch-update` event payload exposes a `through(runner)` function letting listeners substitute the transition runner that applies the fetched content. The runner receives an `apply` function that injects the content into the DOM and is awaited before the `fetch-update-after` event. Registration is only valid synchronously while the event dispatches and the last `through` call wins. With no registered runner, the default paths run: a View Transition when the `viewTransition` option is enabled and supported, a direct update otherwise. + * The `fetch-update` event payload exposes a `wrap(runner)` function letting listeners substitute the transition runner that applies the fetched content. The runner receives an `apply` function that injects the content into the DOM 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, the default paths run: a View Transition when the `viewTransition` option is enabled and supported, a direct update otherwise. */ async update(url: URL, requestInit: RequestInit, content: string) { const { FETCH_EVENTS } = this.constructor; @@ -456,23 +456,23 @@ export class Fetch } /** - * Emit the `fetch-update` event with a `through(runner)` function on its payload and return the registered transition runner, if any. Registration is only valid while the event dispatches: later calls warn and are ignored. A single runner is kept — the last `through` call during dispatch wins. + * Emit the `fetch-update` event with a `wrap(runner)` function on its payload and return the registered transition runner, if any. Registration is only valid while the event dispatches: later calls warn and are ignored. A single runner is kept — the last `wrap` call during dispatch wins. * @private */ - __emitUpdate(url: URL, requestInit: RequestInit, fragment: Document): FetchThroughRunner | null { + __emitUpdate(url: URL, requestInit: RequestInit, fragment: Document): FetchUpdateWrapper | null { const { FETCH_EVENTS } = this.constructor; let dispatching = true; - let runner: FetchThroughRunner | null = null; - const through = (newRunner: FetchThroughRunner) => { + let runner: FetchUpdateWrapper | null = null; + const wrap = (newRunner: FetchUpdateWrapper) => { if (!dispatching) { this.$warn( - `\`through\` must be called synchronously while the \`${FETCH_EVENTS.UPDATE}\` event dispatches.`, + `\`wrap\` must be called synchronously while the \`${FETCH_EVENTS.UPDATE}\` event dispatches.`, ); return; } runner = newRunner; }; - this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment, through }); + this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment, wrap }); dispatching = false; return runner; } diff --git a/packages/ui/src/Fetch/index.ts b/packages/ui/src/Fetch/index.ts index 6c2a8f8e..d72abc0a 100644 --- a/packages/ui/src/Fetch/index.ts +++ b/packages/ui/src/Fetch/index.ts @@ -2,7 +2,7 @@ export { Fetch, type FetchConstructor, type FetchProps, - type FetchThroughRunner, + type FetchUpdateWrapper, } from './Fetch.js'; export { FetchShopifyPartial, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9eb62c12..39e7ab86 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,7 +72,7 @@ export { type FetchShopifyPartialProps, type FetchShopifySectionConstructor, type FetchShopifySectionProps, - type FetchThroughRunner, + type FetchUpdateWrapper, } from './Fetch/index.js'; export { Figure, From c80e3e347f373a7f488ac6f3fda7437a0e1d09f3 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 10:56:50 +0200 Subject: [PATCH 7/9] Route the Fetch view transition through the shared scheduler The default path called document.startViewTransition directly, racing every other view transition on the page over the one-transition-per- document limit. It now runs through the shared viewTransition scheduler: updates requested in the same tick batch into a single transition (covered by a new simultaneous-updates spec), batches serialize behind in-flight transitions, and the unsupported-API fallback moves into the scheduler. The update() promise now settles when the transition finishes instead of when it becomes ready. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 1 + packages/docs/reference/items/Fetch/js-api.md | 2 +- packages/tests/Fetch/Fetch.spec.ts | 46 ++++++++++++++++++- packages/ui/src/Fetch/Fetch.ts | 10 ++-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 701ceb5a..3411f8a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ 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:** let listeners substitute the content transition runner with `event.detail.wrap()` on the `fetch-update` event ([#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/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md index 63aa16e5..308eb7a1 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 diff --git a/packages/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts index e35f7743..e8f83ff0 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', { @@ -955,6 +957,44 @@ describe('The Fetch class', () => { 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 `wrap` runner substitute the default transition runner', async () => { const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); document.body.appendChild(container); @@ -968,6 +1008,7 @@ describe('The Fetch class', () => { callback(); return { ready: Promise.resolve(), + finished: Promise.resolve(), }; }); Object.defineProperty(document, 'startViewTransition', { @@ -1252,6 +1293,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/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 8135cf6e..9480fa6e 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -2,8 +2,8 @@ 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 { adoptNewScripts, getScripts } from './utils.js'; export interface FetchProps extends BaseProps { @@ -406,7 +406,7 @@ export class Fetch /** * Dispatch the contents to update to their matching FrameTarget. * - * The `fetch-update` event payload exposes a `wrap(runner)` function letting listeners substitute the transition runner that applies the fetched content. The runner receives an `apply` function that injects the content into the DOM 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, the default paths run: a View Transition when the `viewTransition` option is enabled and supported, a direct update otherwise. + * The `fetch-update` event payload exposes a `wrap(runner)` function letting listeners substitute the transition runner that applies the fetched content. The runner receives an `apply` function that injects the content into the DOM 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; @@ -444,10 +444,10 @@ export class Fetch apply(); } } - } else if (viewTransition && isFunction(document.startViewTransition)) { - await document.startViewTransition(() => { + } else if (viewTransition) { + await scheduleViewTransition(() => { this.__updateDOM(fragment); - }).ready; + }); } else { this.__updateDOM(fragment); } From f65ddf0b049a19879259c21f3fd7f33c836798d6 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 14:04:07 +0200 Subject: [PATCH 8/9] Move the Fetch wrap seam to the dom-update protocol event Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FVXrJ8idMfvt667yJadvB8 --- CHANGELOG.md | 2 +- .../.vitepress/reference/public-contracts.ts | 16 ++++ packages/docs/reference/items/Fetch/js-api.md | 30 +++++-- packages/tests/Fetch/Fetch.spec.ts | 66 ++++++++++---- .../barrel-exports/barrel-exports.spec.ts | 3 +- packages/ui/src/Fetch/Fetch.ts | 64 ++++---------- packages/ui/src/Fetch/index.ts | 7 +- packages/ui/src/index.ts | 2 +- packages/ui/src/utils/dom-update.ts | 86 +++++++++++++++++++ 9 files changed, 194 insertions(+), 82 deletions(-) create mode 100644 packages/ui/src/utils/dom-update.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3411f8a8..d3f86abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ 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:** let listeners substitute the content transition runner with `event.detail.wrap()` on the `fetch-update` event ([#632](https://github.com/studiometa/ui/pull/632)) +- **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 106764d7..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', diff --git a/packages/docs/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md index 308eb7a1..0258a36c 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -275,7 +275,16 @@ Emitted when the DOM is updated. - `url` (`URL`): the URL that was fetched - `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) - - `wrap` (`(runner: FetchUpdateWrapper) => void`): registers a [transition runner](#substituting-the-transition-runner-with-wrap) that substitutes the default update path + +### `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` @@ -313,22 +322,25 @@ Emitted when the fetch request has been aborted. - `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 -## Substituting the transition runner with `wrap` +## 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: -The [`fetch-update` event](#fetch-update) exposes a `wrap(runner)` function on its payload. Any listener can substitute the transition 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. +- 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 runner has the `FetchUpdateWrapper` 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. +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 `apply` has not run yet. The `fetch-update-after` event is always emitted. +- **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. -This is the seam used by the upcoming `MotionView` component from `@studiometa/ui-motion` to animate fetched-content swaps, wired declaratively through an [Action](/reference/items/Action/): +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 e8f83ff0..79759c12 100644 --- a/packages/tests/Fetch/Fetch.spec.ts +++ b/packages/tests/Fetch/Fetch.spec.ts @@ -995,7 +995,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should let a `wrap` runner substitute the default transition runner', async () => { + 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); @@ -1018,8 +1018,8 @@ describe('The Fetch class', () => { let contentBeforeApply: string | undefined; let contentAfterApply: string | undefined; - fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].wrap((apply: () => void) => { + fetch.$on('dom-update', (event: CustomEvent) => { + event.detail.wrap((apply: () => void) => { contentBeforeApply = document.getElementById('test')?.textContent; apply(); contentAfterApply = document.getElementById('test')?.textContent; @@ -1037,6 +1037,34 @@ describe('The Fetch class', () => { 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); @@ -1048,9 +1076,9 @@ describe('The Fetch class', () => { const firstRunner = vi.fn((apply: () => void) => apply()); const lastRunner = vi.fn((apply: () => void) => apply()); - fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].wrap(firstRunner); - event.detail[0].wrap(lastRunner); + fetch.$on('dom-update', (event: CustomEvent) => { + event.detail.wrap(firstRunner); + event.detail.wrap(lastRunner); }); await fetch.update(new URL('https://example.com'), {}, '
new content
'); @@ -1062,7 +1090,7 @@ describe('The Fetch class', () => { container.remove(); }); - it('should ignore and warn on `wrap` calls after the `fetch-update` event dispatched', async () => { + 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); @@ -1074,8 +1102,8 @@ describe('The Fetch class', () => { await mount(fetch); let wrap: (runner: (apply: () => void) => void) => void; - fetch.$on('fetch-update', (event: CustomEvent) => { - wrap = event.detail[0].wrap; + fetch.$on('dom-update', (event: CustomEvent) => { + wrap = event.detail.wrap; }); await fetch.update(new URL('https://example.com'), {}, '
new content
'); @@ -1088,7 +1116,7 @@ describe('The Fetch class', () => { expect(lateRunner).not.toHaveBeenCalled(); expect(warnFn).toHaveBeenCalledWith( - '`wrap` must be called synchronously while the `fetch-update` event dispatches.', + '`wrap` must be called synchronously while the `dom-update` event dispatches.', ); container.remove(); @@ -1108,14 +1136,14 @@ describe('The Fetch class', () => { await mount(fetch); const error = new Error('Runner failed'); - fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].wrap(() => Promise.reject(error)); + 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 `fetch-update` event runner rejected.', error); + expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error); expect(fn).toHaveBeenCalled(); container.remove(); @@ -1135,8 +1163,8 @@ describe('The Fetch class', () => { await mount(fetch); const error = new Error('Runner failed'); - fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].wrap(() => { + fetch.$on('dom-update', (event: CustomEvent) => { + event.detail.wrap(() => { throw error; }); }); @@ -1144,7 +1172,7 @@ describe('The Fetch class', () => { await fetch.update(new URL('https://example.com'), {}, '
new content
'); expect(document.getElementById('test')?.textContent).toBe('new content'); - expect(warnFn).toHaveBeenCalledWith('The `fetch-update` event runner rejected.', error); + expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error); expect(fn).toHaveBeenCalled(); container.remove(); @@ -1163,8 +1191,8 @@ describe('The Fetch class', () => { await mount(fetch); const error = new Error('Runner failed'); - fetch.$on('fetch-update', (event: CustomEvent) => { - event.detail[0].wrap((apply: () => void) => { + fetch.$on('dom-update', (event: CustomEvent) => { + event.detail.wrap((apply: () => void) => { apply(); return Promise.reject(error); }); @@ -1174,7 +1202,7 @@ describe('The Fetch class', () => { expect(document.getElementById('test')?.textContent).toBe('new content'); expect(updateDOMSpy).toHaveBeenCalledOnce(); - expect(warnFn).toHaveBeenCalledWith('The `fetch-update` event runner rejected.', error); + expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error); container.remove(); }); diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index 93774141..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]", @@ -127,7 +129,6 @@ test('@studiometa/ui barrel export surface', () => { "FetchShopifySection [value]", "FetchShopifySectionConstructor [type]", "FetchShopifySectionProps [type]", - "FetchUpdateWrapper [type]", "Figure [value]", "FigureProps [type]", "FigureShopify [value]", diff --git a/packages/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 9480fa6e..35c19bec 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -4,6 +4,7 @@ import { domScheduler } from '@studiometa/js-toolkit/utils/domScheduler'; import { historyPush } from '@studiometa/js-toolkit/utils/historyPush'; 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 { @@ -28,11 +29,6 @@ export type FetchConstructor = { prototype: Fetch; } & Pick; -/** - * Transition runner registered with `wrap()` on the `fetch-update` event payload. It receives an `apply` function that injects the fetched content into the DOM and is awaited before the `fetch-update-after` event is emitted. - */ -export type FetchUpdateWrapper = (apply: () => void) => void | Promise; - /** * Fetch class. * @@ -85,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, @@ -359,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); } @@ -406,7 +412,7 @@ export class Fetch /** * Dispatch the contents to update to their matching FrameTarget. * - * The `fetch-update` event payload exposes a `wrap(runner)` function letting listeners substitute the transition runner that applies the fetched content. The runner receives an `apply` function that injects the content into the DOM 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. + * 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; @@ -428,22 +434,12 @@ export class Fetch }); } - const runner = this.__emitUpdate(url, requestInit, fragment); + this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment }); + + const runner = emitDomUpdate(this); if (runner) { - let applied = false; - const apply = () => { - applied = true; - this.__updateDOM(fragment); - }; - try { - await runner(apply); - } catch (error) { - this.$warn(`The \`${FETCH_EVENTS.UPDATE}\` event runner rejected.`, error); - if (!applied) { - apply(); - } - } + await runWrapped(this, runner, () => this.__updateDOM(fragment)); } else if (viewTransition) { await scheduleViewTransition(() => { this.__updateDOM(fragment); @@ -455,28 +451,6 @@ export class Fetch this.$emit(FETCH_EVENTS.AFTER_UPDATE, { instance: this, url, requestInit, fragment }); } - /** - * Emit the `fetch-update` event with a `wrap(runner)` function on its payload and return the registered transition runner, if any. Registration is only valid while the event dispatches: later calls warn and are ignored. A single runner is kept — the last `wrap` call during dispatch wins. - * @private - */ - __emitUpdate(url: URL, requestInit: RequestInit, fragment: Document): FetchUpdateWrapper | null { - const { FETCH_EVENTS } = this.constructor; - let dispatching = true; - let runner: FetchUpdateWrapper | null = null; - const wrap = (newRunner: FetchUpdateWrapper) => { - if (!dispatching) { - this.$warn( - `\`wrap\` must be called synchronously while the \`${FETCH_EVENTS.UPDATE}\` event dispatches.`, - ); - return; - } - runner = newRunner; - }; - this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment, wrap }); - dispatching = false; - return runner; - } - /** * Handle errors. */ diff --git a/packages/ui/src/Fetch/index.ts b/packages/ui/src/Fetch/index.ts index d72abc0a..efa561ed 100644 --- a/packages/ui/src/Fetch/index.ts +++ b/packages/ui/src/Fetch/index.ts @@ -1,9 +1,4 @@ -export { - Fetch, - type FetchConstructor, - type FetchProps, - type FetchUpdateWrapper, -} from './Fetch.js'; +export { Fetch, type FetchConstructor, type FetchProps } from './Fetch.js'; export { FetchShopifyPartial, type FetchShopifyPartialConstructor, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 39e7ab86..ee1af124 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,7 +72,6 @@ export { type FetchShopifyPartialProps, type FetchShopifySectionConstructor, type FetchShopifySectionProps, - type FetchUpdateWrapper, } from './Fetch/index.js'; export { Figure, @@ -189,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..2b2eeaaa --- /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; + + 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 2945e5dd92e51fcb694999a76bdc5999874c6dfb Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 13 Aug 2026 14:09:51 +0200 Subject: [PATCH 9/9] 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) {