Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/guide/pages-routing/islands.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ export default function PostForm() {

:::

`useIslandForm` provides the same form state as [`useForm`](./actions-and-forms#useform). Pass the action URL to `post`, `put`, `patch`, or `delete` to submit.
`useIslandForm` provides the same form state as [`useForm`](./actions-and-forms#useform). Pass the action URL to `post`, `put`, `patch`, or `delete` to submit. With [`routing.typedRoutes`](../type-safety.md#checked-url-strings), that URL is a [`RouteUrl`](../../reference/api/types.md#route-types). Cast a path built at runtime with `as RouteUrl`.

Successful submissions reload the page; redirects navigate to their destination. A `422` response populates `errors` from `{ errors: { field: "message" } }`. Submit helpers return `Promise<void>`.

Expand Down
6 changes: 3 additions & 3 deletions docs/guide/type-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ await revalidate({ paths: ['/', '/blog/hello-world'] satisfies RouteUrl[] });

### Checked URL strings

[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Apps created with `void init` have it on. To turn it on in an existing app:
[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, and `useIslandForm()`'s `post()`, `put()`, `patch()`, `delete()`, and `submit()`. Apps created with `void init` have it on. To turn it on in an existing app:

```ts
// void.config.ts
Expand Down Expand Up @@ -336,7 +336,7 @@ c.redirect('https://example.com/login');

`void/routes` exports two types for these strings:

- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()` accepts it. Annotate a `c.redirect()` path or a prerender or revalidate path list with it.
- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()` and `useIslandForm()` accept it. Annotate a `c.redirect()` path or a prerender or revalidate path list with it.
- `Href`: anything a link can point to: a `RouteUrl`, a same-page query or hash, or an external URL.

Cast a string that TypeScript can't check, such as a path from your CMS or a file in `public/`:
Expand Down Expand Up @@ -401,7 +401,7 @@ Route types update with the dev server. For CI or standalone typechecking, run `
| Handlers | `c.env` bindings, validator input, return type |
| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
| Navigation | Route patterns and required parameter names and values in `routeUrl()` and destination objects |
| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, and `c.rewrite()` |
| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, `c.rewrite()`, and `useIslandForm()` |
| `c.redirect` | An internal path built with `routeUrl()`, or a full path annotated with `satisfies RouteUrl` |
| `useParams` | Page patterns and the names of the params they return |
| `c.req.param` | Page param names, when the result is annotated with `PageParams` |
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/api/pages.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ routing: {

#### `routing.typedRoutes`

Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `c.redirect()`, `revalidate({ paths })`, and `routing.prerender` stay plain strings until you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings).
Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, and `useIslandForm()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Link and router strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `useIslandForm()` takes a route URL. `c.redirect()`, `revalidate({ paths })`, and `routing.prerender` stay plain strings until you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings).

```ts
routing: {
Expand Down
9 changes: 5 additions & 4 deletions packages/react/src/runtime/use-island-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type IslandFormState,
type VoidActionError,
} from 'void/pages-client';
import type { RouteUrl } from 'void/routes';

export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'setters'> &
Readonly<{ setData: <K extends keyof T & string>(field: K, value: T[K]) => void }>;
Expand Down Expand Up @@ -117,9 +118,9 @@ export function useIslandForm<T extends Record<string, unknown>>(defaults: T): I
reset,
clearErrors,
clearError,
post: (url: string) => submit(url, 'POST'),
put: (url: string) => submit(url, 'PUT'),
patch: (url: string) => submit(url, 'PATCH'),
delete: (url: string) => submit(url, 'DELETE'),
post: (url: RouteUrl) => submit(url, 'POST'),
put: (url: RouteUrl) => submit(url, 'PUT'),
patch: (url: RouteUrl) => submit(url, 'PATCH'),
delete: (url: RouteUrl) => submit(url, 'DELETE'),
};
}
9 changes: 5 additions & 4 deletions packages/solid/src/runtime/use-island-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type IslandFormState,
type VoidActionError,
} from 'void/pages-client';
import type { RouteUrl } from 'void/routes';

export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'setters'> &
Readonly<{ setData: <K extends keyof T & string>(field: K, value: T[K]) => void }>;
Expand Down Expand Up @@ -121,9 +122,9 @@ export function useIslandForm<T extends Record<string, unknown>>(defaults: T): I
reset,
clearErrors,
clearError,
post: (url: string) => submit(url, 'POST'),
put: (url: string) => submit(url, 'PUT'),
patch: (url: string) => submit(url, 'PATCH'),
delete: (url: string) => submit(url, 'DELETE'),
post: (url: RouteUrl) => submit(url, 'POST'),
put: (url: RouteUrl) => submit(url, 'PUT'),
patch: (url: RouteUrl) => submit(url, 'PATCH'),
delete: (url: RouteUrl) => submit(url, 'DELETE'),
};
}
13 changes: 7 additions & 6 deletions packages/svelte/src/runtime/use-island-form.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {
type IslandFormState,
type VoidActionError,
} from 'void/pages-client';
import type { RouteUrl } from 'void/routes';

export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'in-place'> &
Readonly<{ submit(url: string, method: string): Promise<void> }>;
Readonly<{ submit(url: RouteUrl, method: string): Promise<void> }>;

/**
* Form composable for island pages. Uses fetch + page reload instead of the
Expand Down Expand Up @@ -51,7 +52,7 @@ export function useIslandForm<T extends Record<string, unknown>>(defaults: T): I
error = null;
}

async function submit(url: string, method: string) {
async function submit(url: RouteUrl, method: string) {
pending = true;
wasSuccessful = false;
recentlySuccessful = false;
Expand Down Expand Up @@ -113,9 +114,9 @@ export function useIslandForm<T extends Record<string, unknown>>(defaults: T): I
reset,
clearErrors,
clearError,
post: (url: string) => submit(url, 'POST'),
put: (url: string) => submit(url, 'PUT'),
patch: (url: string) => submit(url, 'PATCH'),
delete: (url: string) => submit(url, 'DELETE'),
post: (url: RouteUrl) => submit(url, 'POST'),
put: (url: RouteUrl) => submit(url, 'PUT'),
patch: (url: RouteUrl) => submit(url, 'PATCH'),
delete: (url: RouteUrl) => submit(url, 'DELETE'),
};
}
10 changes: 6 additions & 4 deletions packages/void/src/pages/form-presentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RouteUrl } from 'void/routes';
import type { VoidActionError } from './action-result.ts';
import type { FormResetOptions } from './form-reset.ts';
import type { VisitOptions } from './navigation-engine.ts';
Expand Down Expand Up @@ -46,17 +47,18 @@ export type FormState<TData, TEditing extends FormEditing> = FormStatus &
/**
* The `useIslandForm()` contract every adapter shares. Errors are keyed by whatever names the
* action returns, not only the default fields. Submit methods take the action URL.
* That URL is a `RouteUrl`: any string, unless `routing.typedRoutes` is on.
*/
export type IslandFormState<TData, TEditing extends FormEditing> = FormStatus &
FormFields<TData, Partial<Record<string, string>>, TEditing> &
Readonly<{
reset(...fields: ReadonlyArray<keyof TData & string>): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
post(url: string): Promise<void>;
put(url: string): Promise<void>;
patch(url: string): Promise<void>;
delete(url: string): Promise<void>;
post(url: RouteUrl): Promise<void>;
put(url: RouteUrl): Promise<void>;
patch(url: RouteUrl): Promise<void>;
delete(url: RouteUrl): Promise<void>;
}>;

export function formErrorMessage(form: Pick<FormModel, 'error' | 'errors'>): string | null {
Expand Down
41 changes: 41 additions & 0 deletions packages/void/test/integration/route-url-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const typedCheck = `import { routeUrl, type Href, type RouteUrl } from 'void/rou
import { defineConfig } from 'void/config';
import type { VoidRouter } from 'void/pages-client';
import { defineMiddleware, type CloudContext } from 'void/handler';
import type { IslandFormState } from 'void/pages-client';
declare const id: number;
declare const dynamic: string;
const user: RouteUrl = routeUrl('/users/:id', { params: { id } });
Expand Down Expand Up @@ -92,6 +93,14 @@ void context.rewrite('https://example.com');
// @ts-expect-error rewrites do not take same-page hashes
void context.rewrite('#details');
context.redirect('https://example.com/elsewhere');
declare const island: IslandFormState<{ title: string }, 'setters'>;
void island.post('/about');
void island.post('/users/42?update');
void island.delete(user);
// @ts-expect-error unknown routes are rejected
void island.post('/unknown');
// @ts-expect-error external URLs are rejected
void island.put('https://example.com');
// Config rewrites keep placeholders and static files.
defineConfig({
routing: {
Expand Down Expand Up @@ -127,6 +136,26 @@ const svelte: SvelteLinkProps['href'] = '/unknown';
void [same, known, react, vue, svelte];
`;

// Svelte's implementation uses runes, so this tsc fixture covers the other adapters.
// Svelte's `submit` takes the same `RouteUrl`, and `post`/`put`/`patch`/`delete` come from IslandFormState.
const islandFormCheck = `import { useIslandForm as react } from ${adapterSource('react/src/runtime/use-island-form.ts')};
import { useIslandForm as vue } from ${adapterSource('vue/src/runtime/use-island-form.ts')};
import { useIslandForm as solid } from ${adapterSource('solid/src/runtime/use-island-form.ts')};
const reactForm = react({ title: '' });
const vueForm = vue({ title: '' });
const solidForm = solid({ title: '' });
void reactForm.post('/about');
void vueForm.put('/users/42?update');
void solidForm.delete('/api/legacy');
void vueForm.submit('/about', 'POST');
// @ts-expect-error unknown routes are rejected
void reactForm.post('/unknown');
// @ts-expect-error external URLs are rejected
void solidForm.put('https://example.com');
// @ts-expect-error Vue submit is a route URL
void vueForm.submit('/unknown', 'POST');
`;

const solidLinkHrefCheck = `import type { Href } from 'void/routes';
import type { LinkProps } from ${adapterSource('solid/src/runtime/link.tsx')};
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
Expand Down Expand Up @@ -373,6 +402,18 @@ void loose;
});
});

it('checks useIslandForm URLs in every adapter with routing.typedRoutes', async () => {
const root = await createFixture({
typedRoutes: true,
extraFiles: { 'check-island-form.ts': islandFormCheck },
});
expect(
typecheck(root, 'generated', ['check-island-form.ts'], {
allowImportingTsExtensions: true,
}),
).toEqual({ output: '', status: 0 });
});

it('checks Link href in every adapter with routing.typedRoutes', async () => {
const root = await createFixture({ typedRoutes: true });
const tsSources = { allowImportingTsExtensions: true };
Expand Down
13 changes: 7 additions & 6 deletions packages/vue/src/runtime/use-island-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
type IslandFormState,
type VoidActionError,
} from 'void/pages-client';
import type { RouteUrl } from 'void/routes';
import { reactive, ref, watch } from 'vue';

export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'in-place'> &
Readonly<{ submit(url: string, method: string): Promise<void> }>;
Readonly<{ submit(url: RouteUrl, method: string): Promise<void> }>;

/**
* Form composable for island pages. Uses fetch + page reload instead of the
Expand Down Expand Up @@ -66,7 +67,7 @@ export function useIslandForm(
error.value = null;
}

async function submit(url: string, method: string) {
async function submit(url: RouteUrl, method: string) {
pending.value = true;
wasSuccessful.value = false;
recentlySuccessful.value = false;
Expand Down Expand Up @@ -114,9 +115,9 @@ export function useIslandForm(
reset,
clearErrors,
clearError,
post: (url: string) => submit(url, 'POST'),
put: (url: string) => submit(url, 'PUT'),
patch: (url: string) => submit(url, 'PATCH'),
delete: (url: string) => submit(url, 'DELETE'),
post: (url: RouteUrl) => submit(url, 'POST'),
put: (url: RouteUrl) => submit(url, 'PUT'),
patch: (url: RouteUrl) => submit(url, 'PATCH'),
delete: (url: RouteUrl) => submit(url, 'DELETE'),
});
}
Loading