From 32664a2d268199a15ec2396fbe8c6700caeeea0d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 11 Nov 2026 22:09:14 +0800 Subject: [PATCH 1/2] fix: make the TanStack Start output work with current TanStack Start TanStack Start decodes the payload search parameter of a GET server function call with seroval's fromJSON, which rejects the plain JSON the compiled component sent. The new createTanstackImageUrl helper encodes the payload with seroval. The generated code also imports createServerFn from @tanstack/solid-start and no longer passes the response option, which createServerFn does not take. Co-Authored-By: Claude Opus 5.5 --- packages/ogp/package.json | 1 + packages/ogp/src/compiler/index.ts | 15 ++++--- packages/ogp/src/index.tsx | 11 +++++ .../fixtures/tanstack-start/basic/output.js | 23 +++++----- .../custom-image-options/output.js | 21 +++++---- .../tanstack-start/multiple-images/output.js | 44 +++++++++---------- .../tanstack-start/nested-in-jsx/output.js | 23 +++++----- .../tanstack-start/variables/output.js | 23 +++++----- packages/ogp/tests/tanstack.test.ts | 36 +++++++++++++++ packages/ogp/tests/unplugin.test.ts | 2 +- pnpm-lock.yaml | 3 ++ 11 files changed, 123 insertions(+), 79 deletions(-) create mode 100644 packages/ogp/tests/tanstack.test.ts diff --git a/packages/ogp/package.json b/packages/ogp/package.json index a91a091..e35d4ad 100644 --- a/packages/ogp/package.json +++ b/packages/ogp/package.json @@ -100,6 +100,7 @@ "magic-string": "^1.4.5", "oxc-parser": "^0.158.0", "satori-html": "^0.3.2", + "seroval": "^1.7.0", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, diff --git a/packages/ogp/src/compiler/index.ts b/packages/ogp/src/compiler/index.ts index 631ba50..fa8419c 100644 --- a/packages/ogp/src/compiler/index.ts +++ b/packages/ogp/src/compiler/index.ts @@ -157,16 +157,14 @@ const DynamicImage${id} = (props) => { function createTanstackImage(image: DynamicImage, id: number): string { const serverFn = `DynamicImage${id}ServerFunction`; - return `const ${serverFn} = createServerFn({ - method: "GET", - response: "raw", -}).handler((ctx) => { + // The image request carries no x-tsr-serverFn header, so TanStack Start + // sends the returned Response as is. + return `const ${serverFn} = createServerFn({ method: 'GET' }).handler((ctx) => { const args = ctx.data.values; ${createArgumentDeclaration(image)}${createRenderCall(image)} }); const DynamicImage${id} = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }); - const url = createMemo(() => \`\${${serverFn}.url}?payload=\${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw\`); + const url = createMemo(() => createTanstackImageUrl(${serverFn}.url, props.values)); return url; }; `; @@ -269,7 +267,10 @@ export function compile( ['createMemo', 'solid-js'], ['createOpenGraphImage', '@solidjs/ogp/server'], ]; - if (runtime === 'Solid') { + if (runtime === 'TanstackStart') { + needed.push(['createServerFn', '@tanstack/solid-start']); + needed.push(['createTanstackImageUrl', '@solidjs/ogp']); + } else { needed.push(['GET', '@solidjs/web/server-functions']); needed.push(['serverFunctionUrl', '@solidjs/web/server-functions']); } diff --git a/packages/ogp/src/index.tsx b/packages/ogp/src/index.tsx index b364b21..d303673 100644 --- a/packages/ogp/src/index.tsx +++ b/packages/ogp/src/index.tsx @@ -3,6 +3,7 @@ import { useHead } from '@solidjs/web'; import type { ImageResponseOptions } from '@vercel/og'; import type { ParentProps } from 'solid-js'; import { children, createMemo, flatten } from 'solid-js'; +import { toJSON } from 'seroval'; export interface DynamicImageProps { children: JSX.Element | (() => JSX.Element | Promise); @@ -44,3 +45,13 @@ export function urlFromImage(comp: JSX.Element): () => string { // The compiler turns `DynamicImage` into an accessor of its url. return () => readUrl(flatten(comp)); } + +/** + * Builds the GET url of a TanStack Start server function called with + * `{ data: { values } }`. TanStack Start reads the `payload` search parameter + * with seroval's `fromJSON`, so the payload is encoded with seroval too. + */ +export function createTanstackImageUrl(url: string, values: unknown[]): string { + const payload = encodeURIComponent(JSON.stringify(toJSON({ data: { values } }))); + return `${url}${url.includes('?') ? '&' : '?'}payload=${payload}`; +} diff --git a/packages/ogp/tests/fixtures/tanstack-start/basic/output.js b/packages/ogp/tests/fixtures/tanstack-start/basic/output.js index aabbfa8..bfd6212 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/basic/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/basic/output.js @@ -1,19 +1,18 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' import { createSignal } from 'solid-js' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
) -}) +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js b/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js index 031d907..4957238 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/custom-image-options/output.js @@ -1,17 +1,16 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - return createOpenGraphImage(
Hi!
, { emoji: 'openmoji' }) -}) +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + return createOpenGraphImage(
Hi!
, { emoji: 'openmoji' }) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js b/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js index 0a3e031..1edddcb 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/multiple-images/output.js @@ -1,34 +1,30 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
) -}) +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } -const DynamicImage2ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
, { width: 800 }) -}) +const DynamicImage2ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
, { width: 800 }) + }, +) const DynamicImage2 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage2ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage2ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js b/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js index d4e0327..829f9e6 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/nested-in-jsx/output.js @@ -1,20 +1,19 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' import { Image } from '@solidjs/ogp' import { createSignal } from 'solid-js' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{`Hello ${r0 * 2}!`}
) -}) +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{`Hello ${r0 * 2}!`}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/fixtures/tanstack-start/variables/output.js b/packages/ogp/tests/fixtures/tanstack-start/variables/output.js index b89023f..ee009d9 100644 --- a/packages/ogp/tests/fixtures/tanstack-start/variables/output.js +++ b/packages/ogp/tests/fixtures/tanstack-start/variables/output.js @@ -1,18 +1,17 @@ import { createMemo } from 'solid-js' import { createOpenGraphImage } from '@solidjs/ogp/server' -const DynamicImage1ServerFunction = createServerFn({ - method: 'GET', - response: 'raw', -}).handler((ctx) => { - const args = ctx.data.values - const [r0] = args - return createOpenGraphImage(
{r0}
) -}) +import { createServerFn } from '@tanstack/solid-start' +import { createTanstackImageUrl } from '@solidjs/ogp' +const DynamicImage1ServerFunction = createServerFn({ method: 'GET' }).handler( + (ctx) => { + const args = ctx.data.values + const [r0] = args + return createOpenGraphImage(
{r0}
) + }, +) const DynamicImage1 = (props) => { - const payload = () => ({ data: { values: props.values }, context: {} }) - const url = createMemo( - () => - `${DynamicImage1ServerFunction.url}?payload=${encodeURIComponent(JSON.stringify(payload()))}&createServerFn&raw`, + const url = createMemo(() => + createTanstackImageUrl(DynamicImage1ServerFunction.url, props.values), ) return url } diff --git a/packages/ogp/tests/tanstack.test.ts b/packages/ogp/tests/tanstack.test.ts new file mode 100644 index 0000000..4efb7b9 --- /dev/null +++ b/packages/ogp/tests/tanstack.test.ts @@ -0,0 +1,36 @@ +import type { SerovalJSON } from 'seroval'; +import { fromJSON } from 'seroval'; +import { describe, expect, it } from 'vitest'; +import { createTanstackImageUrl } from '../src/index'; + +function isSerovalJSON(value: unknown): value is SerovalJSON { + return typeof value === 'object' && value !== null && 't' in value; +} + +/** Reads the payload the way TanStack Start's server function handler does for a GET. */ +function readPayload(url: string): unknown { + const payload = new URL(url, 'http://localhost').searchParams.get('payload'); + if (payload === null) { + return {}; + } + const json: unknown = JSON.parse(payload); + if (!isSerovalJSON(json)) { + throw new TypeError('payload is not seroval JSON'); + } + return fromJSON(json); +} + +describe('createTanstackImageUrl', () => { + it('encodes the values as the payload data TanStack Start decodes', () => { + const values = ['hello', 42, { nested: true }, null]; + const url = createTanstackImageUrl('/_serverFn/abc', values); + expect(url.startsWith('/_serverFn/abc?payload=')).toBe(true); + expect(readPayload(url)).toEqual({ data: { values } }); + }); + + it('appends to a url that already has a query', () => { + const url = createTanstackImageUrl('/_serverFn/abc?x=1', []); + expect(url.startsWith('/_serverFn/abc?x=1&payload=')).toBe(true); + expect(readPayload(url)).toEqual({ data: { values: [] } }); + }); +}); diff --git a/packages/ogp/tests/unplugin.test.ts b/packages/ogp/tests/unplugin.test.ts index 2399e08..9a4c0ec 100644 --- a/packages/ogp/tests/unplugin.test.ts +++ b/packages/ogp/tests/unplugin.test.ts @@ -57,7 +57,7 @@ describe('transform', () => { runtime: 'TanstackStart', }); expect(res!.code).toContain('createServerFn('); - expect(res!.code).toContain('response: "raw"'); + expect(res!.code).toContain("import { createServerFn } from '@tanstack/solid-start'"); expect(res!.code).not.toContain("'use server'"); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4435b4f..e661ac9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: satori-html: specifier: ^0.3.2 version: 0.3.2 + seroval: + specifier: ^1.7.0 + version: 1.7.0 unplugin: specifier: ^3.3.0 version: 3.3.0(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(yaml@2.9.1)) From 7348fa3aef8c8d1b30303e129af20d4d83e9f684 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 11 Nov 2026 22:13:52 +0800 Subject: [PATCH 2/2] chore: add a changeset for the TanStack Start fix Co-Authored-By: Claude Opus 5.5 --- .changeset/tanstack-start-image-url.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tanstack-start-image-url.md diff --git a/.changeset/tanstack-start-image-url.md b/.changeset/tanstack-start-image-url.md new file mode 100644 index 0000000..8c32fdc --- /dev/null +++ b/.changeset/tanstack-start-image-url.md @@ -0,0 +1,6 @@ +--- +'@solidjs/ogp': minor +--- + +- The `TanstackStart` runtime works with current TanStack Start: the image url encodes its payload the way TanStack Start reads it, and the compiled code imports `createServerFn`. +- New `createTanstackImageUrl(url, values)` export, which the compiled code uses to build that url.