feat(fetch): respect native submitter semantics, add historyMode and keep src on popstate - #656
Open
titouanmathis wants to merge 4 commits into
Open
titouanmathis wants to merge 4 commits into
titouanmathis wants to merge 4 commits into
Conversation
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
force-pushed
the
feat/v2-fetch-request-semantics
branch
from
September 16, 2026 16:42
4aead40 to
5adbdc3
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
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
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.formactionoverrides the form's action for that submission — and therefore the element destination, so the URL written to history follows it too.formmethodoverrides the form's method, moving the fields between the URL and the body.formenctypeoverrides the form'senctype, which now selects the body encoding:URLSearchParamsforapplication/x-www-form-urlencoded,FormDataformultipart/form-data, plain text fortext/plain.fetch()derives thecontent-typefrom 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
enctypeused 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-datacarries 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 thatenctype="multipart/form-data"is what sends it. A form that already declares multipart is unaffected and says nothing.fetch()with no submitter is unchanged.How the submitter is threaded
url,historyUrlandrequestInitare 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 itsname=valueto the next programmaticfetch()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:The three getters were split into
__buildUrl(context),__buildHistoryUrl(context)and__buildRequestInit(context), withurl/historyUrl/requestInitcalling 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'sRequestInitonce instead of twice.onSubmitpasses{ submitter: event.submitter }and stops passingthis.requestInitas 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.FetchShopifySectionmoved itssectionsappend from theurlgetter to__buildUrl(context), so every URL the element resolves for itself gets the parameter — the click, the submit and the popstate replay alike.FetchShopifyPartialthreads the context throughfetch(),canUsePartials()andmergeRequestInit(), so a submitter'sformmethod="post"correctly makes a request unexpressible through the partials transport.FetchRequestContextis exported from the package barrel.feat(fetch): history mode and popstate withsrc(#649)historyMode: 'push' | 'replace'option, defaulting topush.replaceuseshistoryReplacefrom@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.__updateHistory(url, requestInit)method, shared byFetch.update()andFetchShopifyPartial.applyPartials(), which duplicated the block.How the popstate path rebuilds the source request
onWindowPopstateused to callfetch(new URL(window.location.href), …). An explicit URL reads as a caller naming a destination, which discards thesrcseparation 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 andsrckeeps deciding what is requested. The restored entry travels in the context, where it stands for two things:__destination()returns it, because the address bar already shows it while the element'shreforactionstill points wherever it pointed when the page was rendered. Withoutsrcthis reproduces the old behaviour exactly.__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 withaction="/help"andsrc="/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.Fetchgains no knowledge of search, filter names or analytics.Tests
packages/tests/Fetch/Fetch.spec.tsgains two suites and astubHistory()helper that records which writer each update reaches for. Submissions are driven withform.requestSubmit(button), which carries a real submitter — the spec's navigation guard cancels a submit-button click before asubmitevent exists.Native submitter semantics — a clicked submit button contributes its name and value; two buttons of the same name select different
pagevalues; a submission with no submitter stays valid and sends no button;formactionon the request and on the pushed URL;formmethodboth ways (GET form to POST, POST form to GET);formenctype; repeated names alongside the submitter, on the URL and in the body; a programmaticfetch()after a submit carries nopage. The POST body specs now assert the three encodings.File controls — a file input with no
enctypesends the filename and reportsfetch.file-not-uploaded; atext/plainbody sends the filename too; an explicitmultipart/form-datasends the realFileand reports nothing; a GET form sends the filename and reports; a submitter'sformenctype="multipart/form-data"sends the realFile.History mode — one entry per update by default, none in
replacemode (history.lengthunchanged,replaceStatecalled twice,pushStatenever), nothing written whenhistoryis off, and the entry carries the destination URL rather than the fetchedsrc.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:checkandnpm run docs:buildall pass.Docs
index.mdgains a "Submit buttons" section and a "History" section.js-api.mddocumentshistoryMode, a "Form submissions" section with a "File controls" subsection, a "Diagnostics" table, the thirdfetch()parameter, and extends thehistoryUrlexplanation with what happens on back and forward navigation — where thesrcseparation was previously lost.examples.mdgains a runnable "Pagination with submit buttons" story and a "Live search with a separate source" example built on the shape from #649.FetchRequestContextis registered inpublic-contracts.ts, whichdocs:buildvalidates.Closes #650
Closes #649
🤖 Generated with Claude Code
https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu