diff --git a/.changeset/plugin-config-typing.md b/.changeset/plugin-config-typing.md new file mode 100644 index 000000000..afc7ff8a2 --- /dev/null +++ b/.changeset/plugin-config-typing.md @@ -0,0 +1,5 @@ +--- +"houdini": patch +--- + +Allow locally-defined plugins referenced by path in the `plugins` config while keeping type checking for known plugin options. diff --git a/.changeset/slick-llamas-hammer.md b/.changeset/slick-llamas-hammer.md new file mode 100644 index 000000000..c29f3f661 --- /dev/null +++ b/.changeset/slick-llamas-hammer.md @@ -0,0 +1,5 @@ +--- +"houdini-react": minor +--- + +Add a devtools overlay for inspecting client-side requests, controlled by the `devtools` plugin config value (`'dev'`, `'always'`, or `'never'`). diff --git a/docs/react/05-guides/08-devtools.mdx b/docs/react/05-guides/08-devtools.mdx new file mode 100644 index 000000000..92c321d8d --- /dev/null +++ b/docs/react/05-guides/08-devtools.mdx @@ -0,0 +1,43 @@ +--- +title: Devtools +description: Inspecting Houdini's client-side requests with the devtools overlay +--- + +# Devtools + +Houdini ships with a devtools overlay that records every client-side request our +application sends: queries, mutations, and subscriptions. It shows each document's +lifecycle, its variables, the data it resolved with, whether it was served from the +cache or the network, and any errors along the way. During development a small hat +appears in the corner of the page; clicking it opens the panel. + +The overlay renders inside a shadow root, so its styles never leak into our +application (and ours never leak into it). + +## Configuration + +The overlay is enabled during development by default and dropped from production +builds entirely, so there is nothing to configure for the usual workflow. If you want +different behavior, the `devtools` value in the plugin config controls when the +overlay is available: + +```javascript title="houdini.config.js" +export default { + plugins: { + 'houdini-react': { + devtools: 'always' + } + } +} +``` + +There are three modes: + +- `dev` (the default): the overlay is only available during development. Production + builds drop it entirely, so it adds nothing to the bundle we ship. +- `always`: the overlay is available in development and in production. This is handy + for staging environments or debugging a deployed app. +- `never`: the overlay is disabled and never bundled. + +Since the mode is resolved during code generation, changing it requires a fresh +`generate` (the vite plugin takes care of this on the next dev server start or build). diff --git a/e2e/react/houdini.config.ts b/e2e/react/houdini.config.ts index a8ef8c9e7..9bf7f40f1 100644 --- a/e2e/react/houdini.config.ts +++ b/e2e/react/houdini.config.ts @@ -1,5 +1,4 @@ -// @ts-ignore -/// +/// import type { ConfigFile } from 'houdini' const config: ConfigFile = { @@ -47,7 +46,9 @@ const config: ConfigFile = { }, plugins: { - 'houdini-react': {}, + 'houdini-react': { + devtools: 'always', + }, './plugins/node-plugin.mjs': {}, }, diff --git a/e2e/react/src/routes/devtools/+page.gql b/e2e/react/src/routes/devtools/+page.gql new file mode 100644 index 000000000..7cbcce941 --- /dev/null +++ b/e2e/react/src/routes/devtools/+page.gql @@ -0,0 +1,3 @@ +query DevtoolsQuery { + hello +} diff --git a/e2e/react/src/routes/devtools/+page.tsx b/e2e/react/src/routes/devtools/+page.tsx new file mode 100644 index 000000000..e3ee9c168 --- /dev/null +++ b/e2e/react/src/routes/devtools/+page.tsx @@ -0,0 +1,32 @@ +import { graphql, useMutation } from '$houdini' + +import type { PageProps } from './$types' + +export default function ({ DevtoolsQuery }: PageProps) { + const [update] = useMutation( + graphql(` + mutation DevtoolsUpdateMutation($snapshot: String!, $id: ID!, $name: String!) { + updateUser(id: $id, snapshot: $snapshot, name: $name) { + id + name + } + } + `) + ) + + return ( + <> +
{DevtoolsQuery.hello}
+ + + ) +} diff --git a/e2e/react/src/routes/devtools/test.ts b/e2e/react/src/routes/devtools/test.ts new file mode 100644 index 000000000..2727599c0 --- /dev/null +++ b/e2e/react/src/routes/devtools/test.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' + +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('devtools overlay captures client requests', async ({ page }) => { + await goto(page, routes.devtools) + + // make sure the page rendered before interacting + await expect(page.locator('#result')).toHaveText('Hello World! // From Houdini!') + + // fire a client-side request for the overlay to capture + await page.click('#trigger-mutation') + + // the overlay mounts shortly after hydration; playwright locators pierce the open shadow root + const overlay = page.locator('#houdini-devtools-overlay') + await overlay.locator('.hdt-trigger').click() + + // the mutation shows up in the request list + const row = overlay.locator('.hdt-row', { hasText: 'DevtoolsUpdateMutation' }) + await expect(row).toBeVisible() + + // selecting it shows its variables in the default tab + await row.click() + await expect(overlay.locator('.hdt-pre')).toContainText('Devtools User') +}) diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts index da152abad..6f289e414 100644 --- a/e2e/react/src/utils/routes.ts +++ b/e2e/react/src/utils/routes.ts @@ -1,6 +1,7 @@ export const routes = { api: '/_api', hello: '/hello-world', + devtools: '/devtools', use_query: '/use-query', use_query_rerender: '/use-query-rerender', use_query_reactivity: '/use-query-reactivity', diff --git a/packages/_scripts/buildNode.js b/packages/_scripts/buildNode.js index a8c9963d2..4034541a9 100644 --- a/packages/_scripts/buildNode.js +++ b/packages/_scripts/buildNode.js @@ -280,7 +280,7 @@ export async function build({ outDir, packages, source, bundle = true, plugin, c // copy runtime files as raw .ts/.tsx files without compilation export async function copyRuntimeFiles({ outDir, source }) { - // find all .ts, .tsx, and .json files in the runtime directory, excluding test files + // find all .ts, .tsx, .css, and .json files in the runtime directory, excluding test files // (tsconfig.json and similar config files need to be included for Go plugins that read from runtimeDir) const files = ( await Promise.all([ @@ -288,6 +288,9 @@ export async function copyRuntimeFiles({ outDir, source }) { nodir: true, ignore: ['**/*.test.*', '**/test.ts'], }), + glob(path.join(source, '**/*.css').replaceAll('\\', '/'), { + nodir: true, + }), glob(path.join(source, '**/*.json').replaceAll('\\', '/'), { nodir: true, ignore: ['**/package.json'], diff --git a/packages/create-houdini/templates/react-typescript/houdini.config.js b/packages/create-houdini/templates/react-typescript/houdini.config.js index 72bd7a21d..923667544 100644 --- a/packages/create-houdini/templates/react-typescript/houdini.config.js +++ b/packages/create-houdini/templates/react-typescript/houdini.config.js @@ -1,4 +1,4 @@ -/// +/// /** @type {import('houdini').ConfigFile} */ const config = {'CONFIG_FILE' plugins: { diff --git a/packages/create-houdini/templates/react/houdini.config.js b/packages/create-houdini/templates/react/houdini.config.js index 72bd7a21d..923667544 100644 --- a/packages/create-houdini/templates/react/houdini.config.js +++ b/packages/create-houdini/templates/react/houdini.config.js @@ -1,4 +1,4 @@ -/// +/// /** @type {import('houdini').ConfigFile} */ const config = {'CONFIG_FILE' plugins: { diff --git a/packages/houdini-react/package/lib/index.ts b/packages/houdini-react/package/lib/index.ts new file mode 100644 index 000000000..ab2d0256c --- /dev/null +++ b/packages/houdini-react/package/lib/index.ts @@ -0,0 +1,19 @@ +declare module 'houdini' { + // @ts-ignore + interface HoudiniPluginConfig { + 'houdini-react': HoudiniReactConfig + } +} + +export type HoudiniReactConfig = { + /** + * Controls when the Houdini React devtools overlay is shown. + * + * - `dev`: only during development (production builds drop the overlay entirely) + * - `always`: in development and production + * - `never`: never (the overlay is never bundled) + * + * @default 'dev' + */ + devtools?: 'dev' | 'always' | 'never' +} diff --git a/packages/houdini-react/plugin/runtime.go b/packages/houdini-react/plugin/runtime.go index 4b9e0ea67..a3c9a3609 100644 --- a/packages/houdini-react/plugin/runtime.go +++ b/packages/houdini-react/plugin/runtime.go @@ -2,6 +2,7 @@ package plugin import ( "context" + "encoding/json" "fmt" "path/filepath" "sort" @@ -28,7 +29,21 @@ func (p *HoudiniReact) TransformRuntime(ctx context.Context, fp string, content } } - switch fp { + switch filepath.ToSlash(fp) { + case "devtools/index.ts": + // the devtools entry is generated from the plugin config so that disabled modes + // never import the overlay and bundlers can drop it entirely + switch p.devtoolsMode(ctx) { + case "never": + return "export default null\n", nil + case "always": + return "import plugin from './plugin.js'\n\nexport default plugin\n", nil + default: + // 'dev': keep the import.meta.env.DEV guard from the static file so + // production builds tree-shake the overlay + return content, nil + } + case "client.ts": projectConfig, err := p.DB.ProjectConfig(ctx) if err != nil { @@ -80,6 +95,27 @@ func (p *HoudiniReact) TransformRuntime(ctx context.Context, fp string, content return content, nil } +// devtoolsMode reads the `devtools` value from the plugin's config in houdini.config, +// defaulting to 'dev'. It queries the plugins table directly instead of going through +// PluginConfig so a missing row (nothing configured) falls back cleanly. +func (p *HoudiniReact) devtoolsMode(ctx context.Context) string { + var configJSON string + _ = p.DB.StepQuery(ctx, `SELECT config FROM plugins WHERE name = 'houdini-react'`, nil, func(q plugins.Row) { + configJSON = q.ColumnText(0) + }) + if configJSON == "" { + return "dev" + } + + var config struct { + Devtools string `json:"devtools"` + } + if err := json.Unmarshal([]byte(configJSON), &config); err != nil || config.Devtools == "" { + return "dev" + } + return config.Devtools +} + // UpdateIndexFiles injects typed graphql() overloads into the core runtime index.ts. // For each visible document it adds an import of the artifact type and an overload that // maps the document's literal template string to { artifact: ${name}$artifact }. diff --git a/packages/houdini-react/plugin/runtime_test.go b/packages/houdini-react/plugin/runtime_test.go index 4a584987c..870fc4839 100644 --- a/packages/houdini-react/plugin/runtime_test.go +++ b/packages/houdini-react/plugin/runtime_test.go @@ -122,6 +122,79 @@ func TestTransformRuntimeLoginURL(t *testing.T) { }) } +// devtoolsIndexStub mirrors the static devtools/index.ts entry that ships with the +// runtime: the 'dev' default that gates the overlay behind import.meta.env.DEV. +const devtoolsIndexStub = "import plugin from './plugin.js'\n\n" + + "// @ts-ignore: vite provides import.meta.env\n" + + "export default import.meta.env.DEV ? plugin : null\n" + +// TestTransformRuntimeDevtools verifies that the devtools entry is generated from the +// plugin's `devtools` config value: 'dev' (and no config at all) keeps the static +// import.meta.env.DEV guard, 'always' exports the plugin unconditionally, and 'never' +// exports null without importing the overlay. +func TestTransformRuntimeDevtools(t *testing.T) { + tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{ + Schema: `type Query { id: ID }`, + + SetupTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + config, _ := test.Extra["config"].(string) + if config == "" { + return + } + ctx := context.Background() + conn, err := p.DB.Take(ctx) + require.NoError(t, err) + defer p.DB.Put(conn) + stmt, err := conn.Prepare( + `INSERT INTO plugins (name, port, hooks, config) VALUES ('houdini-react', 0, '[]', $config)`, + ) + require.NoError(t, err) + require.NoError(t, p.DB.ExecStatement(stmt, map[string]any{"config": config})) + stmt.Finalize() + }, + + PerformTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + got, err := p.TransformRuntime(context.Background(), "devtools/index.ts", devtoolsIndexStub) + require.NoError(t, err) + require.Equal(t, test.Extra["expected"].(string), got) + }, + + Tests: []tests.Test[coreConfig.PluginConfig]{ + { + Name: "defaults to the dev guard when no plugin config exists", + Pass: true, + Extra: map[string]any{ + "expected": devtoolsIndexStub, + }, + }, + { + Name: "keeps the dev guard for devtools: dev", + Pass: true, + Extra: map[string]any{ + "config": `{"devtools":"dev"}`, + "expected": devtoolsIndexStub, + }, + }, + { + Name: "exports the plugin unconditionally for devtools: always", + Pass: true, + Extra: map[string]any{ + "config": `{"devtools":"always"}`, + "expected": "import plugin from './plugin.js'\n\nexport default plugin\n", + }, + }, + { + Name: "exports null for devtools: never", + Pass: true, + Extra: map[string]any{ + "config": `{"devtools":"never"}`, + "expected": "export default null\n", + }, + }, + }, + }) +} + // indexStub mirrors the real core runtime index.ts: it has leading imports and // exports before the generic graphql() declaration, so tests verify that // overloads land immediately before the marker rather than at the file top. diff --git a/packages/houdini-react/runtime/clientPlugin.ts b/packages/houdini-react/runtime/clientPlugin.ts index e81c02a80..17dd3b75d 100644 --- a/packages/houdini-react/runtime/clientPlugin.ts +++ b/packages/houdini-react/runtime/clientPlugin.ts @@ -1,17 +1,22 @@ import type { ClientPlugin } from 'houdini/runtime/client' +import devtools from './devtools/index.js' + const plugin: () => ClientPlugin = () => () => { - return { - start(ctx, { next }) { - next({ - ...ctx, - cacheParams: { - ...ctx.cacheParams, - serverSideFallback: false, - }, - }) + return [ + { + start(ctx, { next }) { + next({ + ...ctx, + cacheParams: { + ...ctx.cacheParams, + serverSideFallback: false, + }, + }) + }, }, - } + devtools, + ] } export default plugin diff --git a/packages/houdini-react/runtime/devtools/HatLogo.tsx b/packages/houdini-react/runtime/devtools/HatLogo.tsx new file mode 100644 index 000000000..9c2d94fc0 --- /dev/null +++ b/packages/houdini-react/runtime/devtools/HatLogo.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +export function HatLogo() { + return ( + + ) +} diff --git a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx new file mode 100644 index 000000000..3d051578a --- /dev/null +++ b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx @@ -0,0 +1,301 @@ +import React from 'react' + +import { HatLogo } from './HatLogo' +import { clearRequests, getSnapshot, subscribe } from './store' +import type { DevToolRequest, RequestSource } from './type' + +type DetailTab = 'variables' | 'data' | 'errors' +type SourceFilterValue = 'all' | RequestSource + +const DEFAULT_PANEL_HEIGHT_RATIO = 0.48 +const MIN_PANEL_HEIGHT = 280 +const MAX_PANEL_OFFSET = 48 + +export function HoudiniDevtools() { + const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const [open, setOpen] = React.useState(false) + const [selectedId, setSelectedId] = React.useState(null) + const [detailTab, setDetailTab] = React.useState('variables') + const [sourceFilter, setSourceFilter] = React.useState('all') + const [panelHeight, setPanelHeight] = React.useState(() => + typeof window === 'undefined' + ? 390 + : Math.round(window.innerHeight * DEFAULT_PANEL_HEIGHT_RATIO) + ) + + // pending durations are sampled at render time, so tick while the panel is open + // and something is still in flight + const hasPending = snapshot.requests.some((request) => request.status === 'pending') + const [, tick] = React.useReducer((count: number) => count + 1, 0) + React.useEffect(() => { + if (!open || !hasPending) { + return + } + + const interval = window.setInterval(tick, 250) + return () => window.clearInterval(interval) + }, [open, hasPending]) + + const filteredRequests = React.useMemo(() => { + if (sourceFilter === 'all') { + return snapshot.requests + } + + return snapshot.requests.filter((request) => getRequestSource(request) === sourceFilter) + }, [snapshot.requests, sourceFilter]) + + const latest = filteredRequests[0] ?? snapshot.requests[0] + const selected = filteredRequests.find((request) => request.id === selectedId) ?? latest + + const startResize = React.useCallback((event: React.MouseEvent) => { + event.preventDefault() + + const resize = (moveEvent: MouseEvent) => { + const max = window.innerHeight - MAX_PANEL_OFFSET + setPanelHeight(clamp(window.innerHeight - moveEvent.clientY, MIN_PANEL_HEIGHT, max)) + } + + const stop = () => { + window.removeEventListener('mousemove', resize) + window.removeEventListener('mouseup', stop) + } + + window.addEventListener('mousemove', resize) + window.addEventListener('mouseup', stop) + }, []) + + return ( +
+ {open ? ( +
+
+
+
+ + Houdini Devtools + {snapshot.requests.length} requests +
+
+ + +
+
+ +
+
+
+
+
Requests
+
+ {filteredRequests.length} shown +
+
+ +
+ {filteredRequests.map((request) => ( + + ))} + {filteredRequests.length === 0 ? ( +
No requests match this filter.
+ ) : null} +
+ +
+ {selected ? ( + <> +
+
+

{selected.name}

+
+
+ + {selected.events.length} lifecycle events + +
+
+
+ setDetailTab('variables')} + > + Variables + + setDetailTab('data')} + > + Data + + setDetailTab('errors')} + > + Errors + +
+ + {detailTab === 'variables' ? ( +
+ ) : null} + {detailTab === 'data' ? ( +
+ ) : null} + {detailTab === 'errors' ? ( +
+ ) : null} + + ) : ( +
No Houdini requests captured yet.
+ )} +
+
+
+ ) : ( + + )} +
+ ) +} + +function SourceFilter({ + value, + onChange, +}: { + value: SourceFilterValue + onChange: (value: SourceFilterValue) => void +}) { + return ( +
+ {(['all', 'cache', 'network'] as SourceFilterValue[]).map((option) => ( + + ))} +
+ ) +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +function RequestDot({ request }: { request: DevToolRequest }) { + return +} + +function getRequestSource(request: DevToolRequest) { + return request.status === 'success' ? request.result.source : null +} + +function displayKind(kind: DevToolRequest['kind']) { + return kind.replace('Houdini', '').toLowerCase() +} + +function getDurationMs(request: DevToolRequest) { + return Math.round(getFinishedAt(request) - request.startedAt) +} + +function getFinishedAt(request: DevToolRequest) { + return request.status === 'pending' ? performance.now() : request.finishedAt +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function Section({ title, value }: { title: string; value: unknown }) { + const [copied, setCopied] = React.useState(false) + const text = JSON.stringify(value ?? null, null, 2) + + const copy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), 1200) + } + + return ( +
+
+
{title}
+ +
+
{text}
+
+ ) +} diff --git a/packages/houdini-react/runtime/devtools/index.ts b/packages/houdini-react/runtime/devtools/index.ts new file mode 100644 index 000000000..2d445c577 --- /dev/null +++ b/packages/houdini-react/runtime/devtools/index.ts @@ -0,0 +1,8 @@ +import plugin from './plugin.js' + +// this file is rewritten during codegen based on the plugin's `devtools` config value: +// 'dev' (the default) keeps the import.meta.env.DEV guard below so production builds +// tree-shake the overlay, 'always' exports the plugin unconditionally, and 'never' +// exports null without importing the plugin at all. +// @ts-ignore: vite provides import.meta.env +export default import.meta.env.DEV ? plugin : null diff --git a/packages/houdini-react/runtime/devtools/plugin.ts b/packages/houdini-react/runtime/devtools/plugin.ts new file mode 100644 index 000000000..adbffded9 --- /dev/null +++ b/packages/houdini-react/runtime/devtools/plugin.ts @@ -0,0 +1,132 @@ +/// + +import type { DocumentArtifact } from 'houdini/runtime' +import type { ClientPlugin } from 'houdini/runtime/client' +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' + +import { HoudiniDevtools } from './HoudiniDevtools' +import { addRequestEvent, createRequest, failRequest, succeedRequest } from './store' +import styles from './styles.css?inline' +import type { RequestKind } from './type' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let mountQueued = false + +function mountOverlay() { + if (typeof document === 'undefined') { + return + } + + if (root && container?.isConnected) { + return + } + + container = document.createElement('div') + container.id = 'houdini-devtools-overlay' + document.body.appendChild(container) + + const shadowRoot = container.attachShadow({ mode: 'open' }) + const style = document.createElement('style') + style.textContent = styles + shadowRoot.appendChild(style) + + const mountPoint = document.createElement('div') + shadowRoot.appendChild(mountPoint) + + root = createRoot(mountPoint) + root.render(React.createElement(HoudiniDevtools)) +} + +function scheduleMountOverlay() { + if (typeof window === 'undefined' || mountQueued) { + return + } + + if (root && container?.isConnected) { + return + } + + mountQueued = true + + const mountAfterHydration = () => { + window.setTimeout(() => { + mountQueued = false + mountOverlay() + }, 100) + } + + if (document.readyState === 'complete') { + mountAfterHydration() + } else { + window.addEventListener('load', mountAfterHydration, { once: true }) + } +} + +function isRequestKind(kind: DocumentArtifact['kind']): kind is RequestKind { + return kind !== 'HoudiniFragment' +} + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error + } + + return new Error(typeof error === 'string' ? error : JSON.stringify(error)) +} + +const devToolPlugin: ClientPlugin = () => { + if (typeof window === 'undefined') { + return {} + } + + return { + start(ctx, { next }) { + if (!isRequestKind(ctx.artifact.kind)) { + next(ctx) + return + } + + scheduleMountOverlay() + createRequest(ctx, ctx.artifact.kind) + addRequestEvent(ctx, 'start') + next(ctx) + }, + beforeNetwork(ctx, { next }) { + addRequestEvent(ctx, 'beforeNetwork') + next(ctx) + }, + network(ctx, { next }) { + addRequestEvent(ctx, 'network') + next(ctx) + }, + afterNetwork(ctx, { resolve }) { + addRequestEvent(ctx, 'afterNetwork') + resolve(ctx) + }, + end(ctx, { value, resolve }) { + addRequestEvent(ctx, 'end') + if (value.errors?.length) { + failRequest(ctx, new Error(value.errors.map((error) => error.message).join('\n'))) + } else { + succeedRequest(ctx, value) + } + resolve(ctx) + }, + catch(ctx, { error }) { + addRequestEvent(ctx, 'catch') + failRequest(ctx, normalizeError(error)) + throw error + }, + cleanup(ctx) { + // a store that never ran a request cleans up with a null context + if (!ctx) { + return + } + addRequestEvent(ctx, 'cleanup') + }, + } +} + +export default devToolPlugin diff --git a/packages/houdini-react/runtime/devtools/store.ts b/packages/houdini-react/runtime/devtools/store.ts new file mode 100644 index 000000000..0711bb566 --- /dev/null +++ b/packages/houdini-react/runtime/devtools/store.ts @@ -0,0 +1,157 @@ +import type { QueryResult } from 'houdini/runtime' +import type { ClientPluginContext } from 'houdini/runtime/documentStore' + +import type { DevToolRequest, DevToolState, RequestKind, RequestPhase } from './type' + +const MAX_REQUESTS = 50 + +let requestCounter = 0 +let eventCounter = 0 +// using abortcontrolller as weakmap to identify request across hooks +let requestIdsBySignal = new WeakMap() +let state: DevToolState = { + requests: [], + activeRequest: null, +} + +const listeners = new Set<() => void>() + +function emit() { + for (const listener of listeners) { + listener() + } +} + +function setState(nextState: DevToolState) { + state = nextState + emit() +} + +function now() { + return performance.now() +} + +function nextRequestId() { + requestCounter += 1 + return String(requestCounter) +} + +function nextEventId() { + eventCounter += 1 + return String(eventCounter) +} + +function getRequestId(ctx: ClientPluginContext) { + return requestIdsBySignal.get(ctx.abortController.signal) +} + +function updateRequest( + requestId: string | undefined, + updater: (request: DevToolRequest) => DevToolRequest +) { + if (!requestId) { + return + } + + let activeRequest = state.activeRequest + const requests = state.requests.map((request) => { + if (request.id !== requestId) { + return request + } + + const nextRequest = updater(request) + if (activeRequest?.id === requestId) { + activeRequest = nextRequest + } + return nextRequest + }) + + setState({ + requests, + activeRequest, + }) +} + +export function subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function getSnapshot() { + return state +} + +export function createRequest(ctx: ClientPluginContext, kind: RequestKind) { + // only keep what the panel renders (name + variables) instead of the whole plugin + // context so requests don't retain artifacts, stores, and session references + const request: DevToolRequest = { + id: nextRequestId(), + kind, + name: ctx.name, + variables: ctx.variables ?? null, + status: 'pending', + startedAt: now(), + events: [], + } + + requestIdsBySignal.set(ctx.abortController.signal, request.id) + + setState({ + requests: [request, ...state.requests].slice(0, MAX_REQUESTS), + activeRequest: request, + }) + + return request.id +} + +export function addRequestEvent(ctx: ClientPluginContext, phase: RequestPhase) { + updateRequest(getRequestId(ctx), (request) => ({ + ...request, + // variables can be resolved later in the pipeline, so track the latest value + variables: ctx.variables ?? request.variables, + events: [ + ...request.events, + { + id: nextEventId(), + phase, + timestamp: now(), + }, + ], + })) +} + +export function succeedRequest(ctx: ClientPluginContext, result: QueryResult) { + updateRequest(getRequestId(ctx), (request) => ({ + id: request.id, + kind: request.kind, + name: request.name, + variables: ctx.variables ?? request.variables, + status: 'success', + startedAt: request.startedAt, + finishedAt: now(), + events: request.events, + result, + })) +} + +export function failRequest(ctx: ClientPluginContext, error: Error) { + updateRequest(getRequestId(ctx), (request) => ({ + id: request.id, + kind: request.kind, + name: request.name, + variables: ctx.variables ?? request.variables, + status: 'error', + startedAt: request.startedAt, + finishedAt: now(), + events: request.events, + error, + })) +} + +export function clearRequests() { + requestIdsBySignal = new WeakMap() + setState({ + requests: [], + activeRequest: null, + }) +} diff --git a/packages/houdini-react/runtime/devtools/styles.css b/packages/houdini-react/runtime/devtools/styles.css new file mode 100644 index 000000000..ab8562355 --- /dev/null +++ b/packages/houdini-react/runtime/devtools/styles.css @@ -0,0 +1,479 @@ +.hdt { + --hdt-bg: #101113; + --hdt-panel: #17191c; + --hdt-header: #1f252c; + --hdt-raised: #171d26; + --hdt-text: #f4f7fb; + --hdt-muted: #a8b3c2; + --hdt-dim: #6f7b8a; + --hdt-line: #2a3038; + --hdt-blue: #7fb4ff; + --hdt-blue-bg: rgba(127, 180, 255, 0.13); + --hdt-ok: #7db65d; + --hdt-warn: #d7b45a; + --hdt-error: #ff6b58; + + position: fixed; + z-index: 999999; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 13px; + line-height: 1.4; + color: var(--hdt-text); +} + +.hdt, +.hdt * { + box-sizing: border-box; +} + +.hdt button, +.hdt button:hover, +.hdt button:focus, +.hdt button:focus-visible, +.hdt button:active { + appearance: none !important; + -webkit-appearance: none !important; + margin: 0 !important; + font: inherit !important; + line-height: inherit !important; + letter-spacing: inherit !important; + text-align: inherit !important; + text-decoration: none !important; + text-transform: none !important; + transform: none !important; + transition: none !important; + animation: none !important; + outline: none !important; + box-shadow: none !important; +} + +.hdt--open { + left: 0; + right: 0; + bottom: 0; +} + +.hdt--closed { + right: 18px; + bottom: 18px; +} + +.hdt-panel { + position: relative; + width: 100vw; + min-height: 320px; + max-height: calc(100vh - 48px); + background: var(--hdt-bg); + border-top: 1px solid var(--hdt-line); + box-shadow: 0 -18px 48px rgba(0, 0, 0, 0.38); + overflow: hidden; +} + +.hdt-resize-handle { + position: absolute; + z-index: 2; + left: 0; + top: 0; + width: 100%; + height: 8px; + cursor: ns-resize; +} + +.hdt-resize-handle::before { + content: ""; + position: absolute; + left: 50%; + top: 2px; + width: 54px; + height: 3px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + transform: translateX(-50%); +} + +.hdt-header { + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + background: var(--hdt-header); + border-bottom: 1px solid var(--hdt-line); +} + +.hdt-title, +.hdt-actions, +.hdt-list-toolbar, +.hdt-row-title, +.hdt-row-meta, +.hdt-detail-header, +.hdt-heading-row, +.hdt-summary, +.hdt-trigger, +.hdt-filter { + display: flex; + align-items: center; +} + +.hdt-title { + gap: 8px; + font-weight: 700; + color: var(--hdt-text) !important; +} + +.hdt-title .hdt-hat { + width: 18px; + height: 18px; +} + +.hdt-title strong { + color: var(--hdt-text) !important; +} + +.hdt-count, +.hdt-row-meta, +.hdt-muted { + color: var(--hdt-muted) !important; +} + +.hdt-actions { + gap: 8px; +} + +.hdt-button, +.hdt-filter-button { + padding: 5px 10px !important; + border: 1px solid var(--hdt-line) !important; + border-radius: 6px !important; + background: #15191f !important; + color: var(--hdt-text) !important; + cursor: pointer !important; +} + +.hdt-button:hover, +.hdt-filter-button:hover, +.hdt-filter-button--active { + background: var(--hdt-blue-bg) !important; + border-color: rgba(127, 180, 255, 0.38) !important; +} + +.hdt-filter { + gap: 4px; +} + +.hdt-filter-button { + padding: 4px 8px !important; + color: var(--hdt-muted) !important; + font-size: 12px !important; +} + +.hdt-filter-button--active { + color: var(--hdt-text) !important; +} + +.hdt-body { + display: grid; + grid-template-columns: 400px 1fr; + height: calc(100% - 44px); + min-height: 0; +} + +.hdt-list, +.hdt-detail { + overflow: auto; + min-height: 0; +} + +.hdt-list { + background: var(--hdt-panel); + border-right: 1px solid var(--hdt-line); +} + +.hdt-list-toolbar { + position: sticky; + top: 0; + z-index: 1; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--hdt-line); + background: var(--hdt-panel); +} + +.hdt-list-title { + font-size: 12px; + font-weight: 750; + color: var(--hdt-text); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.hdt-list-count { + margin-top: 1px; + font-size: 11px; + color: var(--hdt-dim); +} + +.hdt-row { + position: relative; + width: 100%; + padding: 10px 14px 10px 18px !important; + border: 0 !important; + border-bottom: 1px solid var(--hdt-line) !important; + border-radius: 0 !important; + background: transparent !important; + color: var(--hdt-text) !important; + cursor: pointer !important; +} + +.hdt-row::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: transparent; +} + +.hdt-row:hover { + background: rgba(127, 180, 255, 0.06) !important; +} + +.hdt-row--selected { + background: var(--hdt-blue-bg) !important; +} + +.hdt-row--selected::before { + background: var(--hdt-blue); +} + +.hdt-row-title { + gap: 8px; + min-width: 0; + font-size: 13px; + font-weight: 650; + color: var(--hdt-text); +} + +.hdt-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hdt-row-kind { + flex: 0 0 auto; + margin-left: auto; + color: var(--hdt-dim); + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.hdt-row-meta { + gap: 6px; + margin-top: 4px; + padding-left: 16px; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hdt-row-source { + font-size: 13px; + font-weight: 750; +} + +.hdt-row-source--network { + color: var(--hdt-blue); +} + +.hdt-row-source--cache { + color: var(--hdt-warn); +} + +.hdt-row-source--unknown { + color: var(--hdt-dim); +} + +.hdt-detail { + padding: 20px 24px 28px; + background: var(--hdt-bg); +} + +.hdt-detail-header { + justify-content: space-between; + gap: 16px; + margin: 0 0 18px; +} + +.hdt-heading-row { + gap: 10px; + min-width: 0; +} + +.hdt-heading { + min-width: 0; + margin: 0; + font-size: 22px; + line-height: 1.15; + font-weight: 700; + color: var(--hdt-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hdt-summary { + flex: 0 0 auto; + justify-content: flex-end; + gap: 8px; + max-width: 52%; + flex-wrap: wrap; +} + +.hdt-summary-badge { + flex: 0 0 auto; + padding: 1px 6px; + border: 1px solid var(--hdt-line); + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + color: var(--hdt-muted); + font-size: 11px; + font-weight: 700; + line-height: 1.5; +} + +.hdt-tabs { + display: flex; + gap: 22px; + margin-bottom: 18px; + border-bottom: 1px solid var(--hdt-line); +} + +.hdt-tab { + padding: 0 0 9px !important; + border: 0 !important; + border-bottom: 2px solid transparent !important; + border-radius: 0 !important; + background: transparent !important; + color: var(--hdt-muted) !important; + font-size: 13px !important; + font-weight: 650 !important; + cursor: pointer !important; +} + +.hdt-tab:hover, +.hdt-tab--active, +.hdt-tab--active:hover { + color: var(--hdt-text) !important; + background: transparent !important; +} + +.hdt-tab--active, +.hdt-tab--active:hover { + border-bottom-color: var(--hdt-blue) !important; +} + +.hdt-section { + margin-bottom: 14px; +} + +.hdt-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.hdt-section-title { + font-size: 14px; + font-weight: 650; + color: var(--hdt-text); +} + +.hdt-copy { + padding: 3px 8px !important; + border: 1px solid var(--hdt-line) !important; + border-radius: 4px !important; + background: transparent !important; + color: var(--hdt-muted) !important; + font-size: 12px !important; + cursor: pointer !important; +} + +.hdt-copy:hover { + background: var(--hdt-blue-bg) !important; + color: var(--hdt-text) !important; +} + +.hdt-pre { + margin: 0; + padding: 12px; + border: 1px solid var(--hdt-line); + border-radius: 4px; + background: var(--hdt-raised); + color: var(--hdt-text); + overflow: auto; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Monaco, monospace; + font-size: 12px; + line-height: 1.55; +} + +.hdt-empty { + padding: 18px; + color: var(--hdt-muted); +} + +.hdt-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--hdt-dim); + flex: 0 0 auto; +} + +.hdt-dot--success { + background: var(--hdt-ok); +} + +.hdt-dot--pending { + background: var(--hdt-warn); +} + +.hdt-dot--error { + background: var(--hdt-error); +} + +.hdt-trigger, +.hdt-trigger:hover, +.hdt-trigger:focus, +.hdt-trigger:active { + justify-content: center; + width: 64px; + height: 64px; + padding: 0 !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + border-radius: 18px !important; + background: #252a31 !important; + color: #fff !important; + cursor: pointer !important; + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.42) !important; +} + +.hdt-trigger .hdt-hat { + width: 42px; + height: 42px; +} + +.hdt-hat { + fill: currentColor; + color: #fff; + flex: 0 0 auto; +} diff --git a/packages/houdini-react/runtime/devtools/type.ts b/packages/houdini-react/runtime/devtools/type.ts new file mode 100644 index 000000000..ff0a053ad --- /dev/null +++ b/packages/houdini-react/runtime/devtools/type.ts @@ -0,0 +1,48 @@ +import type { DocumentArtifact, QueryResult } from 'houdini/runtime' +import type { ClientHooks } from 'houdini/runtime/documentStore' + +export type RequestStatus = 'pending' | 'success' | 'error' + +export type RequestSource = NonNullable + +export type RequestPhase = keyof ClientHooks + +export type RequestEvent = { + id: string + phase: RequestPhase + timestamp: number +} + +export type RequestKind = Exclude + +type BaseRequest = { + id: string + kind: RequestKind + name: string + variables: Record | null + startedAt: number + events: RequestEvent[] +} + +type PendingRequest = BaseRequest & { + status: 'pending' +} + +type SuccessfulRequest = BaseRequest & { + status: 'success' + finishedAt: number + result: QueryResult +} + +type ErrorRequest = BaseRequest & { + status: 'error' + finishedAt: number + error: Error +} + +export type DevToolRequest = PendingRequest | SuccessfulRequest | ErrorRequest + +export type DevToolState = { + requests: DevToolRequest[] + activeRequest: DevToolRequest | null +} diff --git a/packages/houdini-react/runtime/vite-env.d.ts b/packages/houdini-react/runtime/vite-env.d.ts new file mode 100644 index 000000000..ac8e774f1 --- /dev/null +++ b/packages/houdini-react/runtime/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*.css?inline' { + const content: string + export default content +} diff --git a/packages/houdini/src/lib/config.ts b/packages/houdini/src/lib/config.ts index b4d6bd8e3..f9e4d25bb 100644 --- a/packages/houdini/src/lib/config.ts +++ b/packages/houdini/src/lib/config.ts @@ -148,9 +148,11 @@ export type ConfigFile = { persistedQueriesPath?: string /** - * An object describing the plugins enabled for the project + * An object describing the plugins enabled for the project. Known plugins get + * their config checked against the types they register on HoudiniPluginConfig; + * the string index allows locally-defined plugins referenced by path. */ - plugins?: HoudiniPluginConfig + plugins?: HoudiniPluginConfig & Record /** * The relative path from your houdini config file pointing to your application. @@ -518,7 +520,6 @@ export class Config { } pluginConfig(name: string): ConfigType { - // @ts-expect-error return (this.config_file.plugins?.[name] as ConfigType) ?? {} } }