diff --git a/e2e/start/basic/app/routeTree.gen.ts b/e2e/start/basic/app/routeTree.gen.ts index 5668aecde4..2ab05c9e35 100644 --- a/e2e/start/basic/app/routeTree.gen.ts +++ b/e2e/start/basic/app/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ServerFnsImport } from './routes/server-fns' import { Route as SearchParamsImport } from './routes/search-params' import { Route as RedirectImport } from './routes/redirect' import { Route as PostsImport } from './routes/posts' +import { Route as IsomorphicFnsImport } from './routes/isomorphic-fns' import { Route as DeferredImport } from './routes/deferred' import { Route as LayoutImport } from './routes/_layout' import { Route as IndexImport } from './routes/index' @@ -67,6 +68,12 @@ const PostsRoute = PostsImport.update({ getParentRoute: () => rootRoute, } as any) +const IsomorphicFnsRoute = IsomorphicFnsImport.update({ + id: '/isomorphic-fns', + path: '/isomorphic-fns', + getParentRoute: () => rootRoute, +} as any) + const DeferredRoute = DeferredImport.update({ id: '/deferred', path: '/deferred', @@ -156,6 +163,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DeferredImport parentRoute: typeof rootRoute } + '/isomorphic-fns': { + id: '/isomorphic-fns' + path: '/isomorphic-fns' + fullPath: '/isomorphic-fns' + preLoaderRoute: typeof IsomorphicFnsImport + parentRoute: typeof rootRoute + } '/posts': { id: '/posts' path: '/posts' @@ -312,6 +326,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '': typeof LayoutLayout2RouteWithChildren '/deferred': typeof DeferredRoute + '/isomorphic-fns': typeof IsomorphicFnsRoute '/posts': typeof PostsRouteWithChildren '/redirect': typeof RedirectRoute '/search-params': typeof SearchParamsRoute @@ -331,6 +346,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '': typeof LayoutLayout2RouteWithChildren '/deferred': typeof DeferredRoute + '/isomorphic-fns': typeof IsomorphicFnsRoute '/redirect': typeof RedirectRoute '/search-params': typeof SearchParamsRoute '/server-fns': typeof ServerFnsRoute @@ -349,6 +365,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute + '/isomorphic-fns': typeof IsomorphicFnsRoute '/posts': typeof PostsRouteWithChildren '/redirect': typeof RedirectRoute '/search-params': typeof SearchParamsRoute @@ -371,6 +388,7 @@ export interface FileRouteTypes { | '/' | '' | '/deferred' + | '/isomorphic-fns' | '/posts' | '/redirect' | '/search-params' @@ -389,6 +407,7 @@ export interface FileRouteTypes { | '/' | '' | '/deferred' + | '/isomorphic-fns' | '/redirect' | '/search-params' | '/server-fns' @@ -405,6 +424,7 @@ export interface FileRouteTypes { | '/' | '/_layout' | '/deferred' + | '/isomorphic-fns' | '/posts' | '/redirect' | '/search-params' @@ -426,6 +446,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute + IsomorphicFnsRoute: typeof IsomorphicFnsRoute PostsRoute: typeof PostsRouteWithChildren RedirectRoute: typeof RedirectRoute SearchParamsRoute: typeof SearchParamsRoute @@ -439,6 +460,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, + IsomorphicFnsRoute: IsomorphicFnsRoute, PostsRoute: PostsRouteWithChildren, RedirectRoute: RedirectRoute, SearchParamsRoute: SearchParamsRoute, @@ -461,6 +483,7 @@ export const routeTree = rootRoute "/", "/_layout", "/deferred", + "/isomorphic-fns", "/posts", "/redirect", "/search-params", @@ -482,6 +505,9 @@ export const routeTree = rootRoute "/deferred": { "filePath": "deferred.tsx" }, + "/isomorphic-fns": { + "filePath": "isomorphic-fns.tsx" + }, "/posts": { "filePath": "posts.tsx", "children": [ diff --git a/e2e/start/basic/app/routes/isomorphic-fns.tsx b/e2e/start/basic/app/routes/isomorphic-fns.tsx new file mode 100644 index 0000000000..6797b3ff6d --- /dev/null +++ b/e2e/start/basic/app/routes/isomorphic-fns.tsx @@ -0,0 +1,61 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn, createServerFn } from '@tanstack/start' +import { useState } from 'react' + +const getEnv = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') +const getServerEnv = createServerFn().handler(() => getEnv()) + +const getEcho = createIsomorphicFn() + .server((input: string) => 'server received ' + input) + .client((input) => 'client received ' + input) +const getServerEcho = createServerFn() + .validator((input: string) => input) + .handler(({ data }) => getEcho(data)) + +export const Route = createFileRoute('/isomorphic-fns')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [results, setResults] = useState>>() + async function handleClick() { + const env = getEnv() + const echo = getEcho('hello') + const [serverEnv, serverEcho] = await Promise.all([ + getServerEnv(), + getServerEcho({ data: 'hello' }), + ]) + setResults({ env, echo, serverEnv, serverEcho }) + } + const { env, echo, serverEnv, serverEcho } = results || {} + return ( +
+ + {!!results && ( +
+

+ getEnv +

+ When we called the function on the server it returned: +
{JSON.stringify(serverEnv)}
+ When we called the function on the client it returned: +
{JSON.stringify(env)}
+
+

+ echo +

+ When we called the function on the server it returned: +
+            {JSON.stringify(serverEcho)}
+          
+ When we called the function on the client it returned: +
{JSON.stringify(echo)}
+
+ )} +
+ ) +} diff --git a/e2e/start/basic/tests/base.spec.ts b/e2e/start/basic/tests/base.spec.ts index 0abc18773e..d1e7ca996c 100644 --- a/e2e/start/basic/tests/base.spec.ts +++ b/e2e/start/basic/tests/base.spec.ts @@ -146,3 +146,24 @@ test('submitting multipart/form-data as server function input', async ({ expected, ) }) + +test('isomorphic functions can have different implementations on client and server', async ({ + page, +}) => { + await page.goto('/isomorphic-fns') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-isomorphic-results-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('server-result')).toContainText('server') + await expect(page.getByTestId('client-result')).toContainText('client') + + await expect(page.getByTestId('server-echo-result')).toContainText( + 'server received hello', + ) + await expect(page.getByTestId('client-echo-result')).toContainText( + 'client received hello', + ) +}) diff --git a/packages/start-vite-plugin/src/compilers.ts b/packages/start-vite-plugin/src/compilers.ts index a3b7d617e3..a2117d649c 100644 --- a/packages/start-vite-plugin/src/compilers.ts +++ b/packages/start-vite-plugin/src/compilers.ts @@ -59,6 +59,7 @@ export function compileStartOutput(opts: ParseAstOptions) { createServerFn: IdentifierConfig createMiddleware: IdentifierConfig serverOnly: IdentifierConfig + createIsomorphicFn: IdentifierConfig } = { createServerFn: { name: 'createServerFn', @@ -81,6 +82,13 @@ export function compileStartOutput(opts: ParseAstOptions) { handleCallExpression: handleServerOnlyCallExpression, paths: [], }, + createIsomorphicFn: { + name: 'createIsomorphicFn', + type: 'ImportSpecifier', + namespaceId: '', + handleCallExpression: handleCreateIsomorphicFnCallExpression, + paths: [], + }, } const identifierKeys = Object.keys(identifiers) as Array< @@ -572,6 +580,82 @@ function handleServerOnlyCallExpression( ) } +function handleCreateIsomorphicFnCallExpression( + path: babel.NodePath, + opts: ParseAstOptions, +) { + const rootCallExpression = getRootCallExpression(path) + + if (debug) + console.info( + 'Handling createIsomorphicFn call expression:', + rootCallExpression.toString(), + ) + + // Check if the call is assigned to a variable + if (!rootCallExpression.parentPath.isVariableDeclarator()) { + throw new Error('createIsomorphicFn must be assigned to a variable!') + } + + const callExpressionPaths = { + client: null as babel.NodePath | null, + server: null as babel.NodePath | null, + } + + const validMethods = Object.keys(callExpressionPaths) + + rootCallExpression.traverse({ + MemberExpression(memberExpressionPath) { + if (t.isIdentifier(memberExpressionPath.node.property)) { + const name = memberExpressionPath.node.property + .name as keyof typeof callExpressionPaths + + if ( + validMethods.includes(name) && + memberExpressionPath.parentPath.isCallExpression() + ) { + callExpressionPaths[name] = memberExpressionPath.parentPath + } + } + }, + }) + + if ( + validMethods.every( + (method) => + !callExpressionPaths[method as keyof typeof callExpressionPaths], + ) + ) { + const variableId = rootCallExpression.parentPath.node.id + console.warn( + 'createIsomorphicFn called without a client or server implementation!', + 'This will result in a no-op function.', + 'Variable name:', + t.isIdentifier(variableId) ? variableId.name : 'unknown', + ) + } + + const envCallExpression = callExpressionPaths[opts.env] + + if (!envCallExpression) { + // if we don't have an implementation for this environment, default to a no-op + rootCallExpression.replaceWith( + t.arrowFunctionExpression([], t.blockStatement([])), + ) + return + } + + const innerInputExpression = envCallExpression.node.arguments[0] + + if (!t.isExpression(innerInputExpression)) { + throw new Error( + `createIsomorphicFn().${opts.env}(func) must be called with a function!`, + ) + } + + rootCallExpression.replaceWith(innerInputExpression) +} + function getRootCallExpression(path: babel.NodePath) { // Find the highest callExpression parent let rootCallExpression: babel.NodePath = path diff --git a/packages/start-vite-plugin/src/index.ts b/packages/start-vite-plugin/src/index.ts index 2298e2ab91..55e0b2231d 100644 --- a/packages/start-vite-plugin/src/index.ts +++ b/packages/start-vite-plugin/src/index.ts @@ -26,11 +26,12 @@ export function TanStackStartViteServerFn( url.searchParams.delete('v') id = fileURLToPath(url).replace(/\\/g, '/') - const includesToken = /createServerFn|createMiddleware|serverOnly/.test( - code, - ) + const includesToken = + /createServerFn|createMiddleware|serverOnly|createIsomorphicFn/.test( + code, + ) const includesEitherFunc = - /(function createServerFn|function createMiddleware|function serverOnly)/.test( + /(function createServerFn|function createMiddleware|function serverOnly|function createIsomorphicFn)/.test( code, ) @@ -93,7 +94,9 @@ export function TanStackStartViteDeadCodeElimination( if ( code.includes('createServerFn') || - code.includes('createMiddleware') + code.includes('createMiddleware') || + code.includes('serverOnly') || + code.includes('createIsomorphicFn') ) { const compiled = compileEliminateDeadCode({ code, diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/createIsomorphicFn.test.ts b/packages/start-vite-plugin/tests/createIsomorphicFn/createIsomorphicFn.test.ts new file mode 100644 index 0000000000..9510def007 --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/createIsomorphicFn.test.ts @@ -0,0 +1,102 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { afterAll, describe, expect, test, vi } from 'vitest' + +import { compileStartOutput } from '../../src/compilers' + +async function getFilenames() { + return await readdir(path.resolve(import.meta.dirname, './test-files')) +} + +describe('createIsomorphicFn compiles correctly', async () => { + const noImplWarning = + 'createIsomorphicFn called without a client or server implementation!' + + const originalConsoleWarn = console.warn + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation((...args) => { + // we want to avoid sending this warning to the console, we know about it + if (args[0] === noImplWarning) { + return + } + originalConsoleWarn(...args) + }) + + afterAll(() => { + consoleSpy.mockRestore() + }) + + const filenames = await getFilenames() + + describe.each(filenames)('should handle "%s"', async (filename) => { + const file = await readFile( + path.resolve(import.meta.dirname, `./test-files/${filename}`), + ) + const code = file.toString() + + test.each(['client', 'server'] as const)( + `should compile for ${filename} %s`, + async (env) => { + const compiledResult = compileStartOutput({ + env, + code, + root: './test-files', + filename, + }) + + await expect(compiledResult.code).toMatchFileSnapshot( + `./snapshots/${env}/${filename}`, + ) + }, + ) + }) + test('should error if implementation not provided', () => { + expect(() => { + compileStartOutput({ + env: 'client', + code: ` + import { createIsomorphicFn } from '@tanstack/start' + const clientOnly = createIsomorphicFn().client()`, + root: './test-files', + filename: 'no-fn.ts', + }) + }).toThrowError() + expect(() => { + compileStartOutput({ + env: 'server', + code: ` + import { createIsomorphicFn } from '@tanstack/start' + const serverOnly = createIsomorphicFn().server()`, + root: './test-files', + filename: 'no-fn.ts', + }) + }).toThrowError() + }) + test('should be assigned to a variable', () => { + expect(() => { + compileStartOutput({ + env: 'client', + code: ` + import { createIsomorphicFn } from '@tanstack/start' + createIsomorphicFn()`, + root: './test-files', + filename: 'no-fn.ts', + }) + }).toThrowError() + }) + test('should warn to console if no implementations provided', () => { + compileStartOutput({ + env: 'client', + code: ` + import { createIsomorphicFn } from '@tanstack/start' + const noImpl = createIsomorphicFn()`, + root: './test-files', + filename: 'no-fn.ts', + }) + expect(consoleSpy).toHaveBeenCalledWith( + noImplWarning, + 'This will result in a no-op function.', + 'Variable name:', + 'noImpl', + ) + }) +}) diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx new file mode 100644 index 0000000000..6fcb30691c --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx @@ -0,0 +1,16 @@ +import { createIsomorphicFn } from '@tanstack/start'; +const noImpl = () => {}; +const serverOnlyFn = () => {}; +const clientOnlyFn = () => 'client'; +const serverThenClientFn = () => 'client'; +const clientThenServerFn = () => 'client'; +function abstractedServerFn() { + return 'server'; +} +const serverOnlyFnAbstracted = () => {}; +function abstractedClientFn() { + return 'client'; +} +const clientOnlyFnAbstracted = abstractedClientFn; +const serverThenClientFnAbstracted = abstractedClientFn; +const clientThenServerFnAbstracted = abstractedClientFn; \ No newline at end of file diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx new file mode 100644 index 0000000000..d273a49f40 --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx @@ -0,0 +1,16 @@ +import { createIsomorphicFn as isomorphicFn } from '@tanstack/start'; +const noImpl = () => {}; +const serverOnlyFn = () => {}; +const clientOnlyFn = () => 'client'; +const serverThenClientFn = () => 'client'; +const clientThenServerFn = () => 'client'; +function abstractedServerFn() { + return 'server'; +} +const serverOnlyFnAbstracted = () => {}; +function abstractedClientFn() { + return 'client'; +} +const clientOnlyFnAbstracted = abstractedClientFn; +const serverThenClientFnAbstracted = abstractedClientFn; +const clientThenServerFnAbstracted = abstractedClientFn; \ No newline at end of file diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx new file mode 100644 index 0000000000..ffb2ffe79c --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx @@ -0,0 +1,16 @@ +import * as TanStackStart from '@tanstack/start'; +const noImpl = () => {}; +const serverOnlyFn = () => {}; +const clientOnlyFn = () => 'client'; +const serverThenClientFn = () => 'client'; +const clientThenServerFn = () => 'client'; +function abstractedServerFn() { + return 'server'; +} +const serverOnlyFnAbstracted = () => {}; +function abstractedClientFn() { + return 'client'; +} +const clientOnlyFnAbstracted = abstractedClientFn; +const serverThenClientFnAbstracted = abstractedClientFn; +const clientThenServerFnAbstracted = abstractedClientFn; \ No newline at end of file diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx new file mode 100644 index 0000000000..0e0134e347 --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx @@ -0,0 +1,16 @@ +import { createIsomorphicFn } from '@tanstack/start'; +const noImpl = () => {}; +const serverOnlyFn = () => 'server'; +const clientOnlyFn = () => {}; +const serverThenClientFn = () => 'server'; +const clientThenServerFn = () => 'server'; +function abstractedServerFn() { + return 'server'; +} +const serverOnlyFnAbstracted = abstractedServerFn; +function abstractedClientFn() { + return 'client'; +} +const clientOnlyFnAbstracted = () => {}; +const serverThenClientFnAbstracted = abstractedServerFn; +const clientThenServerFnAbstracted = abstractedServerFn; \ No newline at end of file diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx new file mode 100644 index 0000000000..3ba9d0598f --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx @@ -0,0 +1,16 @@ +import { createIsomorphicFn as isomorphicFn } from '@tanstack/start'; +const noImpl = () => {}; +const serverOnlyFn = () => 'server'; +const clientOnlyFn = () => {}; +const serverThenClientFn = () => 'server'; +const clientThenServerFn = () => 'server'; +function abstractedServerFn() { + return 'server'; +} +const serverOnlyFnAbstracted = abstractedServerFn; +function abstractedClientFn() { + return 'client'; +} +const clientOnlyFnAbstracted = () => {}; +const serverThenClientFnAbstracted = abstractedServerFn; +const clientThenServerFnAbstracted = abstractedServerFn; \ No newline at end of file diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx new file mode 100644 index 0000000000..c77c6ac839 --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx @@ -0,0 +1,16 @@ +import * as TanStackStart from '@tanstack/start'; +const noImpl = () => {}; +const serverOnlyFn = () => 'server'; +const clientOnlyFn = () => {}; +const serverThenClientFn = () => 'server'; +const clientThenServerFn = () => 'server'; +function abstractedServerFn() { + return 'server'; +} +const serverOnlyFnAbstracted = abstractedServerFn; +function abstractedClientFn() { + return 'client'; +} +const clientOnlyFnAbstracted = () => {}; +const serverThenClientFnAbstracted = abstractedServerFn; +const clientThenServerFnAbstracted = abstractedServerFn; \ No newline at end of file diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx new file mode 100644 index 0000000000..e6c5c35943 --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx @@ -0,0 +1,35 @@ +import { createIsomorphicFn } from '@tanstack/start' + +const noImpl = createIsomorphicFn() + +const serverOnlyFn = createIsomorphicFn().server(() => 'server') + +const clientOnlyFn = createIsomorphicFn().client(() => 'client') + +const serverThenClientFn = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +const clientThenServerFn = createIsomorphicFn() + .client(() => 'client') + .server(() => 'server') + +function abstractedServerFn() { + return 'server' +} + +const serverOnlyFnAbstracted = createIsomorphicFn().server(abstractedServerFn) + +function abstractedClientFn() { + return 'client' +} + +const clientOnlyFnAbstracted = createIsomorphicFn().client(abstractedClientFn) + +const serverThenClientFnAbstracted = createIsomorphicFn() + .server(abstractedServerFn) + .client(abstractedClientFn) + +const clientThenServerFnAbstracted = createIsomorphicFn() + .client(abstractedClientFn) + .server(abstractedServerFn) diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructuredRename.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructuredRename.tsx new file mode 100644 index 0000000000..9c31330b1b --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructuredRename.tsx @@ -0,0 +1,35 @@ +import { createIsomorphicFn as isomorphicFn } from '@tanstack/start' + +const noImpl = isomorphicFn() + +const serverOnlyFn = isomorphicFn().server(() => 'server') + +const clientOnlyFn = isomorphicFn().client(() => 'client') + +const serverThenClientFn = isomorphicFn() + .server(() => 'server') + .client(() => 'client') + +const clientThenServerFn = isomorphicFn() + .client(() => 'client') + .server(() => 'server') + +function abstractedServerFn() { + return 'server' +} + +const serverOnlyFnAbstracted = isomorphicFn().server(abstractedServerFn) + +function abstractedClientFn() { + return 'client' +} + +const clientOnlyFnAbstracted = isomorphicFn().client(abstractedClientFn) + +const serverThenClientFnAbstracted = isomorphicFn() + .server(abstractedServerFn) + .client(abstractedClientFn) + +const clientThenServerFnAbstracted = isomorphicFn() + .client(abstractedClientFn) + .server(abstractedServerFn) diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnStarImport.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnStarImport.tsx new file mode 100644 index 0000000000..4c1bed5741 --- /dev/null +++ b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnStarImport.tsx @@ -0,0 +1,37 @@ +import * as TanStackStart from '@tanstack/start' + +const noImpl = TanStackStart.createIsomorphicFn() + +const serverOnlyFn = TanStackStart.createIsomorphicFn().server(() => 'server') + +const clientOnlyFn = TanStackStart.createIsomorphicFn().client(() => 'client') + +const serverThenClientFn = TanStackStart.createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +const clientThenServerFn = TanStackStart.createIsomorphicFn() + .client(() => 'client') + .server(() => 'server') + +function abstractedServerFn() { + return 'server' +} + +const serverOnlyFnAbstracted = + TanStackStart.createIsomorphicFn().server(abstractedServerFn) + +function abstractedClientFn() { + return 'client' +} + +const clientOnlyFnAbstracted = + TanStackStart.createIsomorphicFn().client(abstractedClientFn) + +const serverThenClientFnAbstracted = TanStackStart.createIsomorphicFn() + .server(abstractedServerFn) + .client(abstractedClientFn) + +const clientThenServerFnAbstracted = TanStackStart.createIsomorphicFn() + .client(abstractedClientFn) + .server(abstractedServerFn) diff --git a/packages/start/src/client/createIsomorphicFn.ts b/packages/start/src/client/createIsomorphicFn.ts new file mode 100644 index 0000000000..8703bd465b --- /dev/null +++ b/packages/start/src/client/createIsomorphicFn.ts @@ -0,0 +1,36 @@ +// a function that can have different implementations on the client and server. +// implementations not provided will default to a no-op function. + +export type IsomorphicFn< + TArgs extends Array = [], + TServer = undefined, + TClient = undefined, +> = (...args: TArgs) => TServer | TClient + +export interface ServerOnlyFn, TServer> + extends IsomorphicFn { + client: ( + clientImpl: (...args: TArgs) => TClient, + ) => IsomorphicFn +} + +export interface ClientOnlyFn, TClient> + extends IsomorphicFn { + server: ( + serverImpl: (...args: TArgs) => TServer, + ) => IsomorphicFn +} + +export interface IsomorphicFnBase extends IsomorphicFn { + server: , TServer>( + serverImpl: (...args: TArgs) => TServer, + ) => ServerOnlyFn + client: , TClient>( + clientImpl: (...args: TArgs) => TClient, + ) => ClientOnlyFn +} + +// this is a dummy function, it will be replaced by the transformer +export function createIsomorphicFn(): IsomorphicFnBase { + return null! +} diff --git a/packages/start/src/client/index.tsx b/packages/start/src/client/index.tsx index 63db8db204..ea73a9641d 100644 --- a/packages/start/src/client/index.tsx +++ b/packages/start/src/client/index.tsx @@ -1,5 +1,12 @@ /// export { Asset } from './Asset' +export { + createIsomorphicFn, + type IsomorphicFn, + type ServerOnlyFn, + type ClientOnlyFn, + type IsomorphicFnBase, +} from './createIsomorphicFn' export { createServerFn, type JsonResponse, diff --git a/packages/start/src/client/tests/createIsomorphicFn.test-d.ts b/packages/start/src/client/tests/createIsomorphicFn.test-d.ts new file mode 100644 index 0000000000..89f427d8c6 --- /dev/null +++ b/packages/start/src/client/tests/createIsomorphicFn.test-d.ts @@ -0,0 +1,72 @@ +import { expectTypeOf, test } from 'vitest' +import { createIsomorphicFn } from '../createIsomorphicFn' + +test('createIsomorphicFn with no implementations', () => { + const fn = createIsomorphicFn() + + expectTypeOf(fn).toBeCallableWith() + expectTypeOf(fn).returns.toBeUndefined() + + expectTypeOf(fn).toHaveProperty('server') + expectTypeOf(fn).toHaveProperty('client') +}) + +test('createIsomorphicFn with server implementation', () => { + const fn = createIsomorphicFn().server(() => 'data') + + expectTypeOf(fn).toBeCallableWith() + expectTypeOf(fn).returns.toEqualTypeOf() + + expectTypeOf(fn).toHaveProperty('client') + expectTypeOf(fn).not.toHaveProperty('server') +}) + +test('createIsomorphicFn with client implementation', () => { + const fn = createIsomorphicFn().client(() => 'data') + + expectTypeOf(fn).toBeCallableWith() + expectTypeOf(fn).returns.toEqualTypeOf() + + expectTypeOf(fn).toHaveProperty('server') + expectTypeOf(fn).not.toHaveProperty('client') +}) + +test('createIsomorphicFn with server and client implementation', () => { + const fn = createIsomorphicFn() + .server(() => 'data') + .client(() => 'data') + + expectTypeOf(fn).toBeCallableWith() + expectTypeOf(fn).returns.toEqualTypeOf() + + expectTypeOf(fn).not.toHaveProperty('server') + expectTypeOf(fn).not.toHaveProperty('client') +}) + +test('createIsomorphicFn with varying returns', () => { + const fn = createIsomorphicFn() + .server(() => 'data') + .client(() => 1) + expectTypeOf(fn).toBeCallableWith() + expectTypeOf(fn).returns.toEqualTypeOf() +}) + +test('createIsomorphicFn with arguments', () => { + const fn = createIsomorphicFn() + .server((a: number, b: string) => 'data') + .client((...args) => { + expectTypeOf(args).toEqualTypeOf<[number, string]>() + return 1 + }) + expectTypeOf(fn).toBeCallableWith(1, 'a') + expectTypeOf(fn).returns.toEqualTypeOf() + + const fn2 = createIsomorphicFn() + .client((a: number, b: string) => 'data') + .server((...args) => { + expectTypeOf(args).toEqualTypeOf<[number, string]>() + return 1 + }) + expectTypeOf(fn2).toBeCallableWith(1, 'a') + expectTypeOf(fn2).returns.toEqualTypeOf() +})