diff --git a/docs/docs/graphql.md b/docs/docs/graphql.md index 977d423e7d0d..3d610d71f31c 100644 --- a/docs/docs/graphql.md +++ b/docs/docs/graphql.md @@ -407,9 +407,48 @@ query { } ``` -How is this possible? Via Redwood's [root schema](https://github.com/redwoodjs/redwood/blob/main/packages/api/src/makeMergedSchema/rootSchema.ts#L22-L38). The root schema is where things like currentUser are defined. +How is this possible? Via Redwood's [root schema](https://github.com/redwoodjs/redwood/blob/main/packages/graphql-server/src/rootSchema.ts). The root schema is where things like currentUser are defined: -Now that you've seen the sdl, be sure to check out [the resolvers](https://github.com/redwoodjs/redwood/blob/34a6444432b409774d54be17789a7109add9709a/packages/api/src/makeMergedSchema/rootSchema.ts#L31-L45). +```graphql + scalar BigInt + scalar Date + scalar Time + scalar DateTime + scalar JSON + scalar JSONObject + + type Redwood { + version: String + currentUser: JSON + prismaVersion: String + } + + type Query { + redwood: Redwood + } +``` + +Now that you've seen the sdl, be sure to check out [the resolvers](https://github.com/redwoodjs/redwood/blob/main/packages/graphql-server/src/rootSchema.ts): + +```ts +export const resolvers: Resolvers = { + BigInt: BigIntResolver, + Date: DateResolver, + Time: TimeResolver, + DateTime: DateTimeResolver, + JSON: JSONResolver, + JSONObject: JSONObjectResolver, + Query: { + redwood: () => ({ + version: redwoodVersion, + prismaVersion: prismaVersion, + currentUser: (_args: any, context: GlobalContext) => { + return context?.currentUser + }, + }), + }, +} +``` @@ -60,7 +62,37 @@ For Private Routes, Redwood prerenders your Private Routes' `whileLoadingAuth` p ``` -## Dynamic routes +### Rendering skeletons while authenticating +Sometimes you want to render the shell of the page, while you wait for your authentication checks to happen. This can make the experience feel a lot snappier to the user, since they don't wait on a blank screen while their credentials are checked. + +To do this, make use of the `whileLoadingAuth` prop on `` or a `` in your Routes file. For example, if we have a dashboard that you need to be logged in to access: + +```js ./web/src/Routes.{tsx,js} +// This renders the layout with skeleton loaders in the content area +// highlight-next-line +const DashboardLoader = () => + + +const Routes = () => { + return ( + + + + + {/* ... */} +``` + +## Dynamic routes & Route Hooks + + Let's say you have a route like this @@ -115,6 +147,8 @@ export async function routeParameters() { Take note of the special syntax for the import, with a dollar-sign in front of api. This lets our tooling (typescript and babel) know that you want to break out of the web side the page is in to access code on the api side. This only works in the routeHook scripts (and scripts in the root /scripts directory). +--- + ## Prerender Utils Sometimes you need more fine-grained control over whether something gets prerendered. This may be because the component or library you're using needs access to browser APIs like `window` or `localStorage`. Redwood has three utils to help you handle these situations: @@ -123,11 +157,11 @@ Sometimes you need more fine-grained control over whether something gets prerend - `useIsBrowser` - `isBrowser` -> **Heads-up!** -> -> If you're prerendering a page that uses a third-party library, make sure it's "universal". If it's not, try calling the library after doing a browser check using one of the utils above. -> -> Look for these key words when choosing a library: _universal module, SSR compatible, server compatible_—all these indicate that the library also works in Node.js. +:::tip Heads-up! +If you're prerendering a page that uses a third-party library, make sure it's "universal". If it's not, try calling the library after doing a browser check using one of the utils above. + +Look for these key words when choosing a library: _universal module, SSR compatible, server compatible_—all these indicate that the library also works in Node.js. +::: ### `` component @@ -176,26 +210,6 @@ if (isBrowser) { } ``` -### Optimization Tip - -If you dynamically load third-party libraries that aren't part of your JS bundle, using these prerendering utils can help you avoid loading them at build time: - -```jsx -import { useIsBrowser } from '@redwoodjs/prerender/browserUtils' - -const ComponentUsingAnExternalLibrary = () => { - const browser = useIsBrowser() - - // if `browser` evaluates to false, this won't be included - if (browser) { - loadMyLargeExternalLibrary() - } - - return ( - // ... - ) -``` - ### Debugging If you just want to debug your app, or check for possible prerendering errors, after you've built it, you can run this command: @@ -204,7 +218,9 @@ If you just want to debug your app, or check for possible prerendering errors, a yarn rw prerender --dry-run ``` -Since we just shipped this in v0.26, we're actively looking for feedback! Do let us know if: everything built ok? you encountered specific libraries that you were using that didn’t work? +We're actively looking for feedback! Do let us know if: everything built ok? you encountered specific libraries that you were using that didn’t work? + +--- ## Images and Assets @@ -239,12 +255,94 @@ const LogoComponent = () => export default LogoComponent ``` -## Configuring redirects +--- +## Cell prerendering +As of v3.x, Redwood supports prerendering your Cells with the data you were querying. There's no special config to do here, but a couple of things to note: + +#### 1. Prerendering always happens as an unauthenticated user + +Because prerendering happens at _build_ time, before any authentication is set, all your queries on a Route marked for prerender will be made as a public user + +#### 2. We use your graphql handler to make queries during prerendering -Depending on what pages you're prerendering, you may want to change your redirect settings. Using Netlify as an example: +When prerendering we look for your graphql function defined in `./api/src/functions/graphql.{ts,js}` and use it to run queries against it. + + +### Common Warnings & Errors + +#### Could not load your GraphQL handler - the Loading fallback + +During builds if you encounter this warning +```shell + ⚠️ Could not load your GraphQL handler. + Your Cells have been prerendered in the "Loading" state. +``` + +It could mean one of two things: + +a) We couldn't locate the GraphQL handler at the usual path + +or + +b) There was an error when trying to import your GraphQL handler - maybe due to missing dependencies or an error in the code + + + +If you've moved this GraphQL function, or we encounter an error executing it, it won't break your builds. All your Cells will be prerendered in their `Loading` state, and will update once the JavaScript loads on the browser. This is effectively skipping prerendering your Cells, but they'll still work! + + +#### Cannot prerender the query {queryName} as it requires auth. +This error happens during builds when you have a Cell on a page you're prerendering that makes a query marked with `@requireAuth` in your SDL. + +During prerender you are not logged in ([see point 1](#1-prerendering-always-happens-as-an-unauthenticated-user)), so you'll have to conditionally render the Cell - for example: + +```js +import { useAuth } from '@redwoodjs/auth' + +const HomePage = () => { + // highlight-next-line + const { isAuthenticated } = useAuth + + return ( + <> + // highlight-next-line + { isAuthenticated ? : } + +``` + +--- +## Optimization Tips + + +### Dynamically loading large libraries + +If you dynamically load third-party libraries that aren't part of your JS bundle, using these prerendering utils can help you avoid loading them at build time: + +```jsx +import { useIsBrowser } from '@redwoodjs/prerender/browserUtils' + +const ComponentUsingAnExternalLibrary = () => { + const browser = useIsBrowser() + + // if `browser` evaluates to false, this won't be included + if (browser) { + loadMyLargeExternalLibrary() + } + + return ( + // ... + ) +``` + +### Configuring redirects + +Depending on what pages you're prerendering, you may want to change your redirect settings. Keep in mind your redirect settings will vary a lot based on what routes you are prerendering, and the settings of your deployment provider. + + +Using Netlify as an example:
-If you prerender your `notFoundPage` +If you prerender your `notFoundPage`, and all your other routes You can remove the default redirect to index in your `netlify.toml`. This means the browser will accurately receive 404 statuses when navigating to a route that doesn't exist: @@ -256,6 +354,7 @@ You can remove the default redirect to index in your `netlify.toml`. This means - status = 200 ``` +This makes your app behave much more like a traditional website, where all the possible routes are defined up front. But take care to make sure you are prerendering all your pages, otherwise you will receive 404s on pages that do exist, but that Netlify hasn't been told about.
@@ -271,16 +370,21 @@ You can add a 404 redirect if you want: + status = 404 ``` +This makes your app behave much more like a traditional website, where all the possible routes are defined up front. But take care to make sure you are prerendering all your pages, otherwise you will receive 404s on pages that do exist, but that Netlify hasn't been told about.
-## Flash after page load -> We're actively working preventing these flashes with upcoming changes to the Router. -You might notice a flash after page load. A quick workaround for this is to make sure whatever page you're seeing the flash on isn't code split. You can do this by explicitly importing the page in `Routes.js`: +### Flash after page load + +You might notice a flash after page load. Prerendering pages still has various benefits (such as SEO), but may seem jarring to users if there's a flash. + +A quick workaround for this is to make sure whatever page you're seeing the flash on isn't dynamically loaded i.e. prevent code splitting. You can do this by explicitly importing the page in `Routes.js`: ```jsx import { Router, Route } from '@redwoodjs/router' +// We don't want HomePage to be dynamically loaded +// highlight-next-line import HomePage from 'src/pages/HomePage' const Routes = () => { diff --git a/docs/docs/testing.md b/docs/docs/testing.md index 19ed201a6151..9493ffc63774 100644 --- a/docs/docs/testing.md +++ b/docs/docs/testing.md @@ -1006,7 +1006,7 @@ export const standard = (variables) => { Assuming you had a **<ProductPage>** component: -```jsx title="web/src/components/ProductCell/ProductCell.mock.js" +```jsx title="web/src/pages/ProductPage/ProductPage.js" import ProductCell from 'src/components/ProductCell' const ProductPage = ({ status }) => { diff --git a/docs/docs/tutorial/chapter4/deployment.md b/docs/docs/tutorial/chapter4/deployment.md index 4aa91b1d48e9..16dd7784f2ba 100644 --- a/docs/docs/tutorial/chapter4/deployment.md +++ b/docs/docs/tutorial/chapter4/deployment.md @@ -162,7 +162,7 @@ You also have the ability to "lock" the `main` branch so that deploys do not aut #### Connections -In this tutorial, your serverless functions will be connecting directly to the Postgres database. Because Postgres has a limited number of concurrent connections it will accept, this does not scale—imagine a flood of traffic to your site which causes a 100x increase in the number of serverless function calls. Netlify (and behind the scenes, AWS) will happily spin up 100+ serverless Lambda instances to handle the traffic. The problem is that each one will open it's own connection to your database, potentially exhausting the number of available connections. The proper solution is to put a connection pooling service in front of Postgres and connect to that from your lambda functions. To learn how to do that, see the [Connection Pooling](../../connection-pooling.md) guide. +In this tutorial, your serverless functions will be connecting directly to the Postgres database. Because Postgres has a limited number of concurrent connections it will accept, this does not scale—imagine a flood of traffic to your site which causes a 100x increase in the number of serverless function calls. Netlify (and behind the scenes, AWS) will happily spin up 100+ serverless Lambda instances to handle the traffic. The problem is that each one will open its own connection to your database, potentially exhausting the number of available connections. The proper solution is to put a connection pooling service in front of Postgres and connect to that from your lambda functions. To learn how to do that, see the [Connection Pooling](../../connection-pooling.md) guide. #### Security diff --git a/docs/docs/tutorial/chapter6/comments-schema.md b/docs/docs/tutorial/chapter6/comments-schema.md index 335d415fadaf..1e32c4b4d975 100644 --- a/docs/docs/tutorial/chapter6/comments-schema.md +++ b/docs/docs/tutorial/chapter6/comments-schema.md @@ -805,7 +805,9 @@ describe('comments', () => { input: { name: 'Billy Bob', body: 'What is your favorite tree bark?', - postId: scenario.post.bark.id, + post: { + connect: { id: scenario.post.bark.id }, + }, }, }) @@ -844,7 +846,9 @@ describe('comments', () => { input: { name: 'Billy Bob', body: 'What is your favorite tree bark?', - postId: scenario.post.bark.id, + post: { + connect: { id: scenario.post.bark.id }, + }, }, }) @@ -865,6 +869,34 @@ We pass an optional first argument to `scenario()` which is the named scenario t We were able to use the `id` of the post that we created in our scenario because the scenarios contain the actual database data after being inserted, not just the few fields we defined in the scenario itself. In addition to `id` we could access `createdAt` which is defaulted to `now()` in the database. +:::info What's that post…connect…id-Voodoo?! Can't we simply pass the Post's ID directly here? + +What you're looking at is the [connect syntax](https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#connect-an-existing-record), which is a Prisma +core concept. And yes, we could simply pass `postId: scenario.post.bark.id` instead – as a so-called "unchecked" input. But as the name implies, the connect syntax is king +in Prisma-land. + + +Note that if you try to use `postId` that would give you red squiggles, because that input would violate the `CreateCommentArgs` interface definition in +`api/src/services/comments/comments.ts`. In order to use the `postId` input, that'd need to be changed to + +```ts +interface CreateCommentArgs { + input: Prisma.CommentUncheckedCreateInput +} +``` + +or + +```ts +interface CreateCommentArgs { + input: Prisma.CommentCreateInput | Prisma.CommentUncheckedCreateInput +} +``` +in case we wanted to allow both ways – which Prisma generally allows, however [it doesn't allow to pick and mix](https://stackoverflow.com/a/69169106/1246547) within the same input. + + +::: + We'll test that all the fields we give to the `createComment()` function are actually created in the database, and for good measure just make sure that `createdAt` is set to a non-null value. We could test that the actual timestamp is correct, but that involves freezing the Javascript Date object so that no matter how long the test takes, you can still compare the value to `new Date` which is right *now*, down to the millisecond. While possible, it's beyond the scope of our easy, breezy tutorial since it gets [very gnarly](https://codewithhugo.com/mocking-the-current-date-in-jest-tests/)! :::info What's up with the names for scenario data? posts.bark? Really? diff --git a/docs/versioned_docs/version-2.2/tutorial/chapter4/deployment.md b/docs/versioned_docs/version-2.2/tutorial/chapter4/deployment.md index 4aa91b1d48e9..16dd7784f2ba 100644 --- a/docs/versioned_docs/version-2.2/tutorial/chapter4/deployment.md +++ b/docs/versioned_docs/version-2.2/tutorial/chapter4/deployment.md @@ -162,7 +162,7 @@ You also have the ability to "lock" the `main` branch so that deploys do not aut #### Connections -In this tutorial, your serverless functions will be connecting directly to the Postgres database. Because Postgres has a limited number of concurrent connections it will accept, this does not scale—imagine a flood of traffic to your site which causes a 100x increase in the number of serverless function calls. Netlify (and behind the scenes, AWS) will happily spin up 100+ serverless Lambda instances to handle the traffic. The problem is that each one will open it's own connection to your database, potentially exhausting the number of available connections. The proper solution is to put a connection pooling service in front of Postgres and connect to that from your lambda functions. To learn how to do that, see the [Connection Pooling](../../connection-pooling.md) guide. +In this tutorial, your serverless functions will be connecting directly to the Postgres database. Because Postgres has a limited number of concurrent connections it will accept, this does not scale—imagine a flood of traffic to your site which causes a 100x increase in the number of serverless function calls. Netlify (and behind the scenes, AWS) will happily spin up 100+ serverless Lambda instances to handle the traffic. The problem is that each one will open its own connection to your database, potentially exhausting the number of available connections. The proper solution is to put a connection pooling service in front of Postgres and connect to that from your lambda functions. To learn how to do that, see the [Connection Pooling](../../connection-pooling.md) guide. #### Security diff --git a/packages/cli/src/commands/__tests__/build.test.js b/packages/cli/src/commands/__tests__/build.test.js index 48ac5c96029c..d45702757870 100644 --- a/packages/cli/src/commands/__tests__/build.test.js +++ b/packages/cli/src/commands/__tests__/build.test.js @@ -7,6 +7,7 @@ jest.mock('@redwoodjs/internal/dist/paths', () => { }, web: { dist: '/mocked/project/web/dist', + routes: '/mocked/project/web/Routes.tsx', }, } }, @@ -26,6 +27,14 @@ jest.mock('listr', () => { }) }) +// Make sure prerender doesn't get triggered +jest.mock('execa', () => + jest.fn((cmd, params) => ({ + cmd, + params, + })) +) + import { handler } from '../build' afterEach(() => jest.clearAllMocks()) @@ -33,14 +42,14 @@ afterEach(() => jest.clearAllMocks()) test('the build tasks are in the correct sequence', async () => { await handler({}) expect(Listr.mock.calls[0][0].map((x) => x.title)).toMatchInlineSnapshot(` -Array [ - "Generating Prisma Client...", - "Verifying graphql schema...", - "Building API...", - "Cleaning Web...", - "Building Web...", -] -`) + Array [ + "Generating Prisma Client...", + "Verifying graphql schema...", + "Building API...", + "Cleaning Web...", + "Building Web...", + ] + `) }) jest.mock('@redwoodjs/prerender/detection', () => { @@ -48,16 +57,20 @@ jest.mock('@redwoodjs/prerender/detection', () => { }) test('Should run prerender for web', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) + await handler({ side: ['web'], prerender: true }) expect(Listr.mock.calls[0][0].map((x) => x.title)).toMatchInlineSnapshot(` Array [ "Cleaning Web...", "Building Web...", - "Prerendering Web...", ] `) - // Run prerendering task, but expect failure, + + // Run prerendering task, but expect warning, // because `detectPrerenderRoutes` is empty. - const x = await Listr.mock.calls[0][0][2].task() - expect(x.startsWith('You have not marked any "prerender" in your Routes')) + expect(consoleSpy.mock.calls[0][0]).toBe('Starting prerendering...') + expect(consoleSpy.mock.calls[1][0]).toBe( + 'You have not marked any routes to "prerender" in your Routes (​file:///mocked/project/web/Routes.tsx​).' + ) }) diff --git a/packages/cli/src/commands/buildHandler.js b/packages/cli/src/commands/buildHandler.js index d81279c2b6a5..a9cdf05c436e 100644 --- a/packages/cli/src/commands/buildHandler.js +++ b/packages/cli/src/commands/buildHandler.js @@ -114,27 +114,27 @@ export const handler = async ({ ) }, }, - side.includes('web') && - prerender && { - title: 'Prerendering Web...', - task: async () => { - if (prerenderRoutes.length === 0) { - return `You have not marked any "prerender" in your ${terminalLink( - 'Routes', - 'file://' + rwjsPaths.web.routes - )}.` - } - // Running a separate process here, otherwise it wouldn't pick up the - // generated Prisma Client - await execa('yarn rw prerender', { - stdio: verbose ? 'inherit' : 'pipe', - shell: true, - cwd: rwjsPaths.web.base, - }) - }, - }, ].filter(Boolean) + const triggerPrerender = async () => { + console.log('Starting prerendering...') + if (prerenderRoutes.length === 0) { + console.log( + `You have not marked any routes to "prerender" in your ${terminalLink( + 'Routes', + 'file://' + rwjsPaths.web.routes + )}.` + ) + } + // Running a separate process here, otherwise it wouldn't pick up the + // generated Prisma Client due to require module caching + await execa('yarn rw prerender', { + stdio: 'inherit', + shell: true, + cwd: rwjsPaths.web.base, + }) + } + const jobs = new Listr(tasks, { renderer: verbose && VerboseRenderer, }) @@ -142,6 +142,11 @@ export const handler = async ({ try { await timedTelemetry(process.argv, { type: 'build' }, async () => { await jobs.run() + + if (side.includes('web') && prerender) { + // This step is outside Listr so that it prints clearer, complete messages + await triggerPrerender() + } }) } catch (e) { console.log(c.error(e.message)) diff --git a/packages/cli/src/commands/prerenderHandler.js b/packages/cli/src/commands/prerenderHandler.js index 513bea57b35a..3f8985c75cdb 100644 --- a/packages/cli/src/commands/prerenderHandler.js +++ b/packages/cli/src/commands/prerenderHandler.js @@ -108,7 +108,8 @@ async function expandRouteParameters(route) { return { ...route, path: newPath } }) } - } catch { + } catch (e) { + console.error(c.error(e.stack)) return [route] } @@ -168,11 +169,10 @@ export const getTasks = async (dryrun, routerPathFilter = null) => { { title: `Prerendering ${routeToPrerender.path} -> ${outputHtmlPath}`, task: async () => { + // Check if route param templates in e.g. /path/{param1} have been replaced if (/\{.*}/.test(routeToPrerender.path)) { throw new PathParamError( - 'You did not provide values for all of the route ' + - 'parameters. Please supply parameters via a ' + - '*.routeHooks.{js,ts} file' + `Could not retrieve route parameters for ${routeToPrerender.path}` ) } @@ -311,6 +311,12 @@ export const handler = async ({ path: routerPath, dryRun, verbose }) => { '- Avoid using `window` in the initial render path through your React components without checks. \n See https://redwoodjs.com/docs/prerender#prerender-utils' ) ) + + console.log( + c.info( + '- Avoid prerendering Cells with authenticated queries, by conditionally rendering them.\n See https://redwoodjs.com/docs/prerender#common-warnings--errors' + ) + ) } console.log() diff --git a/packages/prerender/src/errors.tsx b/packages/prerender/src/errors.tsx new file mode 100644 index 000000000000..4f673aa1e0c1 --- /dev/null +++ b/packages/prerender/src/errors.tsx @@ -0,0 +1,25 @@ +export class PrerenderGqlError { + message: string + stack: string + + constructor(message: string) { + this.message = 'GQL error: ' + message + // The stacktrace would just point to this file, which isn't helpful, + // because that's not where the error is. So we're just putting the + // message there as well + this.stack = this.message + } +} + +export class GqlHandlerImportError { + message: string + stack: string + + constructor(message: string) { + this.message = 'Gql Handler Import Error: ' + message + // The stacktrace would just point to this file, which isn't helpful, + // because that's not where the error is. So we're just putting the + // message there as well + this.stack = this.message + } +} diff --git a/packages/prerender/src/graphql/graphql.ts b/packages/prerender/src/graphql/graphql.ts index 1eb66648954a..bbf8c81e173c 100644 --- a/packages/prerender/src/graphql/graphql.ts +++ b/packages/prerender/src/graphql/graphql.ts @@ -3,7 +3,17 @@ import { DocumentNode, print } from 'graphql' import { getPaths } from '@redwoodjs/internal/dist/paths' import { getOperationName } from '@redwoodjs/web' +import { GqlHandlerImportError } from '../errors' +/** + * Loads the graphql server, with all the user's settings + * And execute the query against it + * + * Note that this function does NOT throw errors, even when + * there is a GraphQL error. Instead, it returns the result with the graphql error. + * + * @returns {Promise} + */ export async function executeQuery( gqlHandler: (args: any) => Promise, query: DocumentNode, @@ -13,16 +23,30 @@ export async function executeQuery( const operation = { operationName, query: print(query), variables } const handlerResult = await gqlHandler(operation) - return handlerResult.body + return handlerResult?.body } +/** + * Finds the graphql handler, returns a function + * that can be used to execute queries against it + * + * Throws GqlHandlerImportError, so that we can warn the user (but not blow up) + */ export async function getGqlHandler() { const gqlPath = path.join(getPaths().api.functions, 'graphql') - const { handler } = await import(gqlPath) + try { + const { handler } = await import(gqlPath) - return async (operation: Record) => { - return await handler(buildApiEvent(operation), buildContext()) + return async (operation: Record) => { + return await handler(buildApiEvent(operation), buildContext()) + } + } catch (e) { + return () => { + throw new GqlHandlerImportError( + `Unable to import GraphQL handler at ${gqlPath}` + ) + } } } diff --git a/packages/prerender/src/runPrerender.tsx b/packages/prerender/src/runPrerender.tsx index 1f4f49a24211..43ea2d7b9982 100644 --- a/packages/prerender/src/runPrerender.tsx +++ b/packages/prerender/src/runPrerender.tsx @@ -10,59 +10,83 @@ import { registerApiSideBabelHook } from '@redwoodjs/internal/dist/build/babel/a import { registerWebSideBabelHook } from '@redwoodjs/internal/dist/build/babel/web' import { getPaths } from '@redwoodjs/internal/dist/paths' import { LocationProvider } from '@redwoodjs/router' -import { CellCacheContextProvider, QueryInfo } from '@redwoodjs/web' +import { + CellCacheContextProvider, + getOperationName, + QueryInfo, +} from '@redwoodjs/web' import mediaImportsPlugin from './babelPlugins/babel-plugin-redwood-prerender-media-imports' +import { GqlHandlerImportError, PrerenderGqlError } from './errors' import { executeQuery, getGqlHandler } from './graphql/graphql' import { getRootHtmlPath, registerShims, writeToDist } from './internal' -export class PrerenderGqlError { - message: string - stack: string - - constructor(message: string) { - this.message = 'GQL error: ' + message - // The stacktrace would just point to this file, which isn't helpful, - // because that's not where the error is. So we're just putting the - // message there as well - this.stack = this.message - } -} - async function recursivelyRender( App: React.ElementType, renderPath: string, gqlHandler: any, queryCache: Record ): Promise { + let shouldShowGraphqlHandlerNotFoundWarn = false // Execute all gql queries we haven't already fetched await Promise.all( Object.entries(queryCache).map(async ([cacheKey, value]) => { - if (value.hasFetched) { - // Already fetched this one; skip it! + if (value.hasProcessed) { + // Already fetched, or decided that we can't render this one; skip it! return Promise.resolve('') } - const resultString = await executeQuery( - gqlHandler, - value.query, - value.variables - ) - const result = JSON.parse(resultString) - - if (result.errors) { - const message = - result.errors[0].message ?? JSON.stringify(result.errors) - throw new PrerenderGqlError(message) - } - - queryCache[cacheKey] = { - ...value, - data: result.data, - hasFetched: true, + try { + const resultString = await executeQuery( + gqlHandler, + value.query, + value.variables + ) + + const result = JSON.parse(resultString) + + if (result.errors) { + const message = + result.errors[0].message ?? JSON.stringify(result.errors, null, 4) + + if (result.errors[0]?.extensions?.code === 'UNAUTHENTICATED') { + console.error( + `\n \n 🛑 Cannot prerender the query ${getOperationName( + value.query + )} as it requires auth. \n` + ) + } + + throw new PrerenderGqlError(message) + } + + queryCache[cacheKey] = { + ...value, + data: result.data, + hasProcessed: true, + } + + return result + } catch (e) { + if (e instanceof GqlHandlerImportError) { + // We need to need to swallow the error here, so that + // we can continue to render the page, with cells in loading state + // e.g. if the GQL handler is located elsewhere + shouldShowGraphqlHandlerNotFoundWarn = true + + queryCache[cacheKey] = { + ...value, + // tried to fetch, but failed + renderLoading: true, + hasProcessed: true, + } + + return + } else { + // Otherwise forward on the error + throw e + } } - - return result }) ) @@ -74,11 +98,17 @@ async function recursivelyRender( ) - if (Object.values(queryCache).some((value) => !value.hasFetched)) { + if (Object.values(queryCache).some((value) => !value.hasProcessed)) { // We found new queries that we haven't fetched yet. Execute all new // queries and render again return recursivelyRender(App, renderPath, gqlHandler, queryCache) } else { + if (shouldShowGraphqlHandlerNotFoundWarn) { + console.warn( + '\n ⚠️ Could not load your GraphQL handler. \n Your Cells have been prerendered in the "Loading" state. \n' + ) + } + return Promise.resolve(componentAsHtml) } } diff --git a/packages/web/src/components/CellCacheContext.tsx b/packages/web/src/components/CellCacheContext.tsx index 26bae6690f3c..9f9e3712face 100644 --- a/packages/web/src/components/CellCacheContext.tsx +++ b/packages/web/src/components/CellCacheContext.tsx @@ -5,7 +5,8 @@ import { DocumentNode } from 'graphql' export interface QueryInfo { query: DocumentNode variables?: Record - hasFetched: boolean + renderLoading?: boolean + hasProcessed: boolean data?: unknown } diff --git a/packages/web/src/components/createCell.tsx b/packages/web/src/components/createCell.tsx index da2c74537b29..ab5d0dbde8ef 100644 --- a/packages/web/src/components/createCell.tsx +++ b/packages/web/src/components/createCell.tsx @@ -306,17 +306,24 @@ export function createCell< const queryInfo = queryCache[cacheKey] - if (queryInfo?.hasFetched) { - loading = false - data = queryInfo.data - // All of the gql client's props aren't available when pre-rendering, - // so using `any` here - queryRest = { variables } as any + // This is true when the graphql handler couldn't be loaded + // So we fallback to the loading state + if (queryInfo?.renderLoading) { + loading = true } else { - queryCache[cacheKey] ||= { - query, - variables: options.variables, - hasFetched: false, + if (queryInfo?.hasProcessed) { + loading = false + data = queryInfo.data + + // All of the gql client's props aren't available when pre-rendering, + // so using `any` here + queryRest = { variables } as any + } else { + queryCache[cacheKey] ||= { + query, + variables: options.variables, + hasProcessed: false, + } } } }