From 64f8ba8227e7baebdaa1745725f167430c99b602 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 9 Feb 2026 10:01:11 +0100 Subject: [PATCH 1/2] fix: allow commands in more places In POST/PUT/PATCH/DELETE handlers and in form actions Closes #14325 --- .changeset/wild-vans-bake.md | 5 ++++ .../src/runtime/app/server/remote/command.js | 11 ++----- packages/kit/src/runtime/server/endpoint.js | 3 +- .../kit/src/runtime/server/page/actions.js | 1 + .../kit/src/runtime/server/page/render.js | 1 + packages/kit/src/runtime/server/respond.js | 1 + packages/kit/src/types/internal.d.ts | 2 +- .../kit/test/apps/async/src/hooks.server.js | 20 +++++++++++++ .../remote/server-action/+page.server.ts | 9 ++++++ .../routes/remote/server-action/+page.svelte | 12 ++++++++ .../remote/server-action/action.remote.ts | 6 ++++ .../server-load-command/+page.server.ts | 6 ++++ .../remote/server-load-command/+page.svelte | 5 ++++ .../kit/test/apps/async/test/client.test.js | 29 +++++++++++++++++++ 14 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 .changeset/wild-vans-bake.md create mode 100644 packages/kit/test/apps/async/src/routes/remote/server-action/+page.server.ts create mode 100644 packages/kit/test/apps/async/src/routes/remote/server-action/+page.svelte create mode 100644 packages/kit/test/apps/async/src/routes/remote/server-action/action.remote.ts create mode 100644 packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.server.ts create mode 100644 packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.svelte diff --git a/.changeset/wild-vans-bake.md b/.changeset/wild-vans-bake.md new file mode 100644 index 000000000000..c0ce00c87ea1 --- /dev/null +++ b/.changeset/wild-vans-bake.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: allow commands in more places diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index 0ce1575c08f1..f9e1a67c07d1 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -64,15 +64,10 @@ export function command(validate_or_fn, maybe_fn) { const wrapper = (arg) => { const { event, state } = get_request_store(); - if (state.is_endpoint_request) { - if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.request.method)) { - throw new Error( - `Cannot call a command (\`${__.name}(${maybe_fn ? '...' : ''})\`) from a ${event.request.method} handler` - ); - } - } else if (!event.isRemoteRequest) { + if (!state.allows_commands) { + const disallowed_method = !['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.request.method); throw new Error( - `Cannot call a command (\`${__.name}(${maybe_fn ? '...' : ''})\`) during server-side rendering` + `Cannot call a command (\`${__.name}(${maybe_fn ? '...' : ''})\`) ${disallowed_method ? `from a ${event.request.method} handler or ` : ''}during server-side rendering` ); } diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index a8fe8719ca34..67aaf1614033 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -41,9 +41,8 @@ export async function render_endpoint(event, event_state, mod, state) { } } - event_state.is_endpoint_request = true; - try { + event_state.allows_commands = true; const response = await with_request_store({ event, state: event_state }, () => handler(/** @type {import('@sveltejs/kit').RequestEvent>} */ (event)) ); diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 0d4838cc8c61..d0e13e265c52 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -266,6 +266,7 @@ async function call_action(event, event_state, actions) { }, fn: async (current) => { const traced_event = merge_tracing(event, current); + event_state.allows_commands = true; const result = await with_request_store({ event: traced_event, state: event_state }, () => action(traced_event) ); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 4a6b80be0373..7648a757e02c 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -200,6 +200,7 @@ export async function render_response({ }; } + event_state.allows_commands = false; rendered = await with_request_store({ event, state: event_state }, async () => { // use relative paths during rendering, so that the resulting HTML is as // portable as possible, but reset afterwards diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 8a7dfcc0d1f8..54d0077e6987 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -425,6 +425,7 @@ export async function internal_respond(request, options, manifest, state) { current: root_span } }; + event_state.allows_commands = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method); return await with_request_store({ event: traced_event, state: event_state }, () => options.hooks.handle({ event: traced_event, diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 39a0b30f8a78..4cfa55550248 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -625,7 +625,7 @@ export interface RequestState { form_instances?: Map; remote_data?: Map>>; refreshes?: Record>; - is_endpoint_request?: boolean; + allows_commands?: boolean; } export interface RequestStore { diff --git a/packages/kit/test/apps/async/src/hooks.server.js b/packages/kit/test/apps/async/src/hooks.server.js index 9851e98922eb..18ccd895e57f 100644 --- a/packages/kit/test/apps/async/src/hooks.server.js +++ b/packages/kit/test/apps/async/src/hooks.server.js @@ -1,3 +1,23 @@ +import { do_something } from './routes/remote/server-action/action.remote'; + +/** @type {import('@sveltejs/kit').Handle} */ +export async function handle({ event, resolve }) { + if (event.url.pathname === '/remote/hook-command') { + try { + const result = await do_something('from-hook'); + return new Response(JSON.stringify({ result }), { + headers: { 'content-type': 'application/json' } + }); + } catch (e) { + return new Response(JSON.stringify({ error: /** @type {Error} */ (e).message }), { + status: 500, + headers: { 'content-type': 'application/json' } + }); + } + } + return resolve(event); +} + /** @type {import('@sveltejs/kit').HandleValidationError} */ export const handleValidationError = ({ issues }) => { return { message: issues[0].message }; diff --git a/packages/kit/test/apps/async/src/routes/remote/server-action/+page.server.ts b/packages/kit/test/apps/async/src/routes/remote/server-action/+page.server.ts new file mode 100644 index 000000000000..9d600a585571 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/server-action/+page.server.ts @@ -0,0 +1,9 @@ +import { do_something } from './action.remote'; + +export const actions = { + default: async ({ request }) => { + const fields = await request.formData(); + const result = await do_something(fields.get('input') as string); + return { result }; + } +}; diff --git a/packages/kit/test/apps/async/src/routes/remote/server-action/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/server-action/+page.svelte new file mode 100644 index 000000000000..7020d9edad54 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/server-action/+page.svelte @@ -0,0 +1,12 @@ + + +

{form?.result ?? ''}

+ +
+ + +
diff --git a/packages/kit/test/apps/async/src/routes/remote/server-action/action.remote.ts b/packages/kit/test/apps/async/src/routes/remote/server-action/action.remote.ts new file mode 100644 index 000000000000..4961785f9349 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/server-action/action.remote.ts @@ -0,0 +1,6 @@ +import * as v from 'valibot'; +import { command } from '$app/server'; + +export const do_something = command(v.string(), (input) => { + return `action: ${input}`; +}); diff --git a/packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.server.ts b/packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.server.ts new file mode 100644 index 000000000000..17313ce2249a --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.server.ts @@ -0,0 +1,6 @@ +import { do_something } from '../server-action/action.remote'; + +export async function load() { + const result = await do_something('test'); + return { result }; +} diff --git a/packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.svelte new file mode 100644 index 000000000000..e0661c9a3787 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/server-load-command/+page.svelte @@ -0,0 +1,5 @@ + + +

{data.result}

diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index d6d4a4da9717..1c456fc5d242 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -146,6 +146,35 @@ test.describe('remote function mutations', () => { await expect(page.locator('p')).toHaveText('post'); }); + test('command inside form action works', async ({ page }) => { + await page.goto('/remote/server-action'); + + await page.getByRole('button', { name: 'submit' }).click(); + await expect(page.locator('#result')).toHaveText('action: hello'); + }); + + test('command inside handle hook works with POST', async ({ request }) => { + const response = await request.post('/remote/hook-command'); + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.result).toBe('action: from-hook'); + }); + + test('command is blocked inside load functions', async ({ page }) => { + const response = await page.goto('/remote/server-load-command'); + expect(response?.status()).toBe(500); + await expect(page.locator('#message')).toContainText( + 'Cannot call a command' + ); + }); + + test('command is blocked inside handle hook with GET', async ({ request }) => { + const response = await request.get('/remote/hook-command'); + expect(response.status()).toBe(500); + const data = await response.json(); + expect(data.error).toContain('Cannot call a command'); + }); + test('prerendered entries not called in prod', async ({ page }) => { let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); From 875003af73bfcb9845c0c394e3d35d0520ae038e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 9 Feb 2026 16:26:46 +0100 Subject: [PATCH 2/2] lint --- packages/kit/test/apps/async/test/client.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 1c456fc5d242..cc2c46fc06b7 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -163,9 +163,7 @@ test.describe('remote function mutations', () => { test('command is blocked inside load functions', async ({ page }) => { const response = await page.goto('/remote/server-load-command'); expect(response?.status()).toBe(500); - await expect(page.locator('#message')).toContainText( - 'Cannot call a command' - ); + await expect(page.locator('#message')).toContainText('Cannot call a command'); }); test('command is blocked inside handle hook with GET', async ({ request }) => {