Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: experimenting with standalone React-client #5498

Open
wants to merge 26 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,121 @@
import type { TRPCLink, TRPCLinkDecoratorObject } from '@trpc/client';
import type { AnyTRPCRouter } from '@trpc/server';
import { observable, share, tap } from '@trpc/server/observable';
/* istanbul ignore file -- @preserve */
// We're not actually exporting this link
import type { Observable, Unsubscribable } from '@trpc/server/observable';
import type { AnyRouter } from '@trpc/server/unstable-core-do-not-import';

type CacheLinkDecorator = TRPCLinkDecoratorObject<{
query: {
/**
* If true, the cache will be ignored and the request will be made as if it was the first time
*/
ignoreCache: boolean;
};
}>;

/**
* @link https://trpc.io/docs/v11/client/links/cacheLink
*/
export function cacheLink<TRouter extends AnyTRPCRouter>(
// eslint-disable-next-line @typescript-eslint/ban-types
_opts: {} = {},
): TRPCLink<TRouter, CacheLinkDecorator> {
return () => {
return ({ op, next }) => {
return observable((observer) => {
return next(op)
.pipe(
tap({
next(result) {
// logResult(result);
},
error(result) {
// logResult(result);
},
}),
)
.subscribe(observer);
});
};
};
}

/**
* @link https://trpc.io/docs/v11/client/links/loggerLink
*/
export function testDecorationLink<TRouter extends AnyTRPCRouter>(
// eslint-disable-next-line @typescript-eslint/ban-types
_opts: {} = {},
): TRPCLink<
TRouter,
TRPCLinkDecoratorObject<{
mutation: {
foo: string;
};
}>
> {
return () => {
return (opts) => {
return observable((observer) => {
return opts
.next(opts.op)
.pipe(
tap({
next(result) {
// logResult(result);
},
error(result) {
// logResult(result);
},
}),
)
.subscribe(observer);
});
};
};
}

export function refetchLink<TRouter extends AnyRouter>(): TRPCLink<TRouter> {
return () => {
return ({ op, next }) => {
return observable((observer) => {
let next$: Unsubscribable | null = null;
let nextTimer: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
let isDone = false;
function attempt() {
console.log('fetching.......');
attempts++;
next$?.unsubscribe();
next$ = next(op).subscribe({
error(error) {
observer.error(error);
},
next(result) {
observer.next(result);

if (nextTimer) {
clearTimeout(nextTimer);
}
nextTimer = setTimeout(() => {
attempt();
}, 3000);
},
complete() {
if (isDone) {
observer.complete();
}
},
});
}
attempt();
return () => {
isDone = true;
next$?.unsubscribe();
};
});
};
};
}
@@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
CreateTRPCClient,
CreateTRPCClientOptions,
TRPCLinkDecoration,
} from '@trpc/client';
import { createTRPCClient, getUntypedClient } from '@trpc/client';
import type {
AnyTRPCRouter,
ProcedureType,
TRPCProcedureType,
} from '@trpc/server';
import type { Unsubscribable } from '@trpc/server/observable';
import { observableToPromise } from '@trpc/server/observable';
import { createRecursiveProxy } from '@trpc/server/unstable-core-do-not-import';
import React, { use, useEffect, useRef } from 'react';

function getBaseUrl() {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return 'http://localhost:3000';
}

function getUrl() {
return getBaseUrl() + '/api/trpc';
}

const normalize = (opts: {
path: string[];
input: unknown;
type: ProcedureType;
}) => {
return JSON.stringify(opts);
};
export function createReactClient<
TRouter extends AnyTRPCRouter,
TDecoration extends Partial<TRPCLinkDecoration>,
>(getOptions: () => CreateTRPCClientOptions<TRouter, TDecoration>) {
type Context = {
client: CreateTRPCClient<TRouter>;
};
const Provider = React.createContext(null as unknown as Context);
return {
Provider: (props: { children: React.ReactNode }) => {
const [client] = React.useState(() => {
const options = getOptions();
return createTRPCClient(options);
});

return (
<Provider.Provider value={{ client }}>
{props.children}
</Provider.Provider>
);
},
useClient: () => {
const ctx = use(Provider);
const ref = useRef();
// force rendered
const [renderCount, setRenderCount] = React.useState(0);
const forceRender = React.useCallback(() => {
setRenderCount((c) => c + 1);
}, []);

type Track = {
promise: Promise<unknown>;
unsub: Unsubscribable;
};
const trackRef = useRef(new Map<string, Track>());
useEffect(() => {
const tracked = trackRef.current;
return () => {
tracked.forEach((val) => {
val.unsub.unsubscribe();
});
};
}, []);
if (!ctx) {
throw new Error('No tRPC client found');
}
const untyped = getUntypedClient(ctx.client);

return createRecursiveProxy((opts) => {
console.log('opts', opts);
const path = [...opts.path];
const type = path.pop()! as TRPCProcedureType;
if (type !== 'query') {
throw new Error('only queries are supported');
}

const input = opts.args[0];

const normalized = normalize({ path, input, type });

let tracked = trackRef.current.get(normalized);
if (!tracked) {
tracked = {} as Track;
const observable = untyped.$request({
type,
input,
path: path.join('.'),
});
const first = true;
const unsub = observable.subscribe({
next() {
if (!first) {
// something made the observable emit again, probably a cache update

// reset promise
tracked!.promise = observableToPromise(observable).promise;

// force re-render
forceRender();
}
},
error() {
// [...?]
},
});

const promise = observableToPromise(observable).promise;

tracked.promise = promise;
tracked.unsub = unsub;

trackRef.current.set(normalized, tracked);
}

return tracked.promise;
}) as CreateTRPCClient<TRouter>;
},
};
}
@@ -0,0 +1,47 @@
'use client';

import type { inferTRPCClientOptionsDecoration } from '@trpc/client';
import {
createTRPCClientOptions,
httpBatchLink,
loggerLink,
} from '@trpc/client';
import type { AppRouter } from '~/server/routers/_app';
import { getUrl } from '~/trpc/shared';
import superjson from 'superjson';
import { cacheLink, refetchLink } from './_lib/cacheLink';
import { createReactClient } from './_lib/createReactClient';

const getTrpcOptions = createTRPCClientOptions<AppRouter>()(() => ({
links: [
loggerLink({
enabled: (op) => true,
}),
cacheLink(),
// FIXME: this is not working
// testDecorationLink(),
refetchLink(),
KATT marked this conversation as resolved.
Show resolved Hide resolved
httpBatchLink({
transformer: superjson,
url: getUrl(),
headers() {
return {
'x-trpc-source': 'standalone',
};
},
}),
],
}));

type $Decoration = inferTRPCClientOptionsDecoration<typeof getTrpcOptions>;
// ^?
type $TT = $Decoration['query']['ignoreCache'];
// ^?

export const standaloneClient = createReactClient(getTrpcOptions);

export function Provider(props: { children: React.ReactNode }) {
return (
<standaloneClient.Provider>{props.children}</standaloneClient.Provider>
);
}
@@ -0,0 +1,5 @@
import { Provider } from './_provider';

export default function Layout(props: { children: React.ReactNode }) {
return <Provider>{props.children}</Provider>;
}
32 changes: 32 additions & 0 deletions examples/.experimental/next-app-dir/src/app/standalone/page.tsx
@@ -0,0 +1,32 @@
'use client';

import { use, useEffect, useState } from 'react';
import { standaloneClient } from './_provider';

export default function Page() {
const client = standaloneClient.useClient();
const [, forceRender] = useState(0);

useEffect(() => {
// force render every second
const interval = setInterval(() => {
console.log('force rendering the page');
forceRender((c) => c + 1);
}, 1000);

return () => {
clearInterval(interval);
};
}, []);

const data = use(
client.greeting.query({
text: 'standalone client',
}),
);
return (
<>
<pre>{JSON.stringify(data, null, 4)}</pre>
</>
);
}
Expand Up @@ -34,7 +34,9 @@ export const appRouter = router({
)
.query(async (opts) => {
console.log('request from', opts.ctx.headers?.['x-trpc-source']);
return `hello ${opts.input.text} - ${Math.random()}`;
return `hello ${opts.input.text} - current second: ${Math.round(
new Date().getTime() / 1000,
)}`;
}),

secret: publicProcedure.query(async (opts) => {
Expand Down
26 changes: 13 additions & 13 deletions examples/.experimental/next-app-dir/src/server/trpc.ts
Expand Up @@ -8,19 +8,19 @@ import type { Context } from './context';

const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter(opts) {
const { shape, error } = opts;
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
// errorFormatter(opts) {
// const { shape, error } = opts;
// return {
// ...shape,
// data: {
// ...shape.data,
// zodError:
// error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
// ? error.cause.flatten()
// : null,
// },
// };
// },
});

export const router = t.router;
Expand Down