feat(fetch): respect native submitter semantics, add historyMode and keep src on popstate - #656
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
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
Code ReviewRisk: Low — No blocking defects were found in the reviewed source changes; the MR is safe to merge aside from the unreviewed files listed below. The MR adds submitter-aware native form semantics, explicit request contexts, configurable history replacement, and correct source URL handling during popstate replays. It also adds Shopify partial/section integration for the new request context and exports The reviewed files were Review usage: 47,819 in (32,836 cached) / 1,048 out tokens — $0.0141 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 80574ac. |
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