Skip to content
Merged
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: 2 additions & 0 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const action = defineHandler.withValidator({
| Nothing (void) | Re-runs the loader, page re-renders with fresh props. |
| `c.redirect('/path')` | Navigates to another page. |

Middleware does not run again after the action. To change [shared data](./layouts.md#shared-data), update it in the action or redirect.

## Form

Pair `Form` with `useForm` to submit values and display validation and action errors. Inputs stay editable when a submission fails.
Expand Down
16 changes: 15 additions & 1 deletion docs/guide/pages-routing/layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,18 @@ export default defineMiddleware(async (c, next) => {
});
```

Call `useShared()` from your framework adapter, as in the layout examples above. Its return type is inferred from `CloudContextVariables`. Shared data comes from middleware; page props come from the loader. See [Context variables](../type-safety.md#context-variables).
Call `useShared()` from your framework adapter, as in the layout examples above. Its return type is inferred from `CloudContextVariables`. See [Context variables](../type-safety.md#context-variables).

Middleware sets the starting value. A loader or action can update it before the page is sent:

```ts
// pages/profile.server.ts
import { defineHandler } from 'void';

export const action = defineHandler(async (c) => {
const user = await updateProfile(c);
c.set('shared', { ...c.get('shared'), auth: { user } });
});
```

Middleware runs before the action. If an action changes data that shared data is built from, update shared data in the action, or redirect with `c.redirect()` so middleware runs again on the next page load.
8 changes: 4 additions & 4 deletions docs/reference/api/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Imported from `"@void/vue"`.
| `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
| `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). |

Vue `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links.

Expand Down Expand Up @@ -144,7 +144,7 @@ Imported from `"@void/react"`.
| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper hook for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
| `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). |
| `Deferred<T>` | React-only prop type for `defer()` results. It is `Promise<T>` and is consumed with React `use()`. |

React `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links.
Expand Down Expand Up @@ -221,7 +221,7 @@ Imported from `"@void/svelte"`.
| `useForm(url, defaults, options?)` | Form helper using Svelte 5 runes. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
| `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). |
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. |
| `useParams()` | Returns dynamic route params for the page currently being rendered. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
Expand Down Expand Up @@ -272,7 +272,7 @@ Imported from `"@void/solid"`.
| `useForm(url, defaults, options?)` | Form helper using Solid stores. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
| `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). |
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. |
| `useParams()` | Returns dynamic route params for the page currently being rendered. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
Expand Down
92 changes: 92 additions & 0 deletions packages/void/test/unit/pages-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,27 @@ describe('handlePageGet', () => {
expect(body.shared).toEqual({ auth: { user: { name: 'Test' } } });
});

it('uses shared data the loader updates', async () => {
const c = await createTestContext('GET', '/', {
headers: { 'x-voidpages': 'true' },
shared: { count: 2 },
});

const response = await handlePageGet(
(ctx: CloudContext) => {
ctx.set('shared', { ...(ctx.get('shared') as object), count: 77 });
return {};
},
undefined,
c,
'index',
mockRenderFn,
mockGetAssetTags,
);

expect((await json<PageResponse>(response)).shared).toEqual({ count: 77 });
});

it('prefetch request — resolves deferred props inline as plain JSON', async () => {
const c = await createTestContext('GET', '/dashboard', {
headers: { 'x-voidpages': 'true', purpose: 'prefetch' },
Expand Down Expand Up @@ -655,6 +676,77 @@ describe('handlePageMutation', () => {
expect(body.props).toEqual({ users: [] });
});

it.each([true, false])(
'sends shared data the action updates (page JSON: %s)',
async (pagesRequest) => {
const c = await createTestContext('POST', '/users', {
headers: pagesRequest ? { 'x-voidpages': 'true' } : {},
shared: { count: 2 },
});
const render = vi.fn(async (_page: { shared: unknown }) => '<html></html>');

const response = await handlePageMutation(
(ctx: CloudContext) => {
ctx.set('shared', { ...(ctx.get('shared') as object), count: 99 });
},
() => ({}),
undefined,
c,
'users',
render,
mockGetAssetTags,
);

const shared = pagesRequest
? (await json<PageResponse>(response)).shared
: render.mock.lastCall?.[0].shared;
expect(shared).toEqual({ count: 99 });
},
);

it('keeps the shared data an action updated before a ValidationError', async () => {
const c = await createTestContext('POST', '/users', {
headers: { 'x-voidpages': 'true' },
shared: { count: 2 },
});

const response = await handlePageMutation(
(ctx: CloudContext) => {
ctx.set('shared', { ...(ctx.get('shared') as object), count: 99 });
throw new ValidationError({ name: 'required' });
},
() => ({}),
undefined,
c,
'users',
mockRenderFn,
mockGetAssetTags,
);

const body = await json<PageResponse>(response);
expect(body.errors).toEqual({ name: 'required' });
expect(body.shared).toEqual({ count: 99 });
});

it('keeps the middleware shared data when the action leaves it alone', async () => {
const c = await createTestContext('POST', '/users', {
headers: { 'x-voidpages': 'true' },
shared: { count: 2 },
});

const response = await handlePageMutation(
() => undefined,
() => ({}),
undefined,
c,
'users',
mockRenderFn,
mockGetAssetTags,
);

expect((await json<PageResponse>(response)).shared).toEqual({ count: 2 });
});

it('ValidationError HTML responses include collected dev CSS', async () => {
const c = await createTestContext('POST', '/users');
const mutationHandler = vi.fn(() => {
Expand Down
Loading
Loading