diff --git a/e2e/solid-start/serialization-adapters/.gitignore b/e2e/solid-start/serialization-adapters/.gitignore new file mode 100644 index 00000000000..08eba9e7065 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/.gitignore @@ -0,0 +1,20 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +count.txt diff --git a/e2e/solid-start/serialization-adapters/.prettierignore b/e2e/solid-start/serialization-adapters/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/solid-start/serialization-adapters/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/e2e/solid-start/serialization-adapters/package.json b/e2e/solid-start/serialization-adapters/package.json new file mode 100644 index 00000000000..40cef1c37f0 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/package.json @@ -0,0 +1,33 @@ +{ + "name": "tanstack-solid-start-e2e-serialization-adapters", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-router-devtools": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^3.24.2" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "postcss": "^8.5.1", + "seroval": "^1.3.2", + "srvx": "^0.8.6", + "tailwindcss": "^4.1.15", + "typescript": "^5.7.2", + "vite-plugin-solid": "^2.11.9" + } +} diff --git a/e2e/solid-start/serialization-adapters/playwright.config.ts b/e2e/solid-start/serialization-adapters/playwright.config.ts new file mode 100644 index 00000000000..badd6db0eb3 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/serialization-adapters/postcss.config.mjs b/e2e/solid-start/serialization-adapters/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/solid-start/serialization-adapters/src/CustomError.ts b/e2e/solid-start/serialization-adapters/src/CustomError.ts new file mode 100644 index 00000000000..06104320b23 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/CustomError.ts @@ -0,0 +1,31 @@ +import { createSerializationAdapter } from '@tanstack/solid-router' + +export class CustomError extends Error { + public foo: string + public bar: bigint + + constructor(message: string, options: { foo: string; bar: bigint }) { + super(message) + + Object.setPrototypeOf(this, new.target.prototype) + + this.name = this.constructor.name + this.foo = options.foo + this.bar = options.bar + } +} + +export const customErrorAdapter = createSerializationAdapter({ + key: 'custom-error', + test: (v) => v instanceof CustomError, + toSerializable: ({ message, foo, bar }) => { + return { + message, + foo, + bar, + } + }, + fromSerializable: ({ message, foo, bar }) => { + return new CustomError(message, { foo, bar }) + }, +}) diff --git a/e2e/solid-start/serialization-adapters/src/data.tsx b/e2e/solid-start/serialization-adapters/src/data.tsx new file mode 100644 index 00000000000..60293450af6 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/data.tsx @@ -0,0 +1,205 @@ +import { createSerializationAdapter } from '@tanstack/solid-router' + +export class Foo { + constructor(public value: string) {} +} + +export interface Car { + __type: 'car' + make: string + model: string + year: number + honk: () => { message: string; make: string; model: string; year: number } +} + +export function makeCar(opts: { + make: string + model: string + year: number +}): Car { + return { + ...opts, + __type: 'car', + honk: () => { + return { message: `Honk! Honk!`, ...opts } + }, + } +} + +export const fooAdapter = createSerializationAdapter({ + key: 'foo', + test: (value: any) => value instanceof Foo, + toSerializable: (foo) => foo.value, + fromSerializable: (value) => new Foo(value), +}) + +export const carAdapter = createSerializationAdapter({ + key: 'car', + test: (value: any): value is Car => + '__type' in (value as Car) && value.__type === 'car', + toSerializable: (car) => ({ + make: car.make, + model: car.model, + year: car.year, + }), + fromSerializable: (value: { make: string; model: string; year: number }) => + makeCar(value), +}) + +export function makeData() { + function makeFoo(suffix: string = '') { + return new Foo(typeof window === 'undefined' ? 'server' : 'client' + suffix) + } + return { + foo: { + singleInstance: makeFoo(), + array: [makeFoo('0'), makeFoo('1'), makeFoo('2')], + map: new Map([ + [0, makeFoo('0')], + [1, makeFoo('1')], + [2, makeFoo('2')], + ]), + mapOfArrays: new Map([ + [0, [makeFoo('0-a'), makeFoo('0-b')]], + [1, [makeFoo('1-a'), makeFoo('1-b')]], + [2, [makeFoo('2-a'), makeFoo('2-b')]], + ]), + }, + car: { + singleInstance: makeCar({ + make: 'Toyota', + model: 'Camry', + year: 2020, + }), + array: [ + makeCar({ make: 'Honda', model: 'Accord', year: 2019 }), + makeCar({ make: 'Ford', model: 'Mustang', year: 2021 }), + ], + map: new Map([ + [0, makeCar({ make: 'Chevrolet', model: 'Malibu', year: 2018 })], + [1, makeCar({ make: 'Nissan', model: 'Altima', year: 2020 })], + [2, makeCar({ make: 'Hyundai', model: 'Sonata', year: 2021 })], + ]), + mapOfArrays: new Map([ + [0, [makeCar({ make: 'Kia', model: 'Optima', year: 2019 })]], + [1, [makeCar({ make: 'Subaru', model: 'Legacy', year: 2020 })]], + [2, [makeCar({ make: 'Volkswagen', model: 'Passat', year: 2021 })]], + ]), + }, + } +} +export class NestedOuter { + constructor(public inner: NestedInner) {} + whisper() { + return this.inner.value.toLowerCase() + } +} + +export class NestedInner { + constructor(public value: string) {} + shout() { + return this.value.toUpperCase() + } +} + +export const nestedInnerAdapter = createSerializationAdapter({ + key: 'nestedInner', + test: (value): value is NestedInner => value instanceof NestedInner, + toSerializable: (inner) => inner.value, + fromSerializable: (value) => new NestedInner(value), +}) + +export const nestedOuterAdapter = createSerializationAdapter({ + key: 'nestedOuter', + extends: [nestedInnerAdapter], + test: (value) => value instanceof NestedOuter, + toSerializable: (outer) => outer.inner, + fromSerializable: (value) => new NestedOuter(value), +}) + +export function makeNested() { + return new NestedOuter(new NestedInner('Hello World')) +} + +export function RenderData({ + id, + data, +}: { + id: string + data: ReturnType +}) { + const localData = makeData() + return ( +
+

Car

+

expected

+
+ {JSON.stringify({ + make: localData.car.singleInstance.make, + model: localData.car.singleInstance.model, + year: localData.car.singleInstance.year, + })} +
+

actual

+
+ {JSON.stringify({ + make: data.car.singleInstance.make, + model: data.car.singleInstance.model, + year: data.car.singleInstance.year, + })} +
+ Foo +
+ {JSON.stringify({ + value: data.foo.singleInstance.value, + })} +
+
+ ) +} + +export function RenderNestedData({ nested }: { nested: NestedOuter }) { + { + const localData = makeNested() + const expectedShoutState = localData.inner.shout() + const expectedWhisperState = localData.whisper() + const shoutState = nested.inner.shout() + const whisperState = nested.whisper() + + return ( +
+

data-only

+
+

shout

+
+ expected:{' '} +
+ {JSON.stringify(expectedShoutState)} +
+
+
+ actual:{' '} +
+ {JSON.stringify(shoutState)} +
+
+
+
+

whisper

+
+ expected:{' '} +
+ {JSON.stringify(expectedWhisperState)} +
+
+
+ actual:{' '} +
+ {JSON.stringify(whisperState)} +
+
+
+
+ ) + } +} diff --git a/e2e/solid-start/serialization-adapters/src/routeTree.gen.ts b/e2e/solid-start/serialization-adapters/src/routeTree.gen.ts new file mode 100644 index 00000000000..09d0211154c --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routeTree.gen.ts @@ -0,0 +1,179 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SsrStreamRouteImport } from './routes/ssr/stream' +import { Route as SsrNestedRouteImport } from './routes/ssr/nested' +import { Route as SsrDataOnlyRouteImport } from './routes/ssr/data-only' +import { Route as ServerFunctionNestedRouteImport } from './routes/server-function/nested' +import { Route as ServerFunctionCustomErrorRouteImport } from './routes/server-function/custom-error' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const SsrStreamRoute = SsrStreamRouteImport.update({ + id: '/ssr/stream', + path: '/ssr/stream', + getParentRoute: () => rootRouteImport, +} as any) +const SsrNestedRoute = SsrNestedRouteImport.update({ + id: '/ssr/nested', + path: '/ssr/nested', + getParentRoute: () => rootRouteImport, +} as any) +const SsrDataOnlyRoute = SsrDataOnlyRouteImport.update({ + id: '/ssr/data-only', + path: '/ssr/data-only', + getParentRoute: () => rootRouteImport, +} as any) +const ServerFunctionNestedRoute = ServerFunctionNestedRouteImport.update({ + id: '/server-function/nested', + path: '/server-function/nested', + getParentRoute: () => rootRouteImport, +} as any) +const ServerFunctionCustomErrorRoute = + ServerFunctionCustomErrorRouteImport.update({ + id: '/server-function/custom-error', + path: '/server-function/custom-error', + getParentRoute: () => rootRouteImport, + } as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute + '/server-function/nested': typeof ServerFunctionNestedRoute + '/ssr/data-only': typeof SsrDataOnlyRoute + '/ssr/nested': typeof SsrNestedRoute + '/ssr/stream': typeof SsrStreamRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute + '/server-function/nested': typeof ServerFunctionNestedRoute + '/ssr/data-only': typeof SsrDataOnlyRoute + '/ssr/nested': typeof SsrNestedRoute + '/ssr/stream': typeof SsrStreamRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute + '/server-function/nested': typeof ServerFunctionNestedRoute + '/ssr/data-only': typeof SsrDataOnlyRoute + '/ssr/nested': typeof SsrNestedRoute + '/ssr/stream': typeof SsrStreamRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/server-function/custom-error' + | '/server-function/nested' + | '/ssr/data-only' + | '/ssr/nested' + | '/ssr/stream' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/server-function/custom-error' + | '/server-function/nested' + | '/ssr/data-only' + | '/ssr/nested' + | '/ssr/stream' + id: + | '__root__' + | '/' + | '/server-function/custom-error' + | '/server-function/nested' + | '/ssr/data-only' + | '/ssr/nested' + | '/ssr/stream' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ServerFunctionCustomErrorRoute: typeof ServerFunctionCustomErrorRoute + ServerFunctionNestedRoute: typeof ServerFunctionNestedRoute + SsrDataOnlyRoute: typeof SsrDataOnlyRoute + SsrNestedRoute: typeof SsrNestedRoute + SsrStreamRoute: typeof SsrStreamRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/ssr/stream': { + id: '/ssr/stream' + path: '/ssr/stream' + fullPath: '/ssr/stream' + preLoaderRoute: typeof SsrStreamRouteImport + parentRoute: typeof rootRouteImport + } + '/ssr/nested': { + id: '/ssr/nested' + path: '/ssr/nested' + fullPath: '/ssr/nested' + preLoaderRoute: typeof SsrNestedRouteImport + parentRoute: typeof rootRouteImport + } + '/ssr/data-only': { + id: '/ssr/data-only' + path: '/ssr/data-only' + fullPath: '/ssr/data-only' + preLoaderRoute: typeof SsrDataOnlyRouteImport + parentRoute: typeof rootRouteImport + } + '/server-function/nested': { + id: '/server-function/nested' + path: '/server-function/nested' + fullPath: '/server-function/nested' + preLoaderRoute: typeof ServerFunctionNestedRouteImport + parentRoute: typeof rootRouteImport + } + '/server-function/custom-error': { + id: '/server-function/custom-error' + path: '/server-function/custom-error' + fullPath: '/server-function/custom-error' + preLoaderRoute: typeof ServerFunctionCustomErrorRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ServerFunctionCustomErrorRoute: ServerFunctionCustomErrorRoute, + ServerFunctionNestedRoute: ServerFunctionNestedRoute, + SsrDataOnlyRoute: SsrDataOnlyRoute, + SsrNestedRoute: SsrNestedRoute, + SsrStreamRoute: SsrStreamRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.tsx' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/e2e/solid-start/serialization-adapters/src/router.tsx b/e2e/solid-start/serialization-adapters/src/router.tsx new file mode 100644 index 00000000000..b8757ff85b5 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/router.tsx @@ -0,0 +1,11 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/solid-start/serialization-adapters/src/routes/__root.tsx b/e2e/solid-start/serialization-adapters/src/routes/__root.tsx new file mode 100644 index 00000000000..efd3a0abd53 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/__root.tsx @@ -0,0 +1,74 @@ +/// +import { + ClientOnly, + HeadContent, + Link, + Scripts, + createRootRoute, + useRouterState, +} from '@tanstack/solid-router' +import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools' +import { HydrationScript } from 'solid-js/web' +import type { JSX } from 'solid-js' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charset: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'Serialization Adapters E2E Test', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + shellComponent: RootDocument, + notFoundComponent: (e) =>
404 - Not Found {JSON.stringify(e.data)}
, +}) + +function RootDocument({ children }: { children: JSX.Element }) { + const { isLoading, status } = useRouterState({ + select: (state) => ({ isLoading: state.isLoading, status: state.status }), + })() + return ( + + + + + + +
+

Serialization Adapters E2E Test

+ + Home + +
+
+ +
+ router isLoading:{' '} + {isLoading ? 'true' : 'false'} +
+
+ router status: {status} +
+
+
+ {children} + + + + + ) +} diff --git a/e2e/solid-start/serialization-adapters/src/routes/index.tsx b/e2e/solid-start/serialization-adapters/src/routes/index.tsx new file mode 100644 index 00000000000..7a7fc72ce0e --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/index.tsx @@ -0,0 +1,57 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: Home, + errorComponent: (e) =>
{e.error.message}
, +}) + +function Home() { + return ( + <> +
+

SSR

+ + Data Only + +
+ + Stream + +
+ + Nested Classes + +
+
+

Server Functions

+ + Custom Error Serialization + +
+ + Nested Classes returned from Server Function + +
+ + ) +} diff --git a/e2e/solid-start/serialization-adapters/src/routes/server-function/custom-error.tsx b/e2e/solid-start/serialization-adapters/src/routes/server-function/custom-error.tsx new file mode 100644 index 00000000000..ce242797200 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/server-function/custom-error.tsx @@ -0,0 +1,68 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { setResponseStatus } from '@tanstack/solid-start/server' +import { createSignal } from 'solid-js' +import { z } from 'zod' +import { CustomError } from '~/CustomError' + +const schema = z.object({ hello: z.string() }) +const serverFnThrowing = createServerFn() + .inputValidator(schema) + .handler(async ({ data }) => { + if (data.hello === 'world') { + return 'Hello, world!' + } + setResponseStatus(499) + throw new CustomError('Invalid input', { foo: 'bar', bar: BigInt(123) }) + }) + +export const Route = createFileRoute('/server-function/custom-error')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [validResponse, setValidResponse] = createSignal(null) + const [invalidResponse, setInvalidResponse] = + createSignal(null) + + return ( +
+ +
+ {JSON.stringify(validResponse())} +
+ +
+ +
+ {invalidResponse() + ? JSON.stringify({ + message: invalidResponse()?.message, + foo: invalidResponse()?.foo, + bar: invalidResponse()?.bar.toString(), + }) + : JSON.stringify(invalidResponse())} +
+
+ ) +} diff --git a/e2e/solid-start/serialization-adapters/src/routes/server-function/nested.tsx b/e2e/solid-start/serialization-adapters/src/routes/server-function/nested.tsx new file mode 100644 index 00000000000..96cdabea416 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/server-function/nested.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { createSignal } from 'solid-js' +import type { NestedOuter } from '~/data' +import { RenderNestedData, makeNested } from '~/data' + +const serverFnReturningNested = createServerFn().handler(() => { + return makeNested() +}) + +export const Route = createFileRoute('/server-function/nested')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [nestedResponse, setNestedResponse] = createSignal() + + return ( +
+ + + {nestedResponse() ? ( + + ) : ( +
waiting for response...
+ )} +
+ ) +} diff --git a/e2e/solid-start/serialization-adapters/src/routes/ssr/data-only.tsx b/e2e/solid-start/serialization-adapters/src/routes/ssr/data-only.tsx new file mode 100644 index 00000000000..0e07e552196 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/ssr/data-only.tsx @@ -0,0 +1,51 @@ +import { Outlet, createFileRoute } from '@tanstack/solid-router' +import { RenderData, makeData } from '~/data' + +export const Route = createFileRoute('/ssr/data-only')({ + ssr: 'data-only', + beforeLoad: () => { + return makeData() + }, + loader: ({ context }) => { + return context + }, + component: () => { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + const localData = makeData() + const expectedHonkState = localData.car.singleInstance.honk() + + const honkState = loaderData().car.singleInstance.honk() + + return ( +
+

data-only

+
+ context: +
+
+ loader: +
+
+

honk

+
+ expected:{' '} +
+ {JSON.stringify(expectedHonkState)} +
+
+
+ actual:{' '} +
+ {JSON.stringify(honkState)} +
+
+
+
+ +
+ ) + }, + pendingComponent: () =>
posts Loading...
, +}) diff --git a/e2e/solid-start/serialization-adapters/src/routes/ssr/nested.tsx b/e2e/solid-start/serialization-adapters/src/routes/ssr/nested.tsx new file mode 100644 index 00000000000..026f9c52d14 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/ssr/nested.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { RenderNestedData, makeNested } from '~/data' + +export const Route = createFileRoute('/ssr/nested')({ + beforeLoad: () => { + return { nested: makeNested() } + }, + loader: ({ context }) => { + return context + }, + component: () => { + const loaderData = Route.useLoaderData() + return + }, +}) diff --git a/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx b/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx new file mode 100644 index 00000000000..473818eef6e --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx @@ -0,0 +1,33 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { Suspense } from 'solid-js' +import { RenderData, makeData } from '~/data' + +export const Route = createFileRoute('/ssr/stream')({ + loader: () => { + const dataPromise = new Promise>((r) => + setTimeout(() => r(makeData()), 1000), + ) + return { + someString: 'hello world', + dataPromise, + } + }, + + errorComponent: (e) =>
{e.error.message}
, + component: RouteComponent, +}) + +function RouteComponent() { + const loaderData = Route.useLoaderData() + return ( +
+

Stream

+
{loaderData().someString}
+ Loading...
}> + + {(data) => } + + + + ) +} diff --git a/e2e/solid-start/serialization-adapters/src/start.tsx b/e2e/solid-start/serialization-adapters/src/start.tsx new file mode 100644 index 00000000000..a1701b2d43b --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/start.tsx @@ -0,0 +1,16 @@ +import { createStart } from '@tanstack/solid-start' +import { carAdapter, fooAdapter, nestedOuterAdapter } from './data' +import { customErrorAdapter } from './CustomError' + +export const startInstance = createStart(() => { + return { + defaultSsr: true, + serializationAdapters: [ + fooAdapter, + carAdapter, + customErrorAdapter, + // only register nestedOuterAdapter here, nestedInnerAdapter is registered as an "extends" of nestedOuterAdapter + nestedOuterAdapter, + ], + } +}) diff --git a/e2e/solid-start/serialization-adapters/src/styles/app.css b/e2e/solid-start/serialization-adapters/src/styles/app.css new file mode 100644 index 00000000000..fbb4ba08a58 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/src/styles/app.css @@ -0,0 +1,38 @@ +@import 'tailwindcss'; + +/* + The default border color has changed to `currentcolor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +@layer base { + html { + color-scheme: light dark; + } + + * { + @apply border-gray-200 dark:border-gray-800; + } + + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } +} diff --git a/e2e/solid-start/serialization-adapters/tests/app.spec.ts b/e2e/solid-start/serialization-adapters/tests/app.spec.ts new file mode 100644 index 00000000000..292212a4d1a --- /dev/null +++ b/e2e/solid-start/serialization-adapters/tests/app.spec.ts @@ -0,0 +1,112 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' + +async function awaitPageLoaded(page: Page) { + // wait for page to be loaded by waiting for the ClientOnly component to be rendered + + await expect(page.getByTestId('router-isLoading')).toContainText('false') + await expect(page.getByTestId('router-status')).toContainText('idle') +} +async function checkData(page: Page, id: string) { + const expectedData = await page + .getByTestId(`${id}-car-expected`) + .textContent() + expect(expectedData).not.toBeNull() + await expect(page.getByTestId(`${id}-car-actual`)).toContainText( + expectedData!, + ) + + await expect(page.getByTestId(`${id}-foo`)).toContainText( + '{"value":"server"}', + ) +} + +async function checkNestedData(page: Page) { + const expectedShout = await page + .getByTestId(`shout-expected-state`) + .textContent() + expect(expectedShout).not.toBeNull() + await expect(page.getByTestId(`shout-actual-state`)).toContainText( + expectedShout!, + ) + + const expectedWhisper = await page + .getByTestId(`whisper-expected-state`) + .textContent() + expect(expectedWhisper).not.toBeNull() + await expect(page.getByTestId(`whisper-actual-state`)).toContainText( + expectedWhisper!, + ) +} +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 499/, + ], +}) +test.describe('SSR serialization adapters', () => { + test(`data-only`, async ({ page }) => { + await page.goto('/ssr/data-only') + await awaitPageLoaded(page) + + await Promise.all( + ['context', 'loader'].map(async (id) => checkData(page, id)), + ) + + const expectedHonkData = await page + .getByTestId('honk-expected-state') + .textContent() + expect(expectedHonkData).not.toBeNull() + await expect(page.getByTestId('honk-actual-state')).toContainText( + expectedHonkData!, + ) + }) + + test('stream', async ({ page }) => { + await page.goto('/ssr/stream') + await awaitPageLoaded(page) + await checkData(page, 'stream') + }) + + test('nested', async ({ page }) => { + await page.goto('/ssr/nested') + await awaitPageLoaded(page) + + await checkNestedData(page) + }) +}) + +test.describe('server functions serialization adapters', () => { + test('custom error', async ({ page }) => { + await page.goto('/server-function/custom-error') + await awaitPageLoaded(page) + + await expect( + page.getByTestId('server-function-valid-response'), + ).toContainText('null') + await expect( + page.getByTestId('server-function-invalid-response'), + ).toContainText('null') + + await page.getByTestId('server-function-valid-input').click() + await expect( + page.getByTestId('server-function-valid-response'), + ).toContainText('Hello, world!') + + await page.getByTestId('server-function-invalid-input').click() + await expect( + page.getByTestId('server-function-invalid-response'), + ).toContainText('{"message":"Invalid input","foo":"bar","bar":"123"}') + }) + test('nested', async ({ page }) => { + await page.goto('/server-function/nested') + await awaitPageLoaded(page) + + await expect(page.getByTestId('waiting-for-response')).toContainText( + 'waiting for response...', + ) + + await page.getByTestId('server-function-trigger').click() + await checkNestedData(page) + }) +}) diff --git a/e2e/solid-start/serialization-adapters/tsconfig.json b/e2e/solid-start/serialization-adapters/tsconfig.json new file mode 100644 index 00000000000..a40235b863f --- /dev/null +++ b/e2e/solid-start/serialization-adapters/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/solid-start/serialization-adapters/vite.config.ts b/e2e/solid-start/serialization-adapters/vite.config.ts new file mode 100644 index 00000000000..20ffdb3d6e5 --- /dev/null +++ b/e2e/solid-start/serialization-adapters/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteSolid({ ssr: true }), + ], +}) diff --git a/packages/solid-router/src/awaited.tsx b/packages/solid-router/src/awaited.tsx index 42ef0498b6f..892c67b19cd 100644 --- a/packages/solid-router/src/awaited.tsx +++ b/packages/solid-router/src/awaited.tsx @@ -30,7 +30,14 @@ export function Await( children: (result: T) => SolidNode }, ) { - const [resource] = Solid.createResource(() => props.promise) + const [resource] = Solid.createResource( + () => defer(props.promise), + // Simple passthrough - just return the promise for Solid to await + (p) => p, + { + deferStream: true, + }, + ) return ( diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8f5a8c2c56f..5c3e4030603 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -1,6 +1,7 @@ import * as Solid from 'solid-js/web' import { isbot } from 'isbot' import { transformReadableStreamWithRouter } from '@tanstack/router-core/ssr/server' +import { makeSsrSerovalPlugin } from '@tanstack/router-core' import type { JSXElement } from 'solid-js' import type { ReadableStream } from 'node:stream/web' import type { AnyRouter } from '@tanstack/router-core' @@ -20,6 +21,14 @@ export const renderRouterToStream = async ({ const docType = Solid.ssr('') + const serializationAdapters = + (router.options as any)?.serializationAdapters || + (router.options.ssr as any)?.serializationAdapters + const serovalPlugins = serializationAdapters?.map((adapter: any) => { + const plugin = makeSsrSerovalPlugin(adapter, { didRun: false }) + return plugin + }) + const stream = Solid.renderToStream( () => ( <> @@ -29,7 +38,8 @@ export const renderRouterToStream = async ({ ), { nonce: router.options.ssr?.nonce, - }, + plugins: serovalPlugins, + } as any, ) if (isbot(request.headers.get('User-Agent'))) { diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index 1ba1db46df2..4394062fed3 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -1,6 +1,7 @@ import * as Solid from 'solid-js/web' -import type { JSXElement } from 'solid-js' +import { makeSsrSerovalPlugin } from '@tanstack/router-core' import type { AnyRouter } from '@tanstack/router-core' +import type { JSXElement } from 'solid-js' export const renderRouterToString = async ({ router, @@ -12,9 +13,18 @@ export const renderRouterToString = async ({ children: () => JSXElement }) => { try { + const serializationAdapters = + (router.options as any)?.serializationAdapters || + (router.options.ssr as any)?.serializationAdapters + const serovalPlugins = serializationAdapters?.map((adapter: any) => { + const plugin = makeSsrSerovalPlugin(adapter, { didRun: false }) + return plugin + }) + let html = Solid.renderToString(children, { nonce: router.options.ssr?.nonce, - }) + plugins: serovalPlugins, + } as any) router.serverSsr!.setRenderFinished() const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then( (htmls) => htmls.join(''), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ac510e3327..e8a7931e64a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3130,6 +3130,58 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/serialization-adapters: + dependencies: + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-router-devtools': + specifier: workspace:^ + version: link:../../../packages/solid-router-devtools + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.10 + version: 1.9.10 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.15 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + postcss: + specifier: ^8.5.1 + version: 8.5.6 + seroval: + specifier: ^1.3.2 + version: 1.3.2 + srvx: + specifier: ^0.8.6 + version: 0.8.15 + tailwindcss: + specifier: ^4.1.15 + version: 4.1.15 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-plugin-solid: + specifier: ^2.11.9 + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.10)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/server-functions: dependencies: '@tanstack/solid-query': @@ -15768,14 +15820,6 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true - jiti@2.4.2: - resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} - hasBin: true - - jiti@2.6.0: - resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -23066,7 +23110,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.18.3 - jiti: 2.6.0 + jiti: 2.6.1 lightningcss: 1.30.1 magic-string: 0.30.19 source-map-js: 1.2.1 @@ -23086,7 +23130,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 enhanced-resolve: 5.18.1 - jiti: 2.4.2 + jiti: 2.6.1 lightningcss: 1.29.2 magic-string: 0.30.17 source-map-js: 1.2.1 @@ -24817,7 +24861,7 @@ snapshots: dotenv: 17.2.2 exsolve: 1.0.7 giget: 2.0.0 - jiti: 2.6.0 + jiti: 2.6.1 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 2.0.0 @@ -26964,10 +27008,6 @@ snapshots: jiti@1.21.7: {} - jiti@2.4.2: {} - - jiti@2.6.0: {} - jiti@2.6.1: {} jju@1.4.0: {} @@ -27330,7 +27370,7 @@ snapshots: get-port-please: 3.2.0 h3: 1.15.4 http-shutdown: 1.2.2 - jiti: 2.6.0 + jiti: 2.6.1 mlly: 1.8.0 node-forge: 1.3.1 pathe: 1.1.2 @@ -27815,7 +27855,7 @@ snapshots: hookable: 5.5.3 httpxy: 0.1.7 ioredis: 5.8.0 - jiti: 2.6.0 + jiti: 2.6.1 klona: 2.0.6 knitwork: 1.2.0 listhen: 1.9.0 @@ -29840,7 +29880,7 @@ snapshots: dependencies: citty: 0.1.6 defu: 6.1.4 - jiti: 2.6.0 + jiti: 2.6.1 knitwork: 1.2.0 scule: 1.3.0