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