diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 1b0f0e465..df2d7898c 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -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. diff --git a/docs/guide/pages-routing/layouts.md b/docs/guide/pages-routing/layouts.md index 03007097d..a51cb604a 100644 --- a/docs/guide/pages-routing/layouts.md +++ b/docs/guide/pages-routing/layouts.md @@ -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. diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 1e26ac2ed..7ff472639 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -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. @@ -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` | React-only prop type for `defer()` results. It is `Promise` 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. @@ -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. | @@ -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. | diff --git a/packages/void/test/unit/pages-protocol.test.ts b/packages/void/test/unit/pages-protocol.test.ts index 40a5b75c4..e4d259002 100644 --- a/packages/void/test/unit/pages-protocol.test.ts +++ b/packages/void/test/unit/pages-protocol.test.ts @@ -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(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' }, @@ -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 }) => ''); + + 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(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(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(response)).shared).toEqual({ count: 2 }); + }); + it('ValidationError HTML responses include collected dev CSS', async () => { const c = await createTestContext('POST', '/users'); const mutationHandler = vi.fn(() => { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index a7aaf8e48..383432c9a 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1,4 +1,4 @@ -import { Hono, type Context } from 'hono'; +import { Hono, type Context, type MiddlewareHandler } from 'hono'; import { etag } from 'hono/etag'; import { HTTPException } from 'hono/http-exception'; import { matchedRoutes } from 'hono/route'; @@ -1328,10 +1328,13 @@ describe('page loaders and actions composed with defineHandler middleware', () = }); type PageJson = { props: Record; errors: unknown; shared: unknown }; - function createPageApp(mod: Record) { + function createPageApp(mod: Record, appMiddleware?: MiddlewareHandler) { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true }); app.use(runtime.contextMiddleware); + if (appMiddleware) { + app.use(appMiddleware); + } const load = async () => mod; app.get('/db', createPageGetHandler({ ...pageOptions, load })); app.post('/db', createPageMutationHandler({ ...pageOptions, load, namedActions: true })); @@ -1436,6 +1439,57 @@ describe('page loaders and actions composed with defineHandler middleware', () = } }); + it('lets a loader or action update shared data set by app middleware', async () => { + // Like middleware/01.shared.ts: app middleware runs before the page handler. + const count = (n: number) => ({ count: n }); + const appShared: MiddlewareHandler = async (c, next) => { + c.set('shared', count(2)); + await next(); + }; + const sharedOf = async (response: Response) => ((await response.json()) as PageJson).shared; + const setCount = (n: number) => (c: Context) => { + c.set('shared', { ...(c.get('shared') as object), ...count(n) }); + }; + const assignCount = (n: number) => (c: Context) => { + Object.assign(c.get('shared') as object, count(n)); + }; + const loader = () => ({}); + + // The loader runs last, so its update is what the page gets. + for (const update of [setCount(77), assignCount(77)]) { + const app = createPageApp( + { + loader: defineHandler(passThrough, (c) => (update(c), {})), + actions: { create: defineHandler(async () => {}) }, + }, + appShared, + ); + expect(await sharedOf(await get(app))).toEqual(count(77)); + expect(await sharedOf(await post(app, {}))).toEqual(count(77)); + } + + // The action's update reaches the page and the loader that runs after it. + for (const update of [setCount(99), assignCount(99)]) { + let loaderSaw: unknown; + const app = createPageApp( + { + loader: (c: Context) => ((loaderSaw = c.get('shared')), {}), + actions: { create: defineHandler(passThrough, async (c) => update(c)) }, + }, + appShared, + ); + expect(await sharedOf(await post(app, {}))).toEqual(count(99)); + expect(loaderSaw).toEqual(count(99)); + } + + // Middleware ran before the action, so an action that leaves shared alone keeps its value. + const untouched = createPageApp( + { loader, actions: { create: defineHandler(async () => {}) } }, + appShared, + ); + expect(await sharedOf(await post(untouched, {}))).toEqual(count(2)); + }); + it('keeps edits outer middleware makes after an inner middleware answers', async () => { const outer = defineMiddleware(async (c, next) => { await next(); diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index 50e0e063c..8cbf4c539 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -29,6 +29,7 @@ const START_ATTEMPTS = 3; const GENERATED_PLAYGROUND_ENTRIES = new Set([ '.astro', + '.cloudflare', '.next', '.nuxt', '.output',