Skip to content
This repository was archived by the owner on Feb 11, 2025. It is now read-only.

Commit

Permalink
fix(next): add ssrPrepass-callback to avoid bundling of react-dom (
Browse files Browse the repository at this point in the history
  • Loading branch information
KATT authored Feb 2, 2024
1 parent 34a2b58 commit d61ece3
Show file tree
Hide file tree
Showing 20 changed files with 279 additions and 267 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ yarn.lock
yalc.lock

coverage/
playwright/
__generated__/


Expand Down
1 change: 0 additions & 1 deletion examples/.test/diagnostics-big-router/src/utils/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,4 @@ export const trpc = createTRPCNext<AppRouter>({
],
};
},
ssr: true,
});
1 change: 0 additions & 1 deletion examples/next-big-router/src/utils/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,4 @@ export const trpc = createTRPCNext<AppRouter>({
],
};
},
ssr: true,
});
1 change: 0 additions & 1 deletion examples/next-edge-runtime/src/utils/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,4 @@ export const trpc = createTRPCNext<AppRouter>({
],
};
},
ssr: true,
});
2 changes: 2 additions & 0 deletions examples/next-minimal-starter/src/utils/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from '../pages/api/trpc/[trpc]';

function getBaseUrl() {
Expand Down Expand Up @@ -29,4 +30,5 @@ export const trpc = createTRPCNext<AppRouter>({
};
},
ssr: true,
ssrPrepass,
});
38 changes: 3 additions & 35 deletions examples/next-prisma-starter/playwright/smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';

test.setTimeout(35e3);

Expand All @@ -8,12 +8,7 @@ test('go to /', async ({ page }) => {
await page.waitForSelector(`text=Starter`);
});

test('test 404', async ({ page }) => {
const res = await page.goto('/post/not-found');
expect(res?.status()).toBe(404);
});

test('add a post', async ({ page, browser }) => {
test('add a post', async ({ page }) => {
const nonce = `${Math.random()}`;

await page.goto('/');
Expand All @@ -23,32 +18,5 @@ test('add a post', async ({ page, browser }) => {
await page.waitForLoadState('networkidle');
await page.reload();

expect(await page.content()).toContain(nonce);

const ssrContext = await browser.newContext({
javaScriptEnabled: false,
});
const ssrPage = await ssrContext.newPage();
await ssrPage.goto('/');

expect(await ssrPage.content()).toContain(nonce);
});

test('server-side rendering test', async ({ page, browser }) => {
// add a post
const nonce = `${Math.random()}`;

await page.goto('/');
await page.fill(`[name=title]`, nonce);
await page.fill(`[name=text]`, nonce);
await page.click(`form [type=submit]`);
await page.waitForLoadState('networkidle');

// load the page without js
const ssrContext = await browser.newContext({
javaScriptEnabled: false,
});
const ssrPage = await ssrContext.newPage();
await ssrPage.goto('/');
expect(await ssrPage.content()).toContain(nonce);
await page.waitForSelector(`text="${nonce}"`);
});
28 changes: 2 additions & 26 deletions examples/next-prisma-starter/src/utils/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';

import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { NextPageContext } from 'next';
// ℹ️ Type-only import:
Expand Down Expand Up @@ -96,32 +97,7 @@ export const trpc = createTRPCNext<AppRouter, SSRContext>({
/**
* @link https://trpc.io/docs/v11/ssr
*/
ssr: true,
/**
* Set headers or status code when doing SSR
*/
responseMeta(opts) {
const ctx = opts.ctx as SSRContext;

if (ctx.status) {
// If HTTP status set, propagate that
return {
status: ctx.status,
};
}

const error = opts.clientErrors[0];
if (error) {
// Propagate http first error from API calls
return {
status: error.data?.httpStatus ?? 500,
};
}

// for app caching with SSR see https://trpc.io/docs/v11/caching

return {};
},
ssr: false,
/**
* @link https://trpc.io/docs/v11/data-transformers
*/
Expand Down
10 changes: 6 additions & 4 deletions examples/next-prisma-websockets-starter/src/utils/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
wsLink,
} from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { inferRouterOutputs } from '@trpc/server';
import type { NextPageContext } from 'next';
import getConfig from 'next/config';
Expand Down Expand Up @@ -56,6 +57,11 @@ function getEndingLink(ctx: NextPageContext | undefined): TRPCLink<AppRouter> {
* @link https://trpc.io/docs/v11/react#3-create-trpc-hooks
*/
export const trpc = createTRPCNext<AppRouter>({
/**
* @link https://trpc.io/docs/v11/ssr
*/
ssr: true,
ssrPrepass,
config({ ctx }) {
/**
* If you want to use SSR, you need to use the server's full URL
Expand All @@ -82,10 +88,6 @@ export const trpc = createTRPCNext<AppRouter>({
queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } },
};
},
/**
* @link https://trpc.io/docs/v11/ssr
*/
ssr: true,
/**
* @link https://trpc.io/docs/v11/data-transformers
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const trpc = createTRPCNext<AppRouter>({
],
};
},
ssr: true,
ssr: false,
});
```

Expand Down
6 changes: 6 additions & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"import": "./dist/app-dir/server.mjs",
"require": "./dist/app-dir/server.js",
"default": "./dist/app-dir/server.js"
},
"./ssrPrepass": {
"import": "./dist/ssrPrepass.mjs",
"require": "./dist/ssrPrepass.js",
"default": "./dist/ssrPrepass.js"
}
},
"files": [
Expand All @@ -63,6 +68,7 @@
"README.md",
"package.json",
"app-dir",
"ssrPrepass",
"!**/*.test.*"
],
"dependencies": {},
Expand Down
1 change: 1 addition & 0 deletions packages/next/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const input = [
'src/app-dir/client.ts',
'src/app-dir/links/nextCache.ts',
'src/app-dir/links/nextHttp.ts',
'src/ssrPrepass.ts',
];

export default function rollup(): RollupOptions[] {
Expand Down
185 changes: 185 additions & 0 deletions packages/next/src/ssrPrepass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Heavily based on urql's ssr
* https://github.com/FormidableLabs/urql/blob/main/packages/next-urql/src/with-urql-client.ts
*/
import type { DehydratedState } from '@tanstack/react-query';
import { dehydrate } from '@tanstack/react-query';
import { createTRPCUntypedClient } from '@trpc/client';
import type { CoercedTransformerParameters } from '@trpc/client/unstable-internals';
import { getTransformer } from '@trpc/client/unstable-internals';
import type { TRPCClientError, TRPCClientErrorLike } from '@trpc/react-query';
import { getQueryClient } from '@trpc/react-query/shared';
import type {
AnyRouter,
Dict,
Maybe,
} from '@trpc/server/unstable-core-do-not-import';
import type {
AppContextType,
NextPageContext,
} from 'next/dist/shared/lib/utils';
import { createElement } from 'react';
import type { TRPCPrepassHelper, TRPCPrepassProps } from './withTRPC';

function transformQueryOrMutationCacheErrors<
TState extends
| DehydratedState['mutations'][0]
| DehydratedState['queries'][0],
>(result: TState): TState {
const error = result.state.error as Maybe<TRPCClientError<any>>;
if (error instanceof Error && error.name === 'TRPCClientError') {
const newError: TRPCClientErrorLike<any> = {
message: error.message,
data: error.data,
shape: error.shape,
};
return {
...result,
state: {
...result.state,
error: newError,
},
};
}
return result;
}

export const ssrPrepass: TRPCPrepassHelper = (opts) => {
const { parent, WithTRPC, AppOrPage } = opts;
type $PrepassProps = TRPCPrepassProps<AnyRouter, any>;

const transformer = getTransformer(
(parent as CoercedTransformerParameters).transformer,
);
WithTRPC.getInitialProps = async (appOrPageCtx: AppContextType) => {
const shouldSsr = async () => {
if (typeof window !== 'undefined') {
return false;
}
if (typeof parent.ssr === 'function') {
try {
return await parent.ssr({ ctx: appOrPageCtx.ctx });
} catch (e) {
return false;
}
}
return parent.ssr;
};
const ssrEnabled = await shouldSsr();
const AppTree = appOrPageCtx.AppTree;

// Determine if we are wrapping an App component or a Page component.
const isApp = !!appOrPageCtx.Component;
const ctx: NextPageContext = isApp
? appOrPageCtx.ctx
: (appOrPageCtx as any as NextPageContext);

// Run the wrapped component's getInitialProps function.
let pageProps: Dict<unknown> = {};
if (AppOrPage.getInitialProps) {
const originalProps = await AppOrPage.getInitialProps(
appOrPageCtx as any,
);
const originalPageProps = isApp
? originalProps.pageProps ?? {}
: originalProps;

pageProps = {
...originalPageProps,
...pageProps,
};
}
const getAppTreeProps = (props: Record<string, unknown>) =>
isApp ? { pageProps: props } : props;

if (typeof window !== 'undefined' || !ssrEnabled) {
return getAppTreeProps(pageProps);
}

const config = parent.config({ ctx });
const trpcClient = createTRPCUntypedClient(config);
const queryClient = getQueryClient(config);

const trpcProp: $PrepassProps = {
config,
trpcClient,
queryClient,
ssrState: 'prepass',
ssrContext: ctx,
};
const prepassProps = {
pageProps,
trpc: trpcProp,
};

const reactDomServer = await import('react-dom/server');

// Run the prepass step on AppTree. This will run all trpc queries on the server.
// multiple prepass ensures that we can do batching on the server
while (true) {
// render full tree
reactDomServer.renderToString(createElement(AppTree, prepassProps));
if (!queryClient.isFetching()) {
// the render didn't cause the queryClient to fetch anything
break;
}

// wait until the query cache has settled it's promises
await new Promise<void>((resolve) => {
const unsub = queryClient.getQueryCache().subscribe((event) => {
if (event?.query.getObserversCount() === 0) {
resolve();
unsub();
}
});
});
}
const dehydratedCache = dehydrate(queryClient, {
shouldDehydrateQuery(query) {
// filter out queries that are marked as trpc: { ssr: false } or are not enabled, but make sure errors are dehydrated
const isExcludedFromSSr =
query.state.fetchStatus === 'idle' &&
query.state.status === 'pending';
return !isExcludedFromSSr;
},
});
// since error instances can't be serialized, let's make them into `TRPCClientErrorLike`-objects
const dehydratedCacheWithErrors = {
...dehydratedCache,
queries: dehydratedCache.queries.map(transformQueryOrMutationCacheErrors),
mutations: dehydratedCache.mutations.map(
transformQueryOrMutationCacheErrors,
),
};

// dehydrate query client's state and add it to the props
pageProps['trpcState'] = transformer.input.serialize(
dehydratedCacheWithErrors,
);

const appTreeProps = getAppTreeProps(pageProps);

const meta =
parent.responseMeta?.({
ctx,
clientErrors: [...dehydratedCache.queries, ...dehydratedCache.mutations]
.map((v) => v.state.error)
.flatMap((err) =>
err instanceof Error && err.name === 'TRPCClientError'
? [err as TRPCClientError<AnyRouter>]
: [],
),
}) ?? {};

for (const [key, value] of Object.entries(meta.headers ?? {})) {
if (typeof value === 'string') {
ctx.res?.setHeader(key, value);
}
}
if (meta.status && ctx.res) {
ctx.res.statusCode = meta.status;
}

return appTreeProps;
};
};
Loading

0 comments on commit d61ece3

Please sign in to comment.