diff --git a/CHANGELOG.md b/CHANGELOG.md
index 435a03d23..66988baac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- **Fetch:** add the `historyMode` option and keep the `src` separation on popstate ([#656](https://github.com/studiometa/ui/pull/656))
- **Fetch:** report `fetch.file-not-uploaded` when a file control cannot be sent as a file ([#656](https://github.com/studiometa/ui/pull/656))
+### Changed
+
+- ⚠️ **Fetch:** replace the `url` and `requestInit` event payload fields with one progressive lifecycle detail ([#657](https://github.com/studiometa/ui/pull/657))
+- ⚠️ **Fetch:** drop the raw `Response` from the `fetch-response` payload ([#657](https://github.com/studiometa/ui/pull/657))
+
### Fixed
- **Fetch:** send a form submission's submitter and its `formaction`, `formmethod` and `formenctype` ([#656](https://github.com/studiometa/ui/pull/656))
+- **Fetch:** await the DOM update so `fetch-update-after` settles it and a failed update reaches `fetch-error` ([#657](https://github.com/studiometa/ui/pull/657))
## [v2.0.0-alpha.0](https://github.com/studiometa/ui/compare/1.11.1..2.0.0-alpha.0) (2026-09-03)
diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts
index b0f125f80..1558bbbc7 100644
--- a/packages/docs/.vitepress/reference/public-contracts.ts
+++ b/packages/docs/.vitepress/reference/public-contracts.ts
@@ -367,7 +367,7 @@ export const publicContractSymbols = [
status: 'stable',
},
{
- name: 'FetchEventBase',
+ name: 'FetchLifecycleDetail',
kind: 'type',
package: 'npm:@studiometa/ui',
importPath: '@studiometa/ui',
@@ -390,6 +390,30 @@ export const publicContractSymbols = [
href: '/reference/items/Fetch/js-api',
status: 'stable',
},
+ {
+ name: 'FetchRequestDetail',
+ kind: 'type',
+ package: 'npm:@studiometa/ui',
+ importPath: '@studiometa/ui',
+ href: '/reference/items/Fetch/js-api',
+ status: 'stable',
+ },
+ {
+ name: 'FetchResponseDetail',
+ kind: 'type',
+ package: 'npm:@studiometa/ui',
+ importPath: '@studiometa/ui',
+ href: '/reference/items/Fetch/js-api',
+ status: 'stable',
+ },
+ {
+ name: 'FetchShopifyPartialDetail',
+ kind: 'type',
+ package: 'npm:@studiometa/ui',
+ importPath: '@studiometa/ui',
+ href: '/reference/items/FetchShopifyPartial/js-api',
+ status: 'preview',
+ },
{
name: 'FetchShopifyPartialProps',
kind: 'type',
diff --git a/packages/docs/migration-guides/1.0-2.0/index.md b/packages/docs/migration-guides/1.0-2.0/index.md
index b01718665..a06f6eb86 100644
--- a/packages/docs/migration-guides/1.0-2.0/index.md
+++ b/packages/docs/migration-guides/1.0-2.0/index.md
@@ -644,6 +644,8 @@ In v1 every `detail` was an array of the positional arguments. In v2 it is the p
This includes components whose payload was already an object: `Fetch` and `Draggable` were `[{ … }]` in v1 and are `{ … }` in v2.
+`Fetch` also changes what that object holds: the `url` and `requestInit` fields are replaced by one plain `request` description, and the update events gain the response status and headers. See [the event detail](/reference/items/Fetch/js-api#the-event-detail).
+
| Component | Event | v1.x `detail` | v2.x `detail` |
| ----------------- | ---------------------------------- | ------------------------ | -------------------------- |
| `Carousel` | `progress` | `[progress]` | `{ progress }` |
@@ -654,7 +656,7 @@ This includes components whose payload was already an object: `Fetch` and `Dragg
| `DisclosureGroup` | `disclosure-group-open` / `-close` | `[item, index]` | `{ item, index }` |
| `DisclosureGroup` | `disclosure-group-change` | `[openItems]` | `{ items }` |
| `Draggable` | `drag-*` | `[props]` | `props` |
-| `Fetch` | `fetch-*` | `[{ instance, url, … }]` | `{ instance, url, … }` |
+| `Fetch` | `fetch-*` | `[{ instance, url, … }]` | `{ instance, request, … }` |
| `Indexable` | `index` | `[index]` | `{ index }` |
| `Prefetch` | `prefetched` | `[url]` | `{ url }` |
| `Sentinel` | `intersected` | `[entries]` | `{ isInView, entry }` |
diff --git a/packages/docs/reference/items/Fetch/index.md b/packages/docs/reference/items/Fetch/index.md
index 725e63d2b..489d7a4f3 100644
--- a/packages/docs/reference/items/Fetch/index.md
+++ b/packages/docs/reference/items/Fetch/index.md
@@ -129,8 +129,8 @@ Use the [`Action`](../Action/index.md) and [`Transition`](../Transition/index.md
href="/"
data-component="Fetch Action"
data-option-history
- data-on:before-fetch="Transition(#foo) -> transition.enter()"
- data-on:after-fetch="Transition(#foo) -> transition.leave()"
+ data-on:fetch-before="Transition(#foo) -> transition.enter()"
+ data-on:fetch-after="Transition(#foo) -> transition.leave()"
data-on:fetch-error="alert('error')">
Click me
@@ -223,7 +223,7 @@ The `Fetch` components catches request errors and emits a [`fetch-error` event](
```html [index.html] {3}
+ data-on:fetch-error="alert(event.detail.error)">
Home
```
diff --git a/packages/docs/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md
index 8be631249..36d2332e3 100644
--- a/packages/docs/reference/items/Fetch/js-api.md
+++ b/packages/docs/reference/items/Fetch/js-api.md
@@ -336,75 +336,128 @@ Every one is a development-only warning on the [toolkit diagnostic channel](http
All events from the `Fetch` component bubble up the DOM tree, so they can be listened to from any parent element.
+### The event detail
+
+Every `fetch-*` event carries a detail of the same shape, holding the fields known at that point in the lifecycle. `event.detail` **is** that object, so a listener reads a field by path with nothing to unwrap.
+
+```ts
+interface FetchLifecycleDetail {
+ instance: Fetch;
+ request: {
+ url: string;
+ method: string;
+ searchParams: Record;
+ };
+ response?: {
+ url: string;
+ status: number;
+ statusText: string;
+ ok: boolean;
+ redirected: boolean;
+ headers: Record;
+ };
+ content?: string;
+ fragment?: Document;
+}
+```
+
+- `instance` (`Fetch`): the `Fetch` instance emitting the event.
+- `request.url` (`string`): the absolute URL the request is sent to.
+- `request.method` (`string`): the HTTP method, uppercase.
+- `request.searchParams` (`Record`): the query, with every value each name carries. A repeated name — a checkbox group, a `` — keeps all of its values, which is why each name maps to a list.
+- `response` (`object`): the response description, present once the request has returned one.
+- `response.headers` (`Record`): the response headers, names lowercase.
+- `content` (`string`): the string extracted from the response body by the [`response` option](#response), present once the body has been read.
+- `fragment` (`Document`): `content` parsed with a [`DOMParser`](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser), present once the update starts.
+
+Three events add one field of their own: [`fetch-after`](#fetch-after) and [`fetch-error`](#fetch-error) carry `error`, [`fetch-abort`](#fetch-abort) carries `reason`.
+
+Everything except `instance` and `fragment` is plain data — no `URL`, no `Headers`, no `RequestInit`, no getters — so a declarative consumer resolves any field by path, with nothing to import:
+
+
+```html
+
+
+
+```
+
+
+`response` describes the response, it is not the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). A body reads once, and the component reads it to produce `content`, so the object itself is never handed to listeners: one of them consuming the body would leave the component with nothing to inject.
+
+### Event order
+
+A successful request emits, in this order:
+
+| Order | Event | Detail added |
+| ----- | --------------------------------------------- | ---------------- |
+| 1 | [`fetch-before`](#fetch-before) | `request` |
+| 2 | [`fetch-fetch`](#fetch-fetch) | |
+| 3 | [`fetch-response`](#fetch-response) | `response` |
+| 4 | [`fetch-after`](#fetch-after) | `content` |
+| 5 | [`fetch-update-before`](#fetch-update-before) | |
+| 6 | [`fetch-update`](#fetch-update) | `fragment` |
+| 7 | [`dom-update`](#dom-update) | _protocol event_ |
+| 8 | [`fetch-update-after`](#fetch-update-after) | |
+
+The promise returned by [`fetch()`](#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) resolves after `fetch-update-after`, so awaiting it means every swap has settled.
+
+A failed request replaces steps 4 to 8 with `fetch-after` carrying `error` instead of `content`, then [`fetch-error`](#fetch-error). `fetch-response` is emitted only when a response came back, so a network failure goes straight from `fetch-fetch` to `fetch-after`.
+
+A failed update — a rejected swap, a rejected [`dom-update`](#the-dom-update-protocol-event) runner — emits `fetch-error` in place of `fetch-update-after`, carrying the `content` and the `fragment` it was applying. It does not emit a second `fetch-after`: the request succeeded, the update did not.
+
+[`fetch-abort`](#fetch-abort) is emitted whenever the request in flight is aborted, which happens when a new request starts on the same instance or when [`abort()`](#abort-reason-any) is called.
+
### `fetch-before`
Emitted before the fetch request is sent.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that will be fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
+- `instance`, `request`.
### `fetch-fetch`
Emitted when the fetch request is sent.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that will be fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
+- `instance`, `request`.
### `fetch-response`
Emitted when the fetch request returned a response, before extracting its body, and before throwing if `response.ok !== true`.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `response` (`Response`): the `Response` object returned by the `fetch` request
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that will be fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
+- `instance`, `request`, `response`.
### `fetch-after`
Emitted after the fetch request is finished, whether it is successful or not.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that was fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
- - `content` (`string | void`): the content of the response if the request succeeded
+- `instance`, `request`, `response` when a response came back, and either `content` when the request succeeded or `error` when it failed.
### `fetch-update-before`
Emitted before the DOM is updated.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that was fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
- - `content` (`string`): the content of the response
+- `instance`, `request`, `response`, `content`.
### `fetch-update`
Emitted when the DOM is updated.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `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)
+- `instance`, `request`, `response`, `content`, `fragment`.
### `dom-update`
@@ -412,44 +465,36 @@ Emitted after the [`fetch-update` event](#fetch-update), right before the fetche
**Detail**
-The event `detail` is a bare object (not an argument array) with the following property:
+The event `detail` carries the same fields as `fetch-update`, plus the one the protocol is made of:
- `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.
+Emitted when the DOM has been updated and every swap has settled.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `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)
+- `instance`, `request`, `response`, `content`, `fragment`.
### `fetch-error`
-Emitted when the fetch request failed.
+Emitted when the fetch request failed, or when the DOM update failed.
-**Payload**
+**Detail**
+
+- `instance`, `request`, everything the lifecycle had learned when it failed, and:
+ - `error` (`Error`): the error thrown by the failing request or the failing update
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that was fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
- - `error` (`Error`): the error object thrown by the failing request
+A failed request carries `response` when one came back and nothing else: there was no content to apply. A failed update carries `response`, `content` and `fragment`, which is what was being applied when it failed.
### `fetch-abort`
Emitted when the fetch request has been aborted.
-**Payload**
+**Detail**
-- `ctx` (`Object`): context for the event with the following properties
- - `instance` (`Fetch`): the `Fetch` instance emitting the event
- - `url` (`URL`): the URL that was fetched
- - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)): options for the `fetch` call
+- `instance`, `request`, and:
- `reason` (`any`): the reason the request was aborted
## The `dom-update` protocol event
diff --git a/packages/docs/reference/items/Fetch/stories/abort/app.twig b/packages/docs/reference/items/Fetch/stories/abort/app.twig
index 20518b822..ded1ffd68 100644
--- a/packages/docs/reference/items/Fetch/stories/abort/app.twig
+++ b/packages/docs/reference/items/Fetch/stories/abort/app.twig
@@ -23,7 +23,7 @@
data-component="Action"
data-on:fetch-before="Transition(#loader) -> target.enter()"
data-on:fetch-after="Transition(#loader) -> target.leave()"
- data-on:fetch-abort="DataBind(#toaster) -> target.value += `${event.detail[0].reason}\n`"
+ data-on:fetch-abort="DataBind(#toaster) -> target.value += `${event.detail.reason}\n`"
class="flex flex-col gap-4">
{{
diff --git a/packages/docs/reference/items/Fetch/stories/error/app.twig b/packages/docs/reference/items/Fetch/stories/error/app.twig
index 44fdf8ca7..dc68d04de 100644
--- a/packages/docs/reference/items/Fetch/stories/error/app.twig
+++ b/packages/docs/reference/items/Fetch/stories/error/app.twig
@@ -23,7 +23,7 @@
data-component="Action"
data-on:fetch-before="Transition(#loader) -> target.enter()"
data-on:fetch-after="Transition(#loader) -> target.leave()"
- data-on:fetch-error="DataBind(#toaster) -> target.value += event.detail[0].error.message + '\n'"
+ data-on:fetch-error="DataBind(#toaster) -> target.value += event.detail.error.message + '\n'"
class="flex flex-col gap-8 max-w-xl">
{{
diff --git a/packages/docs/reference/items/FetchShopifyPartial/js-api.md b/packages/docs/reference/items/FetchShopifyPartial/js-api.md
index 288a24717..fd0869cfc 100644
--- a/packages/docs/reference/items/FetchShopifyPartial/js-api.md
+++ b/packages/docs/reference/items/FetchShopifyPartial/js-api.md
@@ -40,7 +40,7 @@ The package is loaded lazily on the first request, so it never needs to be bundl
`FetchShopifyPartial` emits the same [events as `Fetch`](../Fetch/js-api.md#events), with two differences on the partial rendering path:
-- the [`fetch-response` event](../Fetch/js-api.md#fetch-response) is **not** emitted, as there is no `Response` object to expose;
-- the [`fetch-update` event](../Fetch/js-api.md#fetch-update) payload carries the opaque partials `update` object (as `event.detail[0].update`) instead of a parsed `Document` fragment.
+- the [`fetch-response` event](../Fetch/js-api.md#fetch-response) is **not** emitted, and no event carries a `response` description, as there is no `Response` on this path;
+- every event carries the opaque partials `update` object (as `event.detail.update`) instead of the `content` string and the parsed `fragment`, which do not exist on this path.
On the fallback path, all events — including `fetch-response` — behave exactly like the base [`Fetch`](../Fetch/js-api.md#events) component.
diff --git a/packages/docs/reference/items/FetchShopifySection/js-api.md b/packages/docs/reference/items/FetchShopifySection/js-api.md
index b2e1285e4..26f1e5ba3 100644
--- a/packages/docs/reference/items/FetchShopifySection/js-api.md
+++ b/packages/docs/reference/items/FetchShopifySection/js-api.md
@@ -45,6 +45,8 @@ Overrides the base [`fetch`](../Fetch/js-api.md#fetch-url-string-requestinit-req
Unwraps the Section Rendering JSON object (`{ [id]: html }`) into the concatenated section HTML, dropping any section returned as `null` through `filter(Boolean)`. Each section is then swapped in place by the inherited [`[id]` selector](../Fetch/js-api.md#selector). The unwrap is skipped — deferring to the base [`Fetch`](../Fetch/js-api.md), which evaluates the [`response`](#response) option — when no `sections` are configured (a normal HTML page is requested) or when a custom `response` option is supplied.
-### `update(url, requestInit, content)`
+### `update(url, requestInit, content, detail)`
Overrides the base `update` to remove the `sections` parameter from the URL before delegating to `Fetch`, so — when the [`history` option](../Fetch/js-api.md#history) is enabled — the address bar reflects the human-facing page and not the raw Section Rendering endpoint.
+
+The stripping is a history concern only. The [event detail](../Fetch/js-api.md#the-event-detail) describes the request that was actually made, so `event.detail.request.url` carries the Section Rendering endpoint on every event.
diff --git a/packages/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts
index cda28d81a..e496a05ee 100644
--- a/packages/tests/Fetch/Fetch.spec.ts
+++ b/packages/tests/Fetch/Fetch.spec.ts
@@ -1,7 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getInstance, registerComponents } from '@studiometa/js-toolkit';
-import { captureDiagnostics, mount, recordEvents, resetDom, settle } from '@studiometa/js-toolkit/test';
-import { Fetch, FETCH_EVENTS, type FetchEmits } from '#private/Fetch/Fetch.js';
+import {
+ captureDiagnostics,
+ mount,
+ recordEvents,
+ resetDom,
+ settle,
+} from '@studiometa/js-toolkit/test';
+import {
+ Fetch,
+ FETCH_EVENTS,
+ type FetchEmits,
+ type FetchLifecycleDetail,
+} from '#private/Fetch/Fetch.js';
import { FetchShopifySection } from '#private/Fetch/FetchShopifySection.js';
registerComponents(Fetch, FetchShopifySection);
@@ -58,7 +69,23 @@ function stubClient(
return client;
}
-/** Collect every `Fetch` event dispatched under `root`, in order. */
+/** The detail of the first recorded event of the given type. */
+function detailOf(events: { type: string; detail: unknown }[], type: string): FetchLifecycleDetail {
+ const event = events.find((candidate) => candidate.type === type);
+ expect(event).toBeDefined();
+ return event!.detail as FetchLifecycleDetail;
+}
+
+/**
+ * Read a dotted path off a value, the way a declarative consumer resolves
+ * `$event.detail.response.headers.x-search-result-count` with no knowledge of
+ * `Fetch`.
+ */
+function resolvePath(source: unknown, path: string): unknown {
+ return path
+ .split('.')
+ .reduce((value, key) => (value as Record | undefined)?.[key], source);
+}
/** Take over both history writers and record which one an update reaches for. */
function stubHistory(): {
@@ -840,8 +867,8 @@ describe('Fetch — the request', () => {
await instance.fetch();
expect(detail?.instance).toBe(instance);
- expect(detail?.response).toBeInstanceOf(Response);
- expect(detail?.url).toBeInstanceOf(URL);
+ expect(detail?.request.url).toBe(instance.url.href);
+ expect(detail?.response.status).toBe(200);
});
it('aborts the request in flight when a new one starts', async () => {
@@ -916,6 +943,436 @@ describe('Fetch — the request', () => {
});
});
+describe('Fetch — the lifecycle detail', () => {
+ it('describes the request as plain data, with no URL or RequestInit in the way', async () => {
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ``,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ expect(events.length).toBeGreaterThan(0);
+ for (const { detail } of events as { detail: FetchLifecycleDetail }[]) {
+ expect(detail.instance).toBe(instance);
+ expect(detail.request).toEqual({
+ url: 'https://example.com/search?q=shoes',
+ method: 'GET',
+ searchParams: { q: ['shoes'] },
+ });
+ expect(detail).not.toHaveProperty('url');
+ expect(detail).not.toHaveProperty('requestInit');
+ }
+ });
+
+ it('reports the method of a POST form uppercase', async () => {
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ``,
+ );
+ const { events } = recordEvents(root, FETCH_EVENTS.BEFORE_FETCH);
+
+ await instance.fetch();
+ await settle();
+
+ expect(detailOf(events, FETCH_EVENTS.BEFORE_FETCH).request.method).toBe('POST');
+ });
+
+ it('keeps every value of a repeated query parameter', async () => {
+ // A checkbox group is repeated names by design, so one value per name
+ // would describe a request the visitor never made.
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ``,
+ );
+ const { events } = recordEvents(root, FETCH_EVENTS.BEFORE_FETCH);
+
+ await instance.fetch();
+ await settle();
+
+ expect(detailOf(events, FETCH_EVENTS.BEFORE_FETCH).request.searchParams).toEqual({
+ genre: ['rock', 'jazz'],
+ q: ['shoes'],
+ });
+ });
+
+ it('describes the response instead of handing out the `Response`', async () => {
+ stubClient(
+ async () =>
+ new Response('new
', {
+ status: 200,
+ headers: { 'X-Search-Result-Count': '42' },
+ }),
+ );
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ const { response } = detailOf(events, FETCH_EVENTS.RESPONSE);
+ expect(response).not.toBeInstanceOf(Response);
+ expect(response?.headers).not.toBeInstanceOf(Headers);
+ expect(response?.status).toBe(200);
+ expect(response?.ok).toBe(true);
+ expect(response?.redirected).toBe(false);
+ });
+
+ it('normalises the response header names to lowercase', async () => {
+ stubClient(
+ async () =>
+ new Response('new
', {
+ headers: { 'X-Search-Result-Count': '42' },
+ }),
+ );
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ expect(detailOf(events, FETCH_EVENTS.RESPONSE).response?.headers['x-search-result-count']).toBe(
+ '42',
+ );
+ });
+
+ it('keeps the response status and headers on the update events', async () => {
+ stubClient(
+ async () =>
+ new Response('new
', {
+ status: 200,
+ headers: { 'X-Search-Result-Count': '42' },
+ }),
+ );
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ for (const type of [
+ FETCH_EVENTS.BEFORE_UPDATE,
+ FETCH_EVENTS.UPDATE,
+ FETCH_EVENTS.AFTER_UPDATE,
+ ]) {
+ const detail = detailOf(events, type);
+ expect(detail.response?.status).toBe(200);
+ expect(detail.response?.headers['x-search-result-count']).toBe('42');
+ }
+ });
+
+ it('adds the metadata as the lifecycle progresses', async () => {
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ function known(type: string) {
+ const detail = detailOf(events, type);
+ return {
+ response: detail.response !== undefined,
+ content: detail.content !== undefined,
+ fragment: detail.fragment !== undefined,
+ };
+ }
+
+ expect(known(FETCH_EVENTS.BEFORE_FETCH)).toEqual({
+ response: false,
+ content: false,
+ fragment: false,
+ });
+ expect(known(FETCH_EVENTS.FETCH)).toEqual({
+ response: false,
+ content: false,
+ fragment: false,
+ });
+ expect(known(FETCH_EVENTS.RESPONSE)).toEqual({
+ response: true,
+ content: false,
+ fragment: false,
+ });
+ expect(known(FETCH_EVENTS.AFTER_FETCH)).toEqual({
+ response: true,
+ content: true,
+ fragment: false,
+ });
+ expect(known(FETCH_EVENTS.BEFORE_UPDATE)).toEqual({
+ response: true,
+ content: true,
+ fragment: false,
+ });
+ expect(known(FETCH_EVENTS.UPDATE)).toEqual({ response: true, content: true, fragment: true });
+ expect(known(FETCH_EVENTS.AFTER_UPDATE)).toEqual({
+ response: true,
+ content: true,
+ fragment: true,
+ });
+ });
+
+ it('carries the content and the parsed fragment on the update events', async () => {
+ stubClient(async () => new Response('new
'));
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ const detail = detailOf(events, FETCH_EVENTS.AFTER_UPDATE);
+ expect(detail.content).toBe('new
');
+ expect(detail.fragment?.getElementById('fetch-default')?.textContent).toBe('new');
+ });
+
+ it('resolves every field through a generic nested-path walk', async () => {
+ // This is what a declarative consumer does with the detail: walk it by
+ // path, knowing nothing about `Fetch`. A getter, a `Headers` or a `Map`
+ // anywhere on the way would make the path resolve to `undefined`.
+ stubClient(
+ async () =>
+ new Response('new
', {
+ headers: { 'X-Search-Result-Count': '42' },
+ }),
+ );
+ const { root, instance } = await mountFetch(
+ ``,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+ await settle();
+
+ const detail = detailOf(events, FETCH_EVENTS.AFTER_UPDATE);
+
+ expect(resolvePath(detail, 'instance')).toBe(instance);
+ expect(resolvePath(detail, 'request.url')).toBe(
+ 'https://example.com/search?genre=rock&genre=jazz',
+ );
+ expect(resolvePath(detail, 'request.method')).toBe('GET');
+ expect(resolvePath(detail, 'request.searchParams.genre.0')).toBe('rock');
+ expect(resolvePath(detail, 'request.searchParams.genre.1')).toBe('jazz');
+ expect(resolvePath(detail, 'response.status')).toBe(200);
+ expect(resolvePath(detail, 'response.ok')).toBe(true);
+ expect(resolvePath(detail, 'response.headers.x-search-result-count')).toBe('42');
+ expect(resolvePath(detail, 'content')).toBe('new
');
+ });
+
+ it('describes the response on the error of a failed request', async () => {
+ stubClient(async () => new Response('nope', { status: 500, headers: { 'X-Reason': 'boom' } }));
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ for (const type of [FETCH_EVENTS.AFTER_FETCH, FETCH_EVENTS.ERROR]) {
+ const detail = detailOf(events, type);
+ expect(detail.response?.status).toBe(500);
+ expect(detail.response?.headers['x-reason']).toBe('boom');
+ }
+ });
+
+ it('leaves the response undefined when the request never returned one', async () => {
+ stubClient(async () => {
+ throw new Error('network down');
+ });
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ expect(detailOf(events, FETCH_EVENTS.ERROR).response).toBeUndefined();
+ });
+
+ it('carries the request on the abort event', async () => {
+ stubClient(async () => new Promise(() => {}));
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ void instance.fetch();
+ instance.abort('because');
+ await settle();
+
+ expect(detailOf(events, FETCH_EVENTS.ABORT).request.url).toBe('https://example.com/page');
+ });
+});
+
+describe('Fetch — awaiting the update', () => {
+ it('has emitted the whole lifecycle by the time `fetch()` resolves', async () => {
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ // No `settle()`: the returned promise alone is the guarantee under test.
+ await instance.fetch();
+
+ expect(events.map(({ type }) => type)).toEqual([
+ FETCH_EVENTS.BEFORE_FETCH,
+ FETCH_EVENTS.FETCH,
+ FETCH_EVENTS.RESPONSE,
+ FETCH_EVENTS.AFTER_FETCH,
+ FETCH_EVENTS.BEFORE_UPDATE,
+ FETCH_EVENTS.UPDATE,
+ FETCH_EVENTS.AFTER_UPDATE,
+ ]);
+ });
+
+ it('has applied the DOM update by the time `fetch()` resolves', async () => {
+ await mount(`old
`);
+ stubClient(async () => new Response('new
'));
+ const { instance } = await mountFetch(
+ ` `,
+ );
+
+ await instance.fetch();
+
+ expect(document.getElementById('target')?.textContent).toBe('new');
+ });
+
+ it('routes an update rejection through the error lifecycle', async () => {
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const failure = new Error('swap failed');
+ instance.updateDOM = () => Promise.reject(failure);
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ const detail = detailOf(events, FETCH_EVENTS.ERROR) as FetchLifecycleDetail & {
+ error?: unknown;
+ };
+ expect(detail.instance).toBe(instance);
+ expect(detail.error).toBe(failure);
+ });
+
+ it('does not announce the fetch phase twice when the update fails', async () => {
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ instance.updateDOM = () => Promise.reject(new Error('swap failed'));
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ expect(events.map(({ type }) => type)).toEqual([
+ FETCH_EVENTS.BEFORE_FETCH,
+ FETCH_EVENTS.FETCH,
+ FETCH_EVENTS.RESPONSE,
+ FETCH_EVENTS.AFTER_FETCH,
+ FETCH_EVENTS.BEFORE_UPDATE,
+ FETCH_EVENTS.UPDATE,
+ FETCH_EVENTS.ERROR,
+ ]);
+ });
+
+ it('carries the content and the fragment in flight on the error of a failed update', async () => {
+ // The failed update is the one case where a consumer most needs to see
+ // what was being applied, so nothing learned before it is dropped.
+ stubClient(async () => new Response('new
'));
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ instance.updateDOM = () => Promise.reject(new Error('swap failed'));
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ const detail = detailOf(events, FETCH_EVENTS.ERROR);
+ expect(detail.content).toBe('new
');
+ expect(detail.fragment?.getElementById('fetch-default')?.textContent).toBe('new');
+ });
+
+ it('carries no content or fragment on the error of a failed request', async () => {
+ stubClient(async () => new Response('nope', { status: 500 }));
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ const detail = detailOf(events, FETCH_EVENTS.ERROR);
+ expect(detail.content).toBeUndefined();
+ expect(detail.fragment).toBeUndefined();
+ expect(detail.response?.status).toBe(500);
+ });
+
+ it('does not add a later field to the detail of an earlier event', async () => {
+ // The accumulation is progressive, so each event is given a copy of it:
+ // the detail of `fetch-before` describes the point it fired at, whatever
+ // the lifecycle learns afterwards.
+ stubClient();
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ const before = detailOf(events, FETCH_EVENTS.BEFORE_FETCH);
+ const afterUpdate = detailOf(events, FETCH_EVENTS.AFTER_UPDATE);
+ expect(before).not.toBe(afterUpdate);
+ expect(before.content).toBeUndefined();
+ expect(before.fragment).toBeUndefined();
+ expect(afterUpdate.content).toBeDefined();
+ });
+
+ it('describes the response on the error of a failed update', async () => {
+ stubClient(
+ async () =>
+ new Response('new
', {
+ headers: { 'X-Search-Result-Count': '42' },
+ }),
+ );
+ const { root, instance } = await mountFetch(
+ ` `,
+ );
+ instance.updateDOM = () => Promise.reject(new Error('swap failed'));
+ const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS));
+
+ await instance.fetch();
+
+ expect(detailOf(events, FETCH_EVENTS.ERROR).response?.headers['x-search-result-count']).toBe(
+ '42',
+ );
+ });
+});
+
describe('Fetch — the DOM update', () => {
it('replaces the matching element and leaves the rest alone', async () => {
await mount(`old
keep
`);
diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts
index 5eea6ace3..d49dbaa64 100644
--- a/packages/tests/barrel-exports/barrel-exports.spec.ts
+++ b/packages/tests/barrel-exports/barrel-exports.spec.ts
@@ -134,10 +134,13 @@ test('@studiometa/ui barrel export surface', () => {
"FETCH_EVENTS [value]",
"Fetch [value]",
"FetchEmits [type]",
- "FetchEventBase [type]",
+ "FetchLifecycleDetail [type]",
"FetchProps [type]",
"FetchRequestContext [type]",
+ "FetchRequestDetail [type]",
+ "FetchResponseDetail [type]",
"FetchShopifyPartial [value]",
+ "FetchShopifyPartialDetail [type]",
"FetchShopifyPartialProps [type]",
"FetchShopifySection [value]",
"FetchShopifySectionProps [type]",
diff --git a/packages/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts
index f27c7c4bd..168415a80 100644
--- a/packages/ui/src/Fetch/Fetch.ts
+++ b/packages/ui/src/Fetch/Fetch.ts
@@ -130,24 +130,76 @@ function submitterOverrides(
: null;
}
-/** The context every lifecycle event carries. */
-export interface FetchEventBase {
+/**
+ * The request a lifecycle event describes, as plain data.
+ *
+ * A `URL` and a `RequestInit` state the same things, but only through getters,
+ * a `Headers` and a `URLSearchParams`, none of which a consumer resolving a
+ * path against the detail can walk. Every value here is a string, a number, a
+ * boolean, or a plain object of those.
+ */
+export interface FetchRequestDetail {
+ /** The absolute URL the request is sent to. */
+ url: string;
+
+ /** The HTTP method, uppercase, as `Request.method` reports it. */
+ method: string;
+
+ /**
+ * The query, with every value each name carries.
+ *
+ * A repeated name — a checkbox group, a `` — is why this is
+ * a list per name and not one value: keeping the first would drop the rest.
+ */
+ searchParams: Record;
+}
+
+/**
+ * The response a lifecycle event describes, as plain data.
+ *
+ * It describes the response, it is not the `Response`. A body reads once, and
+ * the component reads it, so the object itself on a bubbling event would hand
+ * every listener a body that is already gone — or let one listener consume it
+ * before the component does.
+ */
+export interface FetchResponseDetail {
+ url: string;
+ status: number;
+ statusText: string;
+ ok: boolean;
+ redirected: boolean;
+
+ /** Header names are lowercase, as the `Headers` iterator yields them. */
+ headers: Record;
+}
+
+/**
+ * The detail every lifecycle event carries.
+ *
+ * One shape for the whole lifecycle: the first events describe the request,
+ * and each later one holds what has since become known. A listener therefore
+ * reads the same path wherever it listens, and reads `undefined` for what had
+ * not happened when the event it is reading fired.
+ */
+export interface FetchLifecycleDetail {
instance: Fetch;
- url: URL;
- requestInit: RequestInit;
+ request: FetchRequestDetail;
+ response?: FetchResponseDetail;
+ content?: string;
+ fragment?: Document;
}
/** The declared event surface, with the payload each event carries. */
export type FetchEmits = {
- 'fetch-before': FetchEventBase;
- 'fetch-fetch': FetchEventBase;
- 'fetch-response': FetchEventBase & { response: Response };
- 'fetch-after': FetchEventBase & { content?: unknown; error?: unknown };
- 'fetch-update-before': FetchEventBase & { content: unknown };
- 'fetch-update': FetchEventBase & { fragment?: Document; update?: unknown };
- 'fetch-update-after': FetchEventBase & { fragment?: Document; update?: unknown };
- 'fetch-error': FetchEventBase & { error: Error };
- 'fetch-abort': FetchEventBase & { reason: unknown };
+ 'fetch-before': FetchLifecycleDetail;
+ 'fetch-fetch': FetchLifecycleDetail;
+ 'fetch-response': FetchLifecycleDetail & { response: FetchResponseDetail };
+ 'fetch-after': FetchLifecycleDetail & { error?: unknown };
+ 'fetch-update-before': FetchLifecycleDetail;
+ 'fetch-update': FetchLifecycleDetail;
+ 'fetch-update-after': FetchLifecycleDetail;
+ 'fetch-error': FetchLifecycleDetail & { error: Error };
+ 'fetch-abort': FetchLifecycleDetail & { reason: unknown };
};
export type FetchProps = BaseProps & {
@@ -513,6 +565,47 @@ export class Fetch extends Base
return this.__buildRequestInit({});
}
+ /**
+ * The plain description of the request a lifecycle event announces.
+ *
+ * @protected
+ */
+ __requestDetail(url: URL, requestInit: RequestInit): FetchRequestDetail {
+ const searchParams: Record = {};
+
+ for (const [name, value] of url.searchParams) {
+ (searchParams[name] ??= []).push(value);
+ }
+
+ return {
+ url: url.href,
+ method: (requestInit.method || 'get').toUpperCase(),
+ searchParams,
+ };
+ }
+
+ /**
+ * The plain description of a response, body excluded.
+ *
+ * @protected
+ */
+ __responseDetail(response: Response): FetchResponseDetail {
+ const headers: Record = {};
+
+ for (const [name, value] of response.headers) {
+ headers[name] = value;
+ }
+
+ return {
+ url: response.url,
+ status: response.status,
+ statusText: response.statusText,
+ ok: response.ok,
+ redirected: response.redirected,
+ headers,
+ };
+ }
+
get isLink(): boolean {
return this.$el instanceof HTMLAnchorElement;
}
@@ -602,6 +695,9 @@ export class Fetch extends Base
* `fetch()` works from an event handler. Strings are resolved against the
* current location, since the history and view-transition paths read
* `url.pathname` and `url.searchParams`.
+ *
+ * The returned promise settles once the DOM update has settled, so
+ * `fetch-update-after` has already fired when a caller awaits it.
*/
async fetch(
url?: URL | string,
@@ -621,52 +717,64 @@ export class Fetch extends Base
this.__historyUrl = fromElement ? this.__buildHistoryUrl(context) : undefined;
- this.$emit(FETCH_EVENTS.BEFORE_FETCH, { instance: this, url: normalizedUrl, requestInit });
+ // The controller is built before the previous request is aborted, so the
+ // request is fully described — merged headers, method and body included —
+ // by the time the first event announces it, while `fetch-abort` still
+ // comes after the `fetch-before` of the request that caused it.
+ const newController = new AbortController();
+ const init = this.mergeRequestInit(requestInit, newController.signal, context);
+
+ // One accumulator for the whole request, filled in as each part becomes
+ // known and handed to `update()`, so the update events — and a
+ // `fetch-error` raised by a failing update — carry everything learned
+ // before them. Each event is given a copy rather than the accumulator
+ // itself, so a field learned later does not turn up on the detail of an
+ // event that fired before it.
+ const detail: FetchLifecycleDetail = {
+ instance: this,
+ request: this.__requestDetail(normalizedUrl, init),
+ };
+
+ this.$emit(FETCH_EVENTS.BEFORE_FETCH, { ...detail });
this.__abortController.abort();
- const newController = new AbortController();
newController.signal.addEventListener('abort', () => {
- this.$emit(FETCH_EVENTS.ABORT, {
- instance: this,
- url: normalizedUrl,
- requestInit,
- reason: newController.signal.reason,
- });
+ this.$emit(FETCH_EVENTS.ABORT, { ...detail, reason: newController.signal.reason });
});
this.__abortController = newController;
- const init = this.mergeRequestInit(requestInit, newController.signal, context);
- this.$emit(FETCH_EVENTS.FETCH, { instance: this, url: normalizedUrl, requestInit: init });
+ this.$emit(FETCH_EVENTS.FETCH, { ...detail });
+
+ let content: string;
try {
- const response = await this.client(normalizedUrl, init);
- this.$emit(FETCH_EVENTS.RESPONSE, {
- instance: this,
- url: normalizedUrl,
- requestInit: init,
- response,
- });
+ const rawResponse = await this.client(normalizedUrl, init);
+ const response = this.__responseDetail(rawResponse);
+ detail.response = response;
+ this.$emit(FETCH_EVENTS.RESPONSE, { ...detail, response });
- if (!response.ok) {
- throw new Error(`Fetch failed with status ${response.status}`);
+ if (!rawResponse.ok) {
+ throw new Error(`Fetch failed with status ${rawResponse.status}`);
}
- const content = await this.parseResponse(response, normalizedUrl, requestInit);
- this.$emit(FETCH_EVENTS.AFTER_FETCH, {
- instance: this,
- url: normalizedUrl,
- requestInit: init,
- content,
- });
- void this.update(normalizedUrl, init, content);
+ content = await this.parseResponse(rawResponse, normalizedUrl, requestInit);
+ detail.content = content;
+ this.$emit(FETCH_EVENTS.AFTER_FETCH, { ...detail });
} catch (error) {
- this.$emit(FETCH_EVENTS.AFTER_FETCH, {
- instance: this,
- url: normalizedUrl,
- requestInit: init,
- error,
- });
- this.error(normalizedUrl, init, error as Error);
+ this.$emit(FETCH_EVENTS.AFTER_FETCH, { ...detail, error });
+ this.error(detail, error as Error);
+ return;
+ }
+
+ // The update is awaited, so `fetch()` resolves once every swap has
+ // settled and a failing update reaches the same `fetch-error` a failing
+ // request does. It is caught on its own rather than inside the block
+ // above, or a failed update would emit a second `fetch-after` and report
+ // itself as a failed request.
+ try {
+ await this.update(normalizedUrl, init, content, detail);
+ } catch (error) {
+ this.error(detail, error as Error);
}
}
@@ -762,14 +870,31 @@ export class Fetch extends Base
* an `else` branch: `wrap()` keeps the last registration, and the event
* starts on this element, so any listener above overrides the default
* without the component having to ask whether one exists.
+ *
+ * The accumulated `detail` is filled in rather than rebuilt: the `Response`
+ * it describes is consumed by the time the update runs, and the update
+ * events are where a consumer reads the status and the headers that came
+ * with the content being applied. A caller driving `update()` directly
+ * passes no detail and gets one describing this call alone.
*/
- async update(url: URL, requestInit: RequestInit, content: string): Promise {
+ async update(
+ url: URL,
+ requestInit: RequestInit,
+ content: string,
+ detail: FetchLifecycleDetail = {
+ instance: this,
+ request: this.__requestDetail(url, requestInit),
+ },
+ ): Promise {
const { history, viewTransition: hasViewTransition } = this.$options;
- this.$emit(FETCH_EVENTS.BEFORE_UPDATE, { instance: this, url, requestInit, content });
+ detail.content = content;
+
+ this.$emit(FETCH_EVENTS.BEFORE_UPDATE, { ...detail });
domParser ??= new DOMParser();
const fragment = domParser.parseFromString(content, 'text/html');
+ detail.fragment = fragment;
this.__updateHistory(url, requestInit);
@@ -781,7 +906,7 @@ export class Fetch extends Base
});
}
- this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, fragment });
+ this.$emit(FETCH_EVENTS.UPDATE, { ...detail });
const releaseDefault = hasViewTransition
? this.$on(EVENTS.dom.update, (event) => {
@@ -790,17 +915,12 @@ export class Fetch extends Base
: null;
try {
- await domUpdate(this.$el, () => this.updateDOM(fragment), {
- instance: this,
- url,
- requestInit,
- fragment,
- });
+ await domUpdate(this.$el, () => this.updateDOM(fragment), { ...detail });
} finally {
releaseDefault?.();
}
- this.$emit(FETCH_EVENTS.AFTER_UPDATE, { instance: this, url, requestInit, fragment });
+ this.$emit(FETCH_EVENTS.AFTER_UPDATE, { ...detail });
}
/**
@@ -828,13 +948,20 @@ export class Fetch extends Base
write({ path: target.pathname, search: target.searchParams });
}
- /** Announce a failed request, ignoring the abort the component caused. */
- error(url: URL, requestInit: RequestInit, error: Error): void {
+ /**
+ * Announce a failed request or a failed update, ignoring the abort the
+ * component caused.
+ *
+ * The accumulated detail is carried through rather than rebuilt, so a
+ * failed update reports the content and the fragment it was applying — the
+ * one case where a consumer most needs them.
+ */
+ error(detail: FetchLifecycleDetail, error: Error): void {
if (error.name === 'AbortError') {
return;
}
- this.$emit(FETCH_EVENTS.ERROR, { instance: this, url, requestInit, error });
+ this.$emit(FETCH_EVENTS.ERROR, { ...detail, error });
}
/** Abort the request in flight. */
diff --git a/packages/ui/src/Fetch/FetchShopifyPartial.ts b/packages/ui/src/Fetch/FetchShopifyPartial.ts
index e5d7038fd..9fb044303 100644
--- a/packages/ui/src/Fetch/FetchShopifyPartial.ts
+++ b/packages/ui/src/Fetch/FetchShopifyPartial.ts
@@ -4,6 +4,8 @@ import {
Fetch,
HEADER_NAMES,
headerNames,
+ type FetchEmits,
+ type FetchLifecycleDetail,
type FetchProps,
type FetchRequestContext,
} from './Fetch.js';
@@ -21,8 +23,22 @@ interface PartialsModule {
partials: PartialsApi;
}
+/**
+ * The detail of the partial rendering path: the base shape plus the opaque
+ * update object `partials.apply()` consumes.
+ */
+export type FetchShopifyPartialDetail = FetchLifecycleDetail & { update?: unknown };
+
export type FetchShopifyPartialProps = FetchProps & {
$options: FetchProps['$options'] & { partials: string };
+
+ /**
+ * Every event of the partial rendering path also carries the opaque update
+ * object `partials.apply()` consumes. It is the only part of that path's
+ * detail that is not plain data, and it stands where the base carries the
+ * `content` string and the parsed `fragment`, neither of which exists here.
+ */
+ $emits: { [K in keyof FetchEmits]: FetchEmits[K] & Pick };
};
/**
@@ -34,9 +50,10 @@ export type FetchShopifyPartialProps = FetchProps & {
*
* Compared to the base lifecycle, the partials path diverges in two ways:
* the `RESPONSE` event never fires (there is no `Response` object on this
- * path), and the `UPDATE` payload carries the opaque partials `update`
- * object instead of a parsed `Document` fragment — `partials.apply` owns DOM
- * swapping, View Transitions and focus/selection/form/scroll preservation.
+ * path, so no `response` description either), and the payload carries the
+ * opaque partials `update` object where the base carries `content` and a
+ * parsed `fragment` — `partials.apply` owns DOM swapping, View Transitions
+ * and focus/selection/form/scroll preservation.
*
* @link https://ui.studiometa.dev/reference/items/Fetch/
*/
@@ -174,52 +191,52 @@ export class FetchShopifyPartial extends Fetch<
this.__historyUrl = fromElement ? this.__buildHistoryUrl(context) : undefined;
- this.$emit(FETCH_EVENTS.BEFORE_FETCH, { instance: this, url: normalizedUrl, requestInit });
+ // Same ordering as the base: the controller is built first so the request
+ // is fully described by the time `fetch-before` announces it, and the
+ // previous request is aborted after that event.
+ const newController = new AbortController();
+ const init = this.mergeRequestInit(requestInit, newController.signal, context);
+
+ // One accumulator for the whole request, as the base keeps: each event is
+ // given a copy of it, and `applyPartials()` fills in the rest.
+ const detail: FetchShopifyPartialDetail = {
+ instance: this,
+ request: this.__requestDetail(normalizedUrl, init),
+ };
+
+ this.$emit(FETCH_EVENTS.BEFORE_FETCH, { ...detail });
this.__abortController.abort();
- const newController = new AbortController();
newController.signal.addEventListener('abort', () => {
- this.$emit(FETCH_EVENTS.ABORT, {
- instance: this,
- url: normalizedUrl,
- requestInit,
- reason: newController.signal.reason,
- });
+ this.$emit(FETCH_EVENTS.ABORT, { ...detail, reason: newController.signal.reason });
});
this.__abortController = newController;
- const init = this.mergeRequestInit(requestInit, newController.signal, context);
- this.$emit(FETCH_EVENTS.FETCH, { instance: this, url: normalizedUrl, requestInit: init });
+ this.$emit(FETCH_EVENTS.FETCH, { ...detail });
+
+ let update: unknown;
try {
- const update = await partials.fetch(...names, {
+ update = await partials.fetch(...names, {
url: normalizedUrl.toString(),
signal: init.signal ?? undefined,
});
- this.$emit(FETCH_EVENTS.AFTER_FETCH, {
- instance: this,
- url: normalizedUrl,
- requestInit: init,
- content: update,
- });
- // Fire-and-forget the apply phase, matching the base `Fetch.fetch`
- // lifecycle: an `apply()` failure must not be misattributed to the
- // fetch phase and re-emit `AFTER_FETCH` a second time. It still needs a
- // `catch`, or a rejected Shopify DOM update is an unhandled rejection
- // with no observable failure at all.
- void this.applyPartials(normalizedUrl, init, update, partials).catch(
- (applyError: unknown) => {
- this.error(normalizedUrl, init, applyError as Error);
- },
- );
+ detail.update = update;
+ this.$emit(FETCH_EVENTS.AFTER_FETCH, { ...detail });
} catch (error) {
- this.$emit(FETCH_EVENTS.AFTER_FETCH, {
- instance: this,
- url: normalizedUrl,
- requestInit: init,
- error,
- });
- this.error(normalizedUrl, init, error as Error);
+ this.$emit(FETCH_EVENTS.AFTER_FETCH, { ...detail, error });
+ this.error(detail, error as Error);
+ return;
+ }
+
+ // Awaited, as the base awaits its own update: `fetch()` resolves once the
+ // Shopify swap has settled. It is caught on its own rather than inside the
+ // block above, or a failed apply would emit a second `fetch-after` and
+ // report itself as a failed request.
+ try {
+ await this.applyPartials(normalizedUrl, init, update, partials, detail);
+ } catch (applyError) {
+ this.error(detail, applyError as Error);
}
}
@@ -235,15 +252,21 @@ export class FetchShopifyPartial extends Fetch<
requestInit: RequestInit,
update: unknown,
partials: PartialsApi,
+ detail: FetchShopifyPartialDetail = {
+ instance: this,
+ request: this.__requestDetail(url, requestInit),
+ },
): Promise {
- this.$emit(FETCH_EVENTS.BEFORE_UPDATE, { instance: this, url, requestInit, content: update });
+ detail.update = update;
+
+ this.$emit(FETCH_EVENTS.BEFORE_UPDATE, { ...detail });
this.__updateHistory(url, requestInit);
- this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, update });
+ this.$emit(FETCH_EVENTS.UPDATE, { ...detail });
await partials.apply(update);
- this.$emit(FETCH_EVENTS.AFTER_UPDATE, { instance: this, url, requestInit, update });
+ this.$emit(FETCH_EVENTS.AFTER_UPDATE, { ...detail });
}
}
diff --git a/packages/ui/src/Fetch/FetchShopifySection.ts b/packages/ui/src/Fetch/FetchShopifySection.ts
index 9ec0fdf40..139395183 100644
--- a/packages/ui/src/Fetch/FetchShopifySection.ts
+++ b/packages/ui/src/Fetch/FetchShopifySection.ts
@@ -1,5 +1,10 @@
import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';
-import { Fetch, type FetchProps, type FetchRequestContext } from './Fetch.js';
+import {
+ Fetch,
+ type FetchLifecycleDetail,
+ type FetchProps,
+ type FetchRequestContext,
+} from './Fetch.js';
/** The Section Rendering API query parameter name. */
export const SECTIONS_PARAMETER = 'sections';
@@ -114,9 +119,14 @@ export class FetchShopifySection extends Fetch<
* Strip the `sections` parameter before the base update, so the URL pushed
* to the history is the human-facing page and not the raw endpoint.
*/
- update(url: URL, requestInit: RequestInit, content: string): Promise {
+ update(
+ url: URL,
+ requestInit: RequestInit,
+ content: string,
+ detail?: FetchLifecycleDetail,
+ ): Promise {
const displayUrl = new URL(url);
displayUrl.searchParams.delete(SECTIONS_PARAMETER);
- return super.update(displayUrl, requestInit, content);
+ return super.update(displayUrl, requestInit, content, detail);
}
}
diff --git a/packages/ui/src/Fetch/index.ts b/packages/ui/src/Fetch/index.ts
index 85a942e2e..2ff93a072 100644
--- a/packages/ui/src/Fetch/index.ts
+++ b/packages/ui/src/Fetch/index.ts
@@ -3,11 +3,17 @@ export {
FETCH_EVENTS,
HEADER_NAMES,
type FetchEmits,
- type FetchEventBase,
+ type FetchLifecycleDetail,
type FetchProps,
type FetchRequestContext,
+ type FetchRequestDetail,
+ type FetchResponseDetail,
} from './Fetch.js';
-export { FetchShopifyPartial, type FetchShopifyPartialProps } from './FetchShopifyPartial.js';
+export {
+ FetchShopifyPartial,
+ type FetchShopifyPartialDetail,
+ type FetchShopifyPartialProps,
+} from './FetchShopifyPartial.js';
export {
FetchShopifySection,
SECTIONS_PARAMETER,
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 3c5c773ed..d40f5136c 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -88,9 +88,12 @@ export {
HEADER_NAMES,
SECTIONS_PARAMETER,
type FetchEmits,
- type FetchEventBase,
+ type FetchLifecycleDetail,
type FetchProps,
type FetchRequestContext,
+ type FetchRequestDetail,
+ type FetchResponseDetail,
+ type FetchShopifyPartialDetail,
type FetchShopifyPartialProps,
type FetchShopifySectionProps,
} from './Fetch/index.js';