diff --git a/.changeset/gentle-hoops-shave.md b/.changeset/gentle-hoops-shave.md new file mode 100644 index 00000000000..e2e8c12e6e4 --- /dev/null +++ b/.changeset/gentle-hoops-shave.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +allow Temporal types in serializable data diff --git a/e2e/react-start/serialization-adapters/package.json b/e2e/react-start/serialization-adapters/package.json index 0e0d157733c..27633b4e50d 100644 --- a/e2e/react-start/serialization-adapters/package.json +++ b/e2e/react-start/serialization-adapters/package.json @@ -17,6 +17,7 @@ "@tanstack/react-start": "workspace:^", "react": "^19.0.0", "react-dom": "^19.0.0", + "temporal-polyfill": "^1.0.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/e2e/react-start/serialization-adapters/src/client.tsx b/e2e/react-start/serialization-adapters/src/client.tsx new file mode 100644 index 00000000000..4ba538c19b8 --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/client.tsx @@ -0,0 +1,15 @@ +import { StrictMode, startTransition } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { StartClient } from '@tanstack/react-start/client' +import { install as installTemporalPolyfill } from 'temporal-polyfill/shim' + +installTemporalPolyfill() + +startTransition(() => { + hydrateRoot( + document, + + + , + ) +}) diff --git a/e2e/react-start/serialization-adapters/src/routeTree.gen.ts b/e2e/react-start/serialization-adapters/src/routeTree.gen.ts index 1b21d4200b7..bb6c30b459f 100644 --- a/e2e/react-start/serialization-adapters/src/routeTree.gen.ts +++ b/e2e/react-start/serialization-adapters/src/routeTree.gen.ts @@ -13,9 +13,11 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as ServerFunctionCustomErrorRouteImport } from './routes/server-function/custom-error' import { Route as ServerFunctionLateRawStreamRouteImport } from './routes/server-function/late-raw-stream' import { Route as ServerFunctionNestedRouteImport } from './routes/server-function/nested' +import { Route as ServerFunctionTemporalRouteImport } from './routes/server-function/temporal' import { Route as SsrDataOnlyRouteImport } from './routes/ssr/data-only' import { Route as SsrNestedRouteImport } from './routes/ssr/nested' import { Route as SsrStreamRouteImport } from './routes/ssr/stream' +import { Route as SsrTemporalRouteImport } from './routes/ssr/temporal' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -39,6 +41,11 @@ const ServerFunctionNestedRoute = ServerFunctionNestedRouteImport.update({ path: '/server-function/nested', getParentRoute: () => rootRouteImport, } as any) +const ServerFunctionTemporalRoute = ServerFunctionTemporalRouteImport.update({ + id: '/server-function/temporal', + path: '/server-function/temporal', + getParentRoute: () => rootRouteImport, +} as any) const SsrDataOnlyRoute = SsrDataOnlyRouteImport.update({ id: '/ssr/data-only', path: '/ssr/data-only', @@ -54,24 +61,33 @@ const SsrStreamRoute = SsrStreamRouteImport.update({ path: '/ssr/stream', getParentRoute: () => rootRouteImport, } as any) +const SsrTemporalRoute = SsrTemporalRouteImport.update({ + id: '/ssr/temporal', + path: '/ssr/temporal', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute '/server-function/late-raw-stream': typeof ServerFunctionLateRawStreamRoute '/server-function/nested': typeof ServerFunctionNestedRoute + '/server-function/temporal': typeof ServerFunctionTemporalRoute '/ssr/data-only': typeof SsrDataOnlyRoute '/ssr/nested': typeof SsrNestedRoute '/ssr/stream': typeof SsrStreamRoute + '/ssr/temporal': typeof SsrTemporalRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute '/server-function/late-raw-stream': typeof ServerFunctionLateRawStreamRoute '/server-function/nested': typeof ServerFunctionNestedRoute + '/server-function/temporal': typeof ServerFunctionTemporalRoute '/ssr/data-only': typeof SsrDataOnlyRoute '/ssr/nested': typeof SsrNestedRoute '/ssr/stream': typeof SsrStreamRoute + '/ssr/temporal': typeof SsrTemporalRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -79,9 +95,11 @@ export interface FileRoutesById { '/server-function/custom-error': typeof ServerFunctionCustomErrorRoute '/server-function/late-raw-stream': typeof ServerFunctionLateRawStreamRoute '/server-function/nested': typeof ServerFunctionNestedRoute + '/server-function/temporal': typeof ServerFunctionTemporalRoute '/ssr/data-only': typeof SsrDataOnlyRoute '/ssr/nested': typeof SsrNestedRoute '/ssr/stream': typeof SsrStreamRoute + '/ssr/temporal': typeof SsrTemporalRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -90,27 +108,33 @@ export interface FileRouteTypes { | '/server-function/custom-error' | '/server-function/late-raw-stream' | '/server-function/nested' + | '/server-function/temporal' | '/ssr/data-only' | '/ssr/nested' | '/ssr/stream' + | '/ssr/temporal' fileRoutesByTo: FileRoutesByTo to: | '/' | '/server-function/custom-error' | '/server-function/late-raw-stream' | '/server-function/nested' + | '/server-function/temporal' | '/ssr/data-only' | '/ssr/nested' | '/ssr/stream' + | '/ssr/temporal' id: | '__root__' | '/' | '/server-function/custom-error' | '/server-function/late-raw-stream' | '/server-function/nested' + | '/server-function/temporal' | '/ssr/data-only' | '/ssr/nested' | '/ssr/stream' + | '/ssr/temporal' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -118,9 +142,11 @@ export interface RootRouteChildren { ServerFunctionCustomErrorRoute: typeof ServerFunctionCustomErrorRoute ServerFunctionLateRawStreamRoute: typeof ServerFunctionLateRawStreamRoute ServerFunctionNestedRoute: typeof ServerFunctionNestedRoute + ServerFunctionTemporalRoute: typeof ServerFunctionTemporalRoute SsrDataOnlyRoute: typeof SsrDataOnlyRoute SsrNestedRoute: typeof SsrNestedRoute SsrStreamRoute: typeof SsrStreamRoute + SsrTemporalRoute: typeof SsrTemporalRoute } declare module '@tanstack/react-router' { @@ -153,6 +179,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ServerFunctionNestedRouteImport parentRoute: typeof rootRouteImport } + '/server-function/temporal': { + id: '/server-function/temporal' + path: '/server-function/temporal' + fullPath: '/server-function/temporal' + preLoaderRoute: typeof ServerFunctionTemporalRouteImport + parentRoute: typeof rootRouteImport + } '/ssr/data-only': { id: '/ssr/data-only' path: '/ssr/data-only' @@ -174,6 +207,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SsrStreamRouteImport parentRoute: typeof rootRouteImport } + '/ssr/temporal': { + id: '/ssr/temporal' + path: '/ssr/temporal' + fullPath: '/ssr/temporal' + preLoaderRoute: typeof SsrTemporalRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -182,9 +222,11 @@ const rootRouteChildren: RootRouteChildren = { ServerFunctionCustomErrorRoute: ServerFunctionCustomErrorRoute, ServerFunctionLateRawStreamRoute: ServerFunctionLateRawStreamRoute, ServerFunctionNestedRoute: ServerFunctionNestedRoute, + ServerFunctionTemporalRoute: ServerFunctionTemporalRoute, SsrDataOnlyRoute: SsrDataOnlyRoute, SsrNestedRoute: SsrNestedRoute, SsrStreamRoute: SsrStreamRoute, + SsrTemporalRoute: SsrTemporalRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/react-start/serialization-adapters/src/routes/index.tsx b/e2e/react-start/serialization-adapters/src/routes/index.tsx index e39d32cd639..f4b8b98c5b6 100644 --- a/e2e/react-start/serialization-adapters/src/routes/index.tsx +++ b/e2e/react-start/serialization-adapters/src/routes/index.tsx @@ -32,6 +32,14 @@ function Home() { > Nested Classes +
+ + Temporal +

Server Functions

@@ -50,6 +58,14 @@ function Home() { > Nested Classes returned from Server Function +
+ + Temporal returned from Server Function +
) diff --git a/e2e/react-start/serialization-adapters/src/routes/server-function/temporal.tsx b/e2e/react-start/serialization-adapters/src/routes/server-function/temporal.tsx new file mode 100644 index 00000000000..ac07bb6e533 --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/routes/server-function/temporal.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useState } from 'react' +import type { TemporalData } from '~/temporal' +import { RenderTemporalData, makeTemporalData } from '~/temporal' + +const temporalFn = createServerFn().handler(() => { + return makeTemporalData() +}) + +export const Route = createFileRoute('/server-function/temporal')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [temporalResponse, setTemporalResponse] = useState() + + return ( +
+ + + {temporalResponse ? ( + + ) : ( +
waiting for response...
+ )} +
+ ) +} diff --git a/e2e/react-start/serialization-adapters/src/routes/ssr/temporal.tsx b/e2e/react-start/serialization-adapters/src/routes/ssr/temporal.tsx new file mode 100644 index 00000000000..683bb3e67df --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/routes/ssr/temporal.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' +import { RenderTemporalData, makeTemporalData } from '~/temporal' + +export const Route = createFileRoute('/ssr/temporal')({ + loader: () => makeTemporalData(), + component: () => { + const loaderData = Route.useLoaderData() + + return + }, +}) diff --git a/e2e/react-start/serialization-adapters/src/server.ts b/e2e/react-start/serialization-adapters/src/server.ts new file mode 100644 index 00000000000..1777d7ee92d --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/server.ts @@ -0,0 +1,10 @@ +import handler from '@tanstack/react-start/server-entry' +import { install as installTemporalPolyfill } from 'temporal-polyfill/shim' + +installTemporalPolyfill() + +export default { + fetch(request: Request) { + return handler.fetch(request) + }, +} diff --git a/e2e/react-start/serialization-adapters/src/temporal-global.d.ts b/e2e/react-start/serialization-adapters/src/temporal-global.d.ts new file mode 100644 index 00000000000..a29f92fe362 --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/temporal-global.d.ts @@ -0,0 +1 @@ +import 'temporal-polyfill/types/global' diff --git a/e2e/react-start/serialization-adapters/src/temporal.tsx b/e2e/react-start/serialization-adapters/src/temporal.tsx new file mode 100644 index 00000000000..2885cda72c3 --- /dev/null +++ b/e2e/react-start/serialization-adapters/src/temporal.tsx @@ -0,0 +1,56 @@ +export const temporalTypeNames = [ + 'Instant', + 'Duration', + 'PlainDate', + 'PlainDateTime', + 'PlainMonthDay', + 'PlainTime', + 'PlainYearMonth', + 'ZonedDateTime', +] as const + +export function makeTemporalData() { + return { + Instant: Temporal.Instant.from('2024-03-05T06:07:08.9Z'), + Duration: Temporal.Duration.from('P1Y2M3DT4H5M6.7S'), + PlainDate: Temporal.PlainDate.from('2024-03-05'), + PlainDateTime: Temporal.PlainDateTime.from('2024-03-05T06:07:08.9'), + PlainMonthDay: Temporal.PlainMonthDay.from('03-05'), + PlainTime: Temporal.PlainTime.from('06:07:08.9'), + PlainYearMonth: Temporal.PlainYearMonth.from('2024-03'), + ZonedDateTime: Temporal.ZonedDateTime.from( + '2024-03-05T06:07:08.9+01:00[Europe/Berlin]', + ), + } +} + +export type TemporalData = ReturnType + +function formatTemporal(value: unknown) { + return `${Object.prototype.toString.call(value)} ${String(value)}` +} + +export function RenderTemporalData({ + id, + data, +}: { + id: string + data: TemporalData +}) { + const localData = makeTemporalData() + return ( +
+ {temporalTypeNames.map((name) => ( +
+

{name}

+
+ {formatTemporal(localData[name])} +
+
+ {formatTemporal(data[name])} +
+
+ ))} +
+ ) +} diff --git a/e2e/react-start/serialization-adapters/tests/app.spec.ts b/e2e/react-start/serialization-adapters/tests/app.spec.ts index 26f70e81b07..ebec811e9db 100644 --- a/e2e/react-start/serialization-adapters/tests/app.spec.ts +++ b/e2e/react-start/serialization-adapters/tests/app.spec.ts @@ -39,6 +39,29 @@ async function checkNestedData(page: Page) { expectedWhisper!, ) } + +const temporalTypeNames = [ + 'Instant', + 'Duration', + 'PlainDate', + 'PlainDateTime', + 'PlainMonthDay', + 'PlainTime', + 'PlainYearMonth', + 'ZonedDateTime', +] as const + +async function checkTemporalData(page: Page, id: string) { + for (const name of temporalTypeNames) { + const expected = await page + .getByTestId(`${id}-${name}-expected`) + .textContent() + expect(expected).not.toBeNull() + expect(expected).toContain(`[object Temporal.${name}]`) + await expect(page.getByTestId(`${id}-${name}-actual`)).toHaveText(expected!) + } +} + test.use({ whitelistErrors: [ 'Failed to load resource: the server responded with a status of 499', @@ -74,6 +97,13 @@ test.describe('SSR serialization adapters', () => { await checkNestedData(page) }) + + test('temporal', async ({ page }) => { + await page.goto('/ssr/temporal') + await awaitPageLoaded(page) + + await checkTemporalData(page, 'loader') + }) }) test.describe('server functions serialization adapters', () => { @@ -109,6 +139,18 @@ test.describe('server functions serialization adapters', () => { await page.getByTestId('server-function-trigger').click() await checkNestedData(page) }) + + test('temporal', async ({ page }) => { + await page.goto('/server-function/temporal') + await awaitPageLoaded(page) + + await expect(page.getByTestId('waiting-for-response')).toContainText( + 'waiting for response...', + ) + + await page.getByTestId('server-function-trigger').click() + await checkTemporalData(page, 'server-fn') + }) }) test.describe('late RawStream serialization', () => { diff --git a/packages/router-core/src/ssr/serializer/transformer.ts b/packages/router-core/src/ssr/serializer/transformer.ts index 6eeed9b6e66..f300a8aa306 100644 --- a/packages/router-core/src/ssr/serializer/transformer.ts +++ b/packages/router-core/src/ssr/serializer/transformer.ts @@ -15,6 +15,20 @@ export type TSR_SERIALIZABLE = typeof TSR_SERIALIZABLE export type TsrSerializable = { [TSR_SERIALIZABLE]: true } +// Resolve `Temporal` from `globalThis` so it degrades to `never`, not `any`, +// for consumers whose `lib` predates or omits `ESNext.Temporal`. +type TemporalNamespace = typeof globalThis extends { Temporal: infer TTemporal } + ? TTemporal + : Record + +type TemporalInstance = + TemporalNamespace extends Record< + TKey, + abstract new (...args: any) => infer TInstance + > + ? TInstance + : never + export interface DefaultSerializable { number: number string: string @@ -26,6 +40,14 @@ export interface DefaultSerializable { Uint8Array: Uint8Array RawStream: RawStream TsrSerializable: TsrSerializable + TemporalInstant: TemporalInstance<'Instant'> + TemporalDuration: TemporalInstance<'Duration'> + TemporalPlainDate: TemporalInstance<'PlainDate'> + TemporalPlainDateTime: TemporalInstance<'PlainDateTime'> + TemporalPlainMonthDay: TemporalInstance<'PlainMonthDay'> + TemporalPlainTime: TemporalInstance<'PlainTime'> + TemporalPlainYearMonth: TemporalInstance<'PlainYearMonth'> + TemporalZonedDateTime: TemporalInstance<'ZonedDateTime'> void: void } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f19618d3f..724de523a2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3308,6 +3308,9 @@ importers: react-dom: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) + temporal-polyfill: + specifier: ^1.0.4 + version: 1.0.4 zod: specifier: ^4.4.3 version: 4.4.3 @@ -27575,6 +27578,15 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temporal-polyfill@1.0.4: + resolution: {integrity: sha512-MLEU0qOD2uXlz24oINNtdLZQl8RgmMxSnRtKEGZGGetTJjlRwQJjk+VsJ4EREaUoiaTb8NJOcUiKtoAiWn9EBg==} + + temporal-spec@1.0.1: + resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==} + + temporal-utils@1.0.2: + resolution: {integrity: sha512-1B8Dl4KzrOvsNUlpoWGno2VLQlxroLjDgc5NBjVC9ax9ymdo+ezfyvhyjEMx+IVfhINr6CIG2gcnlP95iRrybQ==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -42425,6 +42437,15 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + temporal-polyfill@1.0.4: + dependencies: + temporal-spec: 1.0.1 + temporal-utils: 1.0.2 + + temporal-spec@1.0.1: {} + + temporal-utils@1.0.2: {} + term-size@2.2.1: {} terser-webpack-plugin@5.3.11(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.27.4)(webpack@5.97.1):