From 5573cfa184d2da322030695044997f5fbf6542bd Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Sun, 28 Aug 2022 13:50:06 -0700 Subject: [PATCH] Add ways to customize error behavior (#494) * document getting started with vanilla svelte and vite * add config.quietQueryErrors * add onError hook * add test for onError * schema plugin shouldn't crash with failures * remove relative path hardcode * document +server support * changesets * update snapshot * tidy up PR template * add regex group * document quietQueryErrors --- .changeset/tricky-mangos-brake.md | 5 + .changeset/twenty-crabs-admire.md | 5 + .github/pull_request_template.md | 19 +- integration/src/lib/utils/routes.ts | 1 + .../routes/plugin/query/onError/+page.svelte | 8 + .../src/routes/plugin/query/onError/+page.ts | 16 ++ .../src/routes/plugin/query/onError/spec.ts | 11 ++ site/src/routes/api/config.svx | 1 + site/src/routes/api/routes.svx | 26 ++- site/src/routes/guides/faq.svx | 13 +- site/src/routes/guides/migrating-to-016.svx | 12 ++ .../routes/guides/setting-up-your-project.svx | 26 ++- src/cmd/generators/kit/index.ts | 18 +- src/cmd/generators/kit/kit.test.ts | 72 ++++++- .../generators/runtime/copyRuntime.test.ts | 16 +- src/cmd/validators/uniqueNames.ts | 4 +- src/common/config.ts | 11 +- src/runtime/lib/config.ts | 6 + src/runtime/lib/network.ts | 29 ++- src/runtime/stores/query.ts | 4 +- src/vite/plugin.ts | 1 - src/vite/schema.ts | 18 +- src/vite/transforms/kit.test.ts | 177 ++++++++++++++++-- src/vite/transforms/kit.ts | 150 ++++++++++----- 24 files changed, 537 insertions(+), 112 deletions(-) create mode 100644 .changeset/tricky-mangos-brake.md create mode 100644 .changeset/twenty-crabs-admire.md create mode 100644 integration/src/routes/plugin/query/onError/+page.svelte create mode 100644 integration/src/routes/plugin/query/onError/+page.ts create mode 100644 integration/src/routes/plugin/query/onError/spec.ts diff --git a/.changeset/tricky-mangos-brake.md b/.changeset/tricky-mangos-brake.md new file mode 100644 index 0000000000..adffa768e2 --- /dev/null +++ b/.changeset/tricky-mangos-brake.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +add onError hook diff --git a/.changeset/twenty-crabs-admire.md b/.changeset/twenty-crabs-admire.md new file mode 100644 index 0000000000..5d205ea920 --- /dev/null +++ b/.changeset/twenty-crabs-admire.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +add `quietQueryError` config value to supress all query errors diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5d80782e8f..de3f216483 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,10 @@ -## Will fix -- # +Fixes #TICKET + +### To help everyone out, please make sure your PR does the following: + +- [] Update the first line to point to the ticket that this PR fixes +- [] Add a message that clearly describes the fix +- [] If applicable, add a test that would fail without this fix +- [] Make sure the unit and integration tests pass locally with `pnpm run tests` and `cd integration && pnpm run tests` +- [] Includes a changeset if your fix affects the user with `pnpm changeset` -## Some highlights -- [ ] New tests are passing - - [ ] - - [ ] -- [ ] New settings are available - - [ ] - - [ ] -- [ ] The doc has been updated diff --git a/integration/src/lib/utils/routes.ts b/integration/src/lib/utils/routes.ts index 70caff5601..1b1b98723a 100644 --- a/integration/src/lib/utils/routes.ts +++ b/integration/src/lib/utils/routes.ts @@ -29,6 +29,7 @@ export const routes = { Plugin_query_component: '/plugin/query/component', Plugin_query_beforeLoad: '/plugin/query/beforeLoad', Plugin_query_afterLoad: '/plugin/query/afterLoad', + Plugin_query_onError: '/plugin/query/onError', Plugin_query_layout: '/plugin/query/layout', Plugin_mutation_mutation: '/plugin/mutation/mutation', diff --git a/integration/src/routes/plugin/query/onError/+page.svelte b/integration/src/routes/plugin/query/onError/+page.svelte new file mode 100644 index 0000000000..808eab2b8a --- /dev/null +++ b/integration/src/routes/plugin/query/onError/+page.svelte @@ -0,0 +1,8 @@ + + +
+ {data.fancyMessage} +
diff --git a/integration/src/routes/plugin/query/onError/+page.ts b/integration/src/routes/plugin/query/onError/+page.ts new file mode 100644 index 0000000000..4ce4ca7a0b --- /dev/null +++ b/integration/src/routes/plugin/query/onError/+page.ts @@ -0,0 +1,16 @@ +import { graphql } from '$houdini'; +import type { OnErrorEvent } from './$houdini'; + +export const houdini_load = graphql` + query PreprocessorOnErrorTestQuery { + user(id: "1000", snapshot: "preprocess-on-error-test-simple") { + name + } + } +`; + +export const onError = ({ error, input }: OnErrorEvent) => { + return { + fancyMessage: 'hello' + }; +}; diff --git a/integration/src/routes/plugin/query/onError/spec.ts b/integration/src/routes/plugin/query/onError/spec.ts new file mode 100644 index 0000000000..ee7967664c --- /dev/null +++ b/integration/src/routes/plugin/query/onError/spec.ts @@ -0,0 +1,11 @@ +import { test } from '@playwright/test'; +import { routes } from '../../../../lib/utils/routes.js'; +import { expectToBe, goto } from '../../../../lib/utils/testsHelper.js'; + +test.describe('query preprocessor', () => { + test('onError hook', async ({ page }) => { + await goto(page, routes.Plugin_query_onError); + + await expectToBe(page, 'hello'); + }); +}); diff --git a/site/src/routes/api/config.svx b/site/src/routes/api/config.svx index 4d9d29506c..667956ba4d 100644 --- a/site/src/routes/api/config.svx +++ b/site/src/routes/api/config.svx @@ -32,6 +32,7 @@ It can contain the following values: - `schemaPollHeaders` (optional): An object specifying the headers to use when pulling your schema. Keys of the object are header names and its values can be either a string pointing to the environment variable to use as the header value or a function that takes the current `process.env` and returns the the value to use. - `schemaPollInterval` (optional, default: `2000`): Configures the schema polling behavior for the kit plugin. If its value is greater than `0`, the plugin will poll the set number of milliseconds. If set to `0`, the plugin will only pull the schema when you first run `dev`. If you set to `null`, the plugin will never look for schema changes. You can see use the [pull-schema command] to get updates. - `globalStorePrefix` (optional, default: `GQL_`): The default prefix of your global stores. This lets your editor provide autocompletion with just a few characters. +- `quietQueryErrors` (optional, default: `false`): With this enabled, errors in your query will not be thrown as exceptions. You will have to handle error state in your route components or by hand in your load (or the onError hook) ## Custom Scalars diff --git a/site/src/routes/api/routes.svx b/site/src/routes/api/routes.svx index 62eb256f72..0d02e8a093 100644 --- a/site/src/routes/api/routes.svx +++ b/site/src/routes/api/routes.svx @@ -111,7 +111,7 @@ A query store holds an object with the following fields that accessed like `$sto - `data` contains the result of the query. It's value will update as mutations, subscriptions, and other queries provide more recent information. - `loading` contains the loading state (`true` or `false`) for a query found outside of a route component (ie, not defined in `src/routes`) -- `errors` contains any error values that occur for a query found outside of a route component (ie, not defined in `src/routes`) +- `errors` contains any error values that occur for a query found outside of a route component (ie, not defined in `src/routes`). If you want to use this for managing your errors, you should enable the [quietQueryError](/api/config) configuration option. - `partial` contains a boolean that indicates if the result has a partial match ### Methods @@ -239,6 +239,28 @@ export function afterLoad({ data }) { } ``` +#### `onError` + +If defined, the load function will invoked this function instead of throwing an error when an error is received. +It receives three inputs: the load event, the inputs for each query, and the error that was encountered. Just like +the other hooks, `onError` can return an object that provides props to the route. + +```javascript +// src/routes/myRoute/+page.js + +export const houdini_load = graphql` + query MyProfile { + profile { + name + } + } +` + +export function onError({ error }) { + throw this.redirect(307, '/login') +} +``` + ### Disabling Inline Query Loading Sometimes it's useful to have a lazy query that only fires when you call `fetch` (for @@ -298,7 +320,7 @@ export async function load(event) { } ``` - + Be careful when loading multiple stores at once. diff --git a/site/src/routes/guides/faq.svx b/site/src/routes/guides/faq.svx index 20c557d791..9daf4e9344 100644 --- a/site/src/routes/guides/faq.svx +++ b/site/src/routes/guides/faq.svx @@ -17,13 +17,18 @@ Yep! You can use queries or any document anywhere you can use a svelte store. Ju Yes! You'll just have to rely on the store apis for your documents and write your route's loads manually. For more information on using your document's stores check out [Working with GraphQL](/guides/working-with-graphql) guide. +### Are `+server` files supported? + +Unforunately, `+server` have additional constraints that make them a unique challenge to support (not impossible, just tricky). This is also an area of SvelteKit that is +rapidly changing and so we are opting to not officially support `+server` files until things stabilize a bit more. Sorry for the inconvinience! + ### What's the best way to build a Full-Stack application with SvelteKit? Simple answer, we recommend [KitQL](https://www.kitql.dev/). It gives you everything you need when building a full-stack application with GraphQL (including [Houdini](https://www.houdinigraphql.com/) of course 😉). For more information about our collaboration, head over to this [blog post](https://www.the-guild.dev/blog/houdini-and-kitql). ### How does the plugin generate loads? -Curious how this works under the hook? Consider this query: +Consider this query: ```svelte @@ -83,6 +88,12 @@ If you haven't seen Houdini's document stores before, please check out the [Work You should use the `metadata` parameter in the document store to pass arbitrary information into your client's network function. For more information, please visit [the query store docs](/api/query/store#passing-metadata). +### What is this `Generated an empty chunk...` warning from vite? + +If you have a route directory that does not contain a `+page.js` file and do not have an inline query in your `+page.svelte`, the +plugin will generate an empty `+page.js` file that is never used. This confuses vite hence the warning. Don't worry - this empty +file is never imported if you don't have an inline query so there's no performance implication. + ### My IDE is complaining that the internal directives and fragments don't exist. Every plugin and editor is different so we can't give you an exact answer but Houdini will write a file inside of the `$houdini` directory that contains all of the custom definitions that it relies on. Be default this file is located at `$houdini/graphql/schema.graphql` and `$houdini/graphql/documents.gql`. You can configure this value in your [config file](/api/config) under the `definitionsPath` value. diff --git a/site/src/routes/guides/migrating-to-016.svx b/site/src/routes/guides/migrating-to-016.svx index c0257b1848..3cb0cf15df 100644 --- a/site/src/routes/guides/migrating-to-016.svx +++ b/site/src/routes/guides/migrating-to-016.svx @@ -154,6 +154,18 @@ the store itself and do not need the `$`: ``` +`afterLoad` hooks also now take the whole load event as a single `event` parameter: + +```diff +- export async function afterLoad({ params, data }) { ++ export async function afterLoad({ event, data }) { + return { +- message: params.message ++ message: event.params.message + } + } +``` + ### 2.b Inline Fragments The order for the arguments to inline fragments has been inverted. diff --git a/site/src/routes/guides/setting-up-your-project.svx b/site/src/routes/guides/setting-up-your-project.svx index b15476766b..303099aca0 100644 --- a/site/src/routes/guides/setting-up-your-project.svx +++ b/site/src/routes/guides/setting-up-your-project.svx @@ -93,8 +93,26 @@ this, add the following block of code to `src/routes/+layout.svelte` and any nam ### Svelte -While your exact situation might change, somehow you should import houdini's preprocessor and pass it to your -svelte config +If you are building a vanilla svelte project, you will have to configure the compiler and preprocessor to generate the correct logic by setting the `framework` +field in your config file to `"svelte"`. + +If you are using vite, you should use the `houdini/vite` plugin even if +you aren't using kit. Update your `vite.config.js` file to look like this: + +```javascript +// vite.config.js + +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import houdini from 'houdini/vite' + +export default defineConfig({ + plugins: [houdini(), svelte()] +}) +``` + +If you aren't using vite, it's a lot harder to give an exact recommendation but somehow +you should import houdini's preprocessor and pass it to your svelte config ```javascript // svelte.config.js @@ -105,10 +123,6 @@ export default { } ``` -You also have to configure the compiler and preprocessor to generate the correct logic by setting the `framework` -field in your config file to `"svelte"`. If you are using vite, you should use the `houdini/vite` plugin even if -you aren't using kit. - Please keep in mind that returning the response from a query, you should not rely on `this.redirect` to handle the redirect as it will update your browsers `location` attribute, causing a hard transition to that url. Instead, you should use `this.error` to return an error and handle the redirect in a way that's appropriate for your application. diff --git a/src/cmd/generators/kit/index.ts b/src/cmd/generators/kit/index.ts index c75a14e973..d3fec10ae8 100644 --- a/src/cmd/generators/kit/index.ts +++ b/src/cmd/generators/kit/index.ts @@ -59,6 +59,7 @@ export default async function svelteKitGenerator(config: Config, docs: Collected const afterLoad = scriptExports.includes('afterLoad') const beforeLoad = scriptExports.includes('beforeLoad') + const onError = scriptExports.includes('onError') // we need to create a typescript file that has a definition of the variable and hook functions const typeDefs = `import type * as Kit from '@sveltejs/kit'; @@ -106,7 +107,7 @@ type AfterLoadData = { .join(', \n')} } -type AfterLoadInput = { +type LoadInput = { ${queries .filter((query) => query.variableDefinitions?.length) .map((query) => { @@ -122,7 +123,7 @@ type AfterLoadInput = { export type AfterLoadEvent = { event: PageLoadEvent data: AfterLoadData - input: AfterLoadInput + input: LoadInput } ` : '' @@ -138,7 +139,16 @@ type BeforeLoadReturn = ReturnType; ` : '' } +${ + onError + ? ` + +export type OnErrorEvent = { event: LoadEvent, input: LoadInput, error: Error | Error[] } +type OnErrorReturn = ReturnType; +` + : '' +} export type PageData = { ${queries @@ -148,7 +158,9 @@ export type PageData = { return [name, name + config.storeSuffix].join(': ') }) .join(', \n')} -} ${afterLoad ? '& AfterLoadReturn ' : ''} ${beforeLoad ? '& BeforeLoadReturn ' : ''} +} ${afterLoad ? '& AfterLoadReturn ' : ''} ${beforeLoad ? '& BeforeLoadReturn ' : ''} ${ + onError ? '& OnErrorReturn ' : '' + } ` diff --git a/src/cmd/generators/kit/kit.test.ts b/src/cmd/generators/kit/kit.test.ts index 8777d6550c..a5421ca75a 100644 --- a/src/cmd/generators/kit/kit.test.ts +++ b/src/cmd/generators/kit/kit.test.ts @@ -148,14 +148,14 @@ test('generates types for after load', async function () { MyPageLoad2Query: MyPageLoad2Query$result }; - type AfterLoadInput = { + type LoadInput = { MyPageLoad1Query: MyPageLoad1Query$input }; export type AfterLoadEvent = { event: PageLoadEvent, data: AfterLoadData, - input: AfterLoadInput + input: LoadInput }; export type PageData = { @@ -164,3 +164,71 @@ test('generates types for after load', async function () { } & AfterLoadReturn; `) }) + +test('generates types for onError', async function () { + // create the mock filesystem + await fs.mock({ + [config.routesDir]: { + myProfile: { + '+page.js': ` + import { graphql } from '$houdini' + + const store1 = graphql\`query MyPageLoad1Query($id: ID!) { + viewer(id: $id) { + id + } + }\` + + const store2 = graphql\`query MyPageLoad2Query { + viewer { + id + } + }\` + + export const houdini_load = [ store1, store2 ] + + export function onError() { + return { + hello: 'world' + } + } + `, + }, + }, + }) + + // execute the generator + await runPipeline(config, []) + + // load the contents of the file + const queryContents = await fs.readFile( + path.join(config.typeRouteDir, 'myProfile', '$houdini.d.ts') + ) + expect(queryContents).toBeTruthy() + + // verify contents + expect((await parseJS(queryContents!))?.script).toMatchInlineSnapshot(` + import type * as Kit from "@sveltejs/kit"; + import type { VariableFunction, AfterLoadFunction, BeforeLoadFunction } from "../../../../runtime/lib/types"; + import type { PageLoadEvent, PageData as KitPageData } from "./$types"; + import { MyPageLoad1Query$result, MyPageLoad1Query$input } from "../../../../artifacts/MyPageLoad1Query"; + import { MyPageLoad1QueryStore } from "../../../../stores/MyPageLoad1Query"; + import { MyPageLoad2Query$result, MyPageLoad2Query$input } from "../../../../artifacts/MyPageLoad2Query"; + import { MyPageLoad2QueryStore } from "../../../../stores/MyPageLoad2Query"; + type Params = PageLoadEvent["params"]; + export type MyPageLoad1QueryVariables = VariableFunction; + + export type OnErrorEvent = { + event: LoadEvent, + input: LoadInput, + error: Error | Error[] + }; + + type OnErrorReturn = ReturnType; + + export type PageData = { + MyPageLoad1Query: MyPageLoad1QueryStore, + MyPageLoad2Query: MyPageLoad2QueryStore + } & OnErrorReturn; + `) +}) diff --git a/src/cmd/generators/runtime/copyRuntime.test.ts b/src/cmd/generators/runtime/copyRuntime.test.ts index 50f480e030..79fb9d95c0 100644 --- a/src/cmd/generators/runtime/copyRuntime.test.ts +++ b/src/cmd/generators/runtime/copyRuntime.test.ts @@ -243,24 +243,32 @@ test('updates the network file with the client path', async function () { return this.error(500, 'Encountered invalid response: ' + JSON.stringify(payload)); } // This hook fires before executing any queries, it allows custom props to be passed to the component. - async invokeLoadHook({ variant, hookFn, input, data, }) { + async invokeLoadHook({ variant, hookFn, input, data, error, }) { // call the onLoad function to match the framework let hookCall; if (variant === 'before') { hookCall = hookFn.call(this, this.loadEvent); } - else { + else if (variant === 'after') { // we have to assign input and data onto load so that we don't read values that // are deprecated - Object.assign(this.loadEvent, { + hookCall = hookFn.call(this, { + event: this.loadEvent, input, data: Object.fromEntries(Object.entries(data).map(([key, store]) => [ key, get(store).data, ])), }); - hookCall = hookFn.call(this, this.loadEvent); } + else if (variant === 'error') { + hookCall = hookFn.call(this, { + event: this.loadEvent, + input, + error, + }); + } + // make sure any promises are resolved let result = await hookCall; // If the returnValue is already set through this.error or this.redirect return early if (!this.continue) { diff --git a/src/cmd/validators/uniqueNames.ts b/src/cmd/validators/uniqueNames.ts index 2f5407c337..6ea12b6fbf 100644 --- a/src/cmd/validators/uniqueNames.ts +++ b/src/cmd/validators/uniqueNames.ts @@ -21,8 +21,8 @@ export default async function uniqueDocumentNames( .map( ([docName, fileNames]) => new HoudiniError({ - message: `Operation names must be unique. Encountered duplicate definitions of ${docName} in these files:`, - description: fileNames.join(', '), + message: fileNames.join(', '), + description: `Operation names must be unique. Encountered duplicate definitions of ${docName} in these files:`, }) ) diff --git a/src/common/config.ts b/src/common/config.ts index b2906f20a8..26bf7ef440 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -52,6 +52,7 @@ export class Config { plugin: boolean = false client: string globalStorePrefix: string + quietQueryErrors: boolean constructor({ filepath, @@ -85,6 +86,7 @@ export class Config { projectDir, client, globalStorePrefix = 'GQL_', + quietQueryErrors, } = this.configFile if (!client) { @@ -152,6 +154,7 @@ ${ this.pageQueryFilename = pageQueryFilename this.client = client this.globalStorePrefix = globalStorePrefix + this.quietQueryErrors = quietQueryErrors || false // hold onto the key config if (defaultKeys) { @@ -650,9 +653,11 @@ ${ } resolveRelative(filename: string) { - const relativeMath = filename.match('^(../)+src/routes') - if (filename.startsWith('../../../src/routes')) { - filename = path.join(this.projectRoot, filename.substring('../../../'.length)) + // kit generates relative import for our generated files. we need to fix that so that + // vites importer can find the file. + const match = filename.match('^((../)+)src/routes') + if (match) { + filename = path.join(this.projectRoot, filename.substring(match[1].length)) } return filename diff --git a/src/runtime/lib/config.ts b/src/runtime/lib/config.ts index 27152c5164..18651528d5 100644 --- a/src/runtime/lib/config.ts +++ b/src/runtime/lib/config.ts @@ -155,6 +155,12 @@ export type ConfigFile = { * @default GQL_ */ globalStorePrefix?: string + + /** + * With this enabled, errors in your query will not be thrown as exceptions. You will have to handle + * error state in your route components or by hand in your load (or the onError hook) + */ + quietQueryErrors?: boolean } export type TypeConfig = { diff --git a/src/runtime/lib/network.ts b/src/runtime/lib/network.ts index 7982f046c0..0492df44bb 100644 --- a/src/runtime/lib/network.ts +++ b/src/runtime/lib/network.ts @@ -98,10 +98,15 @@ export type FetchContext = { } export type BeforeLoadArgs = LoadEvent -export type AfterLoadArgs = LoadEvent & { +export type AfterLoadArgs = { + event: LoadEvent input: Record data: Record } +export type OnErrorArgs = { + event: LoadEvent + input: Record +} export type KitLoadResponse = { status?: number @@ -322,20 +327,23 @@ export class RequestContext { hookFn, input, data, + error, }: { - variant: 'before' | 'after' - hookFn: KitBeforeLoad | KitAfterLoad + variant: 'before' | 'after' | 'error' + hookFn: KitBeforeLoad | KitAfterLoad | KitOnError input: Record data: Record + error: unknown }) { // call the onLoad function to match the framework let hookCall if (variant === 'before') { hookCall = (hookFn as KitBeforeLoad).call(this, this.loadEvent as BeforeLoadArgs) - } else { + } else if (variant === 'after') { // we have to assign input and data onto load so that we don't read values that // are deprecated - Object.assign(this.loadEvent, { + hookCall = (hookFn as KitAfterLoad).call(this, { + event: this.loadEvent, input, data: Object.fromEntries( Object.entries(data).map(([key, store]) => [ @@ -343,10 +351,16 @@ export class RequestContext { get>(store).data, ]) ), - }) - hookCall = (hookFn as KitAfterLoad).call(this, this.loadEvent as AfterLoadArgs) + } as AfterLoadArgs) + } else if (variant === 'error') { + hookCall = (hookFn as KitOnError).call(this, { + event: this.loadEvent, + input, + error, + } as OnErrorArgs) } + // make sure any promises are resolved let result = await hookCall // If the returnValue is already set through this.error or this.redirect return early @@ -378,3 +392,4 @@ export class RequestContext { type KitBeforeLoad = (ctx: BeforeLoadArgs) => Record | Promise> type KitAfterLoad = (ctx: AfterLoadArgs) => Record +type KitOnError = (ctx: OnErrorArgs) => Record diff --git a/src/runtime/stores/query.ts b/src/runtime/stores/query.ts index aeab33160f..61ad766a5c 100644 --- a/src/runtime/stores/query.ts +++ b/src/runtime/stores/query.ts @@ -249,7 +249,9 @@ If this is leftovers from old versions of houdini, you can safely remove this \` })) // don't go any further - throw error(500, result.errors.map((error) => error.message).join('. ') + '.') + if (!config.quietQueryErrors) { + throw error(500, result.errors.map((error) => error.message).join('. ') + '.') + } } else { store.set({ data: (unmarshaled || {}) as _Data, diff --git a/src/vite/plugin.ts b/src/vite/plugin.ts index 4e570b826f..b47bdee23d 100644 --- a/src/vite/plugin.ts +++ b/src/vite/plugin.ts @@ -35,7 +35,6 @@ export default function HoudiniPlugin(configFile?: string): Plugin { await generate(config) } catch (e) { formatErrors(e) - throw new Error('see above') } }, diff --git a/src/vite/schema.ts b/src/vite/schema.ts index 9b7b899ef9..6f563bc5dd 100644 --- a/src/vite/schema.ts +++ b/src/vite/schema.ts @@ -4,7 +4,7 @@ import path from 'path' import { Plugin } from 'vite' import { pullSchema } from '../cmd/utils' -import { getConfig } from '../common' +import { formatErrors, getConfig } from '../common' export default function HoudiniWatchSchemaPlugin(configFile?: string): Plugin { let go = true @@ -35,12 +35,16 @@ export default function HoudiniWatchSchemaPlugin(configFile?: string): Plugin { } // the function to call on the appropriate interval async function pull(poll: boolean) { - // Write the schema - await pullSchema( - config.apiUrl!, - config.schemaPath ?? path.resolve(process.cwd(), 'schema.json'), - config.pullHeaders - ) + try { + // Write the schema + await pullSchema( + config.apiUrl!, + config.schemaPath ?? path.resolve(process.cwd(), 'schema.json'), + config.pullHeaders + ) + } catch (e) { + formatErrors(e) + } // if we are supposed to poll, wait the appropriate amount of time and then do it again if (poll) { diff --git a/src/vite/transforms/kit.test.ts b/src/vite/transforms/kit.test.ts index f57eebded2..8f5f4aeafd 100644 --- a/src/vite/transforms/kit.test.ts +++ b/src/vite/transforms/kit.test.ts @@ -68,7 +68,13 @@ describe('kit route processor', function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -191,7 +197,13 @@ describe('kit route processor', function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -254,7 +266,13 @@ describe('kit route processor', function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -398,7 +416,13 @@ describe('kit route processor', function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -442,7 +466,13 @@ describe('kit route processor', function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -521,7 +551,13 @@ test('beforeLoad hook', async function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -609,7 +645,13 @@ test('beforeLoad hook - multiple queries', async function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -679,7 +721,13 @@ test('afterLoad hook', async function () { "blocking": true })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } await houdini_context.invokeLoadHook({ "variant": "after", @@ -768,7 +816,13 @@ test('afterLoad hook - multiple queries', async function () { "blocking": true })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } await houdini_context.invokeLoadHook({ "variant": "after", @@ -859,7 +913,13 @@ test('both beforeLoad and afterLoad hooks', async function () { "blocking": true })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } await houdini_context.invokeLoadHook({ "variant": "after", @@ -936,7 +996,13 @@ test('layout loads', async function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } return { ...houdini_context.returnValue, @@ -989,7 +1055,94 @@ test('layout inline query', async function () { "blocking": false })); - const result = Object.assign({}, ...(await Promise.all(promises))); + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + throw err; + } + + return { + ...houdini_context.returnValue, + ...result + }; + } + `) +}) + +test('onError hook', async function () { + const route = await route_test({ + script: ` + export async function onError(){ + return this.redirect(302, "/test") + } + + export function TestQueryVariables(page) { + return { + test: true + } + } + `, + component: ` + + `, + }) + + expect(route.script).toMatchInlineSnapshot(` + import { load_TestQuery } from "$houdini/stores/TestQuery"; + import { getCurrentConfig } from "$houdini/runtime/lib/config"; + import { RequestContext } from "$houdini/runtime/lib/network"; + import GQL_TestQuery from "$houdini/stores/TestQuery"; + + export async function onError() { + return this.redirect(302, "/test"); + } + + export function TestQueryVariables(page) { + return { + test: true + }; + } + + export async function load(context) { + const houdini_context = new RequestContext(context); + const houdiniConfig = await getCurrentConfig(); + const promises = []; + const inputs = {}; + + inputs["TestQuery"] = await houdini_context.computeInput({ + "config": houdiniConfig, + "variableFunction": TestQueryVariables, + "artifact": GQL_TestQuery.artifact + }); + + promises.push(load_TestQuery({ + "variables": inputs["TestQuery"], + "event": context, + "blocking": false + })); + + let result = {}; + + try { + result = Object.assign({}, ...(await Promise.all(promises))); + } catch (err) { + await houdini_context.invokeLoadHook({ + "variant": "error", + "hookFn": onError, + "error": err, + "input": inputs + }); + } return { ...houdini_context.returnValue, diff --git a/src/vite/transforms/kit.ts b/src/vite/transforms/kit.ts index 6062e8c43d..817552925d 100644 --- a/src/vite/transforms/kit.ts +++ b/src/vite/transforms/kit.ts @@ -172,6 +172,7 @@ function add_load({ // look for any hooks let before_load = page_info.exports.includes('beforeLoad') let after_load = page_info.exports.includes('afterLoad') + let on_error = page_info.exports.includes('onError') // some local variables const request_context = AST.identifier('houdini_context') @@ -214,8 +215,8 @@ function add_load({ AST.variableDeclarator(input_obj, AST.objectExpression([])), ]), - // regardless of what happens between the contenxt instantiation and return, - // all we have to do is mix the return value with the props we want to send one + // regardless of what happens between the context instantiation and return, + // all we have to do is mix the return value with the props we want to send on AST.returnStatement( AST.objectExpression([ AST.spreadElement(return_value), @@ -312,39 +313,88 @@ function add_load({ ) } + // the only thing that's left is to merge the list of load promises into a single + // object using something like Promise.all. We might need to do some custom wrapping + // of the error. + let args = [request_context, input_obj, result_obj] as const + preload_fn.body.body.splice( insert_index++, 0, - AST.variableDeclaration('const', [ - AST.variableDeclarator( - result_obj, - AST.callExpression( - AST.memberExpression(AST.identifier('Object'), AST.identifier('assign')), - [ - AST.objectExpression([]), - AST.spreadElement( - AST.awaitExpression( - AST.callExpression( - AST.memberExpression( - AST.identifier('Promise'), - AST.identifier('all') - ), - [promise_list] + AST.variableDeclaration('let', [ + AST.variableDeclarator(result_obj, AST.objectExpression([])), + ]), + AST.tryStatement( + AST.blockStatement([ + AST.expressionStatement( + AST.assignmentExpression( + '=', + result_obj, + AST.callExpression( + AST.memberExpression( + AST.identifier('Object'), + AST.identifier('assign') + ), + [ + AST.objectExpression([]), + AST.spreadElement( + AST.awaitExpression( + AST.callExpression( + AST.memberExpression( + AST.identifier('Promise'), + AST.identifier('all') + ), + [promise_list] + ) + ) + ), + ] + ) + ) + ), + ]), + AST.catchClause( + AST.identifier('err'), + null, + AST.blockStatement([ + on_error + ? AST.expressionStatement( + AST.awaitExpression( + AST.callExpression( + AST.memberExpression( + request_context, + AST.identifier('invokeLoadHook') + ), + [ + AST.objectExpression([ + AST.objectProperty( + AST.literal('variant'), + AST.stringLiteral('error') + ), + AST.objectProperty( + AST.literal('hookFn'), + AST.identifier('onError') + ), + AST.objectProperty( + AST.literal('error'), + AST.identifier('err') + ), + AST.objectProperty(AST.literal('input'), input_obj), + ]), + ] + ) ) - ) - ), - ] - ) - ), - ]) + ) + : AST.throwStatement(AST.identifier('err')), + ]) + ) + ) ) - let args = [request_context, input_obj, result_obj] as const - // add calls to user before/after load functions if (before_load) { if (before_load) { - preload_fn.body.body.splice(1, 0, ...load_hook_statements('beforeLoad', ...args)) + preload_fn.body.body.splice(1, 0, load_hook_statements('beforeLoad', ...args)) } } @@ -352,7 +402,7 @@ function add_load({ preload_fn.body.body.splice( preload_fn.body.body.length - 1, 0, - ...load_hook_statements('afterLoad', ...args) + load_hook_statements('afterLoad', ...args) ) } } @@ -400,31 +450,29 @@ function load_hook_statements( input_id: IdentifierKind, result_id: IdentifierKind ) { - return [ - AST.expressionStatement( - AST.awaitExpression( - AST.callExpression( - AST.memberExpression(request_context, AST.identifier('invokeLoadHook')), - [ - AST.objectExpression([ - AST.objectProperty( - AST.literal('variant'), - AST.stringLiteral(name === 'afterLoad' ? 'after' : 'before') - ), - AST.objectProperty(AST.literal('hookFn'), AST.identifier(name)), - // after load: pass query data to the hook - ...(name === 'afterLoad' - ? [ - AST.objectProperty(AST.literal('input'), input_id), - AST.objectProperty(AST.literal('data'), result_id), - ] - : []), - ]), - ] - ) + return AST.expressionStatement( + AST.awaitExpression( + AST.callExpression( + AST.memberExpression(request_context, AST.identifier('invokeLoadHook')), + [ + AST.objectExpression([ + AST.objectProperty( + AST.literal('variant'), + AST.stringLiteral(name === 'afterLoad' ? 'after' : 'before') + ), + AST.objectProperty(AST.literal('hookFn'), AST.identifier(name)), + // after load: pass query data to the hook + ...(name === 'afterLoad' + ? [ + AST.objectProperty(AST.literal('input'), input_id), + AST.objectProperty(AST.literal('data'), result_id), + ] + : []), + ]), + ] ) - ), - ] + ) + ) } async function find_page_info(page: TransformPage): Promise {