diff --git a/.changeset/brown-seals-rhyme.md b/.changeset/brown-seals-rhyme.md new file mode 100644 index 000000000000..ecdcb7b1f756 --- /dev/null +++ b/.changeset/brown-seals-rhyme.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: correctly decode custom types streamed from a server load function diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js index f4d0a352f628..1142609fe859 100644 --- a/packages/kit/src/runtime/app/forms.js +++ b/packages/kit/src/runtime/app/forms.js @@ -33,7 +33,7 @@ export function deserialize(result) { if (parsed.data) { // the decoders should never be initialised at the top-level because `app` - // will not initialised yet if `kit.output.bundleStrategy` is 'single' or 'inline' + // will not be initialised yet if `kit.output.bundleStrategy` is 'single' or 'inline' parsed.data = devalue.parse(parsed.data, BROWSER ? client_app.decoders : server_app.decoders); } diff --git a/packages/kit/src/runtime/client/bundle.js b/packages/kit/src/runtime/client/bundle.js index 7a915d3110a4..f375d02e8355 100644 --- a/packages/kit/src/runtime/client/bundle.js +++ b/packages/kit/src/runtime/client/bundle.js @@ -13,3 +13,5 @@ import * as app from '__sveltekit/manifest'; export function start(element, options) { void kit.start(app, element, options); } + +export { app }; diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0d22352839bf..bb55ce707613 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -366,9 +366,28 @@ export async function render_response({ deferred.set(id, { fulfil, reject }); })`); + let app_declaration = ''; + + if (Object.keys(options.hooks.transport).length > 0) { + if (client.inline) { + app_declaration = `const app = __sveltekit_${options.version_hash}.app.app;`; + } else if (client.app) { + app_declaration = `const app = await import(${s(prefixed(client.app))});`; + } else { + app_declaration = `const { app } = await import(${s(prefixed(client.start))});`; + } + } + + const prelude = app_declaration + ? `${app_declaration} + const [data, error] = fn(app);` + : `const [data, error] = fn();`; + // When resolving, the id might not yet be available due to the data // be evaluated upon init of kit, so we use a timeout to retry - properties.push(`resolve: ({ id, data, error }) => { + properties.push(`resolve: async (id, fn) => { + ${prelude} + const try_to_resolve = () => { if (!deferred.has(id)) { setTimeout(try_to_resolve, 0); @@ -652,7 +671,7 @@ function get_data(event, event_state, options, nodes, csp, global) { let str; try { - str = devalue.uneval({ id, data, error }, replacer); + str = devalue.uneval(error ? [, error] : [data], replacer); } catch { error = await handle_error_and_jsonify( event, @@ -661,11 +680,13 @@ function get_data(event, event_state, options, nodes, csp, global) { new Error(`Failed to serialize promise while rendering ${event.route.id}`) ); data = undefined; - str = devalue.uneval({ id, data, error }, replacer); + str = devalue.uneval([, error], replacer); } const nonce = csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''; - push(`${global}.resolve(${str})\n`); + push( + `${global}.resolve(${id}, ${str.includes('app.decode') ? `(app) => ${str}` : `() => ${str}`})\n` + ); if (count === 0) done(); } ); diff --git a/packages/kit/test/apps/basics/src/routes/serialization-stream/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-stream/+page.server.js new file mode 100644 index 000000000000..957f34dc1b76 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-stream/+page.server.js @@ -0,0 +1,7 @@ +import { Foo } from '$lib'; + +export const load = () => { + return { + foo: Promise.resolve(new Foo('It works')) + }; +}; diff --git a/packages/kit/test/apps/basics/src/routes/serialization-stream/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-stream/+page.svelte new file mode 100644 index 000000000000..75c6a2f7a800 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-stream/+page.svelte @@ -0,0 +1,11 @@ + + +

+ {#await data.foo} + Loading... + {:then result} + {result.bar()} + {/await} +

diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 55db0bf20db6..ac9755b2c741 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1560,6 +1560,13 @@ test.describe('Serialization', () => { await page.click('button'); await expect(page.locator('h1')).toHaveText('It works!'); }); + + test('works with streaming', async ({ page, javaScriptEnabled }) => { + test.skip(!javaScriptEnabled, 'skip when JavaScript is disabled'); + + await page.goto('/serialization-stream'); + await expect(page.locator('h1', { hasText: 'It works!' })).toBeVisible(); + }); }); test.describe('getRequestEvent', () => { diff --git a/packages/kit/test/apps/options-2/src/hooks.js b/packages/kit/test/apps/options-2/src/hooks.js new file mode 100644 index 000000000000..238cc7744f2c --- /dev/null +++ b/packages/kit/test/apps/options-2/src/hooks.js @@ -0,0 +1,9 @@ +import { Foo } from '$lib'; + +/** @type {import("@sveltejs/kit").Transport} */ +export const transport = { + Foo: { + encode: (value) => value instanceof Foo && [value.message], + decode: ([message]) => new Foo(message) + } +}; diff --git a/packages/kit/test/apps/options-2/src/lib/index.js b/packages/kit/test/apps/options-2/src/lib/index.js new file mode 100644 index 000000000000..f9917fd877e2 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/lib/index.js @@ -0,0 +1,9 @@ +export class Foo { + constructor(message) { + this.message = message; + } + + bar() { + return this.message + '!'; + } +} diff --git a/packages/kit/test/apps/options-2/src/routes/serialization-stream/+page.server.js b/packages/kit/test/apps/options-2/src/routes/serialization-stream/+page.server.js new file mode 100644 index 000000000000..957f34dc1b76 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/routes/serialization-stream/+page.server.js @@ -0,0 +1,7 @@ +import { Foo } from '$lib'; + +export const load = () => { + return { + foo: Promise.resolve(new Foo('It works')) + }; +}; diff --git a/packages/kit/test/apps/options-2/src/routes/serialization-stream/+page.svelte b/packages/kit/test/apps/options-2/src/routes/serialization-stream/+page.svelte new file mode 100644 index 000000000000..75c6a2f7a800 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/routes/serialization-stream/+page.svelte @@ -0,0 +1,11 @@ + + +

+ {#await data.foo} + Loading... + {:then result} + {result.bar()} + {/await} +

diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js index 4af842e6f7ee..f3df40146a8d 100644 --- a/packages/kit/test/apps/options-2/test/test.js +++ b/packages/kit/test/apps/options-2/test/test.js @@ -190,4 +190,9 @@ test.describe("bundleStrategy: 'single'", () => { await page.goto('/basepath/deserialize'); await expect(page.locator('p')).toHaveText('Hello world!'); }); + + test('serialization works with streaming', async ({ page }) => { + await page.goto('/basepath/serialization-stream'); + await expect(page.locator('h1', { hasText: 'It works!' })).toBeVisible(); + }); }); diff --git a/packages/kit/test/apps/options-3/package.json b/packages/kit/test/apps/options-3/package.json new file mode 100644 index 000000000000..78543175148f --- /dev/null +++ b/packages/kit/test/apps/options-3/package.json @@ -0,0 +1,23 @@ +{ + "name": "test-options-3", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync", + "check": "svelte-kit sync && tsc && svelte-check", + "test": "playwright test" + }, + "devDependencies": { + "@sveltejs/adapter-node": "workspace:^", + "@sveltejs/kit": "workspace:^", + "@sveltejs/vite-plugin-svelte": "catalog:", + "svelte": "^5.35.5", + "svelte-check": "^4.1.1", + "typescript": "^5.5.4", + "vite": "catalog:" + }, + "type": "module" +} diff --git a/packages/kit/test/apps/options-3/playwright.config.js b/packages/kit/test/apps/options-3/playwright.config.js new file mode 100644 index 000000000000..33d36b651014 --- /dev/null +++ b/packages/kit/test/apps/options-3/playwright.config.js @@ -0,0 +1 @@ +export { config as default } from '../../utils.js'; diff --git a/packages/kit/test/apps/options-3/src/app.html b/packages/kit/test/apps/options-3/src/app.html new file mode 100644 index 000000000000..b8ba4699b2ba --- /dev/null +++ b/packages/kit/test/apps/options-3/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/kit/test/apps/options-3/src/hooks.js b/packages/kit/test/apps/options-3/src/hooks.js new file mode 100644 index 000000000000..238cc7744f2c --- /dev/null +++ b/packages/kit/test/apps/options-3/src/hooks.js @@ -0,0 +1,9 @@ +import { Foo } from '$lib'; + +/** @type {import("@sveltejs/kit").Transport} */ +export const transport = { + Foo: { + encode: (value) => value instanceof Foo && [value.message], + decode: ([message]) => new Foo(message) + } +}; diff --git a/packages/kit/test/apps/options-3/src/lib/index.js b/packages/kit/test/apps/options-3/src/lib/index.js new file mode 100644 index 000000000000..f9917fd877e2 --- /dev/null +++ b/packages/kit/test/apps/options-3/src/lib/index.js @@ -0,0 +1,9 @@ +export class Foo { + constructor(message) { + this.message = message; + } + + bar() { + return this.message + '!'; + } +} diff --git a/packages/kit/test/apps/options-3/src/routes/+layout.svelte b/packages/kit/test/apps/options-3/src/routes/+layout.svelte new file mode 100644 index 000000000000..5e1f1fed86c2 --- /dev/null +++ b/packages/kit/test/apps/options-3/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/kit/test/apps/options-3/src/routes/serialization-stream/+page.server.js b/packages/kit/test/apps/options-3/src/routes/serialization-stream/+page.server.js new file mode 100644 index 000000000000..957f34dc1b76 --- /dev/null +++ b/packages/kit/test/apps/options-3/src/routes/serialization-stream/+page.server.js @@ -0,0 +1,7 @@ +import { Foo } from '$lib'; + +export const load = () => { + return { + foo: Promise.resolve(new Foo('It works')) + }; +}; diff --git a/packages/kit/test/apps/options-3/src/routes/serialization-stream/+page.svelte b/packages/kit/test/apps/options-3/src/routes/serialization-stream/+page.svelte new file mode 100644 index 000000000000..75c6a2f7a800 --- /dev/null +++ b/packages/kit/test/apps/options-3/src/routes/serialization-stream/+page.svelte @@ -0,0 +1,11 @@ + + +

+ {#await data.foo} + Loading... + {:then result} + {result.bar()} + {/await} +

diff --git a/packages/kit/test/apps/options-3/svelte.config.js b/packages/kit/test/apps/options-3/svelte.config.js new file mode 100644 index 000000000000..bcfcfccabb1f --- /dev/null +++ b/packages/kit/test/apps/options-3/svelte.config.js @@ -0,0 +1,10 @@ +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + output: { + bundleStrategy: 'inline' + } + } +}; + +export default config; diff --git a/packages/kit/test/apps/options-3/test/test.js b/packages/kit/test/apps/options-3/test/test.js new file mode 100644 index 000000000000..b85f5c61265b --- /dev/null +++ b/packages/kit/test/apps/options-3/test/test.js @@ -0,0 +1,16 @@ +import process from 'node:process'; +import { expect } from '@playwright/test'; +import { test } from '../../../utils.js'; + +/** @typedef {import('@playwright/test').Response} Response */ + +test.describe.configure({ mode: 'parallel' }); + +test.describe("bundleStrategy: 'inline'", () => { + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled || !!process.env.DEV); + + test('serialization works with streaming', async ({ page }) => { + await page.goto('/serialization-stream'); + await expect(page.locator('h1', { hasText: 'It works!' })).toBeVisible(); + }); +}); diff --git a/packages/kit/test/apps/options-3/tsconfig.json b/packages/kit/test/apps/options-3/tsconfig.json new file mode 100644 index 000000000000..b1096bf168cd --- /dev/null +++ b/packages/kit/test/apps/options-3/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/kit/test/apps/options-3/vite.config.js b/packages/kit/test/apps/options-3/vite.config.js new file mode 100644 index 000000000000..69200cdb7cd8 --- /dev/null +++ b/packages/kit/test/apps/options-3/vite.config.js @@ -0,0 +1,18 @@ +import * as path from 'node:path'; +import { sveltekit } from '@sveltejs/kit/vite'; + +/** @type {import('vite').UserConfig} */ +const config = { + build: { + minify: false + }, + clearScreen: false, + plugins: [sveltekit()], + server: { + fs: { + allow: [path.resolve('../../../src')] + } + } +}; + +export default config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 643098904aaa..13b5d6dd4927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -714,6 +714,30 @@ importers: specifier: 'catalog:' version: 6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + packages/kit/test/apps/options-3: + devDependencies: + '@sveltejs/adapter-node': + specifier: workspace:^ + version: link:../../../../adapter-node + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../.. + '@sveltejs/vite-plugin-svelte': + specifier: 'catalog:' + version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) + svelte: + specifier: ^5.35.5 + version: 5.35.5 + svelte-check: + specifier: ^4.1.1 + version: 4.1.1(picomatch@4.0.3)(svelte@5.35.5)(typescript@5.8.3) + typescript: + specifier: ^5.5.4 + version: 5.8.3 + vite: + specifier: 'catalog:' + version: 6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + packages/kit/test/apps/prerendered-app-error-pages: devDependencies: '@sveltejs/kit':