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 all 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,195 @@
import type {
TRPCClientError,
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,
InferrableClientTypes,
ProcedureType,
} from '@trpc/server/unstable-core-do-not-import';

export const normalize = (opts: {
path: string[] | string;
input: unknown;
type: ProcedureType;
}) => {
return JSON.stringify({
path: Array.isArray(opts.path) ? opts.path.join('.') : opts.path,
input: opts.input,
type: opts.type,
});
};

/**
* @link https://trpc.io/docs/v11/client/links/cacheLink
*/
export function cacheLink<TRoot extends InferrableClientTypes>(): TRPCLink<
TRoot,
TRPCLinkDecoratorObject<{
query: {
/**
* If true, the cache will be ignored and the request will be made as if it was the first time
*/
ignoreCache: boolean;
};
runtime: {
cache: Record<
string,
{
observable: Observable<unknown, TRPCClientError<AnyRouter>>;
}
>;
};
}>
> {
// initialized config
return (runtime) => {
// initialized in app
const cache: Record<
string,
{
observable: Observable<unknown, TRPCClientError<TRoot>>;
}
> = {};
runtime.cache = cache;
return (opts) => {
const { op } = opts;
if (op.type !== 'query') {
return opts.next(opts.op);
}
const normalized = normalize({
input: opts.op.input,
path: opts.op.path,
type: opts.op.type,
});

op.ignoreCache;
// ^?

let cached = cache[normalized];
if (!cached) {
console.log('found cache entry');
cached = cache[normalized] = {
observable: observable((observer) => {
const subscription = opts.next(opts.op).subscribe({
next(v) {
console.log(`got new value for ${normalized} in cacheLink`);
observer.next(v);
},
error(e) {
observer.error(e);
},
complete() {
observer.complete();
},
});
return () => {
subscription.unsubscribe();
};
}).pipe(share()),
};
}

console.log({ cached });

return cached.observable;
};
};
}

/**
* @link https://trpc.io/docs/v11/client/links/loggerLink
*/
export function testDecorationLink<TRoot extends InferrableClientTypes>(
// eslint-disable-next-line @typescript-eslint/ban-types
_opts: {} = {},
): TRPCLink<
TRoot,
TRPCLinkDecoratorObject<{
query: {
/**
* I'm just here for testing inference
*/
__fromTestLink1: true;
};
mutation: {
/**
* I'm just here for testing inference
*/
__fromTestLink2: true;
};
}>
> {
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 }) => {
if (typeof document === 'undefined') {
return next(op);
}
return observable((observer) => {
console.log('------------------ fetching refetchLink');
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,153 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
CreateTRPCClient,
TRPCDecoratedClientOptions,
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, useContext, 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 TRPCLinkDecoration,
>(getOptions: () => TRPCDecoratedClientOptions<TRouter, TDecoration>) {
type $Client = CreateTRPCClient<TRouter, TDecoration>;
type Context = {
client: $Client;
};
const Provider = React.createContext(null as unknown as Context);
return {
/**
* @deprecated temporary hack to debug types
*/
$types: {} as unknown as {
decoration: TDecoration;
},
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 = useContext(Provider);

// 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>());
console.log('--------------', trackRef.current);
useEffect(() => {
const tracked = trackRef.current;
return () => {
console.log('unsubscribing');

tracked.forEach((val) => {
val.unsub.unsubscribe();
});
};
}, []);
useEffect(() => {
console.log(`rendered ${renderCount}`);
}, [renderCount]);
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) {
console.log('tracking new query', normalized, trackRef.current);

tracked = {} as Track;
const observable = untyped.$request({
type,
input,
path: path.join('.'),
});

tracked.promise = new Promise((resolve, reject) => {
let first = true;
const unsub = observable.subscribe({
next(val) {
console.log('got new value in useClient');
if (first) {
resolve(val.result.data);
first = false;
} else {
console.log('cache update');
// something made the observable emit again, probably a cache update

// reset promise
tracked!.promise = Promise.resolve(val.result.data);

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

console.log('saving', normalized);
trackRef.current.set(normalized, tracked);
}

return tracked.promise;
}) as $Client;
},
};
}