From 40289cfe5fcf30e6e2fe002d4f88161a92a7436f Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 00:42:40 +0200 Subject: [PATCH 1/4] feat(fetch): await the update and emit one progressive lifecycle detail `fetch()` awaits `update()`, so `fetch-update-after` means every DOM update has settled and a failed update reaches `fetch-error` instead of being lost. Every `fetch-*` event now carries one detail shape, filled in as the lifecycle progresses: `instance`, a plain `request` description, then `response`, `content` and `fragment`. The `url` and `requestInit` fields are replaced: a `URL`, a `Headers` and a `RequestInit` answer only through getters, which a consumer resolving a path against the detail cannot walk. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- .../.vitepress/reference/public-contracts.ts | 18 +- .../docs/migration-guides/1.0-2.0/index.md | 4 +- packages/docs/reference/items/Fetch/index.md | 6 +- packages/docs/reference/items/Fetch/js-api.md | 151 ++++--- .../items/Fetch/stories/abort/app.twig | 2 +- .../items/Fetch/stories/error/app.twig | 2 +- .../items/FetchShopifyPartial/js-api.md | 4 +- .../items/FetchShopifySection/js-api.md | 4 +- packages/tests/Fetch/Fetch.spec.ts | 415 +++++++++++++++++- .../barrel-exports/barrel-exports.spec.ts | 4 +- packages/ui/src/Fetch/Fetch.ts | 237 +++++++--- packages/ui/src/Fetch/FetchShopifyPartial.ts | 91 ++-- packages/ui/src/Fetch/FetchShopifySection.ts | 16 +- packages/ui/src/Fetch/index.ts | 4 +- packages/ui/src/index.ts | 4 +- 15 files changed, 788 insertions(+), 174 deletions(-) diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index b0f125f80..69913b599 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,22 @@ 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: '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..a7b7cf550 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 the same detail object, filled in as the lifecycle progresses. `event.detail` **is** that object, so a listener reads a field by path. + +```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 ` + + +``` + + +`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`. 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,34 @@ 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** -- `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 +- `instance`, `request`, `response` when a response came back, and: + - `error` (`Error`): the error thrown by the failing request or the failing update ### `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">