Skip to content

Commit

Permalink
feat(start): createIsomorphicFn (#2942)
Browse files Browse the repository at this point in the history
* Set up types for createIsomorphicFn

* do dead code analysis for serverOnly and createIsomorphicFn too

* add createIsomorphicFn to relevant regexes

* Add test files

* fix test name

* Add createIsomorphicFn compiler handling

* check for presence/lack of properties

* Add e2e test for isomorphic functions
  • Loading branch information
EskiMojo14 authored Dec 7, 2024
1 parent a97729f commit eeea643
Show file tree
Hide file tree
Showing 18 changed files with 620 additions and 5 deletions.
26 changes: 26 additions & 0 deletions e2e/start/basic/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -371,6 +388,7 @@ export interface FileRouteTypes {
| '/'
| ''
| '/deferred'
| '/isomorphic-fns'
| '/posts'
| '/redirect'
| '/search-params'
Expand All @@ -389,6 +407,7 @@ export interface FileRouteTypes {
| '/'
| ''
| '/deferred'
| '/isomorphic-fns'
| '/redirect'
| '/search-params'
| '/server-fns'
Expand All @@ -405,6 +424,7 @@ export interface FileRouteTypes {
| '/'
| '/_layout'
| '/deferred'
| '/isomorphic-fns'
| '/posts'
| '/redirect'
| '/search-params'
Expand All @@ -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
Expand All @@ -439,6 +460,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LayoutRoute: LayoutRouteWithChildren,
DeferredRoute: DeferredRoute,
IsomorphicFnsRoute: IsomorphicFnsRoute,
PostsRoute: PostsRouteWithChildren,
RedirectRoute: RedirectRoute,
SearchParamsRoute: SearchParamsRoute,
Expand All @@ -461,6 +483,7 @@ export const routeTree = rootRoute
"/",
"/_layout",
"/deferred",
"/isomorphic-fns",
"/posts",
"/redirect",
"/search-params",
Expand All @@ -482,6 +505,9 @@ export const routeTree = rootRoute
"/deferred": {
"filePath": "deferred.tsx"
},
"/isomorphic-fns": {
"filePath": "isomorphic-fns.tsx"
},
"/posts": {
"filePath": "posts.tsx",
"children": [
Expand Down
61 changes: 61 additions & 0 deletions e2e/start/basic/app/routes/isomorphic-fns.tsx
Original file line number Diff line number Diff line change
@@ -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<Partial<Record<string, string>>>()
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 (
<div>
<button onClick={handleClick} data-testid="test-isomorphic-results-btn">
Run
</button>
{!!results && (
<div>
<h1>
<code>getEnv</code>
</h1>
When we called the function on the server it returned:
<pre data-testid="server-result">{JSON.stringify(serverEnv)}</pre>
When we called the function on the client it returned:
<pre data-testid="client-result">{JSON.stringify(env)}</pre>
<br />
<h1>
<code>echo</code>
</h1>
When we called the function on the server it returned:
<pre data-testid="server-echo-result">
{JSON.stringify(serverEcho)}
</pre>
When we called the function on the client it returned:
<pre data-testid="client-echo-result">{JSON.stringify(echo)}</pre>
</div>
)}
</div>
)
}
21 changes: 21 additions & 0 deletions e2e/start/basic/tests/base.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
84 changes: 84 additions & 0 deletions packages/start-vite-plugin/src/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function compileStartOutput(opts: ParseAstOptions) {
createServerFn: IdentifierConfig
createMiddleware: IdentifierConfig
serverOnly: IdentifierConfig
createIsomorphicFn: IdentifierConfig
} = {
createServerFn: {
name: 'createServerFn',
Expand All @@ -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<
Expand Down Expand Up @@ -572,6 +580,82 @@ function handleServerOnlyCallExpression(
)
}

function handleCreateIsomorphicFnCallExpression(
path: babel.NodePath<t.CallExpression>,
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<t.CallExpression> | null,
server: null as babel.NodePath<t.CallExpression> | 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<t.CallExpression>) {
// Find the highest callExpression parent
let rootCallExpression: babel.NodePath<t.CallExpression> = path
Expand Down
13 changes: 8 additions & 5 deletions packages/start-vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit eeea643

Please sign in to comment.