diff --git a/e2e/router/src/routes/+layout.jsx b/e2e/router/src/routes/+layout.tsx similarity index 80% rename from e2e/router/src/routes/+layout.jsx rename to e2e/router/src/routes/+layout.tsx index 00867e69c..121a6c3a0 100644 --- a/e2e/router/src/routes/+layout.jsx +++ b/e2e/router/src/routes/+layout.tsx @@ -1,6 +1,8 @@ import { Link } from '$houdini' -export default function ({ HelloRouter, children }) { +import type { LayoutProps } from './$types' + +export default function ({ HelloRouter, children }: LayoutProps) { return (
message: {HelloRouter.message} diff --git a/e2e/router/src/routes/+page.jsx b/e2e/router/src/routes/+page.jsx deleted file mode 100644 index 64b577db0..000000000 --- a/e2e/router/src/routes/+page.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PendingValue } from '$houdini' - -export default function ({ HelloRouter }) { - return
{HelloRouter.message}!
-} diff --git a/e2e/router/src/routes/+page.tsx b/e2e/router/src/routes/+page.tsx new file mode 100644 index 000000000..9834bf230 --- /dev/null +++ b/e2e/router/src/routes/+page.tsx @@ -0,0 +1,5 @@ +import type { PageProps } from './$types' + +export default function ({ HelloRouter }: PageProps) { + return
{HelloRouter.message}!
+} diff --git a/e2e/router/src/routes/users/[id]/+page.jsx b/e2e/router/src/routes/users/[id]/+page.tsx similarity index 73% rename from e2e/router/src/routes/users/[id]/+page.jsx rename to e2e/router/src/routes/users/[id]/+page.tsx index 432dede3c..7298b3ecf 100644 --- a/e2e/router/src/routes/users/[id]/+page.jsx +++ b/e2e/router/src/routes/users/[id]/+page.tsx @@ -1,7 +1,9 @@ -import { PendingValue, isPending } from '$houdini' +import { isPending } from '$houdini' import React from 'react' -export default function ({ UserInfo }) { +import type { PageProps } from './$types' + +export default function ({ UserInfo }: PageProps) { const { user } = UserInfo // if we are loading the user render the loading state diff --git a/e2e/router/tsconfig.json b/e2e/router/tsconfig.json index b04ec87c5..fd3238fb2 100644 --- a/e2e/router/tsconfig.json +++ b/e2e/router/tsconfig.json @@ -1,25 +1,3 @@ { - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "paths": { - "$houdini": ["./$houdini"], - "$houdini/*": ["./$houdini/*"] - } - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "extends": "./$houdini/tsconfig.json" } diff --git a/e2e/router/tsconfig.node.json b/e2e/router/tsconfig.node.json deleted file mode 100644 index d3bf4b829..000000000 --- a/e2e/router/tsconfig.node.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "ESNext", - "moduleResolution": "Node", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/e2e/router/vite.config.ts b/e2e/router/vite.config.ts index c760cc3cc..ea18f3b48 100644 --- a/e2e/router/vite.config.ts +++ b/e2e/router/vite.config.ts @@ -1,16 +1,8 @@ import react from '@vitejs/plugin-react' import houdini from 'houdini/vite' -import path from 'path' import { defineConfig } from 'vite' // https://vitejs.dev/config/ export default defineConfig({ plugins: [houdini(), react({ fastRefresh: false })], - // TODO: the vite plugin should do this - resolve: { - alias: { - $houdini: path.resolve('.', '/$houdini'), - '$houdini/*': path.resolve('.', '/$houdini', '*'), - }, - }, }) diff --git a/packages/houdini-react/src/plugin/codegen/index.ts b/packages/houdini-react/src/plugin/codegen/index.ts index e69a002a6..030570e9c 100644 --- a/packages/houdini-react/src/plugin/codegen/index.ts +++ b/packages/houdini-react/src/plugin/codegen/index.ts @@ -3,6 +3,7 @@ import type { GenerateHookInput } from 'houdini' import { generate_entries } from './entries' import type { ProjectManifest } from './manifest' import { generate_renders } from './render' +import { generate_type_root } from './typeRoot' /** * The router is fundamentally a component that knows how to render @@ -18,5 +19,9 @@ export default async function routerCodegen({ manifest, }: GenerateHookInput & { manifest: ProjectManifest }) { // use the manifest to generate all of the necessary project files - await Promise.all([generate_entries({ config, manifest }), generate_renders(config)]) + await Promise.all([ + generate_entries({ config, manifest }), + generate_renders(config), + generate_type_root({ config, manifest }), + ]) } diff --git a/packages/houdini-react/src/plugin/codegen/manifest.test.ts b/packages/houdini-react/src/plugin/codegen/manifest.test.ts index 3a5e43af6..ff5b90bd1 100644 --- a/packages/houdini-react/src/plugin/codegen/manifest.test.ts +++ b/packages/houdini-react/src/plugin/codegen/manifest.test.ts @@ -60,7 +60,11 @@ test('route groups', async function () { "_0", "_0_3subRoute_4" ], - "path": "(subRoute)/nested/+page.tsx" + "path": "src/routes/(subRoute)/nested/+page.tsx", + "query_options": [ + "RootQuery", + "FinalQuery" + ] } }, "layouts": { @@ -69,7 +73,8 @@ test('route groups', async function () { "queries": [], "url": "/", "layouts": [], - "path": "+layout.tsx" + "path": "src/routes/+layout.tsx", + "query_options": [] }, "_0_3subRoute_4": { "id": "_0_3subRoute_4", @@ -80,7 +85,10 @@ test('route groups', async function () { "layouts": [ "_0" ], - "path": "(subRoute)/+layout.tsx" + "path": "src/routes/(subRoute)/+layout.tsx", + "query_options": [ + "RootQuery" + ] } }, "page_queries": { @@ -146,7 +154,10 @@ test('nested route structure happy path', async function () { "layouts": [ "_0" ], - "path": "+page.tsx" + "path": "src/routes/+page.tsx", + "query_options": [ + "RootQuery" + ] }, "_0subRoute": { "id": "_0subRoute", @@ -159,7 +170,11 @@ test('nested route structure happy path', async function () { "_0", "_0subRoute" ], - "path": "subRoute/+page.jsx" + "path": "src/routes/subRoute/+page.jsx", + "query_options": [ + "RootQuery", + "SubQuery" + ] }, "_0another": { "id": "_0another", @@ -172,7 +187,12 @@ test('nested route structure happy path', async function () { "_0", "_0another" ], - "path": "another/+page.tsx" + "path": "src/routes/another/+page.tsx", + "query_options": [ + "RootQuery", + "MyLayoutQuery", + "MyQuery" + ] }, "_0subRoute_0nested": { "id": "_0subRoute_0nested", @@ -184,7 +204,12 @@ test('nested route structure happy path', async function () { "_0", "_0subRoute" ], - "path": "subRoute/nested/+page.tsx" + "path": "src/routes/subRoute/nested/+page.tsx", + "query_options": [ + "RootQuery", + "SubQuery", + "FinalQuery" + ] } }, "layouts": { @@ -193,7 +218,10 @@ test('nested route structure happy path', async function () { "queries": [], "url": "/", "layouts": [], - "path": "+layout.tsx" + "path": "src/routes/+layout.tsx", + "query_options": [ + "RootQuery" + ] }, "_0another": { "id": "_0another", @@ -204,7 +232,11 @@ test('nested route structure happy path', async function () { "layouts": [ "_0" ], - "path": "another/+layout.tsx" + "path": "src/routes/another/+layout.tsx", + "query_options": [ + "RootQuery", + "MyLayoutQuery" + ] }, "_0subRoute": { "id": "_0subRoute", @@ -215,7 +247,11 @@ test('nested route structure happy path', async function () { "layouts": [ "_0" ], - "path": "subRoute/+layout.tsx" + "path": "src/routes/subRoute/+layout.tsx", + "query_options": [ + "RootQuery", + "SubQuery" + ] } }, "page_queries": { diff --git a/packages/houdini-react/src/plugin/codegen/manifest.ts b/packages/houdini-react/src/plugin/codegen/manifest.ts index 54739d17c..82368031a 100644 --- a/packages/houdini-react/src/plugin/codegen/manifest.ts +++ b/packages/houdini-react/src/plugin/codegen/manifest.ts @@ -173,7 +173,8 @@ async function add_view(args: { queries, url: args.url, layouts: args.layouts, - path: path.relative(args.config.routesDir, args.path), + path: path.relative(args.config.projectRoot, args.path), + query_options: args.queries, } return target[id] @@ -219,7 +220,7 @@ async function add_query(args: { } export async function extractQueries(source: string): Promise { - const ast = await parseJS(source, { plugins: ['jsx'] }) + const ast = parseJS(source, { plugins: ['jsx'] }) let defaultExportNode: t.Node | null = null let defaultExportIdentifier: string | null = null @@ -319,6 +320,8 @@ export type PageManifest = { id: string /** the name of every query that the page depends on */ queries: string[] + /** the list of queries that this page could potentially ask for */ + query_options: string[] /** the full url pattern of the page */ url: string /** the ids of layouts that wrap this page */ diff --git a/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts b/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts new file mode 100644 index 000000000..5bea96363 --- /dev/null +++ b/packages/houdini-react/src/plugin/codegen/typeRoot.test.ts @@ -0,0 +1,50 @@ +import { fs } from 'houdini' +import { test, expect } from 'vitest' + +import { test_config } from '../config' +import { load_manifest } from './manifest' +import { generate_type_root } from './typeRoot' + +test('generates type files for pages', async function () { + const config = await test_config() + + // create the mock filesystem + await fs.mock({ + [config.routesDir]: { + '+layout.gql': mockQuery('LayoutQuery'), + '+page.tsx': mockView(['LayoutQuery']), + '(subRoute)': { + '+page.tsx': mockView(['FinalQuery']), + '+page.gql': mockQuery('FinalQuery', true), + '+layout.gql': mockQuery('RootQuery'), + }, + }, + }) + + const manifest = await load_manifest({ + config, + }) + + // generate the type rot + await generate_type_root({ config, manifest }) + + // 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\\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\\n" + } + `) +}) + +function mockView(deps: string[]) { + return `export default ({ ${deps.join(', ')} }) =>
hello
` +} + +function mockQuery(name: string, loading?: boolean) { + return ` +query ${name} ${loading ? '@loading' : ''}{ + id +} + ` +} diff --git a/packages/houdini-react/src/plugin/codegen/typeRoot.ts b/packages/houdini-react/src/plugin/codegen/typeRoot.ts new file mode 100644 index 000000000..98ff1b230 --- /dev/null +++ b/packages/houdini-react/src/plugin/codegen/typeRoot.ts @@ -0,0 +1,157 @@ +import { type Config, path, fs } from 'houdini' + +import { dedent } from '../dedent' +import type { PageManifest, ProjectManifest } from './manifest' + +export async function generate_type_root({ + config, + manifest, +}: { + config: Config + manifest: ProjectManifest +}) { + // every page and layout needs an entry in the type root so that + // users can always import from ./$types + // + // page props get exported as PageProps + // layout props get exported as LayoutProps + + // the project's manifest already has all of the information we need + // but we need to group pages and sibling layouts so we can generate + // a single file (since users always import from './$types') + + const pages: Record = {} + for (const page of Object.values(manifest.layouts)) { + const page_path = path.relative(config.projectRoot, path.dirname(page.path)) + pages[page_path] = { + ...pages[page_path], + layout: page, + } + } + for (const page of Object.values(manifest.pages)) { + const page_path = path.relative(config.projectRoot, path.dirname(page.path)) + pages[page_path] = { + ...pages[page_path], + page: page, + } + } + + await Promise.all([ + tsconfig(config), + ...Object.entries(pages).map(async ([relative_path, { page, layout }]) => { + // the type root must mirror the source tree + const target_dir = path.join(config.typeRootDir, relative_path) + + // make sure the necessary directories exist + await fs.mkdirp(target_dir) + + const all_queries = (page?.query_options ?? []).concat(layout?.query_options ?? []) + + // compute the path prefix to bring us to the root of the $houdini directory + const relative = path.relative(target_dir, config.rootDir) + + // build up the type definitions + const definition = ` +import { DocumentHandle } from '${relative}/plugins/houdini-react/runtime' +import React from 'react' + +${ + /* every dependent query needs to be imported */ + all_queries + .map((query) => + dedent(` + import type { ${query}$result, ${query}$artifact, ${query}$input } from '${config + .artifactImportPath(query) + .replace('$houdini', relative)}' + + `) + ) + .join('\n') +} + +${ + /* if there is a page, then we need to define the props object */ + !page + ? '' + : ` +export type PageProps = { +${page.query_options + .map( + (query) => + ` ${query}: ${query}$result, + ${query}$handle: DocumentHandle<${query}$artifact, ${query}$result, ${query}$input>,` + ) + .join('\n')} +} +` +} + +${ + /* if there is a layout, then we need to define the props object */ + !layout + ? '' + : ` +export type LayoutProps = { + children: React.ReactNode, +${layout.query_options + .map( + (query) => + ` ${query}: ${query}$result, + ${query}$handle: DocumentHandle<${query}$artifact, ${query}$result, ${query}$input>,` + ) + .join('\n')} +} +` +} +` + + await fs.writeFile(path.join(target_dir, '$types.d.ts'), definition) + }), + ]) +} + +async function tsconfig(config: Config) { + await fs.writeFile( + path.join(config.rootDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + paths: { + $houdini: ['.'], + '$houdini/*': ['./*'], + '~': ['../src'], + '~/*': ['../src/*'], + }, + rootDirs: ['..', './types'], + target: 'ESNext', + useDefineForClassFields: true, + lib: ['DOM', 'DOM.Iterable', 'ESNext'], + allowJs: false, + skipLibCheck: true, + esModuleInterop: false, + allowSyntheticDefaultImports: true, + strict: true, + forceConsistentCasingInFileNames: true, + module: 'ESNext', + moduleResolution: 'Node', + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + }, + include: [ + 'ambient.d.ts', + './types/**/$types.d.ts', + '../vite.config.ts', + '../src/**/*.js', + '../src/**/*.ts', + '../src/**/*.jsx', + '../src/**/*.tsx', + ], + exclude: ['../node_modules/**', './[!ambient.d.ts]**'], + }, + null, + 4 + ) + ) +} diff --git a/packages/houdini-react/src/plugin/vite.tsx b/packages/houdini-react/src/plugin/vite.tsx index 8f23cc123..08e0b8cad 100644 --- a/packages/houdini-react/src/plugin/vite.tsx +++ b/packages/houdini-react/src/plugin/vite.tsx @@ -21,6 +21,20 @@ import { render_server_path } from './conventions' // @@houdini/artifact/[name] - An entry for loading an artifact and notifying the artifact cache export default { + // we want to set up some vite aliases by default + config(config) { + return { + resolve: { + alias: { + $houdini: config.rootDir, + '$houdini/*': path.join(config.rootDir, '*'), + '~': path.join(config.projectRoot, 'src'), + '~/*': path.join(config.projectRoot, 'src', '*'), + }, + }, + } + }, + resolveId(id) { // we only care about the virtual modules that generate if (!id.includes('@@houdini')) { @@ -30,6 +44,7 @@ export default { // let them all through as is but strip anything that comes before the marker return id.substring(id.indexOf('@@houdini')) }, + async load(id, { config }) { // we only care about the virtual modules that generate if (!id.startsWith('@@houdini')) { diff --git a/packages/houdini/src/lib/fs.ts b/packages/houdini/src/lib/fs.ts index 5a20d1c10..5b7d15d83 100644 --- a/packages/houdini/src/lib/fs.ts +++ b/packages/houdini/src/lib/fs.ts @@ -299,6 +299,14 @@ export async function recursiveCopy( } } +export function snapshot(base?: string) { + return Object.fromEntries( + Object.entries(vol.toJSON()) + .filter(([key]) => !base || key.startsWith(base)) + .map(([key, value]) => [!base ? key : key.substring(base.length), value]) + ) +} + // wrap glob in a promise and enforce that the paths are always posix-style export async function glob(pattern: string) { return await promisify(G)(path.posixify(pattern)) diff --git a/packages/houdini/src/lib/parse.ts b/packages/houdini/src/lib/parse.ts index 2e3c1c96a..bd08f9989 100644 --- a/packages/houdini/src/lib/parse.ts +++ b/packages/houdini/src/lib/parse.ts @@ -9,7 +9,7 @@ export type ParsedFile = Maybe<{ script: Script; start: number; end: number }> // we can't use the recast parser because it normalizes template strings which break the graphql function // overload definitions -export async function parseJS(str: string, config?: Partial): Promise