Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
34a0ec5
feat(qwik-router)!: surface loader/action failures on `.error`, drop …
maiieul Jun 10, 2026
35858ab
chore: changesets
maiieul Jun 10, 2026
1c032cd
feat(qwik-router): resurrect fail() as typed, symbol-branded failure …
maiieul Jun 11, 2026
f5bec33
refactor(qwik-router): route fail() results to .error, revert throws …
maiieul Jun 11, 2026
20c4606
feat(qwik-router): SPA abort path, isServerError guard, honest client…
maiieul Jun 11, 2026
6bf8e27
fix(qwik-router): rewrite() handles query strings and drops fragments
maiieul Jun 11, 2026
43deed6
docs(qwik-router): teach fail()/error() model, migration guide, regen…
maiieul Jun 11, 2026
756acdc
test(qwik-router): e2e fixtures and specs for the fail()/error() model
maiieul Jun 11, 2026
9448c81
refactor(qwik-router): move rewrite() URL handling to a separate PR
maiieul Jun 11, 2026
ff43127
lint(qwik-router): trim comments, shorten changeset
maiieul Jun 11, 2026
5239c62
docs(qwik-router): mention isServerError() in changeset
maiieul Jun 11, 2026
47dd7ea
feat(qwik-router): StrictUnion on .error for flat access across failu…
maiieul Jun 11, 2026
5fb6fd1
fix(qwik-router): resolveValue in head() returns failures as ServerError
maiieul Jun 11, 2026
83bf1c2
refactor(qwik-router): StrictUnion on resolveValue action results
maiieul Jun 11, 2026
b385653
refactor(qwik-router): read message directly in loaders head fixture
maiieul Jun 11, 2026
2841b4a
fix(qwik-router): repair sign-in fixture confirmPassword field error
maiieul Jun 11, 2026
b5eac29
feat(qwik-router): guard-free property access on loader.error
maiieul Jun 11, 2026
87ce752
test(qwik-router): pin loader.error discrimination at type and runtim…
maiieul Jun 11, 2026
c9e544b
fix(qwik-router): self-review fixes — header hygiene, stuck navigatio…
maiieul Jun 12, 2026
8befa4f
refactor(qwik-router): submit() always rejects on abort; harden Serve…
maiieul Jun 11, 2026
47370cd
lint(qwik-router): failure banners gate on error.message directly
maiieul Jun 12, 2026
60e891a
chore: split isServerError into a minor changeset, hardening fixes ar…
maiieul Jun 12, 2026
8e662a6
refactor(qwik-router): lean the major — extract isServerError and the…
maiieul Jun 12, 2026
e0b7ce6
chore: drop changeset for fix to unreleased flattening
maiieul Jun 12, 2026
fb1415c
refactor(qwik-router): submitcompleted does not fire on aborted submi…
maiieul Jun 12, 2026
bf44c78
refactor(qwik-router): lean the major — abort delivery, hang fix, env…
maiieul Jun 12, 2026
7062395
fix(qwik-router): deliver SPA action aborts — submit() rejects, submi…
maiieul Jun 12, 2026
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/loader-action-error-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': major
---

BREAKING: loader/action failures moved from `.value` to `.error`. Return `fail(status, data)` (or fail a validator) and read the typed `ServerError` from `loader.error` / `action.error` — `.value` is the success type only and `value.failed` is gone. `throw error()` keeps aborting to the error page.
5 changes: 5 additions & 0 deletions .changeset/submit-rejects-on-abort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': patch
---

A thrown `error()` (or unexpected server error) during an SPA submission was silently swallowed: the action resolved as an empty success and `isNavigating` could stick. `submit()`/`run()` now rejects with the error, navigation state resets, and a non-JSON action response (e.g. a proxy error page) settles instead of hanging forever.
5 changes: 5 additions & 0 deletions .changeset/submitcompleted-aborted-detail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': minor
---

feat: `submitcompleted` now also fires when a `<Form>` submission aborts, with the error on `detail.aborted`.
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ export const onGet: RequestHandler = async ({ redirect, cookie }) => {
};

export const useSigninAction = globalAction$(
async (data, { cookie, redirect, status, fail }) => {
async (data, { cookie, redirect, fail }) => {
const result = await signIn(data, cookie);

if (result.status === 'signed-in') {
throw redirect(302, '/qwikrouter-test/dashboard/');
}

return fail(403, {
message: ['Invalid username or password'],
message: 'Invalid username or password',
});
},
zod$(
Expand Down Expand Up @@ -65,26 +65,26 @@ export default component$(() => {
<h1>Sign In</h1>

<Form action={signIn} spaReset>
{signIn.value?.message && <p style="color:red">{signIn.value.message}</p>}
{signIn.error?.message && <p style="color:red">{signIn.error.message}</p>}
<label>
<span>Username</span>
<input name="username" type="text" autoComplete="username" required />
{signIn.value?.fieldErrors?.username && (
<p style="color:red">{signIn.value?.fieldErrors?.username}</p>
{signIn.error?.fieldErrors?.username && (
<p style="color:red">{signIn.error.fieldErrors.username}</p>
)}
</label>
<label>
<span>Password</span>
<input name="password" type="password" autoComplete="current-password" required />
{signIn.value?.fieldErrors?.password && (
<p style="color:red">{signIn.value?.fieldErrors?.password}</p>
{signIn.error?.fieldErrors?.password && (
<p style="color:red">{signIn.error.fieldErrors.password}</p>
)}
</label>
<label>
<span>Confirm password</span>
<input name="confirmPassword" type="password" autoComplete="current-password" required />
{signIn.value?.fieldErrors?.confirmPassword && (
<p style="color:red">{signIn.value?.fieldErrors?.confirmPassword}</p>
{signIn.error?.fieldErrors?.confirmPassword && (
<p style="color:red">{signIn.error.fieldErrors.confirmPassword}</p>
)}
</label>
<button data-test-sign-in>Sign In</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { component$, useSignal } from '@qwik.dev/core';
import { Form, routeAction$, useLocation } from '@qwik.dev/router';

export const useAbortAction = routeAction$((form, ev) => {
if (form.boom) {
throw ev.error(418, { reason: 'teapot' });
}
return { ok: true };
});

export default component$(() => {
const action = useAbortAction();
const loc = useLocation();
const caught = useSignal('');
const formDetail = useSignal('none');
return (
<div>
<button
id="abort-run"
onClick$={async () => {
try {
await action.submit({ boom: true });
caught.value = 'resolved';
} catch (err) {
const status = err instanceof Error ? (err as { status?: number }).status : undefined;
caught.value = status ? `caught:${status}` : 'caught:unknown';
}
}}
>
Run aborting action
</button>
<p id="abort-caught">{caught.value}</p>
<p id="abort-state">{`${action.isRunning}:${String(action.value)}:${String(action.error)}:${loc.isNavigating}`}</p>
<Form
action={action}
onSubmitCompleted$={(ev) => {
formDetail.value = ev.detail.aborted
? `aborted:${ev.detail.aborted.status}`
: `completed:${ev.detail.status}`;
}}
>
<input type="hidden" name="boom" value="1" />
<button id="abort-form-submit">Submit aborting form</button>
</Form>
<p id="abort-form-detail">{formDetail.value}</p>
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ export default component$(() => {
fooValue satisfies MyObject;

const zodAction = useZodObjectAction();
const zodValue = zodAction.value!;
if (zodValue.failed) {
zodValue satisfies { failed: true } & ValidatorErrorType<{
if (zodAction.error) {
zodAction.error.data satisfies ValidatorErrorType<{
name: string;
}>;
} else {
const zodValue = zodAction.value!;
zodValue satisfies MyObject;
}
return <>TEST</>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default component$(() => {
>
>;

const errors = dotNotation.value?.fieldErrors satisfies ConfirmType | undefined;
const errors = dotNotation.error?.fieldErrors satisfies ConfirmType | undefined;

return (
<>
Expand All @@ -45,23 +45,23 @@ export default component$(() => {
name="credentials.username"
value="user"
class={{
error: dotNotation.value?.fieldErrors?.['credentials.username'],
error: dotNotation.error?.fieldErrors?.['credentials.username'],
}}
/>
<input
type="hidden"
name="credentials.password"
value="pass"
class={{
error: dotNotation.value?.fieldErrors?.['credentials.password'],
error: dotNotation.error?.fieldErrors?.['credentials.password'],
}}
/>
<input
type="hidden"
name="credentials.password"
value="pass"
class={{
error: dotNotation.value?.fieldErrors?.['evenMoreComplex.deep.firstName'],
error: dotNotation.error?.fieldErrors?.['evenMoreComplex.deep.firstName'],
}}
/>
{errors?.['credentials.password'] ?? 'no error'}
Expand All @@ -71,7 +71,7 @@ export default component$(() => {
name="evenMoreComplex.deep.firstName"
value="John"
class={{
error: dotNotation.value?.fieldErrors?.['evenMoreComplex.deep.firstName'],
error: dotNotation.error?.fieldErrors?.['evenMoreComplex.deep.firstName'],
}}
/>

Expand All @@ -80,7 +80,7 @@ export default component$(() => {
name="persons.0.name"
value="John"
class={{
error: dotNotation.value?.fieldErrors?.['persons[].name']?.[0],
error: dotNotation.error?.fieldErrors?.['persons[].name']?.[0],
}}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,23 @@ export const SecretForm = component$(() => {
placeholder="admin"
value={action.formData?.get('username')}
/>
{action.value?.fieldErrors?.username && (
<p class={styles.error}>{action.value.fieldErrors.username}</p>
{action.error?.fieldErrors?.username && (
<p class={styles.error}>{action.error.fieldErrors.username}</p>
)}
</label>
</div>
<div>
<label id="label-code">
Code:
<input type="text" name="code" placeholder="123" value={action.formData?.get('code')} />
{action.value?.fieldErrors?.code && (
<p class={styles.error}>{action.value.fieldErrors.code}</p>
{action.error?.fieldErrors?.code && (
<p class={styles.error}>{action.error.fieldErrors.code}</p>
)}
</label>
</div>
{action.value?.message && (
{action.error?.message && (
<p id="form-error" class={styles.error}>
{action.value.message}
{action.error.message}
</p>
)}
{action.value?.secret && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const actionQrl = (data: JSONObject, { fail }: RequestEventAction) => {
if (Math.random() > 0.5) {
return fail(500, {
actionFail: 'secret',
} as ActionFailedObject);
} satisfies ActionFailedObject);
}

return {
Expand Down Expand Up @@ -79,99 +79,75 @@ export default component$(() => {

// Use options object, use typed data validator, use data validator
const action1 = useAction1();
if (action1.value) {
if (action1.value.failed) {
action1.value satisfies { failed: true } & (
| TypedDataValidatorError
| DataValidatorError
| ActionFailedObject
);
} else {
action1.value satisfies ActionSuccessObject;
}
if (action1.error) {
action1.error.data satisfies TypedDataValidatorError | DataValidatorError | ActionFailedObject;
} else if (action1.value) {
action1.value satisfies ActionSuccessObject;
}

// Use options object, use typed data validator
const action2 = useAction2();
if (action2.value) {
if (action2.value.failed) {
action2.value satisfies { failed: true } & (TypedDataValidatorError | ActionFailedObject);
} else {
action2.value satisfies ActionSuccessObject;
}
if (action2.error) {
action2.error.data satisfies TypedDataValidatorError | ActionFailedObject;
} else if (action2.value) {
action2.value satisfies ActionSuccessObject;
}

// Use options object, use data validator
const action3 = useAction3();
if (action3.value) {
if (action3.value.failed) {
action3.value satisfies { failed: true } & (DataValidatorError | ActionFailedObject);
} else {
action3.value satisfies ActionSuccessObject;
}
if (action3.error) {
action3.error.data satisfies DataValidatorError | ActionFailedObject;
} else if (action3.value) {
action3.value satisfies ActionSuccessObject;
}

// Use typed data validator, use data validator
const action4 = useAction4();
if (action4.value) {
if (action4.value.failed) {
action4.value satisfies { failed: true } & (
| TypedDataValidatorError
| DataValidatorError
| ActionFailedObject
);
} else {
action4.value satisfies ActionSuccessObject;
}
if (action4.error) {
action4.error.data satisfies TypedDataValidatorError | DataValidatorError | ActionFailedObject;
} else if (action4.value) {
action4.value satisfies ActionSuccessObject;
}

// Use typed data validator
const action5 = useAction5();
if (action5.value) {
if (action5.value.failed) {
action5.value satisfies { failed: true } & (TypedDataValidatorError | ActionFailedObject);
} else {
action5.value satisfies ActionSuccessObject;
}
if (action5.error) {
action5.error.data satisfies TypedDataValidatorError | ActionFailedObject;
} else if (action5.value) {
action5.value satisfies ActionSuccessObject;
}

// Use data validator
const action6 = useAction6();
if (action6.value) {
if (action6.value.failed) {
action6.value satisfies { failed: true } & (DataValidatorError | ActionFailedObject);
} else {
action6.value satisfies ActionSuccessObject;
}
if (action6.error) {
action6.error.data satisfies DataValidatorError | ActionFailedObject;
} else if (action6.value) {
action6.value satisfies ActionSuccessObject;
}

// No validators
const action7 = useAction7();
if (action7.value) {
if (action7.value.failed) {
action7.value satisfies { failed: true } & ActionFailedObject;
} else {
action7.value satisfies ActionSuccessObject;
}
if (action7.error) {
action7.error.data satisfies ActionFailedObject;
} else if (action7.value) {
action7.value satisfies ActionSuccessObject;
}

// No validators, with action id
const action8 = useAction7();
if (action8.value) {
if (action8.value.failed) {
action8.value satisfies { failed: true } & ActionFailedObject;
} else {
action8.value satisfies ActionSuccessObject;
}
if (action8.error) {
action8.error.data satisfies ActionFailedObject;
} else if (action8.value) {
action8.value satisfies ActionSuccessObject;
}

return (
<div>
<h1>Validated</h1>
{loader.value.failed ? (
{loader.error ? (
<div>
<p>Failed</p>
<p>{loader.value.message}</p>
<p>{loader.error.message}</p>
</div>
) : (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ export const head: DocumentHead = ({ resolveValue }) => {
if (action) {
title += ` - ACTION: ${action.name}`;
}
if (actionWithError) {
title += ` - Error: ${actionWithError.name} ${actionWithError.message}`;
if (actionWithError?.message) {
title += ` - Error: ${actionWithError.message}`;
}
return {
title,
Expand Down
Loading
Loading