{
...otherKeys,
fetch: { … the same recipe },
}Stamped with replaceState() just before the first push. Without it, back to the page as first loaded would find no recipe.history booleanfalseWrites the destination of each request to the browser history, and restores it on back and forward navigation. The destination is what the address bar shows: the href of a link, the action of a form with its fields, the formaction of the submitter, or a destination given to fetch().
Fetch on an element that is neither a link nor a form writes none, unless fetch() receives a destination.params option, and back restores it with a GET. A request to a src endpoint that is not a GET writes no entry.<title> of the response.See back and forward navigation for how entries are restored.
historyMode 'push' | 'replace''push'How a request writes history.
push adds an entry, as a native navigation does. Back returns to the previous state of the page. As natively, a GET to the URL the page already shows replaces the current entry instead, so clicking the same link twice does not add a second entry.replace replaces the current entry. Back then leaves the page, not the last state of the request. Use it for a live search, where one entry per keystroke would make back useless.Any other value reports the fetch.invalid-history-mode diagnostic, and push is used.
<form
action="/search"
data-component="Fetch"
data-option-history
data-option-history-mode="replace">
<input type="search" name="q" />
</form>Each entry keeps a restore recipe under one key, fetch, of history.state. Fetch merges this key into the state, and it keeps every key that other scripts put there:
// history.state after a push or a replace
{ ...otherKeys, fetch: RestoreRecipe }
interface RestoreRecipe {
component: string; // the config name of the class that wrote the entry
owner?: string; // the id of the element that wrote the entry, if it has one
selector: string;
mode: SwapMode; // `append` and `prepend` are stored as `replace`
params: Record<string, string>;
src?: string; // the absolute endpoint
response: string;
viewTransition: boolean;
[option: string]: unknown; // the options a subclass adds, such as `partials`
}{
...otherKeys,
fetch: { … the same recipe },
}Stamped with replaceState() just before the first push. Without it, back to the page as first loaded would find no recipe.{
...otherKeys,
fetch: {
component: 'Fetch',
owner: 'pager',
selector: '#results',
mode: 'replace',
params: { view: 'fragment' },
// src: an absolute URL, when set
response: 'response.text()',
viewTransition: true,
},
}Pushed with the full destination, its own hash included. The keys of other scripts are kept. append and prepend are stored as replace, so a restore does not add the content twice. A subclass adds its own options, such as partials. Header values are never stored.{ router: { … } }Not written by Fetch. There is no fetch key, so the coordinator ignores the entry and keeps its state as it is./projects?page=2 and only the hash differs, so it sends no request./projects?view=fragment and swaps #results.component on the owner element runs the restore, so its events and loading states apply. Without one, a detached instance runs it, and its events reach the first element that the selector of the entry matches, then bubble from there.Fetch restores the two it wrote when the page shows other content, and leaves the third alone. A recipe is plain data, so back and forward navigation still work after the element that wrote the entry has left the page.Fetch stamps the current entry with the same recipe through replaceState(). Back to the page as it was first loaded then finds a recipe too.fetch key is ignored on back and forward navigation.history and different selector options share a page, back to the entry of the first one also brings back the region that the second one changed. This holds only when the response contains these regions. A restore sends the params of the restored entry, so when params selects fragments of the page, request every region that an entry of the page changes.popstate, but leaves the navigation in flight alone, as natively. Without the Navigation API, the two cannot be told apart when only the hash changes: back or forward to an entry of the same page then leaves the navigation in flight alone too.requestInit option is not stored, because a value the browser cannot clone would make pushState() throw. Header values are not stored either, because they can hold credentials that session history would keep. A restore sends the headers of the instance that runs it, and none when no instance is left on the page.owner element, so its events reach that element. Give the element an id: without one, the restore runs on a detached instance, and its events reach the first element that the selector of the entry matches, then bubble from there. When no element matches, they reach document.fetch-error and fetch-after with error are emitted as for any request. Listen to fetch-error to reload the page.history has mounted, or has written an entry.params Record<string, string | number | boolean>{}Query parameters set on every request URL, after the query of the destination and of src, so they win. They never reach the address bar, and a restore adds them again, because the recipe of the entry keeps them. Values are serialized as a query string, so reserved characters are encoded: a,b is sent as a%2Cb. Use params to request a lighter copy of the same page:
<a
href="/projects?page=2"
data-component="Fetch"
data-option-history
data-option-params='{"view": "fragment"}'>
2
</a>The link requests /projects?page=2&view=fragment and writes /projects?page=2. Back to /projects?page=1 requests /projects?page=1&view=fragment. A submitter with a formaction names another endpoint, so params does not apply to its submission. See a lighter page: params or src.
src string''A fixed endpoint. When it is set, the request URL takes its origin, its path and its own query from src:
fetch(), the query of the destination is folded on. A name of the destination replaces the value of src for that name, and a repeated name keeps all its values.params option is set last.formaction names another endpoint for its submission, so neither src nor params applies to it.src with params. The query of the page is not folded on.The address bar shows the destination, never src. On back and forward navigation, the request is rebuilt from the restored URL and the src of the entry. Do not set src to a copy of the destination page, such as /projects/page/2/?view=fragment on a link to /projects/page/2/: back to page 1 would request page 2. Use params for a lighter copy of the page. An element with history reports the fetch.src-copy-of-page diagnostic in this case.
<form
action="/help"
method="get"
data-component="Fetch"
data-option-src="/apps/search?view=fragment"
data-option-history
data-option-history-mode="replace">
<input type="search" name="q" />
</form>Typing shoes requests /apps/search?view=fragment&q=shoes and writes /help?q=shoes. Without JavaScript, the form submits to /help.
src is resolved against the page when the request is built, and an entry keeps it as an absolute URL. To request a lighter copy of the page the address bar shows, use params: a copy of the page in src cannot be rebuilt for another page on back.
mode 'replace' | 'prepend' | 'append' | 'morph''replace'Defines how the new content is put in the page. A history entry stores append and prepend as replace, so a restore does not add the content twice.
selector string'[id]'Specifies which content from the response should be updated in the DOM. This option can be any valid CSS selector.
⚠️ Matching with ID
This option can be used to extract specific content from the response, but the matching between the current DOM and the new DOM is still made based on id attributes. This means that elements that should be updated must always have an id attribute.
requestInit RequestInit{}Options given to the client with each request, for example priority, credentials or cache. The request gives the method, the headers and the body:
headers are merged into the request headers.method and body are the method and the body of the request. A form takes them from the form and the submitter.<a href="/path" data-component="Fetch" data-option-request-init='{ "priority": "high" }'>Fetch</a>History entries do not keep this option. A restore uses the requestInit of the instance that runs it, and none when no instance is left on the page.
headers Record<string, string>{}Adds headers to the request. Names are lower-cased. They are merged after requestInit.headers and before the headers[] refs. History entries do not keep header values, so a credential never reaches session history. A restore sends the headers of the instance that runs it.
<a href="/path" data-component="Fetch" data-option-headers='{ "authorization": "Basic ..." }'>
Fetch
</a>viewTransition booleantrueWrap the content update in a View Transition, through the same viewTransition scheduler as every other component. Updates requested in the same tick are batched into one transition, and batches run one after the other, so a Fetch swap never fights a Toaster or ViewTransition animation over the one-transition-per-document limit. Falls back to a direct update when the API is unavailable. A runner registered by an ancestor through the js-toolkit:dom:update event replaces it. Disable it with data-option-no-view-transition.
<a href="/path" data-component="Fetch" data-option-no-view-transition>Fetch</a>response string'response.text()'An expression that gives the content to apply from the raw Response. It receives response, the request and self, and this is the instance. It can return a promise.
This option is useful when you do not have control over an API and need to extract HTML content from an application/json response.
<form
data-component="Fetch"
data-option-response="response.json().then((data) => data.rendered_content)"
action="/api/json">
<button type="submit">Submit</button>
</form>
<!--
The following element will be updated with HTML from the
`rendered_content` property of the /api/json endpoint.
-->
<div id="content"></div>{
"status": "ok",
"rendered_content": "<div id=\"content\">content</div>"
}Fetch builds one plain description of each request, from the element, the submitter and the options. On back and forward navigation, it builds it from the restored URL and the recipe of the entry.
interface FetchRequest {
url: string; // the absolute URL that is requested
destination: string; // the absolute URL the address bar shows, and the one back restores
method: string; // upper case: GET, POST, …
headers: Record<string, string>; // lower-case names
body?: FormData | URLSearchParams | string;
history: 'push' | 'replace' | false;
}The request travels in the fetch-before event, which can be cancelled. A listener can change it, and Fetch reads it back after the event. Later events carry the request as it was sent. A change made in a later event has no effect.
document.addEventListener('fetch-before', (event) => {
const { request } = event.detail;
request.headers['x-csrf-token'] = document.querySelector('meta[name="csrf-token"]').content;
});url is derived from destination through the src and params options.history is the historyMode when the history option is on and the request has a destination, and false otherwise. On a restore, history is always false.headers always holds user-agent, which ends with @studiometa/ui/Fetch. A restore adds x-triggered-by: popstate.client typeof fetchThe function that sends the request. It is the global fetch bound to window by default, and it can be assigned, for example in a test. It is called as client(request.url, init): the URL is an absolute string, and init.method is upper case.
isLink booleanWhether the root element is an <a> element.
isForm booleanWhether the root element is a <form> element.
headers[] HTMLInputElement[]The headers[] refs can be used to add additional headers to the request with <input type="hidden"> elements.
To avoid adding the header to the form data, use the data-name attribute to specify the name of the header. The name is lower-cased.
<form data-component="Fetch">
<input
data-ref="headers[]"
data-name="x-my-token"
value="some-not-sensible-token"
type="hidden" />
</form>The example above will add a x-my-token: some-not-sensible-token header to the triggered request.
fetch(destination?: string | URL) Promise<FetchOutcome>Loads a destination and applies its content. The click, submit and back and forward flows run the same lifecycle, and the method can also be called from an event, a decorator or your own code.
Parameters
destination (string | URL, optional): what the address bar would show. A string is resolved against the page. Without it, the element's own destination is used: the href of a link, the action of a form with its fields, or the page itself for any other element. The request URL is derived from it through the src and params options.On a form, a given destination is used as it is: the method and the body of the form still apply, but the fields of a GET form are not put in its query.
The promise resolves with the outcome once the request has ended, after the DOM change. It never rejects.
<div data-component="Action InView Fetch" data-option-src="/path" data-on:in-view="Fetch.fetch()">
…
</div>abort(reason?: unknown) Aborts the request in flight. It emits fetch-abort with the reason, then fetch-after with the outcome aborted. An aborted request is never reported as an error.
A request that has ended is left alone, and so are a request that has started its DOM change, which ends ok or error, and a request that has failed, which ends error.
A request that waits for such a request to end is dropped: it never starts, emits no event, and its fetch() promise resolves aborted.
Parameters
reason (unknown, optional): the reason why the request was aborted, given as the reason of the fetch-abort event.All events bubble, so they can be listened to from any parent element.
fetch-after, and its outcome says how. A request superseded or aborted while in flight sends fetch-abort and fetch-after at once, then stops at the next check without a DOM change. A request cancelled in fetch-before never starts, so it skips fetch-abort. Once committed, a request ends ok or error, and once failed, error. A new request waits for the fetch-after of such a request before its own fetch-before, and only the latest of the waiting requests starts. A view transition that fails around a successful DOM change is a diagnostic, not an error.| Event | When | Detail |
|---|---|---|
fetch-before | Before the request is sent. Can be cancelled. | request, which listeners can change |
fetch-response | The response arrived, before its body is read. | request, response |
fetch-update-before | The content is parsed, and the DOM has not changed yet. | request, response, content |
fetch-update-after | The DOM change has settled. | request, response, fragment |
fetch-error | The request, the parse or the DOM change failed. | request, response when there is one, error |
fetch-abort | A request in flight was aborted or superseded. | request, reason |
fetch-after | Always last, exactly once per request. | request, outcome |
Every detail also carries instance, the Fetch instance. response is plain data, because the body of a Response can be read only once and Fetch reads it:
type FetchOutcome = 'ok' | 'error' | 'aborted';
interface FetchResponseDetail {
url: string; // the final URL, after redirects
status: number;
statusText: string;
ok: boolean;
redirected: boolean;
headers: Record<string, string>; // lower-case names
}Order and targets
fetch-after. A loader bound to fetch-before and fetch-after covers the whole request, the DOM change and aborts included.fetch-abort, then fetch-after with aborted, then the fetch-before of the new request.fetch-after still comes before the fetch-before of the new request. Only the latest of the requests that wait then starts: an earlier one, or one that abort() dropped, emits no event and resolves aborted.fetch-before. When a fetch-before listener turns history on, the request ends the other navigation after the event.fetch-before has already ended the previous request, so a fetch-before listener cannot keep the request in flight and drop the new one. A native submission behaves the same way: a second submission stops the first one. To ignore a second submission while a request runs, disable the submit button in fetch-before and enable it again in fetch-after.fetch-after hides it.document.selector of the entry matches, or on document. See history entries.fetch-before Emitted before the request is sent. Call event.preventDefault() to cancel the request: nothing is sent, and fetch-after follows with the outcome aborted, with no fetch-abort.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest): the request, which listeners can changefetch-response Emitted when the response has arrived, before its body is read and before a status that is not ok is reported as an error. A 404 gives fetch-response, then fetch-error with the response, then fetch-after with error.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest): the request as it was sentresponse (FetchResponseDetail): the response, as plain datafetch-update-before Emitted when the content is parsed, before history is written and before the DOM changes. A listener that starts a newer request here stops this one: it is not applied.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest): the request as it was sentresponse (FetchResponseDetail | undefined): the response, or undefined for a transport with no responsecontent (unknown): the parsed content, a string with the default response optionfetch-update-after Emitted when the DOM change has settled, after the view transition or the runner.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest): the request as it was sentresponse (FetchResponseDetail | undefined): the responsefragment (Document | undefined): the content, parsed with a DOMParserfetch-error Emitted when the request fails: a network error, a status that is not ok, a parse failure, or an error thrown by the DOM change. An aborted request is never an error. A view transition or a runner that fails around a successful DOM change is reported as a diagnostic, and the outcome stays ok.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest | undefined): the request, or undefined when it could not be builtresponse (FetchResponseDetail | undefined): the response, when one arrivederror (unknown): the errorfetch-abort Emitted when a request in flight is aborted with abort() or superseded by a newer request. It is not emitted for a request that has ended, for a request that has started its DOM change, for a request that has failed, or for a request cancelled in fetch-before.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest): the request as it was sentreason (unknown): the reason given to abort(), or an AbortError DOMExceptionfetch-after Emitted last, exactly once per request.
Detail
instance (Fetch): the instance emitting the eventrequest (FetchRequest | undefined): the request, or undefined when it could not be builtoutcome (FetchOutcome): ok when the content was applied, error after a fetch-error, aborted when the request was cancelled, aborted or supersededjs-toolkit:dom:update protocol event Before it applies the content, Fetch dispatches the bubbling js-toolkit:dom:update event of @studiometa/js-toolkit. It is a shared protocol event that announces an imminent DOM change, not a fetch-* event. A restore with no instance left on the page dispatches it on the first element that the selector of the entry matches, or on document.documentElement.
Detail
instance (Fetch): the instance applying the contentrequest (FetchRequest): the request as it was sentresponse (FetchResponseDetail | undefined): the responsecontent (unknown): the content to applywrap ((runner: DomUpdateRunner) => void): registers what runs the DOM changeAny ancestor can call event.detail.wrap(runner) to replace the default view transition and run the change with its own choreography. A runner is either form:
(apply: () => void) => void | Promise<unknown>: it receives the apply function that changes the DOM, and its return value is awaited before fetch-update-after;update(mutate) method, such as MotionView from @studiometa/ui-motion. Its update() receives the apply function, and its return value is awaited the same way.The protocol has three rules:
wrap() must be called while the event dispatches. A later call reports a diagnostic and is ignored.Fetch registers its own view transition first, on its own element, so a listener above it wins.An error thrown by the DOM change itself is a fetch-error, also under a runner that catches every error. Fetch keeps the error and reports it once the protocol has settled, so a batched view transition still applies its other changes.
With the ambient MotionView from @studiometa/ui-motion, nesting is enough: a MotionView around the updated content picks up the bubbling event by itself. When the transitioner lives elsewhere in the tree, an Action routes the event to it:
<div
data-component="Action"
data-on:js-toolkit:dom:update="MotionView(#list)->event.detail.wrap(target)">
<ul id="list">
…
</ul>
<a href="/page/2" data-component="Fetch">Next page</a>
</div>fetch() holds the whole lifecycle: the request, the checks against newer requests, the events, history and the outcome. A subclass never overrides it. It changes how content is loaded and applied, through two protected methods, and the options a request runs with, through two getters:
| Method | Role |
|---|---|
__load(request, signal, recipe) | Loads the content of a request. Resolves with a FetchLoadResult, { content, response?, viewTransition? }. The base class sends the request with the client, emits fetch-response and calls parseResponse(). |
parseResponse(response, request, recipe) | Gives the content from the raw Response. The base class evaluates the response option of the recipe. |
__apply(content, recipe) | Applies the content. The base class parses it as HTML and swaps the elements that match the selector of the recipe, following its mode. Resolves with the parsed Document. |
__recipe | The options a request runs with, as the plain data a history entry keeps. A subclass adds its own options here. |
__transportParams | The query parameters that the transport needs on every request. They win over the params option, and a submitter with a formaction keeps them. The base class gives none. |
Each method receives the recipe, not the options of the instance: on a restore, the recipe of the entry wins. signal aborts when the request is superseded or aborted. A viewTransition: false result tells Fetch that the transport runs its own transition, so Fetch does not claim one around __apply().
The types of these steps, FetchRequest, FetchLoadResult, FetchResponseDetail, RestoreRecipe and FetchOutcome, are exported from @studiometa/ui:
interface FetchLoadResult {
content: unknown;
response?: FetchResponseDetail;
viewTransition?: boolean; // false when the transport runs its own transition
}The subclasses of this package are built this way:
| Class | __load(request, signal, recipe) | __apply(content, recipe) |
|---|---|---|
Fetch | The client, then fetch-response, then parseResponse() | Swaps the regions that match selector, following mode |
FetchShopifySection | Inherited. __transportParams gives sections from the sections option, and parseResponse() reads the JSON of the Section Rendering API. | Inherited |
FetchShopifyPartial | partials.fetch(...names, { url, signal }), with no fetch-response. Falls back to the inherited steps when the request is not a plain GET or the package does not load. | partials.apply(update), or the inherited swap after a fallback |
Every rule of this page, from the checks to the final event, then applies to each class from one place.
To run code after the DOM change or after a failure, a subclass does not need these steps. Await fetch(): it resolves after the DOM change with ok, error or aborted, and it never rejects. In v1, a subclass overrode update() and error(), which v2 never calls:
import { Fetch } from '@studiometa/ui';
class Tooltip extends Fetch {
static config = { name: 'Tooltip' };
async load() {
const outcome = await this.fetch();
this.$el.classList.toggle('is-open', outcome === 'ok');
this.$el.classList.toggle('has-error', outcome === 'error');
}
}import { Fetch } from '@studiometa/ui';
/** Reads the `html` key of a JSON endpoint. */
class FetchJson extends Fetch {
static config = { name: 'FetchJson' };
async parseResponse(response: Response) {
const data = await response.json();
return data.html;
}
}FETCH_EVENTS: the names of the lifecycle events, in the order they fire: BEFORE_FETCH (fetch-before), RESPONSE, BEFORE_UPDATE, AFTER_UPDATE, ERROR, ABORT and AFTER_FETCH (fetch-after).HEADER_NAMES: the names of the headers the component sends on its own behalf: x-triggered-by and user-agent.fetch.file-not-uploaded: a file control is sent as the name of its file, because the body is not multipart/form-data. Set enctype="multipart/form-data" on a POST form to upload the file.fetch.invalid-history-mode: the historyMode option is neither push nor replace. push is used.fetch.src-copy-of-page: an element with history has a src with the origin and the path of its destination. On back and forward, the request uses the src of the entry, so it loads that page instead of the restored one. Use the params option to request a lighter copy of the page.