From 6c3706116b83819f1ad6dbc5797c282b668661ac Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 4 Nov 2026 13:25:07 +0800 Subject: [PATCH 1/2] test(pages): cover loader and action updates to shared data Middleware sets the starting `shared` value. Since #624 the page object reads it after the loader and action run, so `c.set('shared', ...)` and in-place changes in a loader or action reach the page. Nothing tested that. Add tests for the JSON and HTML page after an action, a validation error, a loader on GET, the loader that runs after an action, and an action that leaves shared data alone (it keeps the middleware value). Document that loaders and actions can update shared data and that middleware does not run again after an action. Fixes #695 Co-Authored-By: Claude Opus 5.5 --- docs/guide/pages-routing/actions-and-forms.md | 2 + docs/guide/pages-routing/layouts.md | 16 +++- docs/reference/api/pages.md | 8 +- .../void/test/unit/pages-protocol.test.ts | 92 +++++++++++++++++++ .../void/test/unit/routing-runtime.test.ts | 58 +++++++++++- 5 files changed, 169 insertions(+), 7 deletions(-) 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(); From b26e9722bc1621c6db1d11e35f5455da037981b9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 4 Nov 2026 13:25:09 +0800 Subject: [PATCH 2/2] test: skip .cloudflare when copying a playground for a test websockets-playground.test.ts builds playground/websockets in place while jsx-websocket.test.ts copies the same folder. The build rewrites .cloudflare/output, so the copy could hit a removed file and fail with ENOENT. .cloudflare is build output, like dist and .void, so do not copy it. Co-Authored-By: Claude Opus 5.5 --- test/integration-helpers.ts | 1 + 1 file changed, 1 insertion(+) 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',