Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forms-require-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': minor
---

feat: require router forms to declare an action or GET method
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default component$((props) => {
const signal = useSignal(false);
return (
<section>
<Form spaReset={signal.value}>
<Form method="get" spaReset={signal.value}>
<button
type="button"
id="issue-4679-button"
Expand Down
2 changes: 1 addition & 1 deletion e2e/qwik-e2e/tests/qwikrouter/actions.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test.describe('actions', () => {
MPA_and_SPA_tests();

test.describe('issue4679', () => {
test('should serialize Form without action', async ({ page }) => {
test('should serialize Form with an explicit GET method', async ({ page }) => {
await page.goto('/qwikrouter-test/issue4679/');
const button = page.locator('#issue-4679-button');
await expect(button).toHaveText('Toggle False');
Expand Down
6 changes: 3 additions & 3 deletions packages/docs/src/routes/api/qwik-router/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@
}
],
"kind": "Function",
"content": "```typescript\nForm: <O, I>(input: FormProps<O, I>, key: string | null) => import(\"@qwik.dev/core\").JSXOutput\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n{ action, spaReset, reloadDocument, onSubmit$, ...rest }\n\n\n</td><td>\n\n(not declared)\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\ninput\n\n\n</td><td>\n\n[FormProps](#formprops)<!-- -->&lt;O, I&gt;\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\nkey\n\n\n</td><td>\n\nstring \\| null\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\nimport(\"@qwik.dev/core\").JSXOutput",
"content": "```typescript\nForm: <O, I>(props: FormProps<O, I>, key: string | null) => import(\"@qwik.dev/core\").JSXOutput\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nprops\n\n\n</td><td>\n\n[FormProps](#formprops)<!-- -->&lt;O, I&gt;\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\nkey\n\n\n</td><td>\n\nstring \\| null\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\nimport(\"@qwik.dev/core\").JSXOutput",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/form-component.tsx",
"mdFile": "router.form.md"
},
Expand All @@ -361,8 +361,8 @@
"id": "formprops"
}
],
"kind": "Interface",
"content": "```typescript\nexport interface FormProps<O, I> extends Omit<QwikJSX.IntrinsicElements['form'], 'action' | 'method'> \n```\n**Extends:** Omit&lt;QwikJSX.IntrinsicElements\\['form'\\], 'action' \\| 'method'&gt;\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\naction?\n\n\n</td><td>\n\n\n</td><td>\n\n[ActionStore](#actionstore)<!-- -->&lt;O, I, true \\| false&gt;\n\n\n</td><td>\n\n_(Optional)_ Reference to the action returned by `action()`<!-- -->.\n\n\n</td></tr>\n<tr><td>\n\nkey?\n\n\n</td><td>\n\n\n</td><td>\n\nstring \\| number \\| null\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\nonSubmitCompleted$?\n\n\n</td><td>\n\n\n</td><td>\n\nQRLEventHandlerMulti&lt;CustomEvent&lt;[FormSubmitCompletedDetail](#formsubmitsuccessdetail)<!-- -->&lt;O&gt;&gt;, HTMLFormElement&gt; \\| undefined\n\n\n</td><td>\n\n_(Optional)_ Event handler executed right after the action is executed successfully and returns some data.\n\n\n</td></tr>\n<tr><td>\n\nreloadDocument?\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_ When `true` the form submission will cause a full page reload, even if SPA mode is enabled and JS is available.\n\n\n</td></tr>\n<tr><td>\n\nspaReset?\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_ When `true` all the form inputs will be reset in SPA mode, just like happens in a full page form submission.\n\nDefaults to `false`\n\n\n</td></tr>\n</tbody></table>",
"kind": "TypeAlias",
"content": "```typescript\nexport type FormProps<O, I> = Omit<QwikJSX.IntrinsicElements['form'], 'action' | 'method'> & {\n reloadDocument?: boolean;\n spaReset?: boolean;\n onSubmitCompleted$?: QRLEventHandlerMulti<CustomEvent<FormSubmitCompletedDetail<O>>, HTMLFormElement> | undefined;\n key?: string | number | null;\n} & ({\n action: ActionStore<O, I, true | false>;\n method?: never;\n} | {\n action?: never;\n method: 'get';\n});\n```\n**References:** [FormSubmitCompletedDetail](#formsubmitsuccessdetail)<!-- -->, [ActionStore](#actionstore)",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/form-component.tsx",
"mdFile": "router.formprops.md"
},
Expand Down
137 changes: 26 additions & 111 deletions packages/docs/src/routes/api/qwik-router/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ export type FailReturn<T> = T & Failed;
<h2 id="form">Form</h2>

```typescript
Form: <O, I>(input: FormProps<O, I>, key: string | null) =>
Form: <O, I>(props: FormProps<O, I>, key: string | null) =>
import("@qwik.dev/core").JSXOutput;
```

Expand All @@ -843,18 +843,7 @@ Description
</th></tr></thead>
<tbody><tr><td>

\{ action, spaReset, reloadDocument, onSubmit$, ...rest }

</td><td>

(not declared)

</td><td>

</td></tr>
<tr><td>

input
props

</td><td>

Expand Down Expand Up @@ -885,106 +874,32 @@ import("@qwik.dev/core").JSXOutput
<h2 id="formprops">FormProps</h2>

```typescript
export interface FormProps<O, I> extends Omit<QwikJSX.IntrinsicElements['form'], 'action' | 'method'>
export type FormProps<O, I> = Omit<
QwikJSX.IntrinsicElements["form"],
"action" | "method"
> & {
reloadDocument?: boolean;
spaReset?: boolean;
onSubmitCompleted$?:
| QRLEventHandlerMulti<
CustomEvent<FormSubmitCompletedDetail<O>>,
HTMLFormElement
>
| undefined;
key?: string | number | null;
} & (
| {
action: ActionStore<O, I, true | false>;
method?: never;
}
| {
action?: never;
method: "get";
}
);
```

**Extends:** Omit&lt;QwikJSX.IntrinsicElements['form'], 'action' \| 'method'&gt;

<table><thead><tr><th>

Property

</th><th>

Modifiers

</th><th>

Type

</th><th>

Description

</th></tr></thead>
<tbody><tr><td>

action?

</td><td>

</td><td>

[ActionStore](#actionstore)&lt;O, I, true \| false&gt;

</td><td>

_(Optional)_ Reference to the action returned by `action()`.

</td></tr>
<tr><td>

key?

</td><td>

</td><td>

string \| number \| null

</td><td>

_(Optional)_

</td></tr>
<tr><td>

onSubmitCompleted$?

</td><td>

</td><td>

QRLEventHandlerMulti&lt;CustomEvent&lt;[FormSubmitCompletedDetail](#formsubmitsuccessdetail)&lt;O&gt;&gt;, HTMLFormElement&gt; \| undefined

</td><td>

_(Optional)_ Event handler executed right after the action is executed successfully and returns some data.

</td></tr>
<tr><td>

reloadDocument?

</td><td>

</td><td>

boolean

</td><td>

_(Optional)_ When `true` the form submission will cause a full page reload, even if SPA mode is enabled and JS is available.

</td></tr>
<tr><td>

spaReset?

</td><td>

</td><td>

boolean

</td><td>

_(Optional)_ When `true` all the form inputs will be reset in SPA mode, just like happens in a full page form submission.

Defaults to `false`

</td></tr>
</tbody></table>
**References:** [FormSubmitCompletedDetail](#formsubmitsuccessdetail), [ActionStore](#actionstore)

[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/form-component.tsx)

Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/routes/docs/(qwikrouter)/action/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ When JS is enabled, the `<Form/>` component will intercept the form submission a

Actions can also be triggered programmatically using the `action.submit()` method (i.e. you don't need a `<Form/>` component). However, you can trigger the action from a button click or any other event, just like you would do with a function.

When handling a submit event yourself, use a native `<form>` instead of the router's `<Form>`, which requires either an `action` or `method="get"`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't programmatic actions work without action/method="get"?

Paragraph above says "(i.e. you don't need a <Form/> component)", but here it says to use <form> to handle submit events. It's a bit unclear to me and will be to many readers.

@Varixo Varixo Aug 17, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I dont understand the question, Form and form are not the same. When handling manual submit you have to use form, when not you should use Form component. This PR disallow using GET method together with manual handling on Form, because it will cause often a race condition for submit and manual redirect and redirect by Form component itself after submit


```tsx {18} title="src/routes/index.tsx"
import { component$ } from '@qwik.dev/core';
import { routeAction$ } from '@qwik.dev/router';
Expand Down
11 changes: 11 additions & 0 deletions packages/docs/src/routes/docs/(qwikrouter)/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@ The `Form` component is a wrapper around the native `<form>` element, and it's d

Since this component uses the native `<form>` element, it will work with any browser with and without JavaScript enabled. In addition, it enhances the native `<form>` element by capturing the `submit` event and preventing the default behavior, so it will behave like an SPA (Single Page Application) instead of a full page reload.

Pass an `action` to submit a server action, or set `method="get"` explicitly to navigate using the form fields as query parameters. A `Form` without either is invalid; use a native `<form>` when handling submission yourself.

```tsx title="src/routes/login/index.tsx"
import { component$ } from '@qwik.dev/core';
import { Form, routeAction$ } from '@qwik.dev/router';
Expand All @@ -553,6 +555,15 @@ export default component$(() => {
});
```

For a GET form, declare the navigation intent explicitly:

```tsx
<Form method="get">
<input type="search" name="q" />
<button type="submit">Search</button>
</Form>
```

## `<Link>`

The `Link` component works like the `<a>` anchor element, but instead of causing a full page to reload, it will navigate as a SPA (Single Page Navigation). This is useful if you need to navigate without losing your current state.
Expand Down
76 changes: 51 additions & 25 deletions packages/qwik-router/src/runtime/src/form-component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { jsx, component$, Slot, $, type QRLEventHandlerMulti, type QwikJSX } from '@qwik.dev/core';
import {
jsx,
component$,
Slot,
$,
isDev,
type QRLEventHandlerMulti,
type QwikJSX,
} from '@qwik.dev/core';
import type { ActionStore } from './types';
import { useNavigate } from './use-functions';

Expand All @@ -9,13 +17,7 @@ export interface FormSubmitCompletedDetail<T> {
}

/** @public */
export interface FormProps<O, I> extends Omit<
QwikJSX.IntrinsicElements['form'],
'action' | 'method'
> {
/** Reference to the action returned by `action()`. */
action?: ActionStore<O, I, true | false>;

export type FormProps<O, I> = Omit<QwikJSX.IntrinsicElements['form'], 'action' | 'method'> & {
/**
* When `true` the form submission will cause a full page reload, even if SPA mode is enabled and
* JS is available.
Expand All @@ -36,13 +38,36 @@ export interface FormProps<O, I> extends Omit<
| undefined;

key?: string | number | null;
}
} & (
| {
/** Reference to the action returned by `action()`. */
action: ActionStore<O, I, true | false>;
method?: never;
}
| {
action?: never;
/** Submit the form as a GET navigation. */
method: 'get';
}
);

/** @public */
export const Form = <O, I>(
{ action, spaReset, reloadDocument, onSubmit$, ...rest }: FormProps<O, I>,
key: string | null
) => {
export const Form = <O, I>(props: FormProps<O, I>, key: string | null) => {
const { action, method, spaReset, reloadDocument, onSubmit$, ...rest } = props;

if (isDev) {
if (action && method === 'get') {
throw new Error(
'Form cannot use both an action and method="get". Choose one, or use a native <form> when handling submission manually.'
);
}
if (!action && method !== 'get') {
throw new Error(
'Form requires either an action or method="get". Use a native <form> when handling submission manually.'
);
}
}

if (action) {
const isArrayApi = Array.isArray(onSubmit$);
// if you pass an array you can choose where you want action.submit in it
Expand Down Expand Up @@ -87,25 +112,26 @@ export const Form = <O, I>(
},
key
);
} else {
return (
<GetForm
key={key}
spaReset={spaReset}
reloadDocument={reloadDocument}
onSubmit$={onSubmit$}
{...(rest as any)}
/>
);
}

return (
<GetForm
key={key}
method="get"
spaReset={spaReset}
reloadDocument={reloadDocument}
onSubmit$={onSubmit$}
{...(rest as any)}
/>
);
};

export const GetForm = component$<FormProps<undefined, undefined>>(
({ action: _0, spaReset, reloadDocument, onSubmit$, ...rest }) => {
({ action: _0, method: _1, spaReset, reloadDocument, onSubmit$, ...rest }) => {
const nav = useNavigate();
return (
<form
action="get"
method="get"
preventdefault:submit={!reloadDocument}
data-spa-reset={spaReset ? 'true' : undefined}
{...rest}
Expand Down
27 changes: 27 additions & 0 deletions packages/qwik-router/src/runtime/src/form-component.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, expectTypeOf, test } from 'vitest';
import { Form, type FormProps } from './form-component';
import type { ActionStore } from './types';

describe('FormProps', () => {
test('requires an action or an explicit GET method', () => {
type TestAction = ActionStore<unknown, unknown, false>;

expectTypeOf<{ action: TestAction }>().toExtend<FormProps<unknown, unknown>>();
expectTypeOf<{ method: 'get' }>().toExtend<FormProps<unknown, unknown>>();
expectTypeOf<{}>().not.toExtend<FormProps<unknown, unknown>>();
expectTypeOf<{ action: TestAction; method: 'get' }>().not.toExtend<
FormProps<unknown, unknown>
>();
});

test('reports ambiguous runtime props in development', () => {
expect(() => Form({} as never, null)).toThrow(
'Form requires either an action or method="get". Use a native <form> when handling submission manually.'
);
expect(() =>
Form({ action: {} as ActionStore<unknown, unknown, false>, method: 'get' } as never, null)
).toThrow(
'Form cannot use both an action and method="get". Choose one, or use a native <form> when handling submission manually.'
);
});
});
Loading
Loading