From db7a3ec5069b44c92d8edf62ab1e3318c6f4a7ac Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:46:35 -0400 Subject: [PATCH 1/8] fix: allow handle functions composed with sequence to run without a request store --- .changeset/quiet-pumas-dance.md | 5 ++ packages/kit/src/exports/hooks/sequence.js | 80 ++++++++++--------- .../kit/src/exports/hooks/sequence.spec.js | 60 ++++++++------ 3 files changed, 83 insertions(+), 62 deletions(-) create mode 100644 .changeset/quiet-pumas-dance.md diff --git a/.changeset/quiet-pumas-dance.md b/.changeset/quiet-pumas-dance.md new file mode 100644 index 000000000000..a91805ee1859 --- /dev/null +++ b/.changeset/quiet-pumas-dance.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: allow handle functions composed with sequence to run without a request store diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 892dc973fbd4..899bc32bea73 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -1,9 +1,14 @@ /** @import { Handle, RequestEvent, ResolveOptions } from '@sveltejs/kit' */ +/** @import { RecordSpan } from 'types' */ import { merge_tracing, - get_request_store, + try_get_request_store, with_request_store } from '@sveltejs/kit/internal/server'; +import { noop_span } from '../../runtime/telemetry/noop.js'; + +/** @type {RecordSpan} */ +const noop_record_span = ({ fn }) => fn(noop_span); /** * A helper function for sequencing multiple `handle` calls in a middleware-like manner. @@ -83,7 +88,8 @@ export function sequence(...handlers) { if (!length) return ({ event, resolve }) => resolve(event); return ({ event, resolve }) => { - const { state } = get_request_store(); + const store = try_get_request_store(); + const record_span = store?.state.tracing.record_span ?? noop_record_span; return apply_handle(0, event, {}); /** @@ -95,49 +101,51 @@ export function sequence(...handlers) { function apply_handle(i, event, parent_options) { const handle = handlers[i]; - return state.tracing.record_span({ + return record_span({ name: `sveltekit.handle.sequenced.${handle.name ? handle.name : i}`, attributes: {}, fn: async (current) => { const traced_event = merge_tracing(event, current); - return await with_request_store({ event: traced_event, state }, () => - handle({ - event: traced_event, - resolve: (event, options) => { - /** @type {ResolveOptions['transformPageChunk']} */ - const transformPageChunk = async ({ html, done }) => { - if (options?.transformPageChunk) { - html = (await options.transformPageChunk({ html, done })) ?? ''; - } + return await with_request_store( + store && { event: traced_event, state: store.state }, + () => + handle({ + event: traced_event, + resolve: (event, options) => { + /** @type {ResolveOptions['transformPageChunk']} */ + const transformPageChunk = async ({ html, done }) => { + if (options?.transformPageChunk) { + html = (await options.transformPageChunk({ html, done })) ?? ''; + } - if (parent_options?.transformPageChunk) { - html = (await parent_options.transformPageChunk({ html, done })) ?? ''; - } + if (parent_options?.transformPageChunk) { + html = (await parent_options.transformPageChunk({ html, done })) ?? ''; + } - return html; - }; + return html; + }; - /** @type {ResolveOptions['filterSerializedResponseHeaders']} */ - const filterSerializedResponseHeaders = - parent_options?.filterSerializedResponseHeaders ?? - options?.filterSerializedResponseHeaders; + /** @type {ResolveOptions['filterSerializedResponseHeaders']} */ + const filterSerializedResponseHeaders = + parent_options?.filterSerializedResponseHeaders ?? + options?.filterSerializedResponseHeaders; - /** @type {ResolveOptions['preload']} */ - const preload = parent_options?.preload ?? options?.preload; + /** @type {ResolveOptions['preload']} */ + const preload = parent_options?.preload ?? options?.preload; - return i < length - 1 - ? apply_handle(i + 1, event, { - transformPageChunk, - filterSerializedResponseHeaders, - preload - }) - : resolve(event, { - transformPageChunk, - filterSerializedResponseHeaders, - preload - }); - } - }) + return i < length - 1 + ? apply_handle(i + 1, event, { + transformPageChunk, + filterSerializedResponseHeaders, + preload + }) + : resolve(event, { + transformPageChunk, + filterSerializedResponseHeaders, + preload + }); + } + }) ); } }); diff --git a/packages/kit/src/exports/hooks/sequence.spec.js b/packages/kit/src/exports/hooks/sequence.spec.js index 7fe3afa49bdc..71755cafcbe5 100644 --- a/packages/kit/src/exports/hooks/sequence.spec.js +++ b/packages/kit/src/exports/hooks/sequence.spec.js @@ -1,31 +1,39 @@ /** @import { RequestEvent } from '@sveltejs/kit' */ -/** @import { RequestState } from 'types' */ -import { assert, expect, test, vi } from 'vitest'; +import { assert, expect, test } from 'vitest'; import { sequence } from './sequence.js'; -import { noop_span } from '../../runtime/telemetry/noop.js'; - -const dummy_event = vi.hoisted( - () => - /** @type {RequestEvent} */ ({ - tracing: { - root: {} - } - }) -); - -vi.mock(import('@sveltejs/kit/internal/server'), async (actualPromise) => { - const actual = await actualPromise(); - return { - ...actual, - get_request_store: () => ({ - event: dummy_event, - state: /** @type {RequestState} */ ({ - tracing: { - record_span: ({ fn }) => fn(noop_span) - } - }) - }) - }; + +const dummy_event = /** @type {RequestEvent} */ ({ + tracing: { + root: {} + } +}); + +test('runs without a request store', async () => { + /** @type {string[]} */ + const order = []; + + const handler = sequence( + async ({ event, resolve }) => { + order.push('1a'); + const response = await resolve(event); + order.push('1b'); + return response; + }, + async ({ event, resolve }) => { + order.push('2a'); + const response = await resolve(event); + order.push('2b'); + return response; + } + ); + + const response = new Response(); + + assert.equal( + await handler({ event: dummy_event, resolve: () => Promise.resolve(response) }), + response + ); + expect(order).toEqual(['1a', '2a', '2b', '1b']); }); test('applies handlers in sequence', async () => { From cdbb9182073b00147cc599d4eb7aa394c9605b83 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:51:59 -0400 Subject: [PATCH 2/8] test: use a bare event in the no-store test --- .../kit/src/exports/hooks/sequence.spec.js | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.spec.js b/packages/kit/src/exports/hooks/sequence.spec.js index 71755cafcbe5..df8e889c0323 100644 --- a/packages/kit/src/exports/hooks/sequence.spec.js +++ b/packages/kit/src/exports/hooks/sequence.spec.js @@ -9,31 +9,15 @@ const dummy_event = /** @type {RequestEvent} */ ({ }); test('runs without a request store', async () => { - /** @type {string[]} */ - const order = []; - const handler = sequence( - async ({ event, resolve }) => { - order.push('1a'); - const response = await resolve(event); - order.push('1b'); - return response; - }, - async ({ event, resolve }) => { - order.push('2a'); - const response = await resolve(event); - order.push('2b'); - return response; - } + async ({ event, resolve }) => resolve(event), + async ({ event, resolve }) => resolve(event) ); const response = new Response(); + const event = /** @type {RequestEvent} */ ({}); - assert.equal( - await handler({ event: dummy_event, resolve: () => Promise.resolve(response) }), - response - ); - expect(order).toEqual(['1a', '2a', '2b', '1b']); + assert.equal(await handler({ event, resolve: () => Promise.resolve(response) }), response); }); test('applies handlers in sequence', async () => { From a6d05eb032097d8c9d09298833bc8aa0c285434e Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:14:49 -0400 Subject: [PATCH 3/8] chore: build the merged resolve options once --- packages/kit/src/exports/hooks/sequence.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 899bc32bea73..3963dddf4e83 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -133,17 +133,11 @@ export function sequence(...handlers) { /** @type {ResolveOptions['preload']} */ const preload = parent_options?.preload ?? options?.preload; + const merged = { transformPageChunk, filterSerializedResponseHeaders, preload }; + return i < length - 1 - ? apply_handle(i + 1, event, { - transformPageChunk, - filterSerializedResponseHeaders, - preload - }) - : resolve(event, { - transformPageChunk, - filterSerializedResponseHeaders, - preload - }); + ? apply_handle(i + 1, event, merged) + : resolve(event, merged); } }) ); From 0067d6f00e6321464f3f96ec79658a39e16bfe95 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:19:40 -0400 Subject: [PATCH 4/8] chore: inline the merged options --- packages/kit/src/exports/hooks/sequence.js | 34 ++++++++++------------ 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 3963dddf4e83..80cee77de5c8 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -112,29 +112,25 @@ export function sequence(...handlers) { handle({ event: traced_event, resolve: (event, options) => { - /** @type {ResolveOptions['transformPageChunk']} */ - const transformPageChunk = async ({ html, done }) => { - if (options?.transformPageChunk) { - html = (await options.transformPageChunk({ html, done })) ?? ''; - } + /** @type {ResolveOptions} */ + const merged = { + transformPageChunk: async ({ html, done }) => { + if (options?.transformPageChunk) { + html = (await options.transformPageChunk({ html, done })) ?? ''; + } - if (parent_options?.transformPageChunk) { - html = (await parent_options.transformPageChunk({ html, done })) ?? ''; - } + if (parent_options?.transformPageChunk) { + html = (await parent_options.transformPageChunk({ html, done })) ?? ''; + } - return html; + return html; + }, + filterSerializedResponseHeaders: + parent_options?.filterSerializedResponseHeaders ?? + options?.filterSerializedResponseHeaders, + preload: parent_options?.preload ?? options?.preload }; - /** @type {ResolveOptions['filterSerializedResponseHeaders']} */ - const filterSerializedResponseHeaders = - parent_options?.filterSerializedResponseHeaders ?? - options?.filterSerializedResponseHeaders; - - /** @type {ResolveOptions['preload']} */ - const preload = parent_options?.preload ?? options?.preload; - - const merged = { transformPageChunk, filterSerializedResponseHeaders, preload }; - return i < length - 1 ? apply_handle(i + 1, event, merged) : resolve(event, merged); From ba6c980fb54ea1aa63dacf725255fa78b1cf6bc7 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:05:14 -0400 Subject: [PATCH 5/8] chore: make the terminal resolve the recursion base case --- packages/kit/src/exports/hooks/sequence.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 80cee77de5c8..a7c642c10cfc 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -99,6 +99,8 @@ export function sequence(...handlers) { * @returns {Promise} */ function apply_handle(i, event, parent_options) { + if (i === length) return resolve(event, parent_options); + const handle = handlers[i]; return record_span({ @@ -131,9 +133,7 @@ export function sequence(...handlers) { preload: parent_options?.preload ?? options?.preload }; - return i < length - 1 - ? apply_handle(i + 1, event, merged) - : resolve(event, merged); + return apply_handle(i + 1, event, merged); } }) ); From e63d4603d78f53a2298cf076787e6494f490cd93 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:15:01 -0400 Subject: [PATCH 6/8] chore: fold the zero-handler case into the recursion --- packages/kit/src/exports/hooks/sequence.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index a7c642c10cfc..097a4200ddab 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -85,12 +85,11 @@ const noop_record_span = ({ fn }) => fn(noop_span); */ export function sequence(...handlers) { const length = handlers.length; - if (!length) return ({ event, resolve }) => resolve(event); return ({ event, resolve }) => { const store = try_get_request_store(); const record_span = store?.state.tracing.record_span ?? noop_record_span; - return apply_handle(0, event, {}); + return apply_handle(0, event, undefined); /** * @param {number} i From 9bb6d9fbf2f83ee176119260c68bdafcdc6e1ca1 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:16:15 -0400 Subject: [PATCH 7/8] chore: only build a transformPageChunk wrapper when one is defined --- packages/kit/src/exports/hooks/sequence.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 097a4200ddab..f77afacd2702 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -115,17 +115,21 @@ export function sequence(...handlers) { resolve: (event, options) => { /** @type {ResolveOptions} */ const merged = { - transformPageChunk: async ({ html, done }) => { - if (options?.transformPageChunk) { - html = (await options.transformPageChunk({ html, done })) ?? ''; - } + transformPageChunk: + options?.transformPageChunk || parent_options?.transformPageChunk + ? async ({ html, done }) => { + if (options?.transformPageChunk) { + html = (await options.transformPageChunk({ html, done })) ?? ''; + } - if (parent_options?.transformPageChunk) { - html = (await parent_options.transformPageChunk({ html, done })) ?? ''; - } + if (parent_options?.transformPageChunk) { + html = + (await parent_options.transformPageChunk({ html, done })) ?? ''; + } - return html; - }, + return html; + } + : undefined, filterSerializedResponseHeaders: parent_options?.filterSerializedResponseHeaders ?? options?.filterSerializedResponseHeaders, From 5f78c1aeb772c943dbab5ac3248f127a214d7169 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:42:46 -0400 Subject: [PATCH 8/8] chore: only fall back to the noop span recorder when there is no store --- packages/kit/src/exports/hooks/sequence.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index f77afacd2702..e1715085ae9e 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -88,7 +88,7 @@ export function sequence(...handlers) { return ({ event, resolve }) => { const store = try_get_request_store(); - const record_span = store?.state.tracing.record_span ?? noop_record_span; + const record_span = store ? store.state.tracing.record_span : noop_record_span; return apply_handle(0, event, undefined); /**