Skip to content

fix: next/form submitter overrides and query-string GET URLs#426

Merged
james-elicx merged 3 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-next-form-submitter-and-query-handling
Mar 11, 2026
Merged

fix: next/form submitter overrides and query-string GET URLs#426
james-elicx merged 3 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-next-form-submitter-and-query-handling

Conversation

@JaredStowell
Copy link
Copy Markdown
Contributor

Fix next/form GET submissions so they preserve existing query strings and honor the clicked submitter's overrides.

This fixes two concrete bugs:

  • GET forms with an existing query string in action built malformed URLs
  • clicked submitter overrides were ignored, so formAction, formMethod, and submitter name=value were not applied

What changed

  • Updated next/form to:

    • preserve existing query params when building GET navigation URLs
    • read the clicked submitter from the submit event
    • honor submitter formAction
    • honor submitter formMethod
    • include submitter name=value in submitted form data
    • fall back gracefully when FormData(form, submitter) is unavailable
  • Added a shared URL helper for safe query param merging and reused it in:

    • next/form
    • next/link object href resolution
    • router object URL resolution

Tests

Added/updated coverage for:

  • direct next/form submit interception behavior
  • GET actions with existing query strings
  • submitter formAction
  • submitter formMethod="GET" overriding a POST form
  • submitter name=value
  • repeated App Router form navigation behavior
  • object Link hrefs that already contain query strings and hashes

Verification

Ran:

  • pnpm test tests/form.test.ts tests/query.test.ts tests/link.test.ts tests/shims.test.ts -t "next/form|Form|appendSearchParamsToUrl|Link resolveHref"
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/form.spec.ts --repeat-each 4
  • pnpm run fmt
  • pnpm run typecheck
  • pnpm run lint
  • pnpm run build
  • pnpm test

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@426

commit: 1fb9cac

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: next/form submitter overrides and query-string GET URLs

Good bug fix overall — the submitter handling and appendSearchParamsToUrl utility are well-structured, and the test coverage (unit + E2E) is thorough. A few things to flag:

Behavioral divergence from Next.js on query string handling

The most significant concern: Next.js strips existing query params from the action URL before appending form data, matching the HTML spec for URL-encoded GET form submissions. vinext now preserves them via appendSearchParamsToUrl.

From Next.js form-shared.tsx:

if (targetUrl.searchParams.size) {
  targetUrl.search = '';
}

Next.js also emits a dev warning: <Form> received action that contains search params: "...". This is not supported, and they will be ignored.

This means the new action="/search?lang=en" pattern in the test fixtures would be warned against by Next.js and the lang=en param would be dropped. vinext preserving it is arguably more useful, but it's a behavioral difference worth documenting or deciding on intentionally. If you want to match Next.js, the existing query should be cleared, and the test expectations adjusted. If you want to intentionally diverge here (reasonable), a brief code comment explaining the choice would help future contributors.

Other notes below as inline comments.

nativeEvent &&
typeof nativeEvent === "object" &&
"submitter" in nativeEvent &&
nativeEvent.submitter instanceof Element
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instanceof Element check here uses the global Element. In the unit tests this works because FakeElement is stubbed as Element. In real browsers this is fine too.

However, there's a subtle difference from Next.js: Next.js accesses event.nativeEvent.submitter directly without instanceof checks — the browser's SubmitEvent.submitter is already typed as HTMLElement | null. The defensive instanceof checking is fine for robustness, but worth noting that Next.js doesn't guard this way.

submitter: FormSubmitter | null,
formMethod: FormHTMLAttributes<HTMLFormElement>["method"],
): string {
const override = submitter?.getAttribute("formmethod");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowercase "formmethod" is correct for getAttribute() — HTML attributes are case-insensitive and the DOM normalizes them to lowercase. Good.

One behavioral note: Next.js doesn't read the effective method this way. Instead it uses hasUnsupportedSubmitterAttributes() which bails out entirely if formMethod is anything other than "get". The practical result is similar (non-GET submissions fall through to native browser behavior), but the Next.js approach also blocks formEncType and formTarget overrides that aren't supported. Consider whether you want to add those guards too.

Comment thread packages/vinext/src/shims/form.tsx Outdated
}

const url = `${action as string}?${params.toString()}`;
const url = appendSearchParamsToUrl(effectiveAction, params);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the key behavioral divergence point. The old code was ${action}?${params} which was buggy (double ? if action already had a query). The fix to use appendSearchParamsToUrl is correct for the merging bug, but it preserves existing query params.

Next.js explicitly clears existing search params before appending form data (matching the HTML spec for URL-encoded GET submissions). A dev-mode warning about query params in the action URL would bring this closer to parity.

If preserving is the intended behavior, a code comment here explaining the intentional divergence would be valuable.

* Append query parameters to a URL while preserving any existing query string
* and fragment identifier.
*/
export function appendSearchParamsToUrl(url: string, params: Iterable<[string, string]>): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean implementation. The manual string parsing (instead of new URL(url, 'http://x')) avoids issues with relative paths and query-only URLs like ?lang=en. Good call.

Minor edge case to consider: URLSearchParams constructor will decode + as space in the existing query string, then toString() will re-encode spaces as +. This is correct behavior, but worth being aware that existing %20-encoded spaces in the URL would be re-encoded as + after round-tripping through URLSearchParams. Probably fine for all practical cases.

Comment thread tests/form.test.ts
};
}

function renderClientForm(props: Record<string, unknown>) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This renderClientForm helper reaches into the component's internal .render method directly. This is a pragmatic approach for unit testing the submit handler without a full DOM, but it's fragile — it depends on forwardRef exposing a .render property, which is a React implementation detail that could change.

Not blocking, but worth a comment explaining why this approach was chosen over something like @testing-library/react.

Comment thread tests/form.test.ts
return class FakeFormData implements Iterable<FormEntry> {
private entries: FormEntry[] = [];

constructor(form?: FormTarget, submitter?: FakeSubmitterElement | null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the FakeFormData constructor receives submitter typed as FakeSubmitterElement | null, but the real FormData constructor accepts HTMLElement | null. The test's supportsSubmitter: false path throws unconditionally when submitter !== undefined, which means passing null explicitly would also throw. In real browsers, new FormData(form, null) does not throw — only truly unsupported environments (old browsers without the two-arg overload) would throw.

This is fine for the specific test cases here since you only pass null implicitly (no submitter arg), but the fake doesn't perfectly model the real API.

await disableViteErrorOverlay(page);
}

async function installPageLoadCounter(page: import("@playwright/test").Page) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement over the window.__FORM_MARKER__ approach. addInitScript + sessionStorage is more reliable for detecting full-page reloads since addInitScript runs on every page load (including navigations that create a new JS context), while sessionStorage persists across navigations within the same tab. Good pattern.

</button>
</Form>

<Form action="/search?lang=en" id="search-form-with-query">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted in the top-level comment: in Next.js, action="/search?lang=en" would trigger a dev-mode warning and the lang=en param would be stripped during form submission. The recommended Next.js pattern is to use a hidden input instead:

Suggested change
<Form action="/search?lang=en" id="search-form-with-query">
<Form action="/search" id="search-form-with-query">
<input type="hidden" name="lang" value="en" />
<input name="q" placeholder="Search with locale..." id="search-input-with-query" />

If you decide to intentionally support query-string preservation (diverging from Next.js), the current approach is fine — but the test fixture should note this is a vinext extension.

const method = getEffectiveMethod(submitter, rest.method);
if (method !== "GET") return;

const effectiveAction = getEffectiveAction(submitter, action as string);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: you re-run isSafeAction on the effective action after resolving submitter overrides. This prevents a safe form from being redirected to an unsafe URL via a submitter's formAction. The e.preventDefault() on line 136 blocks the submission entirely rather than falling through to native behavior — this is the right call for a security guard.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 10, 2026

The review has been posted. Here's a summary of the feedback I provided:

Key finding: Behavioral divergence from Next.js. Next.js explicitly strips existing query params from the form action URL before appending form data (matching the HTML spec for URL-encoded GET submissions), and emits a dev warning when query params are present in the action. This PR preserves them via appendSearchParamsToUrl. This is arguably more useful, but it's worth an intentional decision and documentation.

Inline comments covered:

  1. The instanceof Element guard in getSubmitter — defensive but differs from Next.js's direct access pattern
  2. formmethod/formaction attribute handling — correct casing, but noting Next.js also guards formEncType and formTarget
  3. The core divergence point at line 150 of form.tsx where query params are merged vs stripped
  4. appendSearchParamsToUrl utility — clean implementation, minor note about %20 vs + encoding round-trip
  5. renderClientForm test helper's fragile reliance on forwardRef internals
  6. FakeFormData constructor's imperfect modeling of the null submitter case
  7. Praise for the sessionStorage-based page load counter replacing the old window.__FORM_MARKER__ approach
  8. A suggestion on the test fixture to either use hidden inputs (matching Next.js convention) or document the intentional divergence
  9. Approval of the isSafeAction re-check on the effective action after submitter resolution

github run

@JaredStowell JaredStowell marked this pull request as draft March 10, 2026 23:17
@JaredStowell JaredStowell marked this pull request as ready for review March 10, 2026 23:31
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: next/form submitter overrides and query-string GET URLs

This is a solid PR. The implementation correctly matches Next.js behavior (stripping query params from action URLs and warning in dev), the submitter handling is well-structured, and both unit and E2E test coverage are thorough. The appendSearchParamsToUrl utility is clean and its reuse in link.tsx and router.ts is a nice improvement.

A few issues to address, one of which is a real bug:

Bug: Case mismatch in hasUnsupportedSubmitterAttributes

The function uses mixed-case getAttribute calls ("formEnctype", "formMethod", "formTarget") but getEffectiveMethod and getEffectiveAction use lowercase ("formmethod", "formaction"). In the real DOM, getAttribute is case-insensitive so both work. However, the unit tests' FakeSubmitterElement.getAttribute stores attributes by exact key and does a fallback to name.toLowerCase() — this works, but only because the test attributes are already lowercase. The inconsistency is confusing and could cause bugs if someone adds a test with camelCase attribute keys. More importantly, the HTML spec says attribute names are lowercased in the DOM, so all getAttribute calls should use lowercase for consistency.

Other notes inline.

Comment thread packages/vinext/src/shims/form.tsx Outdated
Comment thread packages/vinext/src/shims/form.tsx Outdated
return true;
}

const formMethod = submitter.getAttribute("formMethod");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same case inconsistency:

Suggested change
const formMethod = submitter.getAttribute("formMethod");
const formMethod = submitter.getAttribute("formmethod");

Comment thread packages/vinext/src/shims/form.tsx Outdated
form: HTMLFormElement,
submitter: FormSubmitter | null,
): string {
const targetUrl = new URL(action, window.location.href);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this new URL(action, window.location.href) call will throw if window.location.href is somehow invalid (shouldn't happen in a real browser, but the unit tests stub window.location.href as a plain string). The code is fine in practice since checkFormActionUrl already ran with a try/catch, but it's worth noting this function assumes a valid base URL.

Also — nice that you strip search params here (line 137-138) to match Next.js form-shared.tsx behavior. This is the right call.

if (e.defaultPrevented) return;
}

const submitter = getSubmitter(e.nativeEvent);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: submitter resolution and unsupported-attribute checks happen before the method !== "GET" early return. This means a POST form with a submitter that has formTarget="_blank" correctly falls through to native behavior rather than silently ignoring the submitter. Correct ordering.

Comment thread tests/form.test.ts
function renderClientForm(props: Record<string, unknown>) {
// `forwardRef()` exposes the wrapped render function on `.render`, which lets us
// exercise the submit handler directly without adding a DOM renderer just for this shim.
const rendered = (Form as unknown as { render: (props: Record<string, unknown>) => any }).render(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted in the existing review: this relies on forwardRef exposing a .render property, which is a React implementation detail. React 19 still has this, but it's not part of the public API.

The comment on line 83-84 documents the rationale well. If React ever removes this, the test will fail with a clear error, so it's low risk. Fine for now.

await disableViteErrorOverlay(page);
}

async function installPageLoadCounter(page: import("@playwright/test").Page) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement. The addInitScript + sessionStorage pattern is more robust than the previous window.__FORM_MARKER__ approach, since addInitScript runs on every navigation (including full page loads) while sessionStorage persists across navigations within the same tab. The count accurately reflects the number of full page loads.

* Append query parameters to a URL while preserving any existing query string
* and fragment identifier.
*/
export function appendSearchParamsToUrl(url: string, params: Iterable<[string, string]>): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean implementation. Using manual string parsing rather than new URL() is the right call here — it avoids issues with relative paths, query-only URLs like ?lang=en, and doesn't require a base URL.

Note that appendSearchParamsToUrl is now used in link.tsx and router.ts resolveHref but not in form.tsx (which uses createFormSubmitDestinationUrl and strips query params). This separation is correct — forms should strip per the HTML spec, while resolveHref for object-style hrefs should preserve since the developer explicitly constructed the pathname with a query string.

</button>
</Form>

<Form action="/search" id="search-form-with-hidden-lang">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: the previous bonk review flagged the potential divergence from Next.js around query params in actions. I see this fixture now uses hidden inputs instead, which matches the Next.js-recommended pattern. The form action is clean (/search) and the lang=en value comes from a hidden <input>. This is correct.

@@ -0,0 +1,19 @@
import SearchForm from "../search/search-form";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this page imports SearchForm but doesn't really need it for the test assertions (the E2E tests never interact with the forms on the /search-alt page — they just verify the result text). Including it is fine for realism though, and it doesn't hurt.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 10, 2026

The review has been posted. Here's a summary of what I flagged:

Bug found: Case mismatch in hasUnsupportedSubmitterAttributes — uses camelCase getAttribute("formEnctype"), getAttribute("formMethod"), getAttribute("formTarget") while getEffectiveMethod and getEffectiveAction correctly use lowercase ("formmethod", "formaction"). While the real DOM is case-insensitive so this works in browsers, it's inconsistent and the test fakes mask the issue. Suggested fixing all three to lowercase.

Positive findings:

  • Implementation correctly matches Next.js behavior by stripping query params from form action URLs (previous bonk review flagged potential divergence — this was already addressed)
  • Test fixtures correctly use hidden inputs instead of query strings in action URLs
  • appendSearchParamsToUrl utility is clean, properly handles relative/query-only URLs, and is correctly separated from form submission logic
  • sessionStorage-based page load counter in E2E tests is a solid improvement over the old window.__FORM_MARKER__ approach
  • Submitter resolution ordering is correct — checks happen before the method !== "GET" early return
  • Security re-check of isSafeAction on effective action after submitter resolution is properly placed

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: next/form submitter overrides and query-string GET URLs

This is well-structured work. The two bugs (malformed GET URLs with existing query strings, ignored submitter overrides) are real, and the fix correctly matches Next.js behavior — stripping query params from the action URL with a dev warning, honoring formAction/formMethod/submitter name=value, and re-checking isSafeAction on the effective action after submitter resolution.

The appendSearchParamsToUrl utility is clean, and reusing it in link.tsx and router.ts for object href resolution is a nice consolidation. The separation between form submission (strips query params) and resolveHref (preserves them) is correct.

Test coverage is thorough — unit tests cover the submit handler directly (including the FormData submitter fallback path), and E2E tests exercise the full navigation flow including cross-page submitter formAction.

A few minor items inline, none blocking.

targetUrl.searchParams.append(name, typeof value === "string" ? value : value.name);
}

return toSameOriginPath(targetUrl.href) ?? targetUrl.href;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: toSameOriginPath returns pathname + search + hash from a URL object. Since you just cleared targetUrl.search and rebuilt it via searchParams.append, this works correctly. But if action originally had a fragment (e.g., /search?q=1#section), the fragment would survive targetUrl.search = "" and appear in the destination URL. Is that intentional?

Next.js also preserves the hash in this path (form-shared.tsx doesn't strip it), so this is fine — just worth a comment if the behavior is intentional, since fragments in form actions are unusual.

}

const submitter = getSubmitter(e.nativeEvent);
if (submitter && hasUnsupportedSubmitterAttributes(submitter)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: hasUnsupportedSubmitterAttributes returns early (falls through to native browser behavior) before the method !== "GET" check. This means a POST form with an unsupported formEnctype on its submitter correctly gets native handling rather than silently ignoring the attribute.

One edge case worth considering: if a submitter has formMethod="POST" (unsupported, triggers the error), the early return means the form never reaches e.preventDefault(), so the browser handles it natively. That's correct — but the console.error message says "This will disable

's navigation functionality", which could be confusing since the form still works (just via the browser, not vinext). Next.js uses the same wording though, so matching it is fine.

Comment thread tests/form.test.ts
function renderClientForm(props: Record<string, unknown>) {
// `forwardRef()` exposes the wrapped render function on `.render`, which lets us
// exercise the submit handler directly without adding a DOM renderer just for this shim.
const rendered = (Form as unknown as { render: (props: Record<string, unknown>) => any }).render(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This .render property on forwardRef components is a React implementation detail. It works in React 18 and 19, but it's not a public API. A brief comment about what to do if this breaks (e.g., switch to @testing-library/react or react-dom/test-utils) would help future contributors.

Not blocking — the existing comment on lines 85-86 explains the "why" well.

Comment thread tests/form.test.ts
return windowStub;
}

afterEach(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The afterEach at module scope means it runs after every test in the file, including the SSR tests that don't stub globals. That's harmless (restoring mocks that don't exist is a no-op), but it would be slightly cleaner to move this into the "Form client GET interception" describe block so the cleanup is co-located with the setup.

merged.append(key, value);
}

const search = merged.toString();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: when params is empty and existingQuery is non-empty, this still round-trips the existing query through URLSearchParams. That means %20 in the original URL becomes + in the output (both are valid, but it's a subtle normalization). The test on line 38-41 of query.test.ts covers the empty-params case, but only with simple values — might be worth a test with %20 to document this behavior.

Not blocking, just a heads-up for anyone debugging URL encoding differences.

Comment thread tests/form.test.ts
submitter?: FakeSubmitterElement | null;
}) {
const event = {
currentTarget: { entries },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The currentTarget is { entries } — a plain object with an entries property. This works because FakeFormData's constructor reads form?.entries. In a real browser, currentTarget would be an HTMLFormElement and new FormData(form) would collect all input values from the DOM.

This is fine for testing the submit handler logic, but means the unit tests can't catch bugs where FormData collection from real DOM elements behaves differently (e.g., disabled fields, unnamed inputs). The E2E tests cover that gap, so this is acceptable.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 11, 2026

Review posted. Here's a summary of my feedback:

Verdict: Approved -- this is solid work that fixes two real bugs with thorough test coverage.

Key observations:

  1. Correct Next.js parity: The implementation correctly strips query params from form action URLs (matching the HTML spec and Next.js form-shared.tsx) and emits dev warnings. The initial bonk review flagged a potential divergence concern, but the code already handles this correctly.

  2. Clean architecture: Good separation between createFormSubmitDestinationUrl (strips query params for forms) and appendSearchParamsToUrl (preserves them for Link/router resolveHref). The utility reuse in link.tsx and router.ts is a nice cleanup.

  3. Security: The isSafeAction re-check on the effective action after submitter resolution is properly placed, preventing submitter formAction from redirecting to unsafe URLs.

Minor inline notes (none blocking):

  • Fragment handling in createFormSubmitDestinationUrl — hash survives the query strip, which matches Next.js but is worth documenting
  • The forwardRef .render property access in tests is a React implementation detail (documented, acceptable)
  • afterEach at module scope could be scoped to the client interception describe block
  • URLSearchParams round-trip normalizes %20 to + — edge case worth a test if it matters
  • The fake currentTarget in unit tests doesn't model real DOM FormData collection, but E2E tests cover that gap

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

Comments look fine.

@james-elicx james-elicx merged commit c4064eb into cloudflare:main Mar 11, 2026
19 checks passed
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.

2 participants