diff --git a/.changeset/tall-boxes-develop.md b/.changeset/tall-boxes-develop.md new file mode 100644 index 000000000000..a47f65683bab --- /dev/null +++ b/.changeset/tall-boxes-develop.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Use custom x-sveltekit-action header to route enhanced form submissions to +page.server.js over +server.js diff --git a/documentation/docs/06-form-actions.md b/documentation/docs/06-form-actions.md index d5fe40ad9b9c..b11a2f74e09a 100644 --- a/documentation/docs/06-form-actions.md +++ b/documentation/docs/06-form-actions.md @@ -352,6 +352,18 @@ We can also implement progressive enhancement ourselves, without `use:enhance`, ``` +If you have a `+server.js` alongside your `+page.server.js`, `fetch` requests will be routed there by default. To `POST` to an action in `+page.server.js` instead, use the custom `x-sveltekit-action` header: + +```diff +const response = await fetch(this.action, { + method: 'POST', + body: data, ++ headers: { ++ 'x-sveltekit-action': 'true' ++ } +}); +``` + ### Alternatives Form actions are the preferred way to send data to the server, since they can be progressively enhanced, but you can also use [`+server.js`](/docs/routing#server) files to expose (for example) a JSON API. \ No newline at end of file diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js index bf0601385ab9..f55f3c837c51 100644 --- a/packages/kit/src/runtime/app/forms.js +++ b/packages/kit/src/runtime/app/forms.js @@ -73,7 +73,8 @@ export function enhance(form, submit = () => {}) { const response = await fetch(action, { method: 'POST', headers: { - accept: 'application/json' + accept: 'application/json', + 'x-sveltekit-action': 'true' }, body: data, signal: controller.signal diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index d47eb0c13a70..697b18b21057 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -77,13 +77,16 @@ export async function render_endpoint(event, mod, state) { * @param {import('types').RequestEvent} event */ export function is_endpoint_request(event) { - const { method } = event.request; + const { method, headers } = event.request; if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') { // These methods exist exclusively for endpoints return true; } + // use:enhance uses a custom header to disambiguate + if (method === 'POST' && headers.get('x-sveltekit-action') === 'true') return false; + // GET/POST requests may be for endpoints or pages. We prefer endpoints if this isn't a text/html request const accept = event.request.headers.get('accept') ?? '*/*'; return negotiate(accept, ['*', 'text/html']) !== 'text/html'; diff --git a/packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+page.server.js b/packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+page.server.js new file mode 100644 index 000000000000..da7d062a943e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+page.server.js @@ -0,0 +1,7 @@ +export const actions = { + default: () => { + return { + submitted: true + }; + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+page.svelte similarity index 59% rename from packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte rename to packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+page.svelte index b88ab6af7f8f..8038c10f39d1 100644 --- a/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+page.svelte @@ -1,10 +1,15 @@ @@ -16,3 +21,9 @@
{result}
+ +
+ +
+ +

form.submitted: {form?.submitted}

diff --git a/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+server.js b/packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+server.js similarity index 100% rename from packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+server.js rename to packages/kit/test/apps/basics/src/routes/routing/content-negotiation/+server.js diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c350b161f3a8..839354b496ff 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -900,15 +900,25 @@ test.describe('data-sveltekit attributes', () => { }); }); -test('+server.js next to +page.svelte works', async ({ page }) => { - await page.goto('/routing/endpoint-next-to-page'); - expect(await page.textContent('p')).toBe('Hi'); - - for (const method of ['GET', 'PUT', 'PATCH', 'POST', 'DELETE']) { - await page.click(`button:has-text("${method}")`); - await page.waitForFunction( - (method) => document.querySelector('pre').textContent === method, - method - ); - } +test.describe('Content negotiation', () => { + test('+server.js next to +page.svelte works', async ({ page }) => { + await page.goto('/routing/content-negotiation'); + expect(await page.textContent('p')).toBe('Hi'); + + for (const method of ['GET', 'PUT', 'PATCH', 'POST', 'DELETE']) { + await page.click(`button:has-text("${method}")`); + await page.waitForFunction( + (method) => document.querySelector('pre')?.textContent === method, + method + ); + } + }); + + test('use:enhance uses action, not POST handler', async ({ page }) => { + await page.goto('/routing/content-negotiation'); + + page.click('button:has-text("Submit")'); + await page.waitForResponse('/routing/content-negotiation'); + await expect(page.locator('[data-testid="form-result"]')).toHaveText('form.submitted: true'); + }); });