Skip to content

feat(fetch): respect native submitter semantics, add historyMode and keep src on popstate - #656

Open
titouanmathis wants to merge 4 commits into
2.xfrom
feat/v2-fetch-request-semantics
Open

titouanmathis wants to merge 4 commits into
2.xfrom
feat/v2-fetch-request-semantics

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Two related changes to Fetch, one commit each.

fix(fetch): native submitter semantics (#650)

An intercepted submission now sends what a native one sends.

  • new FormData(form, submitter) makes the button that caused the submission a successful control, so <button type="submit" name="page" value="2"> is declarative pagination.
  • formaction overrides the form's action for that submission — and therefore the element destination, so the URL written to history follows it too.
  • formmethod overrides the form's method, moving the fields between the URL and the body.
  • formenctype overrides the form's enctype, which now selects the body encoding: URLSearchParams for application/x-www-form-urlencoded, FormData for multipart/form-data, plain text for text/plain. fetch() derives the content-type from the body type, so no branch writes a header of its own.

The enctype change, and what it means for uploads

This changes an existing behaviour. A POST form that declares no enctype used to be sent as multipart and is now URL-encoded, which is what a native submission does.

That matters most for a file control. Only multipart/form-data carries a file, so under any other encoding a native submission sends the file's name — and the two non-multipart branches now do the same, through one __textEntries() helper shared with __fields() so a GET form behaves identically. A GET submission never uploads a file whatever the form declares.

Losing an upload must not be silent, so a file control that reaches either of those branches is reported on the diagnostic channel as fetch.file-not-uploaded, saying that the file is not uploaded and that enctype="multipart/form-data" is what sends it. A form that already declares multipart is unaffected and says nothing.

  • Repeated names and the normal successful-control rules are unchanged.
  • A programmatic fetch() with no submitter is unchanged.

How the submitter is threaded

url, historyUrl and requestInit are parameterless getters, so the submitter cannot reach them as an argument. It is not stashed on the instance either: the instance outlives the submission, and a leftover submitter would keep adding its name=value to the next programmatic fetch() and to every popstate replay. Restoring it after the promise settles does not help — it would still be live for the whole request, which is exactly when a popstate or a second call can land.

So each request now carries an explicit FetchRequestContext, threaded through every step that builds it:

fetch(url?: URL | string, requestInit?: RequestInit, context?: FetchRequestContext): Promise<void>

The three getters were split into __buildUrl(context), __buildHistoryUrl(context) and __buildRequestInit(context), with url / historyUrl / requestInit calling them with the empty context — which is what a request with no submission behind it is. mergeRequestInit() takes the context too, and builds the element's RequestInit once instead of twice.

onSubmit passes { submitter: event.submitter } and stops passing this.requestInit as the per-call init: the per-call init wins over the element's in the merge, so a submitter-less body would have overwritten the one the context builds.

FetchShopifySection moved its sections append from the url getter to __buildUrl(context), so every URL the element resolves for itself gets the parameter — the click, the submit and the popstate replay alike. FetchShopifyPartial threads the context through fetch(), canUsePartials() and mergeRequestInit(), so a submitter's formmethod="post" correctly makes a request unexpressible through the partials transport.

FetchRequestContext is exported from the package barrel.

feat(fetch): history mode and popstate with src (#649)

  • New historyMode: 'push' | 'replace' option, defaulting to push. replace uses historyReplace from @studiometa/js-toolkit, so an update overwrites the current entry instead of adding one — what a live search needs so a keystroke does not cost a back press.
  • The history write moved into one __updateHistory(url, requestInit) method, shared by Fetch.update() and FetchShopifyPartial.applyPartials(), which duplicated the block.

How the popstate path rebuilds the source request

onWindowPopstate used to call fetch(new URL(window.location.href), …). An explicit URL reads as a caller naming a destination, which discards the src separation entirely: the request went to the displayed page instead of the configured source.

It now calls fetch(undefined, …, { restoredUrl: new URL(window.location.href) }). The URL stays absent, so the request is still the element's own and src keeps deciding what is requested. The restored entry travels in the context, where it stands for two things:

  • the destination__destination() returns it, because the address bar already shows it while the element's href or action still points wherever it pointed when the page was rendered. Without src this reproduces the old behaviour exactly.
  • the state of the controls__fields() returns its search params instead of the live form data, rather than folding one over the other. The controls still hold whatever the visitor last typed, which is stale relative to the entry being restored; the response is what brings them back in line.

So /help?q=shipping, restored on a form with action="/help" and src="/apps/search?view=fragment", requests /apps/search?view=fragment&q=shipping. The fixed source parameter survives, and the stale control does not overwrite the restored state.

Fetch gains no knowledge of search, filter names or analytics.

Tests

packages/tests/Fetch/Fetch.spec.ts gains two suites and a stubHistory() helper that records which writer each update reaches for. Submissions are driven with form.requestSubmit(button), which carries a real submitter — the spec's navigation guard cancels a submit-button click before a submit event exists.

Native submitter semantics — a clicked submit button contributes its name and value; two buttons of the same name select different page values; a submission with no submitter stays valid and sends no button; formaction on the request and on the pushed URL; formmethod both ways (GET form to POST, POST form to GET); formenctype; repeated names alongside the submitter, on the URL and in the body; a programmatic fetch() after a submit carries no page. The POST body specs now assert the three encodings.

File controls — a file input with no enctype sends the filename and reports fetch.file-not-uploaded; a text/plain body sends the filename too; an explicit multipart/form-data sends the real File and reports nothing; a GET form sends the filename and reports; a submitter's formenctype="multipart/form-data" sends the real File.

History mode — one entry per update by default, none in replace mode (history.length unchanged, replaceState called twice, pushState never), nothing written when history is off, and the entry carries the destination URL rather than the fetched src.

Popstate with a separate source — the restored location is fetched when there is no src; the source request is rebuilt with its fixed parameters when there is one; the restored state wins over a stale control; a previous submitter is not replayed; no history entry is written.

npm run test, npm run lint, npm run manifest:check and npm run docs:build all pass.

Docs

index.md gains a "Submit buttons" section and a "History" section. js-api.md documents historyMode, a "Form submissions" section with a "File controls" subsection, a "Diagnostics" table, the third fetch() parameter, and extends the historyUrl explanation with what happens on back and forward navigation — where the src separation was previously lost. examples.md gains a runnable "Pagination with submit buttons" story and a "Live search with a separate source" example built on the shape from #649. FetchRequestContext is registered in public-contracts.ts, which docs:build validates.

Closes #650
Closes #649

🤖 Generated with Claude Code

https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu

titouanmathis and others added 2 commits September 16, 2026 18:42
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
`historyMode` picks the history writer when `history` is on: `push`
leaves one entry per update, `replace` leaves none — what a live search
needs so a keystroke does not cost a back press.

A popstate now rebuilds the element's own request instead of naming the
displayed location as its URL, which discarded the `src` separation and
fetched the page rather than the configured source. The restored entry
travels in the request context, where it is both the destination and the
state of the controls: its search parameters replace the live form
fields, which still hold whatever the visitor last typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
@titouanmathis
titouanmathis force-pushed the feat/v2-fetch-request-semantics branch from 4aead40 to 5adbdc3 Compare September 16, 2026 16:42
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.32%. Comparing base (2d5253b) to head (80574ac).
⚠️ Report is 8 commits behind head on 2.x.

Additional details and impacted files
@@            Coverage Diff            @@
##                2.x     #656   +/-   ##
=========================================
  Coverage     86.32%   86.32%           
  Complexity      145      145           
=========================================
  Files            20       20           
  Lines           746      746           
  Branches         88       88           
=========================================
  Hits            644      644           
  Misses           95       95           
  Partials          7        7           
Flag Coverage Δ
unittests 86.32% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

titouanmathis and others added 2 commits September 17, 2026 00:17
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant