diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fca617..435a03d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **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)) + +### Fixed + +- **Fetch:** send a form submission's submitter and its `formaction`, `formmethod` and `formenctype` ([#656](https://github.com/studiometa/ui/pull/656)) + ## [v2.0.0-alpha.0](https://github.com/studiometa/ui/compare/1.11.1..2.0.0-alpha.0) (2026-09-03) This is the first release of the v2 line. It moves every package onto [`@studiometa/js-toolkit` v4](https://js-toolkit-v4.studiometa.dev/), removes six component families that a newer component already covers, renames three components, merges `LargeText` and `CircularMarquee` into `Marquee`, rewrites `Tabs` on the WAI-ARIA Tabs pattern, redesigns `Cursor` around published CSS hooks, completes the `Carousel` family and gives it an accessibility contract, and changes the shape of every event payload. There is no compatibility layer. diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index cdae28e0..b0f125f8 100644 --- a/packages/docs/.vitepress/reference/public-contracts.ts +++ b/packages/docs/.vitepress/reference/public-contracts.ts @@ -382,6 +382,14 @@ export const publicContractSymbols = [ href: '/reference/items/Fetch/js-api', status: 'stable', }, + { + name: 'FetchRequestContext', + 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/reference/items/Fetch/examples.md b/packages/docs/reference/items/Fetch/examples.md index 519a506f..97e7e47e 100644 --- a/packages/docs/reference/items/Fetch/examples.md +++ b/packages/docs/reference/items/Fetch/examples.md @@ -27,7 +27,7 @@ Intercepting clicks on links, displaying a loader and updating the targets' cont ## Fetch from any element -`Fetch` normally reads its URL from an `` or `
`, but the [`src` option](./js-api.md#src) lets it be driven from **any** element and triggered programmatically. In the following example the panel is a `
`: it combines `Fetch` with the [`InViewOnce`](../InViewOnce/index.md) and [`Action`](../Action/index.md) components so that its content is lazy-loaded the first time it scrolls into view, with a bare [`Fetch.fetch()`](./js-api.md#fetch-url-url-string-requestinit-requestinit) call that resolves the `src` URL on its own. +`Fetch` normally reads its URL from an `` or ``, but the [`src` option](./js-api.md#src) lets it be driven from **any** element and triggered programmatically. In the following example the panel is a `
`: it combines `Fetch` with the [`InViewOnce`](../InViewOnce/index.md) and [`Action`](../Action/index.md) components so that its content is lazy-loaded the first time it scrolls into view, with a bare [`Fetch.fetch()`](./js-api.md#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) call that resolves the `src` URL on its own. +## Pagination with submit buttons + +An intercepted submission sends the button that caused it, so a set of ` + + + +
+``` + +The second button requests `/projects?orderby=title&page=2` and swaps `#projects` with the same region from the response. The submitter's `formaction`, `formmethod` and `formenctype` are honoured too — see [form submissions](./js-api.md#form-submissions). + +### History + +Set the [`history` option](./js-api.md#history) to write each update to the browser history, and the [`historyMode` option](./js-api.md#historymode) to choose whether that costs an entry: + +```html + +
2 + + +
+ +
+``` + +With `history` on, a back or forward navigation re-fetches the restored entry. When the request URL and the displayed URL differ — the [`src` option](./js-api.md#src) — the request is rebuilt against `src`, so its fixed parameters survive the replay. See [`historyUrl`](./js-api.md#historyurl). + ### With a loader Use the [`Action`](../Action/index.md) and [`Transition`](../Transition/index.md) components to display a loader while the fetch request is happening. diff --git a/packages/docs/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md index 5bb2baa2..8be63124 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -30,7 +30,35 @@ This option can be used to extract specific content from the response, but the m - Type: `boolean` - Default: `false` -Updates the browser's history when performing a request. The `historyPush` utility from [`@studiometa/js-toolkit`](https://js-toolkit-v4.studiometa.dev) will be used in the background. +Updates the browser's history when performing a request. The [`historyMode` option](#historymode) picks between the `historyPush` and `historyReplace` utilities from [`@studiometa/js-toolkit`](https://js-toolkit-v4.studiometa.dev), which write the [`historyUrl`](#historyurl). + +The component also listens for `popstate` while this option is on, so a back or forward navigation re-fetches the restored entry and updates the same regions. + +### `historyMode` + +- Type: `'push' | 'replace'` +- Default: `'push'` + +Picks the history writer, when the [`history` option](#history) is on. + +- `push` adds one entry per update, so every update is one back press away. +- `replace` overwrites the current entry, so no update adds one. + +Use `replace` for a control that fires often — a live search, a facet list, a map — where one entry per keystroke buries the page the visitor came from. + +```html +
+ +
+``` + +Neither writer runs for an update `popstate` triggered: the entry being restored is already the current one. ### `requestInit` @@ -129,6 +157,8 @@ This is handy for progressive enhancement, where the element's native `action`/` ``` +The separation holds on a back or forward navigation too: the request is rebuilt against `src` rather than aimed at the displayed page. See [`historyUrl`](#historyurl). + ## Getters ### `client` @@ -177,17 +207,68 @@ Without `src` the two are identical. With it, history follows the element's own Clicking that requests the `sections=listing` URL and pushes `/projects/page/2?orderby=title`. -A URL passed explicitly to [`fetch(url)`](#fetch-url-url-string-requestinit-requestinit) is pushed as given: a caller that named a URL meant that URL. +A URL passed explicitly to [`fetch(url)`](#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) is pushed as given: a caller that named a URL meant that URL. + +The `historyMode` option picks how that URL is written — one entry per update, or none. See [`historyMode`](#historymode). + +#### On back and forward navigation + +A `popstate` rebuilds the request the same way, from the entry being restored instead of from the live controls: + +- the destination is the restored entry, which the address bar already shows; +- the request URL is still the [`src`](#src) when one is set, with its fixed parameters intact; +- the restored entry's search parameters replace the form fields, which still hold whatever the visitor last typed. + +So the entry `/help?q=shipping`, restored on the form of the [`historyMode` option](#historymode), requests `/apps/search?view=fragment&q=shipping`. The response is what brings the controls back in line. ::: tip -On a back or forward navigation the component re-fetches `window.location.href`, which is now the pushed URL rather than the `src` one. Keep the [`selector`](#selector) matching elements that exist in **both** responses — the full page and the lighter endpoint — or the two directions will not update the same regions. +Keep the [`selector`](#selector) matching elements that exist in **both** responses — the full page and the lighter endpoint — or the two directions will not update the same regions. ::: ### `requestInit` - Return: [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) -Returns the [`requestInit` option](#requestinit) with additionnal headers from the [`headers` option](#headers) [`headers[]` refs](./js-api.md#headers-1) and if the root element is a form with a `method="post"` attribute, its data as body +Returns the [`requestInit` option](#requestinit) with the headers of the [`headers` option](#headers) and the [`headers[]` refs](#headers-1), and, when the root element is a form, its method — plus its data as the body when that method is `post`. + +The body follows the form's `enctype`, as a native submission does: `application/x-www-form-urlencoded` by default, a `FormData` for `multipart/form-data`, plain text for `text/plain`. + +This getter describes a request with no submission behind it. A `submit` event builds the same parts from its submitter as well; see [form submissions](#form-submissions). + +## Form submissions + +An intercepted submission sends what a native one would send, the button that caused it included. + +- The submitter is a successful control: ` + + +``` + +Pressing the second button requests `/projects?orderby=title&page=2`, with no script of its own. + +The submitter belongs to the submission that carried it: a later [`fetch()`](#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) call, and a back or forward navigation, build their request without it. + +### File controls + +The effective enctype decides what a file control sends. `multipart/form-data` sends the file. Every other encoding sends the file's name, as a native submission does, and no GET submission uploads a file whatever the form declares. + +A form that declares no `enctype` sends `application/x-www-form-urlencoded`, so a file control in it sends a name and not a file. `Fetch` reports that case on the diagnostic channel under the `fetch.file-not-uploaded` code. Declare `enctype="multipart/form-data"` on the form, or `formenctype="multipart/form-data"` on the submitter, to send the file itself. + +```html +
+ + +
+``` ## Refs @@ -213,7 +294,7 @@ The example above will add a `x-my-token: some-not-sensible-token` header to the ## Methods -### `fetch(url?: URL | string, requestInit?: RequestInit)` +### `fetch(url?: URL | string, requestInit?: RequestInit, context?: FetchRequestContext)` Performs the fetch request and updates the DOM with the response. @@ -223,6 +304,7 @@ The declarative click, submit and popstate flows call this method for you, but i - `url` (`URL | string`, optional): the URL to fetch. Defaults to the [`url` getter](#url), so a bare `fetch()` call uses the element's `href`, `action` or [`src` option](#src). A `string` is coerced into a `URL` resolved against the current location. - `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit), optional): extra options merged into the [`requestInit` getter](#requestinit-1) for this call. +- `context` (`FetchRequestContext`, optional): what this one request overrides on the element it is built from — a `submitter` for [a form submission](#form-submissions), a `restoredUrl` for a back or forward navigation. The declarative flows fill it in; it is never kept on the instance, so nothing leaks into the next request. ```html
@@ -242,6 +324,14 @@ Abort the current request. Using an `Error` instance as the `reason` parameter of the `abort(reason?: any)` method will trigger the [`fetch-error` event](#fetch-error) along the [`fetch-abort` event](#fetch-abort). ::: +## Diagnostics + +Every one is a development-only warning on the [toolkit diagnostic channel](https://js-toolkit-v4.studiometa.dev/). + +| Code | Meaning | +| ------------------------- | -------------------------------------------------------------------------------------- | +| `fetch.file-not-uploaded` | A file control is sent as its filename because the effective enctype is not multipart. | + ## Events All events from the `Fetch` component bubble up the DOM tree, so they can be listened to from any parent element. diff --git a/packages/docs/reference/items/Fetch/stories/pagination/app.ts b/packages/docs/reference/items/Fetch/stories/pagination/app.ts new file mode 100644 index 00000000..0b2ce753 --- /dev/null +++ b/packages/docs/reference/items/Fetch/stories/pagination/app.ts @@ -0,0 +1,4 @@ +import { registerComponent } from '@studiometa/js-toolkit'; +import { Fetch } from '@studiometa/ui'; + +registerComponent(Fetch); diff --git a/packages/docs/reference/items/Fetch/stories/pagination/app.twig b/packages/docs/reference/items/Fetch/stories/pagination/app.twig new file mode 100644 index 00000000..d66943ff --- /dev/null +++ b/packages/docs/reference/items/Fetch/stories/pagination/app.twig @@ -0,0 +1,48 @@ +{# + The response is rendered by the API from this template, with the request's + query parameters as variables — so `page` is the value of the button that + was pressed, and `orderby` the hidden field that travels with every + submission. `verbatim` keeps both placeholders for the API to fill in. +#} +{%- set results -%} + {% verbatim %} +
+

+ Showing page {{ page|default(1) }}, + ordered by {{ orderby|default('title') }}. +

+
+ {% endverbatim %} +{%- endset -%} + +
+
+

+ Showing page 1, + ordered by title. +

+
+ + {# + A plain GET form. Each button is a successful control of its own + submission, so the request carries the `page` value of the button that was + pressed, alongside the hidden `orderby` field. + #} +
+ + + {% for page in 1..3 %} + + {% endfor %} +
+
diff --git a/packages/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts index 191a2d42..cda28d81 100644 --- a/packages/tests/Fetch/Fetch.spec.ts +++ b/packages/tests/Fetch/Fetch.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getInstance, registerComponents } from '@studiometa/js-toolkit'; -import { mount, recordEvents, resetDom, settle } from '@studiometa/js-toolkit/test'; +import { captureDiagnostics, mount, recordEvents, resetDom, settle } from '@studiometa/js-toolkit/test'; import { Fetch, FETCH_EVENTS, type FetchEmits } from '#private/Fetch/Fetch.js'; import { FetchShopifySection } from '#private/Fetch/FetchShopifySection.js'; @@ -60,6 +60,28 @@ function stubClient( /** Collect every `Fetch` event dispatched under `root`, in order. */ +/** Take over both history writers and record which one an update reaches for. */ +function stubHistory(): { + push: ReturnType; + replace: ReturnType; + restore: () => void; +} { + const originalPush = window.history.pushState.bind(window.history); + const originalReplace = window.history.replaceState.bind(window.history); + const push = vi.fn(originalPush); + const replace = vi.fn(originalReplace); + window.history.pushState = push as typeof window.history.pushState; + window.history.replaceState = replace as typeof window.history.replaceState; + return { + push, + replace, + restore() { + window.history.pushState = originalPush; + window.history.replaceState = originalReplace; + }, + }; +} + /** Take over `document.startViewTransition` and count the calls. */ function stubViewTransition(): { spy: ReturnType; restore: () => void } { const original = document.startViewTransition; @@ -274,15 +296,38 @@ describe('Fetch — request resolution', () => { }); it('sends the form data as the body of a POST form', async () => { + // A form that declares no `enctype` posts `application/x-www-form-urlencoded`, + // which is what a native submission sends. const { instance } = await mountFetch( `
`, ); expect(instance.requestInit.method).toBe('post'); + expect(instance.requestInit.body).toBeInstanceOf(URLSearchParams); + expect(String(instance.requestInit.body)).toBe('foo=bar'); + }); + + it('sends `FormData` when the form declares `multipart/form-data`', async () => { + const { instance } = await mountFetch( + `
+ +
`, + ); expect(instance.requestInit.body).toBeInstanceOf(FormData); }); + it('sends a plain text body when the form declares `text/plain`', async () => { + const { instance } = await mountFetch( + `
+ + +
`, + ); + expect(instance.requestInit.body).toBe('foo=bar\r\nbaz=qux\r\n'); + }); + it('sends no body for a GET form', async () => { const { instance } = await mountFetch( `
@@ -420,6 +465,316 @@ describe('Fetch — declarative triggers', () => { }); }); +describe('Fetch — native submitter semantics', () => { + /** The submit button of the mounted form, and the form it belongs to. */ + function submit(root: HTMLElement, selector = 'button'): void { + const form = root.querySelector('form') as HTMLFormElement; + form.requestSubmit(form.querySelector(selector)); + } + + it('makes the clicked submit button a successful control', async () => { + const client = stubClient(); + const { root } = await mountFetch( + ` + + + `, + ); + + submit(root); + await settle(); + + expect(String(client.mock.calls[0][0])).toBe('https://example.com/search?q=hello&page=2'); + }); + + it('lets each submit button choose its own value', async () => { + // Declarative pagination is two buttons of the same name over one form. + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + + submit(root, '[value="2"]'); + await settle(); + submit(root, '[value="3"]'); + await settle(); + + expect(new URL(String(client.mock.calls[0][0])).searchParams.get('page')).toBe('2'); + expect(new URL(String(client.mock.calls[1][0])).searchParams.get('page')).toBe('3'); + }); + + it('stays valid when a submission has no submitter', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + + (root.querySelector('form') as HTMLFormElement).requestSubmit(); + await settle(); + + // No submitter, so the button is not a successful control — as natively. + expect(String(client.mock.calls[0][0])).toBe('https://example.com/search?q=hello'); + }); + + it('honours `formaction`, on the request and on the pushed url alike', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + + submit(root); + await settle(); + + expect(String(client.mock.calls[0][0])).toBe( + new URL('/elsewhere?q=hello', window.location.href).href, + ); + expect(window.location.pathname).toBe('/elsewhere'); + expect(window.location.search).toBe('?q=hello'); + }); + + it('honours `formmethod` when it turns a GET form into a POST', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + + submit(root); + await settle(); + + const [url, init] = client.mock.calls[0] as [URL, RequestInit]; + expect(init.method).toBe('post'); + // The fields travel in the body, so the url keeps none of them. + expect(String(url)).toBe('https://example.com/search'); + expect(String(init.body)).toBe('q=hello&page=2'); + }); + + it('honours `formmethod` when it turns a POST form into a GET', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + + submit(root); + await settle(); + + const [url, init] = client.mock.calls[0] as [URL, RequestInit]; + expect(init.method).toBe('get'); + expect(init.body).toBeUndefined(); + expect(String(url)).toBe('https://example.com/search?q=hello&page=2'); + }); + + it('honours `formenctype`', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + + submit(root); + await settle(); + + const [, init] = client.mock.calls[0] as [URL, RequestInit]; + expect(init.body).toBeInstanceOf(FormData); + }); + + it('keeps repeated names alongside the submitter on a GET form', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + + +
`, + ); + + submit(root); + await settle(); + + const { searchParams } = new URL(String(client.mock.calls[0][0])); + expect(searchParams.getAll('genre')).toEqual(['rock', 'jazz']); + expect(searchParams.get('page')).toBe('2'); + }); + + it('keeps repeated names alongside the submitter in a POST body', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + + +
`, + ); + + submit(root); + await settle(); + + const [, init] = client.mock.calls[0] as [URL, RequestInit]; + const body = new URLSearchParams(String(init.body)); + expect(body.getAll('genre')).toEqual(['rock', 'jazz']); + expect(body.get('page')).toBe('2'); + }); + + it('does not carry a submitter into a later programmatic `fetch()`', async () => { + // The submitter belongs to one submission. Kept on the instance it would + // keep adding `page=2` to every request that follows. + const client = stubClient(); + const { root, instance } = await mountFetch( + `
+ + +
`, + ); + + submit(root); + await settle(); + await instance.fetch(); + await settle(); + + expect(new URL(String(client.mock.calls[0][0])).searchParams.get('page')).toBe('2'); + expect(new URL(String(client.mock.calls[1][0])).searchParams.has('page')).toBe(false); + expect(instance.url.searchParams.has('page')).toBe(false); + }); +}); + +describe('Fetch — file controls', () => { + /** Put a real file in a file control, the way a file picker does. */ + function attachFile(root: HTMLElement, name = 'photo.png'): File { + const input = root.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['pixels'], name, { type: 'image/png' }); + const transfer = new DataTransfer(); + transfer.items.add(file); + input.files = transfer.files; + return file; + } + + it('sends a file control as its filename when the enctype is not multipart', async () => { + // A native URL-encoded submission sends the file's name. Stringifying the + // `File` instead would send the literal `[object File]`. + const { root, instance } = await mountFetch( + `
+ +
`, + ); + attachFile(root); + + const body = instance.requestInit.body as URLSearchParams; + expect(body).toBeInstanceOf(URLSearchParams); + expect(body.get('photo')).toBe('photo.png'); + }); + + it('reports the upload it cannot send', async () => { + const diagnostics = captureDiagnostics(); + const { root, instance } = await mountFetch( + `
+ +
`, + ); + attachFile(root); + void instance.requestInit; + + expect(diagnostics.codes).toContain('fetch.file-not-uploaded'); + diagnostics.stop(); + }); + + it('sends a file control as its filename in a `text/plain` body', async () => { + const { root, instance } = await mountFetch( + `
+ +
`, + ); + attachFile(root); + + expect(instance.requestInit.body).toBe('photo=photo.png\r\n'); + }); + + it('sends the file itself when the form declares `multipart/form-data`', async () => { + const { root, instance } = await mountFetch( + `
+ +
`, + ); + const file = attachFile(root); + + const body = instance.requestInit.body as FormData; + expect(body).toBeInstanceOf(FormData); + expect(body.get('photo')).toBeInstanceOf(File); + expect((body.get('photo') as File).name).toBe(file.name); + }); + + it('says nothing about a form that sends its file', async () => { + const diagnostics = captureDiagnostics(); + const { root, instance } = await mountFetch( + `
+ +
`, + ); + attachFile(root); + void instance.requestInit; + + expect(diagnostics.codes).not.toContain('fetch.file-not-uploaded'); + diagnostics.stop(); + }); + + it('sends a file control as its filename on a GET form, and reports it', async () => { + // No GET submission uploads a file, whatever the form declares. + const diagnostics = captureDiagnostics(); + const client = stubClient(); + const { root } = await mountFetch( + `
+ +
`, + ); + attachFile(root); + (root.querySelector('form') as HTMLFormElement).requestSubmit(); + await settle(); + + const { searchParams } = new URL(String(client.mock.calls[0][0])); + expect(searchParams.get('photo')).toBe('photo.png'); + expect(diagnostics.codes).toContain('fetch.file-not-uploaded'); + diagnostics.stop(); + }); + + it('sends the file when a submitter declares `formenctype="multipart/form-data"`', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ + +
`, + ); + const file = attachFile(root); + const form = root.querySelector('form') as HTMLFormElement; + form.requestSubmit(form.querySelector('button')); + await settle(); + + const [, init] = client.mock.calls[0] as [URL, RequestInit]; + expect(init.body).toBeInstanceOf(FormData); + expect((init.body as FormData).get('photo')).toBeInstanceOf(File); + expect(((init.body as FormData).get('photo') as File).name).toBe(file.name); + }); +}); + describe('Fetch — the request', () => { it('defaults to the `url` getter when called bare', async () => { const client = stubClient(); @@ -793,6 +1148,180 @@ describe('Fetch — the DOM update', () => { }); }); +describe('Fetch — history mode', () => { + it('pushes one entry per update by default', async () => { + const history = stubHistory(); + await mount(`
old
`); + const { instance } = await mountFetch( + ``, + ); + + await instance.update(new URL('https://example.com/one'), {}, '
1
'); + await instance.update(new URL('https://example.com/two'), {}, '
2
'); + + expect(instance.$options.historyMode).toBe('push'); + expect(history.push).toHaveBeenCalledTimes(2); + expect(history.replace).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe('/two'); + history.restore(); + }); + + it('adds no entry per update in `replace` mode', async () => { + // One keystroke of a live search must not cost one back press. + const history = stubHistory(); + const entriesBefore = window.history.length; + await mount(`
old
`); + const { instance } = await mountFetch( + ``, + ); + + await instance.update(new URL('https://example.com/one'), {}, '
1
'); + await instance.update(new URL('https://example.com/two'), {}, '
2
'); + + expect(history.push).not.toHaveBeenCalled(); + expect(history.replace).toHaveBeenCalledTimes(2); + expect(window.history.length).toBe(entriesBefore); + expect(window.location.pathname).toBe('/two'); + history.restore(); + }); + + it('writes nothing when `history` is off, whatever the mode', async () => { + const history = stubHistory(); + await mount(`
old
`); + const { instance } = await mountFetch( + ``, + ); + + await instance.update(new URL('https://example.com/one'), {}, '
1
'); + + expect(history.push).not.toHaveBeenCalled(); + expect(history.replace).not.toHaveBeenCalled(); + history.restore(); + }); + + it('replaces the entry with the destination url, not the fetched `src`', async () => { + const client = stubClient(); + const { root } = await mountFetch( + `
+ +
`, + ); + + (root.querySelector('form') as HTMLFormElement).requestSubmit(); + await settle(); + + const requested = new URL(String(client.mock.calls[0][0])); + expect(requested.pathname).toBe('/apps/search'); + expect(requested.searchParams.get('view')).toBe('fragment'); + expect(requested.searchParams.get('q')).toBe('shipping'); + expect(window.location.pathname).toBe('/help'); + expect(window.location.search).toBe('?q=shipping'); + }); +}); + +describe('Fetch — popstate with a separate source', () => { + it('fetches the restored location when there is no `src`', async () => { + // The element's own `href` is where it pointed when the page was + // rendered, not where the visitor just went back to. + const client = stubClient(); + await mountFetch(``); + window.history.replaceState({}, '', '/restored?page=3'); + + window.dispatchEvent(new PopStateEvent('popstate')); + await settle(); + + const requested = new URL(String(client.mock.calls[0][0])); + expect(requested.pathname).toBe('/restored'); + expect(requested.searchParams.get('page')).toBe('3'); + }); + + it('rebuilds the source request, keeping its fixed parameters', async () => { + const client = stubClient(); + window.history.replaceState({}, '', '/help?q=shipping'); + await mountFetch( + `
+ +
`, + ); + + window.dispatchEvent(new PopStateEvent('popstate')); + await settle(); + + const requested = new URL(String(client.mock.calls[0][0])); + expect(requested.pathname).toBe('/apps/search'); + expect(requested.searchParams.get('view')).toBe('fragment'); + expect(requested.searchParams.get('q')).toBe('shipping'); + }); + + it('lets the restored state win over the live controls', async () => { + // The field still holds what the visitor last typed, which is stale + // relative to the entry being restored. + const client = stubClient(); + window.history.replaceState({}, '', '/help?q=returns'); + const { root } = await mountFetch( + `
+ +
`, + ); + expect(root.querySelector('input')?.value).toBe('shipping'); + + window.dispatchEvent(new PopStateEvent('popstate')); + await settle(); + + expect(new URL(String(client.mock.calls[0][0])).searchParams.get('q')).toBe('returns'); + }); + + it('does not replay a previous submitter on popstate', async () => { + const client = stubClient(); + window.history.replaceState({}, '', '/help?q=shipping'); + const { root } = await mountFetch( + `
+ + +
`, + ); + const form = root.querySelector('form') as HTMLFormElement; + + form.requestSubmit(form.querySelector('button')); + await settle(); + // Go back to the entry that came before the submission. + window.history.replaceState({}, '', '/help?q=shipping'); + window.dispatchEvent(new PopStateEvent('popstate')); + await settle(); + + expect(new URL(String(client.mock.calls[0][0])).searchParams.get('page')).toBe('2'); + expect(new URL(String(client.mock.calls[1][0])).searchParams.has('page')).toBe(false); + }); + + it('writes no history entry on popstate', async () => { + const client = stubClient(); + window.history.replaceState({}, '', '/help?q=shipping'); + await mountFetch( + `
+ +
`, + ); + const history = stubHistory(); + + window.dispatchEvent(new PopStateEvent('popstate')); + await settle(); + + expect(client).toHaveBeenCalledTimes(1); + expect(history.push).not.toHaveBeenCalled(); + expect(history.replace).not.toHaveBeenCalled(); + history.restore(); + }); +}); + describe('Fetch — the dom-update negotiation', () => { it('runs the update inside a view transition by default', async () => { const { spy, restore } = stubViewTransition(); diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index 96165090..5eea6ace 100644 --- a/packages/tests/barrel-exports/barrel-exports.spec.ts +++ b/packages/tests/barrel-exports/barrel-exports.spec.ts @@ -136,6 +136,7 @@ test('@studiometa/ui barrel export surface', () => { "FetchEmits [type]", "FetchEventBase [type]", "FetchProps [type]", + "FetchRequestContext [type]", "FetchShopifyPartial [value]", "FetchShopifyPartialProps [type]", "FetchShopifySection [value]", diff --git a/packages/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 521d2bdc..f27c7c4b 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -6,6 +6,7 @@ import { SWAP_MODES } from '@studiometa/js-toolkit/SWAP_MODES'; import { viewTransition } from '@studiometa/js-toolkit/viewTransition'; import type { BaseConfig, BaseProps, DomUpdateDetail, SwapMode } from '@studiometa/js-toolkit'; import { historyPush } from '@studiometa/js-toolkit/utils/historyPush'; +import { historyReplace } from '@studiometa/js-toolkit/utils/historyReplace'; import { compileExpression } from '../utils/expression.js'; /** @@ -82,6 +83,53 @@ let domParser: DOMParser; /** `response` expression argument names, in `parseResponse()`'s call order. */ const RESPONSE_ARGUMENTS = ['response', 'url', 'requestInit', 'self'] as const; +/** + * What one request overrides on the element it is built from. + * + * It is threaded explicitly through every step that builds a request — the + * destination, the fields, the URL, the history URL and the `RequestInit` — + * and never stored on the instance. The instance outlives the submission it + * describes: a submitter left on it would keep adding its `name=value` to the + * next programmatic `fetch()` and to every popstate replay, and a request in + * flight would answer with whichever submission started last. The getters + * below are the empty context, which is what a request with no submission + * behind it is. + */ +export interface FetchRequestContext { + /** + * The control that caused the submission, `SubmitEvent.submitter`. It is a + * successful control of its own form, and it carries the `formaction`, + * `formmethod` and `formenctype` overrides. + */ + submitter?: HTMLElement | null; + + /** + * The history entry being restored, set only on the popstate path. + * + * It is both the destination — the address bar shows it already — and the + * state of the controls, which is why it replaces the live form fields + * instead of folding under them: the controls still hold what the visitor + * last typed, which is stale relative to the entry being restored, and the + * response is what brings them back in line. + */ + restoredUrl?: URL; +} + +/** + * The submission overrides a submitter carries, when it can carry any. + * + * `SubmitEvent.submitter` is typed as an `HTMLElement` because a + * form-associated custom element can submit a form, and such an element has + * no `formaction` of its own to state. + */ +function submitterOverrides( + submitter?: HTMLElement | null, +): HTMLButtonElement | HTMLInputElement | null { + return submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement + ? submitter + : null; +} + /** The context every lifecycle event carries. */ export interface FetchEventBase { instance: Fetch; @@ -109,6 +157,7 @@ export type FetchProps = BaseProps & { }; $options: { history: boolean; + historyMode: 'push' | 'replace'; requestInit: RequestInit; headers: Record; mode: SwapMode; @@ -135,6 +184,10 @@ export class Fetch extends Base refs: ['headers[]'], options: { history: Boolean, + historyMode: { + type: String, + default: 'push', + }, mode: { type: String, default: SWAP_MODES.REPLACE, @@ -194,12 +247,32 @@ export class Fetch extends Base * The element's own destination: a form's `action`, a link's `href`, or the * current location as a last resort. * + * A submitter's `formaction` overrides its form's action for its own + * submission, so it overrides the destination too. It is read from the + * attribute rather than from the `formAction` property, which answers with + * the document URL — not with the form's action — when the attribute is + * absent. + * + * On the popstate path the destination is the entry being restored: the + * address bar shows it already, while the element's `href` or `action` + * still points wherever it pointed when the page was rendered. + * * @private */ - get __destination(): string { + __destination(context: FetchRequestContext): string { + if (context.restoredUrl) { + return context.restoredUrl.href; + } + const { $el, isForm, isLink } = this; if (isForm) { + const submitter = submitterOverrides(context.submitter); + + if (submitter?.hasAttribute('formaction')) { + return submitter.formAction; + } + return ($el as HTMLFormElement).action; } @@ -211,7 +284,106 @@ export class Fetch extends Base } /** - * Resolve a base URL and fold a GET form's fields onto it. + * The method this request uses. A submitter's `formmethod` overrides its + * form's method for its own submission; anything that is not a form has no + * method of its own to state. + * + * @private + */ + __method(context: FetchRequestContext): string { + if (!this.isForm) { + return ''; + } + + const submitter = submitterOverrides(context.submitter); + return (submitter?.formMethod || (this.$el as HTMLFormElement).method).toLowerCase(); + } + + /** + * The form's successful controls, the submitter included. + * + * The two-argument `FormData` constructor is what makes the clicked button + * one of them, as a native submission does, and it is what a declarative + * `