From ea6e3c30729a3b732db5b8feabd2e64c6029348f Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 16 Sep 2026 18:42:06 +0200 Subject: [PATCH 1/4] fix(fetch): respect native submitter semantics on a form submission An intercepted submission now sends what a native one sends: the button that caused it is a successful control, and its `formaction`, `formmethod` and `formenctype` override the form's own for that submission. The body follows the effective enctype instead of always being a multipart `FormData`. The submitter travels as an explicit `FetchRequestContext` argument through every step that builds a request, and is never stored on the instance. Kept there it would outlive its submission and add its `name=value` to the next programmatic `fetch()` and to every popstate replay. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- CHANGELOG.md | 6 + .../docs/reference/items/Fetch/examples.md | 23 +- packages/docs/reference/items/Fetch/index.md | 18 +- packages/docs/reference/items/Fetch/js-api.md | 33 ++- .../items/Fetch/stories/pagination/app.ts | 4 + .../items/Fetch/stories/pagination/app.twig | 48 ++++ packages/tests/Fetch/Fetch.spec.ts | 212 +++++++++++++++++ .../barrel-exports/barrel-exports.spec.ts | 1 + packages/ui/src/Fetch/Fetch.ts | 213 +++++++++++++++--- packages/ui/src/Fetch/FetchShopifyPartial.ts | 31 ++- packages/ui/src/Fetch/FetchShopifySection.ts | 32 ++- packages/ui/src/Fetch/index.ts | 1 + packages/ui/src/index.ts | 1 + 13 files changed, 570 insertions(+), 53 deletions(-) create mode 100644 packages/docs/reference/items/Fetch/stories/pagination/app.ts create mode 100644 packages/docs/reference/items/Fetch/stories/pagination/app.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fca6170..e03745ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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] + +### 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/reference/items/Fetch/examples.md b/packages/docs/reference/items/Fetch/examples.md index 519a506f6..d2cdd06eb 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). + ### 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 5bb2baa2f..8f53ecdaf 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -177,7 +177,7 @@ 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. ::: 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. @@ -187,7 +187,33 @@ On a back or forward navigation the component re-fetches `window.location.href`, - 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 builds its request without it. ## Refs @@ -213,7 +239,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 +249,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). The declarative flows fill it in; it is never kept on the instance, so nothing leaks into the next request. ```html
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 000000000..0b2ce753c --- /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 000000000..d66943ff4 --- /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 191a2d42c..b15a23cf4 100644 --- a/packages/tests/Fetch/Fetch.spec.ts +++ b/packages/tests/Fetch/Fetch.spec.ts @@ -274,15 +274,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 +443,195 @@ 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 — the request', () => { it('defaults to the `url` getter when called bare', async () => { const client = stubClient(); diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index 961650906..5eea6ace3 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 521d2bdce..5e6e1f8d8 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -82,6 +82,42 @@ 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 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 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; @@ -194,12 +230,24 @@ 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. + * * @private */ - get __destination(): string { + __destination(context: FetchRequestContext): string { 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 +259,76 @@ 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 + * `
+ `, + ); + 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/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 5e6e1f8d8..69e24df96 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'; /** @@ -86,7 +87,7 @@ 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 and the `RequestInit` — + * 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 @@ -101,6 +102,17 @@ export interface FetchRequestContext { * `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; } /** @@ -145,6 +157,7 @@ export type FetchProps = BaseProps & { }; $options: { history: boolean; + historyMode: 'push' | 'replace'; requestInit: RequestInit; headers: Record; mode: SwapMode; @@ -171,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, @@ -236,9 +253,17 @@ export class Fetch extends Base * 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 */ __destination(context: FetchRequestContext): string { + if (context.restoredUrl) { + return context.restoredUrl.href; + } + const { $el, isForm, isLink } = this; if (isForm) { @@ -294,6 +319,10 @@ export class Fetch extends Base * @private */ __fields(context: FetchRequestContext): URLSearchParams | undefined { + if (context.restoredUrl) { + return context.restoredUrl.searchParams; + } + if (this.__method(context) !== 'get') { return undefined; } @@ -514,17 +543,30 @@ export class Fetch extends Base } } - /** Update the content on history back/forward navigation. */ + /** + * Update the content on history back/forward navigation. + * + * No URL: the request is still the element's own, so a configured `src` + * keeps deciding what is requested and its fixed parameters survive the + * replay. Naming the restored location as the URL instead would discard + * that separation and fetch the displayed page. The restored entry travels + * in the context, where it stands for both the destination and the state of + * the controls. + */ onWindowPopstate(): void { if (!this.$options.history) { return; } - void this.fetch(new URL(window.location.href), { - headers: { - [HEADER_NAMES.X_TRIGGERED_BY]: 'popstate', + void this.fetch( + undefined, + { + headers: { + [HEADER_NAMES.X_TRIGGERED_BY]: 'popstate', + }, }, - }); + { restoredUrl: new URL(window.location.href) }, + ); } /** @@ -703,11 +745,9 @@ export class Fetch extends Base domParser ??= new DOMParser(); const fragment = domParser.parseFromString(content, 'text/html'); + this.__updateHistory(url, requestInit); + if (history) { - if (headerValue(requestInit.headers, HEADER_NAMES.X_TRIGGERED_BY) !== 'popstate') { - const target = this.__historyUrl ?? url; - historyPush({ path: target.pathname, search: target.searchParams }); - } this.$write(() => { if (fragment.title) { document.title = fragment.title; @@ -737,6 +777,31 @@ export class Fetch extends Base this.$emit(FETCH_EVENTS.AFTER_UPDATE, { instance: this, url, requestInit, fragment }); } + /** + * Record the navigation in the browser history, when the `history` option + * asks for it. + * + * The `historyMode` option picks the writer: `push` leaves one entry per + * update, `replace` leaves none, which is what a live search needs so that + * a keystroke does not cost a back press. + * + * Nothing is written for an update popstate triggered: the entry being + * restored is already the current one. + * + * @protected + */ + __updateHistory(url: URL, requestInit: RequestInit): void { + const { history, historyMode } = this.$options; + + if (!history || headerValue(requestInit.headers, HEADER_NAMES.X_TRIGGERED_BY) === 'popstate') { + return; + } + + const target = this.__historyUrl ?? url; + const write = historyMode === 'replace' ? historyReplace : historyPush; + 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 { if (error.name === 'AbortError') { diff --git a/packages/ui/src/Fetch/FetchShopifyPartial.ts b/packages/ui/src/Fetch/FetchShopifyPartial.ts index 7de4ee1d6..e5d7038fd 100644 --- a/packages/ui/src/Fetch/FetchShopifyPartial.ts +++ b/packages/ui/src/Fetch/FetchShopifyPartial.ts @@ -1,11 +1,9 @@ import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { historyPush } from '@studiometa/js-toolkit/utils/historyPush'; import { FETCH_EVENTS, Fetch, HEADER_NAMES, headerNames, - headerValue, type FetchProps, type FetchRequestContext, } from './Fetch.js'; @@ -238,16 +236,9 @@ export class FetchShopifyPartial extends Fetch< update: unknown, partials: PartialsApi, ): Promise { - const { history } = this.$options; - this.$emit(FETCH_EVENTS.BEFORE_UPDATE, { instance: this, url, requestInit, content: update }); - if (history) { - if (headerValue(requestInit.headers, HEADER_NAMES.X_TRIGGERED_BY) !== 'popstate') { - const target = this.__historyUrl ?? url; - historyPush({ path: target.pathname, search: target.searchParams }); - } - } + this.__updateHistory(url, requestInit); this.$emit(FETCH_EVENTS.UPDATE, { instance: this, url, requestInit, update }); From df40eb2b10979ef22724dbb643bd7ba0a6390f3b Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 00:17:33 +0200 Subject: [PATCH 3/4] fix(docs): list FetchRequestContext in the public contracts Every named export of `@studiometa/ui` needs an entry in `public-contracts.ts`; without one the docs build fails its validation step with an undocumented-export error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- packages/docs/.vitepress/reference/public-contracts.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index cdae28e0a..b0f125f80 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', From 80574ac0343d5f921cce2121d810aaf7386b3c94 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 00:17:37 +0200 Subject: [PATCH 4/4] fix(fetch): send a file control as its filename outside a multipart body Only `multipart/form-data` carries a file, and the URL-encoded and `text/plain` branches stringified the `File` into the literal `[object File]`. A native submission sends the file's name there, so both branches now do, through one helper the GET path shares: no GET submission uploads a file either. An upload that turns into a filename is reported as `fetch.file-not-uploaded`, because silently dropping one is worse than saying which enctype sends it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- CHANGELOG.md | 1 + packages/docs/reference/items/Fetch/js-api.md | 21 +++ packages/tests/Fetch/Fetch.spec.ts | 123 +++++++++++++++++- packages/ui/src/Fetch/Fetch.ts | 34 ++++- 4 files changed, 174 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c95a7390..435a03d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 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 diff --git a/packages/docs/reference/items/Fetch/js-api.md b/packages/docs/reference/items/Fetch/js-api.md index 6135d4474..8be631249 100644 --- a/packages/docs/reference/items/Fetch/js-api.md +++ b/packages/docs/reference/items/Fetch/js-api.md @@ -257,6 +257,19 @@ Pressing the second button requests `/projects?orderby=title&page=2`, with no sc 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 ### `headers[]` @@ -311,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/tests/Fetch/Fetch.spec.ts b/packages/tests/Fetch/Fetch.spec.ts index d1158ffab..cda28d81a 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'; @@ -654,6 +654,127 @@ describe('Fetch — native submitter semantics', () => { }); }); +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(); diff --git a/packages/ui/src/Fetch/Fetch.ts b/packages/ui/src/Fetch/Fetch.ts index 69e24df96..f27c7c4bd 100644 --- a/packages/ui/src/Fetch/Fetch.ts +++ b/packages/ui/src/Fetch/Fetch.ts @@ -312,6 +312,29 @@ export class Fetch extends Base return new FormData(this.$el as HTMLFormElement, context.submitter ?? null); } + /** + * A form's entries as text, the way a submission that is not + * `multipart/form-data` encodes them: a file control contributes its file's + * name, because no other encoding carries the file itself. + * + * An upload that silently turns into a filename is worth saying out loud, + * so a file control reaching this path is reported. + * + * @private + */ + __textEntries(formData: FormData): [string, string][] { + const entries = [...formData]; + + if (entries.some(([, value]) => value instanceof File)) { + this.$warn( + 'fetch.file-not-uploaded', + 'A file control is sent as its filename and the file is not uploaded. Only a POST form with `enctype="multipart/form-data"` sends the file itself.', + ); + } + + return entries.map(([name, value]) => [name, value instanceof File ? value.name : value]); + } + /** * The fields this request folds onto its base URL, or `undefined` when it * has none: a link, a POST form, an element that is neither. @@ -327,7 +350,7 @@ export class Fetch extends Base return undefined; } - return new URLSearchParams(this.__formData(context) as unknown as Record); + return new URLSearchParams(this.__textEntries(this.__formData(context))); } /** @@ -336,7 +359,8 @@ export class Fetch extends Base * defaulting to URL encoding as a native submission does. * * Every branch returns a body `fetch()` derives a `content-type` from, so - * none of them writes a header of its own. + * none of them writes a header of its own. Only the multipart branch + * carries a file; the other two send its name, and say so. * * @private */ @@ -349,11 +373,13 @@ export class Fetch extends Base return formData; } + const entries = this.__textEntries(formData); + if (enctype === 'text/plain') { - return [...formData].map(([name, value]) => `${name}=${String(value)}\r\n`).join(''); + return entries.map(([name, value]) => `${name}=${value}\r\n`).join(''); } - return new URLSearchParams(formData as unknown as Record); + return new URLSearchParams(entries); } /**