From d69d172560c05947a44c548741cc29d6a22e0416 Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Mon, 11 Mar 2024 00:27:42 -0700 Subject: [PATCH] Add useRoute hook for typesafe route params (#1284) --- .changeset/poor-stingrays-cough.md | 5 + .../src/routes/route_params/[id]/+page.tsx | 8 +- e2e/react/src/routes/route_params/test.ts | 4 +- .../codegen/entries/documentWrappers.ts | 22 ++- .../plugin/codegen/entries/entries.test.ts | 84 ++++++-- .../src/plugin/codegen/router.test.ts | 32 +-- .../src/plugin/codegen/typeRoot.test.ts | 60 +++++- .../src/plugin/codegen/typeRoot.ts | 78 ++++++-- packages/houdini-react/src/runtime/index.tsx | 2 +- .../src/runtime/routing/Router.tsx | 38 +++- .../typescript/addReferencedInputTypes.ts | 3 +- .../generators/typescript/documentTypes.ts | 3 +- .../typescript/imperativeTypeDef.ts | 3 +- .../generators/typescript/inlineType.ts | 8 +- .../generators/typescript/loadingState.ts | 2 +- .../generators/typescript/typeReference.ts | 71 ------- packages/houdini/src/lib/graphql.ts | 10 +- packages/houdini/src/lib/index.ts | 1 + .../houdini/src/lib/router/manifest.test.ts | 185 +++++++++++++----- packages/houdini/src/lib/router/manifest.ts | 42 +++- .../typescript/types.ts => lib/typescript.ts} | 87 +++++++- packages/houdini/src/runtime/lib/types.ts | 9 +- packages/houdini/src/runtime/router/types.ts | 2 +- 23 files changed, 550 insertions(+), 209 deletions(-) create mode 100644 .changeset/poor-stingrays-cough.md delete mode 100644 packages/houdini/src/codegen/generators/typescript/typeReference.ts rename packages/houdini/src/{codegen/generators/typescript/types.ts => lib/typescript.ts} (64%) diff --git a/.changeset/poor-stingrays-cough.md b/.changeset/poor-stingrays-cough.md new file mode 100644 index 000000000..7521ebf0f --- /dev/null +++ b/.changeset/poor-stingrays-cough.md @@ -0,0 +1,5 @@ +--- +'houdini-react': patch +--- + +Add useRoute prop for typesafe route parameters diff --git a/e2e/react/src/routes/route_params/[id]/+page.tsx b/e2e/react/src/routes/route_params/[id]/+page.tsx index 5232415c0..528b326e7 100644 --- a/e2e/react/src/routes/route_params/[id]/+page.tsx +++ b/e2e/react/src/routes/route_params/[id]/+page.tsx @@ -1,10 +1,16 @@ +import { useRoute } from '$houdini' + import type { PageProps } from './$types' export default function ({ RouteParamsUserInfo }: PageProps) { + const route = useRoute() + const { user } = RouteParamsUserInfo return (
-
{user.name}
+
+ {route.params.id}: {user.name} +
) } diff --git a/e2e/react/src/routes/route_params/test.ts b/e2e/react/src/routes/route_params/test.ts index 6ec32e069..828a744a8 100644 --- a/e2e/react/src/routes/route_params/test.ts +++ b/e2e/react/src/routes/route_params/test.ts @@ -7,7 +7,7 @@ test('Component fields with correct argument value', async ({ page }) => { await goto(page, routes.route_params) // be default we see user 1 - await expect_to_be(page, 'Bruce Willis') + await expect_to_be(page, '1:Bruce Willis') // click on the link 2 await page.click('user-link-2') @@ -16,5 +16,5 @@ test('Component fields with correct argument value', async ({ page }) => { await sleep(100) // make sure we loaded the second user's information - await expect_to_be(page, 'Samuel Jackson') + await expect_to_be(page, '2:Samuel Jackson') }) diff --git a/packages/houdini-react/src/plugin/codegen/entries/documentWrappers.ts b/packages/houdini-react/src/plugin/codegen/entries/documentWrappers.ts index 0d6cc86f5..920a9d517 100644 --- a/packages/houdini-react/src/plugin/codegen/entries/documentWrappers.ts +++ b/packages/houdini-react/src/plugin/codegen/entries/documentWrappers.ts @@ -85,7 +85,7 @@ async function generate_query_wrapper(args: PageBundleInput) { // build up the file source as a string let source: string[] = [ - "import { useQueryResult } from '$houdini/plugins/houdini-react/runtime/routing'", + "import { useQueryResult, PageContextProvider } from '$houdini/plugins/houdini-react/runtime/routing'", `import ${component_name} from "${relative_path}"`, ] @@ -96,15 +96,17 @@ async function generate_query_wrapper(args: PageBundleInput) { .join('\n')} return ( - <${component_name} - ${page.queries - .map((query) => - [`${query}={${query}$data}`, `${query}$handle={${query}$handle}`].join(' ') - ) - .join('\n')} - > - {children} - + + <${component_name} + ${page.queries + .map((query) => + [`${query}={${query}$data}`, `${query}$handle={${query}$handle}`].join(' ') + ) + .join('\n')} + > + {children} + + ) }`) diff --git a/packages/houdini-react/src/plugin/codegen/entries/entries.test.ts b/packages/houdini-react/src/plugin/codegen/entries/entries.test.ts index 22ee1073a..acb20db1f 100644 --- a/packages/houdini-react/src/plugin/codegen/entries/entries.test.ts +++ b/packages/houdini-react/src/plugin/codegen/entries/entries.test.ts @@ -71,7 +71,7 @@ test('composes layouts and pages', async function () { } ) expect(page_unit).toMatchInlineSnapshot(` - import { useQueryResult } from "$houdini/plugins/houdini-react/runtime/routing"; + import { useQueryResult, PageContextProvider } from "$houdini/plugins/houdini-react/runtime/routing"; import Component__subRoute_nested from "../../../../../src/routes/subRoute/nested/+page"; export default ( @@ -82,9 +82,11 @@ test('composes layouts and pages', async function () { const [FinalQuery$data, FinalQuery$handle] = useQueryResult("FinalQuery"); return ( - ( - {children} - ) + ( + + {children} + + ) ); }; `) @@ -98,7 +100,7 @@ test('composes layouts and pages', async function () { } ) expect(root_layout_unit).toMatchInlineSnapshot(` - import { useQueryResult } from "$houdini/plugins/houdini-react/runtime/routing"; + import { useQueryResult, PageContextProvider } from "$houdini/plugins/houdini-react/runtime/routing"; import Component__ from "../../../../../src/routes/+layout"; export default ( @@ -107,9 +109,11 @@ test('composes layouts and pages', async function () { } ) => { return ( - ( - {children} - ) + ( + + {children} + + ) ); }; `) @@ -123,7 +127,7 @@ test('composes layouts and pages', async function () { } ) expect(deep_layout_unit).toMatchInlineSnapshot(` - import { useQueryResult } from "$houdini/plugins/houdini-react/runtime/routing"; + import { useQueryResult, PageContextProvider } from "$houdini/plugins/houdini-react/runtime/routing"; import Component__subRoute from "../../../../../src/routes/subRoute/+layout"; export default ( @@ -135,13 +139,15 @@ test('composes layouts and pages', async function () { const [SubQuery$data, SubQuery$handle] = useQueryResult("SubQuery"); return ( - ( - {children} - ) + ( + + {children} + + ) ); }; `) @@ -254,6 +260,52 @@ test('composes layouts and pages', async function () { `) }) +test('layout with params', async function () { + const config = await test_config() + + // create the mock filesystem + await fs.mock({ + [config.routesDir]: { + '[id]': { + '+page.gql': 'query RootQuery($id: ID!) { node(id: $id) { id } }', + '+page.tsx': 'export default function({ RootQuery}) { return "hello" }', + }, + }, + }) + // generate the manifest + const manifest = await load_manifest({ config }) + + // generate the bundle for the nested page + await generate_entries({ config, manifest, documents: [], componentFields: [] }) + + const page_entry = await parseJS( + (await fs.readFile( + routerConventions.page_unit_path(config, Object.keys(manifest.pages)[0]) + )) ?? '', + { plugins: ['jsx'] } + ) + expect(page_entry).toMatchInlineSnapshot(` + import { useQueryResult, PageContextProvider } from "$houdini/plugins/houdini-react/runtime/routing"; + import Component___id_ from "../../../../../src/routes/[id]/+page"; + + export default ( + { + children + } + ) => { + const [RootQuery$data, RootQuery$handle] = useQueryResult("RootQuery"); + + return ( + ( + + {children} + + ) + ); + }; + `) +}) + function mockView(deps: string[]) { return `export default ({ ${deps.join(', ')} }) =>
hello
` } diff --git a/packages/houdini-react/src/plugin/codegen/router.test.ts b/packages/houdini-react/src/plugin/codegen/router.test.ts index 57e9615c3..169aea831 100644 --- a/packages/houdini-react/src/plugin/codegen/router.test.ts +++ b/packages/houdini-react/src/plugin/codegen/router.test.ts @@ -53,7 +53,7 @@ test('happy path', async function () { RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: false, - variables: [] + variables: {} } }, @@ -70,12 +70,12 @@ test('happy path', async function () { SubQuery: { artifact: () => import(\\"../../../artifacts/SubQuery\\"), loading: false, - variables: [] + variables: {} }, RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: false, - variables: [] + variables: {} } }, @@ -92,17 +92,17 @@ test('happy path', async function () { MyQuery: { artifact: () => import(\\"../../../artifacts/MyQuery\\"), loading: false, - variables: [] + variables: {} }, MyLayoutQuery: { artifact: () => import(\\"../../../artifacts/MyLayoutQuery\\"), loading: true, - variables: [] + variables: {} }, RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: false, - variables: [] + variables: {} } }, @@ -119,12 +119,12 @@ test('happy path', async function () { FinalQuery: { artifact: () => import(\\"../../../artifacts/FinalQuery\\"), loading: true, - variables: [\\"foo\\"] + variables: {\\"foo\\":{\\"wrappers\\":[\\"Nullable\\"],\\"type\\":\\"Int\\"}} }, RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: false, - variables: [] + variables: {} } }, @@ -198,7 +198,7 @@ test('loading state at root', async function () { RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: true, - variables: [] + variables: {} } }, @@ -215,12 +215,12 @@ test('loading state at root', async function () { SubQuery: { artifact: () => import(\\"../../../artifacts/SubQuery\\"), loading: false, - variables: [] + variables: {} }, RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: true, - variables: [] + variables: {} } }, @@ -237,17 +237,17 @@ test('loading state at root', async function () { MyQuery: { artifact: () => import(\\"../../../artifacts/MyQuery\\"), loading: false, - variables: [] + variables: {} }, MyLayoutQuery: { artifact: () => import(\\"../../../artifacts/MyLayoutQuery\\"), loading: false, - variables: [] + variables: {} }, RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: true, - variables: [] + variables: {} } }, @@ -264,12 +264,12 @@ test('loading state at root', async function () { FinalQuery: { artifact: () => import(\\"../../../artifacts/FinalQuery\\"), loading: false, - variables: [] + variables: {} }, RootQuery: { artifact: () => import(\\"../../../artifacts/RootQuery\\"), loading: true, - variables: [] + variables: {} } }, diff --git a/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts b/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts index f77df694b..f5e932643 100644 --- a/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts +++ b/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts @@ -1,4 +1,4 @@ -import { fs, load_manifest } from 'houdini' +import { fs, path, load_manifest } from 'houdini' import { test, expect } from 'vitest' import { test_config } from '../config' @@ -30,12 +30,66 @@ test('generates type files for pages', async function () { // make sure we generated the right thing expect(fs.snapshot(config.typeRootDir)).toMatchInlineSnapshot(` { - "/src/routes/(subRoute)/$types.d.ts": "\\nimport { DocumentHandle } from '../../../../plugins/houdini-react/runtime'\\nimport React from 'react'\\n\\nimport type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../../artifacts/LayoutQuery'\\nimport type { RootQuery$result, RootQuery$artifact, RootQuery$input } from '../../../../artifacts/RootQuery'\\nimport type { FinalQuery$result, FinalQuery$artifact, FinalQuery$input } from '../../../../artifacts/FinalQuery'\\n\\n\\nexport type PageProps = {\\n LayoutQuery: LayoutQuery$result,\\n LayoutQuery$handle: DocumentHandle,\\n RootQuery: RootQuery$result,\\n RootQuery$handle: DocumentHandle,\\n FinalQuery: FinalQuery$result,\\n FinalQuery$handle: DocumentHandle,\\n}\\n\\n\\n\\nexport type LayoutProps = {\\n\\tchildren: React.ReactNode,\\n\\n}\\n\\n", - "/src/routes/$types.d.ts": "\\nimport { DocumentHandle } from '../../../plugins/houdini-react/runtime'\\nimport React from 'react'\\n\\nimport type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../artifacts/LayoutQuery'\\n\\n\\nexport type PageProps = {\\n LayoutQuery: LayoutQuery$result,\\n LayoutQuery$handle: DocumentHandle,\\n}\\n\\n\\n\\nexport type LayoutProps = {\\n\\tchildren: React.ReactNode,\\n\\n}\\n\\n" + "/src/routes/(subRoute)/$types.d.ts": "\\nimport { DocumentHandle, RouteProp } from '../../../../plugins/houdini-react/runtime'\\nimport React from 'react'\\nimport type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../../artifacts/LayoutQuery'\\nimport type { RootQuery$result, RootQuery$artifact, RootQuery$input } from '../../../../artifacts/RootQuery'\\nimport type { FinalQuery$result, FinalQuery$artifact, FinalQuery$input } from '../../../../artifacts/FinalQuery'\\n\\nexport type PageProps = {\\n\\t\\tParams: {\\n\\t\\t\\n\\t},\\n\\t\\t\\n LayoutQuery: LayoutQuery$result,\\n LayoutQuery$handle: DocumentHandle,\\n RootQuery: RootQuery$result,\\n RootQuery$handle: DocumentHandle,\\n FinalQuery: FinalQuery$result,\\n FinalQuery$handle: DocumentHandle,\\n}\\n\\n\\nexport type LayoutProps = {\\n\\tParams: {\\n\\t\\t\\n\\t},\\n\\tchildren: React.ReactNode,\\n}\\n", + "/src/routes/$types.d.ts": "\\nimport { DocumentHandle, RouteProp } from '../../../plugins/houdini-react/runtime'\\nimport React from 'react'\\nimport type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../artifacts/LayoutQuery'\\n\\nexport type PageProps = {\\n\\t\\tParams: {\\n\\t\\t\\n\\t},\\n\\t\\t\\n LayoutQuery: LayoutQuery$result,\\n LayoutQuery$handle: DocumentHandle,\\n}\\n\\n\\nexport type LayoutProps = {\\n\\tParams: {\\n\\t\\t\\n\\t},\\n\\tchildren: React.ReactNode,\\n}\\n" } `) }) +test('generates route prop type', async function () { + const config = await test_config() + + // create the mock filesystem + await fs.mock({ + [config.routesDir]: { + '[id]': { + '+page.tsx': mockView(['MyQuery']), + '+layout.gql': ` + query MyQuery($id: ID!) { + node(id: $id) { + __typename + } + } + `, + }, + }, + }) + + const manifest = await load_manifest({ + config, + }) + + // generate the type rot + await generate_type_root({ config, manifest }) + + // make sure we generated the right thing + expect(await fs.readFile(path.join(config.typeRootDir, 'src', 'routes', '[id]', '$types.d.ts'))) + .toMatchInlineSnapshot(` + " + import { DocumentHandle, RouteProp } from '../../../../plugins/houdini-react/runtime' + import React from 'react' + import type { MyQuery$result, MyQuery$artifact, MyQuery$input } from '../../../../artifacts/MyQuery' + + export type PageProps = { + Params: { + id: string, + }, + + MyQuery: MyQuery$result, + MyQuery$handle: DocumentHandle, + } + + + export type LayoutProps = { + Params: { + id: string, + }, + children: React.ReactNode, + } + " + `) +}) + function mockView(deps: string[]) { return `export default ({ ${deps.join(', ')} }) =>
hello
` } diff --git a/packages/houdini-react/src/plugin/codegen/typeRoot.ts b/packages/houdini-react/src/plugin/codegen/typeRoot.ts index 17cd13421..e78d12957 100644 --- a/packages/houdini-react/src/plugin/codegen/typeRoot.ts +++ b/packages/houdini-react/src/plugin/codegen/typeRoot.ts @@ -1,4 +1,14 @@ -import { path, fs, type Config, type PageManifest, type ProjectManifest } from 'houdini' +import type { GraphQLNamedType } from 'graphql' +import { + path, + fs, + type Config, + type PageManifest, + type ProjectManifest, + type TypeWrapper, + unwrappedTsTypeReference, +} from 'houdini' +import * as recast from 'recast' import { dedent } from '../dedent' @@ -50,9 +60,8 @@ export async function generate_type_root({ // build up the type definitions const definition = ` -import { DocumentHandle } from '${relative}/plugins/houdini-react/runtime' +import { DocumentHandle, RouteProp } from '${relative}/plugins/houdini-react/runtime' import React from 'react' - ${ /* every dependent query needs to be imported */ all_queries @@ -69,27 +78,29 @@ ${ ${ /* if there is a page, then we need to define the props object */ - ` -export type PageProps = { -${ - !page - ? '' - : page.query_options - .map( - (query) => - ` ${query}: ${query}$result, + `export type PageProps = { + Params: ${!page ? '{}' : paramsType(config, page.params)}, + ${ + !page + ? '' + : ` +` + + page.query_options + .map( + (query) => + ` ${query}: ${query}$result, ${query}$handle: DocumentHandle<${query}$artifact, ${query}$result, ${query}$input>,` - ) - .join('\n') -} + ) + .join('\n') + } } ` } ${ /* if there is a layout, then we need to define the props object */ - ` -export type LayoutProps = { + `export type LayoutProps = { + Params: ${!page ? '{}' : paramsType(config, page.params)}, children: React.ReactNode, ${ !layout @@ -101,9 +112,7 @@ ${ ${query}$handle: DocumentHandle<${query}$artifact, ${query}$result, ${query}$input>,` ) .join('\n') -} -} -` +}}` } ` @@ -158,3 +167,32 @@ export async function writeTsconfig(config: Config) { ) ) } + +function paramsType(config: Config, params?: PageManifest['params']): string { + return `{ + ${Object.entries(params ?? {}) + .map(([param, typeInfo]) => { + let valueString = 'string' + + // if we have type information for the field, convert the description to + // its typescript equivalent + if (typeInfo) { + valueString = recast.print( + unwrappedTsTypeReference( + config, + '', + new Set(), + { + type: config.schema.getType(typeInfo.type) as GraphQLNamedType, + wrappers: typeInfo.wrappers as TypeWrapper[], + }, + [] + ) + ).code + } + + return `${param}: ${valueString},` + }) + .join(', ')} + }` +} diff --git a/packages/houdini-react/src/runtime/index.tsx b/packages/houdini-react/src/runtime/index.tsx index d2893761a..6e1c643b2 100644 --- a/packages/houdini-react/src/runtime/index.tsx +++ b/packages/houdini-react/src/runtime/index.tsx @@ -6,7 +6,7 @@ import manifest from './manifest' import { Router as RouterImpl, RouterCache, RouterContextProvider } from './routing' export * from './hooks' -export { router_cache, useSession, useLocation } from './routing' +export { router_cache, useSession, useLocation, useRoute } from './routing' export function Router({ cache, diff --git a/packages/houdini-react/src/runtime/routing/Router.tsx b/packages/houdini-react/src/runtime/routing/Router.tsx index 7ab18e807..b2a4ae08a 100644 --- a/packages/houdini-react/src/runtime/routing/Router.tsx +++ b/packages/houdini-react/src/runtime/routing/Router.tsx @@ -51,6 +51,10 @@ export function Router({ // find the matching page for the current route const [page, variables] = find_match(manifest, currentURL) + // if we dont have a page, its a 404 + if (!page) { + throw new Error('404') + } // the only time this component will directly suspend (instead of one of its children) // is if we don't have the component source. Dependencies on query results or artifacts @@ -123,7 +127,7 @@ export function Router({ // its needs return ( - + @@ -328,7 +332,7 @@ function usePageData({ // const last = last_variables.get(artifact) let last: GraphQLVariables = {} let usedVariables: GraphQLVariables = {} - for (const variable of pageVariables) { + for (const variable of Object.keys(pageVariables)) { last[variable] = last_variables.get(artifact)![variable] usedVariables[variable] = (variables ?? {})[variable] } @@ -578,7 +582,10 @@ export function useCurrentVariables(): GraphQLVariables { const VariableContext = React.createContext(null) -const LocationContext = React.createContext<{ pathname: string }>({ pathname: '' }) +const LocationContext = React.createContext<{ pathname: string; params: Record }>({ + pathname: '', + params: {}, +}) export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>( name: string @@ -780,6 +787,31 @@ export function router_cache({ return result } +const PageContext = React.createContext<{ params: Record }>({ params: {} }) + +export function PageContextProvider({ + keys, + children, +}: { + keys: string[] + children: React.ReactNode +}) { + const location = useLocation() + const params = Object.fromEntries( + Object.entries(location.params).filter(([key]) => keys.includes(key)) + ) + + return {children} +} + +export function useRoute(): RouteProp { + return useContext(PageContext) +} + +export type RouteProp = { + params: Params +} + // a signal promise is a promise is used to send signals by having listeners attach // actions to the then() function signal_promise(): Promise & { resolve: () => void; reject: () => void } { diff --git a/packages/houdini/src/codegen/generators/typescript/addReferencedInputTypes.ts b/packages/houdini/src/codegen/generators/typescript/addReferencedInputTypes.ts index 506f3cebe..88d4b0e47 100644 --- a/packages/houdini/src/codegen/generators/typescript/addReferencedInputTypes.ts +++ b/packages/houdini/src/codegen/generators/typescript/addReferencedInputTypes.ts @@ -3,8 +3,7 @@ import * as graphql from 'graphql' import * as recast from 'recast' import type { Config } from '../../../lib' -import { ensureImports, HoudiniError, unwrapType } from '../../../lib' -import { tsTypeReference } from './typeReference' +import { ensureImports, HoudiniError, unwrapType, tsTypeReference } from '../../../lib' const AST = recast.types.builders diff --git a/packages/houdini/src/codegen/generators/typescript/documentTypes.ts b/packages/houdini/src/codegen/generators/typescript/documentTypes.ts index e58e3ff6e..6baedddc6 100644 --- a/packages/houdini/src/codegen/generators/typescript/documentTypes.ts +++ b/packages/houdini/src/codegen/generators/typescript/documentTypes.ts @@ -5,14 +5,13 @@ import * as recast from 'recast' import type { Config, Document, DocumentArtifact } from '../../../lib' import { printJS, HoudiniError, siteURL, fs, path } from '../../../lib' +import { readonlyProperty, tsTypeReference } from '../../../lib/typescript' import { fragmentArgumentsDefinitions } from '../../transforms/fragmentVariables' import { flattenSelections } from '../../utils' import { serializeValue } from '../artifacts/utils' import { addReferencedInputTypes } from './addReferencedInputTypes' import { fragmentKey, inlineType } from './inlineType' import { withLoadingState } from './loadingState' -import { tsTypeReference } from './typeReference' -import { readonlyProperty } from './types' const AST = recast.types.builders diff --git a/packages/houdini/src/codegen/generators/typescript/imperativeTypeDef.ts b/packages/houdini/src/codegen/generators/typescript/imperativeTypeDef.ts index 78bda888d..f5609a546 100644 --- a/packages/houdini/src/codegen/generators/typescript/imperativeTypeDef.ts +++ b/packages/houdini/src/codegen/generators/typescript/imperativeTypeDef.ts @@ -11,9 +11,8 @@ import { TypeWrapper, unwrapType, } from '../../../lib' +import { scalarPropertyValue, tsTypeReference } from '../../../lib/typescript' import { addReferencedInputTypes } from './addReferencedInputTypes' -import { tsTypeReference } from './typeReference' -import { scalarPropertyValue } from './types' const AST = recast.types.builders diff --git a/packages/houdini/src/codegen/generators/typescript/inlineType.ts b/packages/houdini/src/codegen/generators/typescript/inlineType.ts index 9316e0c97..7e9f4a209 100644 --- a/packages/houdini/src/codegen/generators/typescript/inlineType.ts +++ b/packages/houdini/src/codegen/generators/typescript/inlineType.ts @@ -4,9 +4,13 @@ import * as recast from 'recast' import type { Config } from '../../../lib' import { ensureImports, HoudiniError, TypeWrapper, unwrapType } from '../../../lib' +import { + nullableField, + readonlyProperty, + scalarPropertyValue, + enumReference, +} from '../../../lib/typescript' import { jsdocComment } from '../comments/jsdoc' -import { enumReference } from './typeReference' -import { nullableField, readonlyProperty, scalarPropertyValue } from './types' const AST = recast.types.builders diff --git a/packages/houdini/src/codegen/generators/typescript/loadingState.ts b/packages/houdini/src/codegen/generators/typescript/loadingState.ts index 28b55eec1..887329fcd 100644 --- a/packages/houdini/src/codegen/generators/typescript/loadingState.ts +++ b/packages/houdini/src/codegen/generators/typescript/loadingState.ts @@ -8,8 +8,8 @@ import { type SubscriptionSelection, fragmentKey, } from '../../../lib' +import { readonlyProperty } from '../../../lib/typescript' import { getFieldsForType } from '../../../runtime/lib/selection' -import { readonlyProperty } from './types' const AST = recast.types.builders diff --git a/packages/houdini/src/codegen/generators/typescript/typeReference.ts b/packages/houdini/src/codegen/generators/typescript/typeReference.ts deleted file mode 100644 index 9749018bb..000000000 --- a/packages/houdini/src/codegen/generators/typescript/typeReference.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { StatementKind, TSTypeKind } from 'ast-types/lib/gen/kinds' -import * as graphql from 'graphql' -import * as recast from 'recast' - -import { ensureImports, TypeWrapper, unwrapType } from '../../../lib' -import type { Config } from '../../../lib' -import { nullableField, scalarPropertyValue } from './types' - -const AST = recast.types.builders - -// return the property -export function tsTypeReference( - config: Config, - filepath: string, - missingScalars: Set, - definition: { - type: - | graphql.GraphQLScalarType - | graphql.GraphQLInputType - | graphql.GraphQLNamedType - | graphql.TypeNode - }, - body: StatementKind[] -): TSTypeKind { - const { type, wrappers } = unwrapType(config, definition.type) - - // convert the inner type - let result - // if we're looking at a scalar - if (graphql.isScalarType(type)) { - result = scalarPropertyValue(config, filepath, missingScalars, type, body, null) - } - // enums need to be passed to ValueOf - else if (graphql.isEnumType(type)) { - result = enumReference(config, body, type.name) - } - // we're looking at an object - else { - // the fields of the object end up as properties in the type literal - result = AST.tsTypeReference(AST.identifier(type.name)) - } - for (const toWrap of wrappers) { - // if its a non-null we don't need to add anything - if (toWrap === TypeWrapper.NonNull) { - continue - } else if (toWrap === TypeWrapper.Nullable) { - result = nullableField(result, true) - } - // it could be a list - else if (toWrap === TypeWrapper.List) { - result = AST.tsArrayType(AST.tsParenthesizedType(result)) - } - } - - return result -} - -export function enumReference(config: Config, body: StatementKind[], name: string) { - // if we looking at an enum we need ValueOf - ensureImports({ - config, - body, - import: ['ValueOf'], - importKind: 'type', - sourceModule: '$houdini/runtime/lib/types', - }) - return AST.tsTypeReference( - AST.identifier('ValueOf'), - AST.tsTypeParameterInstantiation([AST.tsTypeQuery(AST.identifier(name))]) - ) -} diff --git a/packages/houdini/src/lib/graphql.ts b/packages/houdini/src/lib/graphql.ts index ddc71cc5c..a33b84887 100644 --- a/packages/houdini/src/lib/graphql.ts +++ b/packages/houdini/src/lib/graphql.ts @@ -290,7 +290,8 @@ export function operation_requires_variables(operation: graphql.OperationDefinit export function unwrapType( config: Config, type: any, - wrappers: TypeWrapper[] = [] + wrappers: TypeWrapper[] = [], + convertRuntimeScalars?: boolean ): { type: graphql.GraphQLNamedType; wrappers: TypeWrapper[] } { // if we are looking at a non null type if (type.kind === 'NonNullType') { @@ -313,6 +314,13 @@ export function unwrapType( return unwrapType(config, type.ofType, [TypeWrapper.List, ...wrappers]) } + // if we got this far and the type is a runtime scalar, we need to use the underlying type + if (convertRuntimeScalars && config.configFile.features?.runtimeScalars?.[type.name.value]) { + type = config.schema.getType( + config.configFile.features?.runtimeScalars?.[type.name.value].type + ) + } + // get the named type const namedType = config.schema.getType(type.name.value || type.name) if (!namedType) { diff --git a/packages/houdini/src/lib/index.ts b/packages/houdini/src/lib/index.ts index 55b40e479..251c7c48e 100644 --- a/packages/houdini/src/lib/index.ts +++ b/packages/houdini/src/lib/index.ts @@ -13,6 +13,7 @@ export * from './deepMerge' export * from './plugin' export * from './detectTools' export * from '../runtime/router/match' +export * from './typescript' export * from './walk' export type { EmbeddedGraphqlDocument } from './walk' diff --git a/packages/houdini/src/lib/router/manifest.test.ts b/packages/houdini/src/lib/router/manifest.test.ts index f24591be2..2a2b35be8 100644 --- a/packages/houdini/src/lib/router/manifest.test.ts +++ b/packages/houdini/src/lib/router/manifest.test.ts @@ -68,7 +68,8 @@ test('route groups', async function () { "query_options": [ "RootQuery", "FinalQuery" - ] + ], + "params": {} } }, "layouts": { @@ -78,7 +79,8 @@ test('route groups', async function () { "url": "/", "layouts": [], "path": "src/routes/+layout.tsx", - "query_options": [] + "query_options": [], + "params": {} }, "__subRoute_": { "id": "__subRoute_", @@ -92,7 +94,8 @@ test('route groups', async function () { "path": "src/routes/(subRoute)/+layout.tsx", "query_options": [ "RootQuery" - ] + ], + "params": {} } }, "page_queries": { @@ -101,7 +104,7 @@ test('route groups', async function () { "name": "FinalQuery", "url": "/(subRoute)/nested/", "loading": true, - "variables": [] + "variables": {} } }, "layout_queries": { @@ -110,7 +113,7 @@ test('route groups', async function () { "name": "RootQuery", "url": "/(subRoute)/", "loading": false, - "variables": [] + "variables": {} } }, "artifacts": [], @@ -167,7 +170,8 @@ test('nested route structure happy path', async function () { "path": "src/routes/+page.tsx", "query_options": [ "RootQuery" - ] + ], + "params": {} }, "_subRoute": { "id": "_subRoute", @@ -184,7 +188,8 @@ test('nested route structure happy path', async function () { "query_options": [ "RootQuery", "SubQuery" - ] + ], + "params": {} }, "_another": { "id": "_another", @@ -202,7 +207,8 @@ test('nested route structure happy path', async function () { "RootQuery", "MyLayoutQuery", "MyQuery" - ] + ], + "params": {} }, "_subRoute_nested": { "id": "_subRoute_nested", @@ -219,7 +225,8 @@ test('nested route structure happy path', async function () { "RootQuery", "SubQuery", "FinalQuery" - ] + ], + "params": {} } }, "layouts": { @@ -231,7 +238,8 @@ test('nested route structure happy path', async function () { "path": "src/routes/+layout.tsx", "query_options": [ "RootQuery" - ] + ], + "params": {} }, "_another": { "id": "_another", @@ -246,7 +254,8 @@ test('nested route structure happy path', async function () { "query_options": [ "RootQuery", "MyLayoutQuery" - ] + ], + "params": {} }, "_subRoute": { "id": "_subRoute", @@ -261,7 +270,8 @@ test('nested route structure happy path', async function () { "query_options": [ "RootQuery", "SubQuery" - ] + ], + "params": {} } }, "page_queries": { @@ -270,14 +280,14 @@ test('nested route structure happy path', async function () { "name": "MyQuery", "url": "/another/", "loading": false, - "variables": [] + "variables": {} }, "_subRoute_nested": { "path": "subRoute/nested/+page.gql", "name": "FinalQuery", "url": "/subRoute/nested/", "loading": true, - "variables": [] + "variables": {} } }, "layout_queries": { @@ -286,21 +296,21 @@ test('nested route structure happy path', async function () { "name": "RootQuery", "url": "/", "loading": true, - "variables": [] + "variables": {} }, "_another": { "path": "another/+layout.gql", "name": "MyLayoutQuery", "url": "/another/", "loading": false, - "variables": [] + "variables": {} }, "_subRoute": { "path": "subRoute/+layout.gql", "name": "SubQuery", "url": "/subRoute/", "loading": false, - "variables": [] + "variables": {} } }, "artifacts": [], @@ -374,6 +384,79 @@ test('local yoga', async function () { `) }) +test('extract route params', async function () { + const config = testConfig() + + // create the mock filesystem + await fs.mock({ + [config.routesDir]: { + '[id]': { + '+page.tsx': mockView(['MyQuery']), + '+layout.gql': ` + query MyQuery($id: ID!) { + node(id: $id) { + __typename + } + } + `, + }, + }, + }) + + await expect( + load_manifest({ + config, + }) + ).resolves.toMatchInlineSnapshot(` + { + "component_fields": {}, + "pages": { + "__id_": { + "id": "__id_", + "queries": [ + "MyQuery" + ], + "url": "/[id]", + "layouts": [], + "path": "src/routes/[id]/+page.tsx", + "query_options": [ + "MyQuery" + ], + "params": { + "id": { + "wrappers": [ + "NonNull" + ], + "type": "ID" + } + } + } + }, + "layouts": {}, + "page_queries": {}, + "layout_queries": { + "__id_": { + "path": "[id]/+layout.gql", + "name": "MyQuery", + "url": "/[id]/", + "loading": false, + "variables": { + "id": { + "wrappers": [ + "NonNull" + ], + "type": "ID" + } + } + } + }, + "artifacts": [], + "local_schema": false, + "local_yoga": false + } + `) +}) + describe('validate filesystem', async () => { const config = testConfig() @@ -481,14 +564,15 @@ describe('validate filesystem', async () => { } }) -const testCases: { - name: string - source: string - expected: string[] -}[] = [ - { - name: 'Basic functional component', - source: ` +describe('extractQueries', async () => { + const testCases: { + name: string + source: string + expected: string[] + }[] = [ + { + name: 'Basic functional component', + source: ` import React from 'react'; interface Props { @@ -507,11 +591,11 @@ const testCases: { export default MyComponent; `, - expected: ['name', 'age'], - }, - { - name: 'Functional component with arrow function', - source: ` + expected: ['name', 'age'], + }, + { + name: 'Functional component with arrow function', + source: ` import React from 'react'; interface Props { @@ -528,11 +612,11 @@ const testCases: { export default MyComponent; `, - expected: ['title', 'content'], - }, - { - name: 'Functional component with function expression', - source: ` + expected: ['title', 'content'], + }, + { + name: 'Functional component with function expression', + source: ` import React from 'react'; interface Props { @@ -551,11 +635,11 @@ const testCases: { export default MyComponent; `, - expected: ['firstName', 'lastName'], - }, - { - name: 'Inline functional component with function expression', - source: ` + expected: ['firstName', 'lastName'], + }, + { + name: 'Inline functional component with function expression', + source: ` import React from 'react'; interface Props { @@ -572,16 +656,17 @@ const testCases: { ); };; `, - expected: ['firstName', 'lastName'], - }, -] - -for (const testCase of testCases) { - test(testCase.name, async () => { - const props = await extractQueries(testCase.source) - expect(props).toEqual(testCase.expected) - }) -} + expected: ['firstName', 'lastName'], + }, + ] + + for (const testCase of testCases) { + test(testCase.name, async () => { + const props = await extractQueries(testCase.source) + expect(props).toEqual(testCase.expected) + }) + } +}) function mockView(deps: string[]) { return `export default ({ ${deps.join(', ')} }) =>
hello
` diff --git a/packages/houdini/src/lib/router/manifest.ts b/packages/houdini/src/lib/router/manifest.ts index 966173d59..ffec4f2c5 100644 --- a/packages/houdini/src/lib/router/manifest.ts +++ b/packages/houdini/src/lib/router/manifest.ts @@ -1,7 +1,15 @@ import * as t from '@babel/types' import * as graphql from 'graphql' -import { path, fs, parseJS, type Config } from '..' +import { + path, + fs, + parseJS, + type Config, + type TypeWrapper, + unwrapType, + parse_page_pattern, +} from '..' import type { ProjectManifest, PageManifest, QueryManifest } from '../../runtime/lib/types' import { read_layoutQuery, @@ -38,6 +46,7 @@ export async function load_manifest(args: { }, queries: [], layouts: [], + variables: {}, }) // we might need to include the list of aritfacts in the project @@ -88,11 +97,15 @@ async function walk_routes(args: { project: ProjectManifest queries: string[] layouts: string[] + variables?: Record }): Promise { const directory_contents = await fs.readdir(args.filepath, { withFileTypes: true, }) + // every step down defines a new variable context + const variables = { ...args.variables } + // before we can go down, we need to look at the files in the directory // to see what queries were added to the context. this means we have to // first collect the layout query and view, and then check for a page query @@ -129,6 +142,7 @@ async function walk_routes(args: { project: args.project, type: 'layout', contents: layoutQueryContents, + variables, }) newLayoutQueries = [...args.queries, layoutQuery.name] } @@ -144,6 +158,7 @@ async function walk_routes(args: { layouts: args.layouts, queries: newLayoutQueries, config: args.config, + variables, }) newLayouts = [...args.layouts, page_id(layout.url)] } @@ -157,6 +172,7 @@ async function walk_routes(args: { project: args.project, type: 'page', contents: pageQueryContents, + variables, }) } @@ -171,6 +187,7 @@ async function walk_routes(args: { layouts: newLayouts, queries: pageQuery ? [...newLayoutQueries, pageQuery.name] : newLayoutQueries, config: args.config, + variables, }) } @@ -187,6 +204,7 @@ async function walk_routes(args: { url: `${args.url}${dir.name}/`, queries: newLayoutQueries, layouts: newLayouts, + variables, }) }) ) @@ -203,6 +221,7 @@ async function add_view(args: { layouts: string[] queries: string[] config: Config + variables: Record }) { const target = args.type === 'page' ? args.project.pages : args.project.layouts const queries = await extractQueries(args.contents) @@ -223,6 +242,12 @@ async function add_view(args: { layouts: args.layouts, path: path.relative(args.config.projectRoot, args.path), query_options: args.queries, + params: Object.fromEntries( + parse_page_pattern(args.url).params.map((param) => [ + param.name, + args.variables[param.name] ?? null, + ]) + ), } return target[id] @@ -235,6 +260,7 @@ async function add_query(args: { project: ProjectManifest type: 'page' | 'layout' contents: string + variables: Record }) { // we need to parse the query to get the name const parsed = graphql.parse(args.contents) @@ -256,13 +282,25 @@ async function add_query(args: { }, }) + // add this queries variables to the bag + const queryVariables = Object.fromEntries( + query.variableDefinitions?.map((variable) => { + const { type, wrappers } = unwrapType(args.config, variable.type, [], true) + return [ + variable.variable.name.value, + { wrappers: wrappers as string[], type: type.name }, + ] + }) ?? [] + ) + Object.assign(args.variables, queryVariables) + const target = args.type === 'page' ? args.project.page_queries : args.project.layout_queries target[page_id(args.url)] = { path: path.relative(args.config.routesDir, args.path), name: query.name.value, url: args.url, loading, - variables: query.variableDefinitions?.map((variable) => variable.variable.name.value) ?? [], + variables: queryVariables, } return target[page_id(args.url)] diff --git a/packages/houdini/src/codegen/generators/typescript/types.ts b/packages/houdini/src/lib/typescript.ts similarity index 64% rename from packages/houdini/src/codegen/generators/typescript/types.ts rename to packages/houdini/src/lib/typescript.ts index f8d6000a5..a1ad8fdf0 100644 --- a/packages/houdini/src/codegen/generators/typescript/types.ts +++ b/packages/houdini/src/lib/typescript.ts @@ -2,10 +2,95 @@ import type { StatementKind, TSTypeKind } from 'ast-types/lib/gen/kinds' import * as graphql from 'graphql' import * as recast from 'recast' -import { ensureImports, type Config, path } from '../../../lib' +import { ensureImports, type Config, path } from '.' +import { unwrapType, TypeWrapper } from './graphql' const AST = recast.types.builders +export function unwrappedTsTypeReference( + config: Config, + filepath: string, + missingScalars: Set, + { + type, + wrappers, + }: { + type: graphql.GraphQLNamedType + wrappers: TypeWrapper[] + }, + body: StatementKind[] +) { + // convert the inner type + let result + // if we're looking at a scalar + if (graphql.isScalarType(type)) { + result = scalarPropertyValue(config, filepath, missingScalars, type, body, null) + } + // enums need to be passed to ValueOf + else if (graphql.isEnumType(type)) { + result = enumReference(config, body, type.name) + } + // we're looking at an object + else { + // the fields of the object end up as properties in the type literal + result = AST.tsTypeReference(AST.identifier(type.name)) + } + for (const toWrap of wrappers) { + // if its a non-null we don't need to add anything + if (toWrap === TypeWrapper.NonNull) { + continue + } else if (toWrap === TypeWrapper.Nullable) { + result = nullableField(result, true) + } + // it could be a list + else if (toWrap === TypeWrapper.List) { + result = AST.tsArrayType(AST.tsParenthesizedType(result)) + } + } + + return result +} + +// return the property +export function tsTypeReference( + config: Config, + filepath: string, + missingScalars: Set, + definition: { + type: + | graphql.GraphQLScalarType + | graphql.GraphQLInputType + | graphql.GraphQLNamedType + | graphql.TypeNode + }, + body: StatementKind[] +): TSTypeKind { + const { type, wrappers } = unwrapType(config, definition.type) + + return unwrappedTsTypeReference( + config, + filepath, + missingScalars, + { type: type, wrappers }, + body + ) +} + +export function enumReference(config: Config, body: StatementKind[], name: string) { + // if we looking at an enum we need ValueOf + ensureImports({ + config, + body, + import: ['ValueOf'], + importKind: 'type', + sourceModule: '$houdini/runtime/lib/types', + }) + return AST.tsTypeReference( + AST.identifier('ValueOf'), + AST.tsTypeParameterInstantiation([AST.tsTypeQuery(AST.identifier(name))]) + ) +} + export function readonlyProperty( prop: recast.types.namedTypes.TSPropertySignature, enable: boolean = true diff --git a/packages/houdini/src/runtime/lib/types.ts b/packages/houdini/src/runtime/lib/types.ts index d2560ffd4..e212aef31 100644 --- a/packages/houdini/src/runtime/lib/types.ts +++ b/packages/houdini/src/runtime/lib/types.ts @@ -436,6 +436,11 @@ export type PageManifest = { layouts: string[] /** The filepath of the unit */ path: string + /** + * The name and type of every route paramter that this page can use. + * null indicates the type is unknown (not constrained by a query) + **/ + params: Record } export type QueryManifest = { @@ -447,6 +452,6 @@ export type QueryManifest = { loading: boolean /** The filepath of the unit */ path: string - /** The list of variables that this query cares about */ - variables: string[] + /** The name and GraphQL type for the variables that this query cares about */ + variables: Record } diff --git a/packages/houdini/src/runtime/router/types.ts b/packages/houdini/src/runtime/router/types.ts index f8c6ece7d..426bbc2ff 100644 --- a/packages/houdini/src/runtime/router/types.ts +++ b/packages/houdini/src/runtime/router/types.ts @@ -28,7 +28,7 @@ export type RouterPageManifest<_ComponentType> = { { artifact: () => Promise<{ default: QueryArtifact }> loading: boolean - variables: string[] + variables: Record } > component: () => Promise<{ default: (props: any) => _ComponentType }>