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/tricky-schools-rescue.md b/.changeset/tricky-schools-rescue.md new file mode 100644 index 0000000000..bee947f4ce --- /dev/null +++ b/.changeset/tricky-schools-rescue.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +refactoring to use directly vite-plugin-watch-and-run 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..f5ef128e87 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/package.json b/integration/package.json index d63fe817c7..94d7b84574 100644 --- a/integration/package.json +++ b/integration/package.json @@ -17,7 +17,6 @@ "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." }, "devDependencies": { - "@kitql/vite-plugin-watch-and-run": "^0.4.0", "@playwright/test": "1.25.0", "@replayio/playwright": "0.2.23", "@sveltejs/adapter-auto": "1.0.0-next.66", 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/package.json b/package.json index b6b9e5f4d5..49283526ba 100755 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "@babel/parser": "^7.17.2", "@graphql-tools/schema": "^8.3.7", "@kitql/helper": "^0.3.4", - "@kitql/vite-plugin-watch-and-run": "^0.4.2", + "vite-plugin-watch-and-run": "^1.0.1", "@types/memory-fs": "^0.3.3", "babylon": "^7.0.0-beta.47", "commander": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8712526d39..11d899c853 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,6 @@ importers: '@changesets/cli': ^2.22.0 '@graphql-tools/schema': ^8.3.7 '@kitql/helper': ^0.3.4 - '@kitql/vite-plugin-watch-and-run': ^0.4.2 '@rollup/plugin-commonjs': ^22.0.0 '@rollup/plugin-json': ^4.1.0 '@rollup/plugin-node-resolve': ^13.2.0 @@ -73,12 +72,12 @@ importers: tslib: ^2.4.0 typescript: ^4.6.3 vite: ^3.0.7 + vite-plugin-watch-and-run: ^1.0.1 vitest: ^0.21.1 dependencies: '@babel/parser': 7.18.13 '@graphql-tools/schema': 8.5.1_graphql@15.5.0 '@kitql/helper': 0.3.5 - '@kitql/vite-plugin-watch-and-run': 0.4.2 '@types/memory-fs': 0.3.3 babylon: 7.0.0-beta.47 commander: 7.2.0 @@ -94,6 +93,7 @@ importers: remove: 0.1.5 svelte: 3.49.0 vite: 3.0.9 + vite-plugin-watch-and-run: 1.0.1 vitest: 0.21.1_@vitest+ui@0.21.1 devDependencies: '@babel/core': 7.18.13 @@ -186,7 +186,6 @@ importers: integration: specifiers: '@graphql-yoga/node': ^2.8.0 - '@kitql/vite-plugin-watch-and-run': ^0.4.0 '@playwright/test': 1.25.0 '@replayio/playwright': 0.2.23 '@sveltejs/adapter-auto': 1.0.0-next.66 @@ -221,7 +220,6 @@ importers: mdsvex: 0.10.6_svelte@3.49.0 ws: 8.8.1 devDependencies: - '@kitql/vite-plugin-watch-and-run': 0.4.2 '@playwright/test': 1.25.0 '@replayio/playwright': 0.2.23_@playwright+test@1.25.0 '@sveltejs/adapter-auto': 1.0.0-next.66 @@ -2910,6 +2908,7 @@ packages: dependencies: '@kitql/helper': 0.3.5 micromatch: 4.0.5 + dev: true /@manypkg/find-root/1.1.0: resolution: @@ -14098,6 +14097,16 @@ packages: vite: 3.0.9 dev: false + /vite-plugin-watch-and-run/1.0.1: + resolution: + { + integrity: sha512-7M3egaeipLiJgONfpzZs2cWf+u2zn99kGcYGHx6JvLVYVxbUwoQvQNhUt5VnZ4xCmohRww9YC/dI0wxWHvTEug==, + } + dependencies: + '@kitql/helper': 0.3.5 + micromatch: 4.0.5 + dev: false + /vite/3.0.8: resolution: { diff --git a/site/src/routes/api/config.svx b/site/src/routes/api/config.svx index 7ea71889e9..cef6724627 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 strings or a function that takes the current `process.env` and returns the the value to use. If you want to access an environment variable, prefix your string with `env:`, ie `env:API_KEY`. - `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 23ead492e5..734115a732 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) { @@ -678,9 +681,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/index.ts b/src/vite/index.ts index 14e82af406..94a2333ef2 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -1,14 +1,14 @@ import minimatch from 'minimatch' import path from 'path' import type { Plugin } from 'vite' +import watch_and_run from 'vite-plugin-watch-and-run' import generate from '../cmd/generate' -import { getConfig } from '../common' +import { formatErrors, getConfig } from '../common' import { ConfigFile } from '../runtime' import fs_patch from './fsPatch' import houdini from './plugin' import schema from './schema' -import watch_and_run from './watch-and-run' export default function ({ configPath, @@ -56,6 +56,7 @@ export default function ({ }, delay: 100, watchKind: ['add', 'change', 'unlink'], + formatErrors, }, ]), ] 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 { diff --git a/src/vite/watch-and-run.ts b/src/vite/watch-and-run.ts deleted file mode 100644 index b80c6737e7..0000000000 --- a/src/vite/watch-and-run.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { Log, logCyan, logGreen, logMagneta, logRed } from '@kitql/helper' -import { spawn } from 'child_process' -import micromatch from 'micromatch' -import { Plugin } from 'vite' - -import { formatErrors } from '../common' - -function getArraysIntersection(a1: readonly any[], a2: readonly any[]) { - return a1.filter((n) => { - return a2.includes(n) - }) -} - -export type Options = { - /** - * watch files to trigger the run action (glob format) - */ - watch?: string - - watchFile?: (filepath: string) => Promise - /** - * Kind of watch that will trigger the run action - */ - watchKind?: WatchKind[] - /** - * Don't print anything extra to the console when an event is trigger - */ - quiet?: boolean - /** - * run command (yarn gen for example!) - */ - run: string | (() => void | Promise) - /** - * Delay before running the run command (in ms) - * @default 300 ms - */ - delay?: number | null - /** - * Name to display in the logs as prefix - */ - name?: string | null -} - -export const kindWithPath = ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const -export type KindWithPath = typeof kindWithPath[number] -export const kindWithoutPath = ['all', 'error', 'raw', 'ready'] as const -export type KindWithoutPath = typeof kindWithoutPath[number] -export type WatchKind = KindWithPath | KindWithoutPath - -export type StateDetail = { - kind: WatchKind[] - quiet: boolean - run: string | (() => void | Promise) - delay: number - isRunning: boolean - watchFile?: (filepath: string) => boolean | Promise - watch?: string - name?: string | null -} - -async function checkConf(params: Options[]) { - if (!Array.isArray(params)) { - throw new Error('plugin watchAndRun, `params` needs to be an array.') - } - - const paramsChecked: StateDetail[] = [] - - for (const param of params) { - if (!param.watch && !param.watchFile) { - continue - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore (because the config is in a js file, and people maybe didn't update their config.) - if (['ADD', 'CHANGE', 'DELETE'].includes(param.watchKind || '')) { - throw new Error( - 'BREAKING: ADD, CHANGE, DELETE were renamed add, change, unlink. Please update your config.' - ) - } - - if (!param.watch && !param.watchFile) { - throw new Error('plugin watch-and-run, `watch` is missing.') - } - if (!param.run) { - throw new Error('plugin watch-and-run, `run` is missing.') - } - - // watch can be a function or a string - paramsChecked.push({ - kind: param.watchKind ?? ['add', 'change', 'unlink'], - run: param.run, - delay: param.delay ?? 300, - isRunning: false, - name: param.name, - quiet: !!param.quiet, - watch: param.watch, - watchFile: param.watchFile, - }) - } - - return paramsChecked -} - -async function shouldRun( - absolutePath: string | null, - watchKind: WatchKind, - watchAndRunConf: StateDetail[] -): Promise { - for (const info of watchAndRunConf) { - if (!absolutePath || (!info.watchFile && !info.watch)) { - continue - } - - const isWatched = info.kind.includes(watchKind) - let isPathMatching = false - - if (info.watchFile) { - isPathMatching = await info.watchFile(absolutePath) - } else { - isPathMatching = micromatch.isMatch(absolutePath, info.watch!) - } - - const isWatchKindWithoutPath = kindWithoutPath.includes(watchKind as KindWithoutPath) - if (!info.isRunning && isWatched && (isPathMatching || isWatchKindWithoutPath)) { - return info - } - } - return null -} - -function formatLog(str: string, name?: string) { - return `${name ? logMagneta(`[${name}]`) : ''} ${str}` -} - -async function watcher( - absolutePath: string | null, - watchKind: WatchKind, - watchAndRunConf: StateDetail[] -) { - const info = await shouldRun(absolutePath, watchKind, watchAndRunConf) - if (info) { - info.isRunning = true - - // print the message - if (!info.quiet) { - let message = `${logGreen('✔')} Watch ${logCyan(watchKind)}` - if (info.watch && absolutePath) { - message += logGreen(' ' + absolutePath) - } - if (typeof info.run === 'string') { - message + ` and run ${logGreen(info.run)} ` - } - message += logCyan(info.delay + 'ms') - - log.info(message) - } - - // Run after a delay - setTimeout(async () => { - // if the run value is a function, we just have to call it and we're done - if (typeof info.run === 'function') { - const promise = info.run() - try { - if (promise) { - await promise - } - } catch (e) { - formatErrors(e) - } - info.isRunning = false - return - } - - const child = spawn(info.run, [], { shell: true }) - - //spit stdout to screen - child.stdout.on('data', (data) => { - process.stdout.write(formatLog(data.toString(), info.name ?? '')) - }) - - //spit stderr to screen - child.stderr.on('data', (data) => { - process.stdout.write(formatLog(data.toString(), info.name ?? '')) - }) - - child.on('close', (code) => { - if (code === 0) { - log.info(`${logGreen('✔')} finished ${logGreen('successfully')}`) - } else { - log.error(`finished with some ${logRed('errors')}`) - } - info.isRunning = false - }) - - return - }, info.delay) - } - - return -} - -const log = new Log('KitQL Watch-And-Run') - -export default function watchAndRun(params: Options[]): Plugin { - return { - name: 'watch-and-run', - - async configureServer(server) { - // check params, throw Errors if not valid and return a new object representing the state of the plugin - const watchAndRunConf = await checkConf(params) - - kindWithPath.forEach((kind: KindWithPath) => { - const _watcher = async (absolutePath: string) => - watcher(absolutePath, kind, watchAndRunConf) - server.watcher.on(kind, _watcher) - }) - - kindWithoutPath.forEach((kind: KindWithoutPath) => { - const _watcher = () => watcher(null, kind, watchAndRunConf) - server.watcher.on(kind, _watcher) - }) - }, - } -}