From aa75685a3b6fedf11d5a3519bceb348d8824e5a5 Mon Sep 17 00:00:00 2001 From: d2du Date: Thu, 11 Jun 2026 19:22:24 +0530 Subject: [PATCH 01/22] basid plugin setup working plugin registrer, component mount, sowing networks capturing events in requests --- e2e/react/plugins/devtool/HoudiniDevtools.tsx | 179 +++++++++ e2e/react/plugins/devtool/plugin.ts | 135 +++++++ e2e/react/plugins/devtool/store.ts | 147 ++++++++ e2e/react/plugins/devtool/styles.css | 350 ++++++++++++++++++ e2e/react/plugins/devtool/type.ts | 45 +++ e2e/react/src/+client.ts | 6 +- packages/houdini/package.json | 112 ++++++ packages/houdini/src/runtime/documentStore.ts | 2 +- 8 files changed, 974 insertions(+), 2 deletions(-) create mode 100644 e2e/react/plugins/devtool/HoudiniDevtools.tsx create mode 100644 e2e/react/plugins/devtool/plugin.ts create mode 100644 e2e/react/plugins/devtool/store.ts create mode 100644 e2e/react/plugins/devtool/styles.css create mode 100644 e2e/react/plugins/devtool/type.ts diff --git a/e2e/react/plugins/devtool/HoudiniDevtools.tsx b/e2e/react/plugins/devtool/HoudiniDevtools.tsx new file mode 100644 index 0000000000..887fe5559c --- /dev/null +++ b/e2e/react/plugins/devtool/HoudiniDevtools.tsx @@ -0,0 +1,179 @@ +import React from 'react' + +import { clearRequests, getSnapshot, subscribe } from './store' +import './styles.css' +import type { DevToolRequest } from './type' + +function StatusDot({ status }: { status: string }) { + return +} + +type DetailTab = 'timeline' | 'variables' | 'data' | 'errors' + +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('timeline') + + const latest = snapshot.requests[0] + const selected = snapshot.requests.find((request) => request.id === selectedId) ?? latest + + React.useEffect(() => { + if (!selected) { + return + } + + console.log('[Houdini Devtools] selected request', selected) + }, [selected]) + + return ( +
+ {open ? ( +
+
+
+ 🎩 + Houdini Devtools + {snapshot.requests.length} requests +
+
+ + +
+
+ +
+
+ {snapshot.requests.map((request) => ( + + ))} +
+ +
+ {selected ? ( + <> +

{selected.ctx.name}

+
+ {displayKind(selected.kind)} + {selected.status} + {getDurationMs(selected)}ms + {selected.status === 'success' ? {selected.result.source} : null} +
+
+ setDetailTab('timeline')}> + Timeline + + setDetailTab('variables')}> + Variables + + setDetailTab('data')}> + Data + + setDetailTab('errors')}> + Errors + +
+ + {detailTab === 'timeline' ? : null} + {detailTab === 'variables' ?
: null} + {detailTab === 'data' ?
: null} + {detailTab === 'errors' ? ( +
+ ) : null} + + ) : ( +
No Houdini requests captured yet.
+ )} +
+
+
+ ) : ( + + )} +
+ ) +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +function displayKind(kind: DevToolRequest['kind']) { + return kind.replace('Houdini', '').toLowerCase() +} + +function getDurationMs(request: DevToolRequest) { + const finishedAt = request.status === 'pending' ? performance.now() : request.finishedAt + return Math.round(finishedAt - request.startedAt) +} + +function Timeline({ request }: { request: DevToolRequest }) { + return ( +
+
Timeline
+
    + {request.events.map((event) => ( +
  1. + {event.phase} + +{Math.round(event.timestamp - request.startedAt)}ms + +
  2. + ))} +
+
+ ) +} + +function Section({ title, value }: { title: string; value: unknown }) { + return ( +
+
{title}
+
{JSON.stringify(value ?? null, null, 2)}
+
+ ) +} diff --git a/e2e/react/plugins/devtool/plugin.ts b/e2e/react/plugins/devtool/plugin.ts new file mode 100644 index 0000000000..005b7e502c --- /dev/null +++ b/e2e/react/plugins/devtool/plugin.ts @@ -0,0 +1,135 @@ +import type { ClientPlugin } from '$houdini' +import type { DocumentArtifact } from 'houdini/runtime' +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' + +import { HoudiniDevtools } from './HoudiniDevtools' +import styles from './styles.css?inline' +import { addRequestEvent, createRequest, failRequest, succeedRequest } from './store' +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 + } + + mountQueued = true + + const mountAfterHydration = () => { + window.setTimeout(() => { + mountQueued = false + mountOverlay() + renderOverlay() + }, 100) + } + + if (document.readyState === 'complete') { + mountAfterHydration() + } else { + window.addEventListener('load', mountAfterHydration, { once: true }) + } +} + +function renderOverlay() { + if (!root || !container?.isConnected) { + scheduleMountOverlay() + return + } + + root.render(React.createElement(HoudiniDevtools)) +} + +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 = () => { + return { + start(ctx, { next }) { + if (!isRequestKind(ctx.artifact.kind)) { + next(ctx) + return + } + + createRequest(ctx, ctx.artifact.kind) + addRequestEvent(ctx, 'start') + + renderOverlay() + next(ctx) + }, + beforeNetwork(ctx, { next }) { + addRequestEvent(ctx, 'beforeNetwork') + renderOverlay() + next(ctx) + }, + network(ctx, { next }) { + addRequestEvent(ctx, 'network') + renderOverlay() + next(ctx) + }, + afterNetwork(ctx, { resolve }) { + addRequestEvent(ctx, 'afterNetwork') + renderOverlay() + 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) + } + renderOverlay() + resolve(ctx) + }, + catch(ctx, { error }) { + addRequestEvent(ctx, 'catch') + failRequest(ctx, normalizeError(error)) + renderOverlay() + throw error + }, + cleanup(ctx) { + addRequestEvent(ctx, 'cleanup') + renderOverlay() + }, + } +} + +export default devToolPlugin diff --git a/e2e/react/plugins/devtool/store.ts b/e2e/react/plugins/devtool/store.ts new file mode 100644 index 0000000000..c577ff84cc --- /dev/null +++ b/e2e/react/plugins/devtool/store.ts @@ -0,0 +1,147 @@ +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) { + const request: DevToolRequest = { + id: nextRequestId(), + kind, + ctx, + 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, + 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, + ctx: request.ctx, + 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, + ctx: request.ctx, + status: 'error', + startedAt: request.startedAt, + finishedAt: now(), + events: request.events, + error, + })) +} + +export function clearRequests() { + requestIdsBySignal = new WeakMap() + setState({ + requests: [], + activeRequest: null, + }) +} diff --git a/e2e/react/plugins/devtool/styles.css b/e2e/react/plugins/devtool/styles.css new file mode 100644 index 0000000000..e34fe0d2b8 --- /dev/null +++ b/e2e/react/plugins/devtool/styles.css @@ -0,0 +1,350 @@ +.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-red: #ff5c3f; + --hdt-red-bg: rgba(255, 92, 63, 0.12); + --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: 16px; bottom: 16px; } + +.hdt-panel { + width: 100vw; + height: 64vh; + min-height: 390px; + 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-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-row-title, +.hdt-pills, +.hdt-trigger { + display: flex; + align-items: center; +} + +.hdt-title { + gap: 8px; + font-weight: 700; + color: var(--hdt-text) !important; +} + +.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 { + 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 { + background: var(--hdt-blue-bg) !important; + border-color: rgba(127, 180, 255, 0.38) !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-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-meta { + margin-top: 3px; + padding-left: 16px; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hdt-detail { + padding: 20px 24px 28px; + background: var(--hdt-bg); +} + +.hdt-heading { + margin: 0 0 6px; + font-size: 22px; + line-height: 1.15; + font-weight: 700; + color: var(--hdt-text); +} + +.hdt-pills { + gap: 0; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.hdt-pill { + padding: 0; + font-size: 13px; + font-weight: 500; + color: var(--hdt-muted); +} + +.hdt-pill + .hdt-pill::before { + content: "•"; + margin: 0 8px; + color: var(--hdt-dim); +} + +.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 { + color: var(--hdt-text) !important; + background: transparent !important; +} + +.hdt-tab--active, +.hdt-tab--active:hover { + color: var(--hdt-text) !important; + border-bottom-color: var(--hdt-blue) !important; + background: transparent !important; +} + +.hdt-section { margin-bottom: 14px; } + +.hdt-section-title { + margin-bottom: 10px; + font-size: 14px; + font-weight: 650; + color: var(--hdt-text); +} + +.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-timeline { + position: relative; + margin: 0; + padding: 2px 0 2px 22px; + list-style: none; +} + +.hdt-timeline::before { + content: ""; + position: absolute; + left: 6px; + top: 12px; + bottom: 12px; + width: 1px; + background: var(--hdt-line); +} + +.hdt-timeline-item { + position: relative; + display: grid; + grid-template-columns: 140px 72px 1fr; + gap: 12px; + padding: 6px 0; + font-size: 13px; + color: var(--hdt-text); +} + +.hdt-timeline-item::before { + content: ""; + position: absolute; + left: -19px; + top: 12px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--hdt-ok); + box-shadow: 0 0 0 3px var(--hdt-bg); +} + +.hdt-timeline-item--pending::before { background: var(--hdt-warn); } +.hdt-timeline-item--error::before { background: var(--hdt-error); } + +.hdt-timeline-time { + color: var(--hdt-muted); + font-variant-numeric: tabular-nums; +} + +.hdt-timeline-rule { + border-top: 1px solid var(--hdt-line); + align-self: center; +} + +.hdt-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--hdt-ok); + flex: 0 0 auto; +} + +.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 { + gap: 10px; + padding: 10px 12px !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + border-radius: 999px !important; + background: var(--hdt-header) !important; + color: var(--hdt-text) !important; + cursor: pointer !important; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35) !important; +} + +.hdt-trigger * { + color: inherit !important; +} diff --git a/e2e/react/plugins/devtool/type.ts b/e2e/react/plugins/devtool/type.ts new file mode 100644 index 0000000000..67fa04256e --- /dev/null +++ b/e2e/react/plugins/devtool/type.ts @@ -0,0 +1,45 @@ +import type { DocumentArtifact, QueryResult } from 'houdini/runtime' +import type { ClientHooks, ClientPluginContext } from 'houdini/runtime/documentStore' + +export type RequestStatus = 'pending' | 'success' | 'error' + +export type RequestPhase = keyof ClientHooks + +export type RequestEvent = { + id: string + phase: RequestPhase + timestamp: number +} + +export type RequestKind = Exclude + +type BaseRequest = { + id: string + kind: RequestKind + ctx: ClientPluginContext + 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/e2e/react/src/+client.ts b/e2e/react/src/+client.ts index 6e9e951288..58c4ed115d 100644 --- a/e2e/react/src/+client.ts +++ b/e2e/react/src/+client.ts @@ -1,4 +1,8 @@ import { HoudiniClient } from '$houdini' +import devToolPlugin from '../plugins/devtool/plugin' + // Export the Houdini client -export default new HoudiniClient() +export default new HoudiniClient({ + plugins: [devToolPlugin], +}) diff --git a/packages/houdini/package.json b/packages/houdini/package.json index 9e7737c2db..22d93c18d0 100644 --- a/packages/houdini/package.json +++ b/packages/houdini/package.json @@ -82,10 +82,34 @@ "types": "./build/adapter/*.d.ts", "import": "./build/adapter/*.js" }, + "./adapter-cjs": { + "types": "./build/adapter-cjs/index.d.ts", + "import": "./build/adapter-cjs/index.js" + }, + "./adapter-esm": { + "types": "./build/adapter-esm/index.d.ts", + "import": "./build/adapter-esm/index.js" + }, "./cmd": { "types": "./build/cmd/index.d.ts", "import": "./build/cmd/index.js" }, + "./cmd-cjs": { + "types": "./build/cmd-cjs/index.d.ts", + "import": "./build/cmd-cjs/index.js" + }, + "./cmd-esm": { + "types": "./build/cmd-esm/index.d.ts", + "import": "./build/cmd-esm/index.js" + }, + "./codegen-cjs": { + "types": "./build/codegen-cjs/index.d.ts", + "import": "./build/codegen-cjs/index.js" + }, + "./codegen-esm": { + "types": "./build/codegen-esm/index.d.ts", + "import": "./build/codegen-esm/index.js" + }, "./lib": { "types": "./build/lib/index.d.ts", "import": "./build/lib/index.js" @@ -102,6 +126,14 @@ "types": "./build/lib/types.d.ts", "import": "./build/lib/types.js" }, + "./lib-cjs": { + "types": "./build/lib-cjs/index.d.ts", + "import": "./build/lib-cjs/index.js" + }, + "./lib-esm": { + "types": "./build/lib-esm/index.d.ts", + "import": "./build/lib-esm/index.js" + }, "./node": { "types": "./build/node/index.d.ts", "import": "./build/node/index.js" @@ -142,6 +174,70 @@ "types": "./build/runtime/types.d.ts", "import": "./build/runtime/types.js" }, + "./runtime-cjs": { + "types": "./build/runtime-cjs/index.d.ts", + "import": "./build/runtime-cjs/index.js" + }, + "./runtime-cjs/cache": { + "types": "./build/runtime-cjs/cache/index.d.ts", + "import": "./build/runtime-cjs/cache/index.js" + }, + "./runtime-cjs/client": { + "types": "./build/runtime-cjs/client/index.d.ts", + "import": "./build/runtime-cjs/client/index.js" + }, + "./runtime-cjs/client/plugins": { + "types": "./build/runtime-cjs/client/plugins/index.d.ts", + "import": "./build/runtime-cjs/client/plugins/index.js" + }, + "./runtime-cjs/client/utils": { + "types": "./build/runtime-cjs/client/utils/index.d.ts", + "import": "./build/runtime-cjs/client/utils/index.js" + }, + "./runtime-cjs/lib": { + "types": "./build/runtime-cjs/lib/index.d.ts", + "import": "./build/runtime-cjs/lib/index.js" + }, + "./runtime-cjs/public": { + "types": "./build/runtime-cjs/public/index.d.ts", + "import": "./build/runtime-cjs/public/index.js" + }, + "./runtime-cjs/server": { + "types": "./build/runtime-cjs/server/index.d.ts", + "import": "./build/runtime-cjs/server/index.js" + }, + "./runtime-esm": { + "types": "./build/runtime-esm/index.d.ts", + "import": "./build/runtime-esm/index.js" + }, + "./runtime-esm/cache": { + "types": "./build/runtime-esm/cache/index.d.ts", + "import": "./build/runtime-esm/cache/index.js" + }, + "./runtime-esm/client": { + "types": "./build/runtime-esm/client/index.d.ts", + "import": "./build/runtime-esm/client/index.js" + }, + "./runtime-esm/client/plugins": { + "types": "./build/runtime-esm/client/plugins/index.d.ts", + "import": "./build/runtime-esm/client/plugins/index.js" + }, + "./runtime-esm/client/utils": { + "types": "./build/runtime-esm/client/utils/index.d.ts", + "import": "./build/runtime-esm/client/utils/index.js" + }, + "./runtime-esm/lib": { + "types": "./build/runtime-esm/lib/index.d.ts", + "import": "./build/runtime-esm/lib/index.js" + }, + "./runtime-esm/public": { + "types": "./build/runtime-esm/public/index.d.ts", + "import": "./build/runtime-esm/public/index.js" + }, + "./runtime-esm/server": { + "types": "./build/runtime-esm/server/index.d.ts", + "import": "./build/runtime-esm/server/index.js" + }, "./test": { "types": "./build/test/index.d.ts", "import": "./build/test/index.js" @@ -150,6 +246,14 @@ "types": "./build/test/*.d.ts", "import": "./build/test/*.js" }, + "./test-cjs": { + "types": "./build/test-cjs/index.d.ts", + "import": "./build/test-cjs/index.js" + }, + "./test-esm": { + "types": "./build/test-esm/index.d.ts", + "import": "./build/test-esm/index.js" + }, "./vite": { "types": "./build/vite/index.d.ts", "import": "./build/vite/index.js" @@ -157,6 +261,14 @@ "./vite/*": { "types": "./build/vite/*.d.ts", "import": "./build/vite/*.js" + }, + "./vite-cjs": { + "types": "./build/vite-cjs/index.d.ts", + "import": "./build/vite-cjs/index.js" + }, + "./vite-esm": { + "types": "./build/vite-esm/index.d.ts", + "import": "./build/vite-esm/index.js" } }, "typesVersions": { diff --git a/packages/houdini/src/runtime/documentStore.ts b/packages/houdini/src/runtime/documentStore.ts index 48b96a172d..a68ff7730a 100644 --- a/packages/houdini/src/runtime/documentStore.ts +++ b/packages/houdini/src/runtime/documentStore.ts @@ -1,6 +1,6 @@ import type { ConfigFile } from 'houdini' -import type { HoudiniClient } from './index.js' +,mport type { HoudiniClient } from './index.js' import type { Layer } from './cache/storage.js' import { deepEquals } from './deepEquals.js' import { marshalInputs } from './scalars.js' From 9c936dae94d71212a528f9545e0b9f0c5fba73cd Mon Sep 17 00:00:00 2001 From: d2du Date: Thu, 11 Jun 2026 19:32:35 +0530 Subject: [PATCH 02/22] removed netwrok panel --- e2e/react/plugins/devtool/HoudiniDevtools.tsx | 32 ++------ e2e/react/plugins/devtool/styles.css | 75 ++++++------------- 2 files changed, 28 insertions(+), 79 deletions(-) diff --git a/e2e/react/plugins/devtool/HoudiniDevtools.tsx b/e2e/react/plugins/devtool/HoudiniDevtools.tsx index 887fe5559c..2fc3864142 100644 --- a/e2e/react/plugins/devtool/HoudiniDevtools.tsx +++ b/e2e/react/plugins/devtool/HoudiniDevtools.tsx @@ -1,20 +1,19 @@ import React from 'react' import { clearRequests, getSnapshot, subscribe } from './store' -import './styles.css' import type { DevToolRequest } from './type' function StatusDot({ status }: { status: string }) { return } -type DetailTab = 'timeline' | 'variables' | 'data' | 'errors' +type DetailTab = 'variables' | 'data' | 'errors' 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('timeline') + const [detailTab, setDetailTab] = React.useState('variables') const latest = snapshot.requests[0] const selected = snapshot.requests.find((request) => request.id === selectedId) ?? latest @@ -77,9 +76,6 @@ export function HoudiniDevtools() { {selected.status === 'success' ? {selected.result.source} : null}
- setDetailTab('timeline')}> - Timeline - setDetailTab('variables')}> Variables @@ -91,7 +87,6 @@ export function HoudiniDevtools() {
- {detailTab === 'timeline' ? : null} {detailTab === 'variables' ?
: null} {detailTab === 'data' ?
: null} {detailTab === 'errors' ? ( @@ -145,28 +140,11 @@ function displayKind(kind: DevToolRequest['kind']) { } function getDurationMs(request: DevToolRequest) { - const finishedAt = request.status === 'pending' ? performance.now() : request.finishedAt - return Math.round(finishedAt - request.startedAt) + return Math.round(getFinishedAt(request) - request.startedAt) } -function Timeline({ request }: { request: DevToolRequest }) { - return ( -
-
Timeline
-
    - {request.events.map((event) => ( -
  1. - {event.phase} - +{Math.round(event.timestamp - request.startedAt)}ms - -
  2. - ))} -
-
- ) +function getFinishedAt(request: DevToolRequest) { + return request.status === 'pending' ? performance.now() : request.finishedAt } function Section({ title, value }: { title: string; value: unknown }) { diff --git a/e2e/react/plugins/devtool/styles.css b/e2e/react/plugins/devtool/styles.css index e34fe0d2b8..4086be444e 100644 --- a/e2e/react/plugins/devtool/styles.css +++ b/e2e/react/plugins/devtool/styles.css @@ -248,13 +248,35 @@ .hdt-section { margin-bottom: 14px; } -.hdt-section-title { +.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; @@ -268,57 +290,6 @@ line-height: 1.55; } -.hdt-timeline { - position: relative; - margin: 0; - padding: 2px 0 2px 22px; - list-style: none; -} - -.hdt-timeline::before { - content: ""; - position: absolute; - left: 6px; - top: 12px; - bottom: 12px; - width: 1px; - background: var(--hdt-line); -} - -.hdt-timeline-item { - position: relative; - display: grid; - grid-template-columns: 140px 72px 1fr; - gap: 12px; - padding: 6px 0; - font-size: 13px; - color: var(--hdt-text); -} - -.hdt-timeline-item::before { - content: ""; - position: absolute; - left: -19px; - top: 12px; - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--hdt-ok); - box-shadow: 0 0 0 3px var(--hdt-bg); -} - -.hdt-timeline-item--pending::before { background: var(--hdt-warn); } -.hdt-timeline-item--error::before { background: var(--hdt-error); } - -.hdt-timeline-time { - color: var(--hdt-muted); - font-variant-numeric: tabular-nums; -} - -.hdt-timeline-rule { - border-top: 1px solid var(--hdt-line); - align-self: center; -} .hdt-dot { width: 8px; From 10ab27804991fb755a63d04d66843490b33f9f34 Mon Sep 17 00:00:00 2001 From: d2du Date: Thu, 11 Jun 2026 19:49:45 +0530 Subject: [PATCH 03/22] leanup --- e2e/react/plugins/devtool/HoudiniDevtools.tsx | 43 ++++++++-- e2e/react/plugins/devtool/styles.css | 82 +++++++++++++++---- 2 files changed, 99 insertions(+), 26 deletions(-) diff --git a/e2e/react/plugins/devtool/HoudiniDevtools.tsx b/e2e/react/plugins/devtool/HoudiniDevtools.tsx index 2fc3864142..b27f168799 100644 --- a/e2e/react/plugins/devtool/HoudiniDevtools.tsx +++ b/e2e/react/plugins/devtool/HoudiniDevtools.tsx @@ -57,9 +57,10 @@ export function HoudiniDevtools() {
{request.ctx.name} + {displayKind(request.kind)}
- {displayKind(request.kind)} • {request.status} • {getDurationMs(request)}ms + {getRequestSource(request) ?? 'unknown'} • {getDurationMs(request)}ms
))} @@ -68,12 +69,14 @@ export function HoudiniDevtools() {
{selected ? ( <> -

{selected.ctx.name}

-
- {displayKind(selected.kind)} - {selected.status} - {getDurationMs(selected)}ms - {selected.status === 'success' ? {selected.result.source} : null} +
+
+

{selected.ctx.name}

+
+
+ {getRequestSource(selected) ? : null} + {selected.events.length} events +
setDetailTab('variables')}> @@ -135,6 +138,14 @@ function TabButton({ ) } +function SourceBadge({ source }: { source: string }) { + return {source} +} + +function getRequestSource(request: DevToolRequest) { + return request.status === 'success' ? request.result.source : null +} + function displayKind(kind: DevToolRequest['kind']) { return kind.replace('Houdini', '').toLowerCase() } @@ -148,10 +159,24 @@ function getFinishedAt(request: DevToolRequest) { } 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}
-
{JSON.stringify(value ?? null, null, 2)}
+
+
{title}
+ +
+
{text}
) } diff --git a/e2e/react/plugins/devtool/styles.css b/e2e/react/plugins/devtool/styles.css index 4086be444e..039400da92 100644 --- a/e2e/react/plugins/devtool/styles.css +++ b/e2e/react/plugins/devtool/styles.css @@ -9,8 +9,6 @@ --hdt-line: #2a3038; --hdt-blue: #7fb4ff; --hdt-blue-bg: rgba(127, 180, 255, 0.13); - --hdt-red: #ff5c3f; - --hdt-red-bg: rgba(255, 92, 63, 0.12); --hdt-ok: #7db65d; --hdt-warn: #d7b45a; --hdt-error: #ff6b58; @@ -75,7 +73,9 @@ .hdt-title, .hdt-actions, .hdt-row-title, -.hdt-pills, +.hdt-detail-header, +.hdt-heading-row, +.hdt-summary, .hdt-trigger { display: flex; align-items: center; @@ -174,6 +174,16 @@ 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 { margin-top: 3px; padding-left: 16px; @@ -183,36 +193,74 @@ text-overflow: ellipsis; } +.hdt-source { + flex: 0 0 auto; + padding: 1px 6px; + border: 1px solid var(--hdt-line); + border-radius: 4px; + font-size: 11px; + font-weight: 700; + line-height: 1.5; +} + +.hdt-source--network { + border-color: rgba(127, 180, 255, 0.34); + background: rgba(127, 180, 255, 0.1); + color: var(--hdt-blue); +} + +.hdt-source--cache { + border-color: rgba(215, 180, 90, 0.34); + background: rgba(215, 180, 90, 0.1); + color: var(--hdt-warn); +} + .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 { - margin: 0 0 6px; + 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-pills { - gap: 0; +.hdt-summary { + flex: 0 0 auto; + justify-content: flex-end; + gap: 8px; + max-width: 52%; flex-wrap: wrap; - margin-bottom: 18px; } -.hdt-pill { - padding: 0; - font-size: 13px; - font-weight: 500; +.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); -} - -.hdt-pill + .hdt-pill::before { - content: "•"; - margin: 0 8px; - color: var(--hdt-dim); + font-size: 11px; + font-weight: 700; + line-height: 1.5; } .hdt-tabs { From 19dd13eb09da8a8f7603765cdef43cee9685b21e Mon Sep 17 00:00:00 2001 From: d2du Date: Thu, 11 Jun 2026 20:07:02 +0530 Subject: [PATCH 04/22] cleanup --- e2e/react/plugins/devtool/HoudiniDevtools.tsx | 7 +----- e2e/react/plugins/devtool/styles.css | 22 ------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/e2e/react/plugins/devtool/HoudiniDevtools.tsx b/e2e/react/plugins/devtool/HoudiniDevtools.tsx index b27f168799..1be4dfd545 100644 --- a/e2e/react/plugins/devtool/HoudiniDevtools.tsx +++ b/e2e/react/plugins/devtool/HoudiniDevtools.tsx @@ -74,8 +74,7 @@ export function HoudiniDevtools() {

{selected.ctx.name}

- {getRequestSource(selected) ? : null} - {selected.events.length} events + {selected.events.length} lifecycle events
@@ -138,10 +137,6 @@ function TabButton({ ) } -function SourceBadge({ source }: { source: string }) { - return {source} -} - function getRequestSource(request: DevToolRequest) { return request.status === 'success' ? request.result.source : null } diff --git a/e2e/react/plugins/devtool/styles.css b/e2e/react/plugins/devtool/styles.css index 039400da92..a0f2288581 100644 --- a/e2e/react/plugins/devtool/styles.css +++ b/e2e/react/plugins/devtool/styles.css @@ -193,28 +193,6 @@ text-overflow: ellipsis; } -.hdt-source { - flex: 0 0 auto; - padding: 1px 6px; - border: 1px solid var(--hdt-line); - border-radius: 4px; - font-size: 11px; - font-weight: 700; - line-height: 1.5; -} - -.hdt-source--network { - border-color: rgba(127, 180, 255, 0.34); - background: rgba(127, 180, 255, 0.1); - color: var(--hdt-blue); -} - -.hdt-source--cache { - border-color: rgba(215, 180, 90, 0.34); - background: rgba(215, 180, 90, 0.1); - color: var(--hdt-warn); -} - .hdt-detail { padding: 20px 24px 28px; background: var(--hdt-bg); From 5908c774d91becf4fe2af69699974f465377c51c Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 18:09:10 +0530 Subject: [PATCH 05/22] fixed typo --- packages/houdini/package.json | 114 +----------------- packages/houdini/src/runtime/documentStore.ts | 2 +- 2 files changed, 2 insertions(+), 114 deletions(-) diff --git a/packages/houdini/package.json b/packages/houdini/package.json index 22d93c18d0..7f3c8a4d9f 100644 --- a/packages/houdini/package.json +++ b/packages/houdini/package.json @@ -82,34 +82,10 @@ "types": "./build/adapter/*.d.ts", "import": "./build/adapter/*.js" }, - "./adapter-cjs": { - "types": "./build/adapter-cjs/index.d.ts", - "import": "./build/adapter-cjs/index.js" - }, - "./adapter-esm": { - "types": "./build/adapter-esm/index.d.ts", - "import": "./build/adapter-esm/index.js" - }, "./cmd": { "types": "./build/cmd/index.d.ts", "import": "./build/cmd/index.js" }, - "./cmd-cjs": { - "types": "./build/cmd-cjs/index.d.ts", - "import": "./build/cmd-cjs/index.js" - }, - "./cmd-esm": { - "types": "./build/cmd-esm/index.d.ts", - "import": "./build/cmd-esm/index.js" - }, - "./codegen-cjs": { - "types": "./build/codegen-cjs/index.d.ts", - "import": "./build/codegen-cjs/index.js" - }, - "./codegen-esm": { - "types": "./build/codegen-esm/index.d.ts", - "import": "./build/codegen-esm/index.js" - }, "./lib": { "types": "./build/lib/index.d.ts", "import": "./build/lib/index.js" @@ -126,14 +102,6 @@ "types": "./build/lib/types.d.ts", "import": "./build/lib/types.js" }, - "./lib-cjs": { - "types": "./build/lib-cjs/index.d.ts", - "import": "./build/lib-cjs/index.js" - }, - "./lib-esm": { - "types": "./build/lib-esm/index.d.ts", - "import": "./build/lib-esm/index.js" - }, "./node": { "types": "./build/node/index.d.ts", "import": "./build/node/index.js" @@ -174,70 +142,6 @@ "types": "./build/runtime/types.d.ts", "import": "./build/runtime/types.js" }, - "./runtime-cjs": { - "types": "./build/runtime-cjs/index.d.ts", - "import": "./build/runtime-cjs/index.js" - }, - "./runtime-cjs/cache": { - "types": "./build/runtime-cjs/cache/index.d.ts", - "import": "./build/runtime-cjs/cache/index.js" - }, - "./runtime-cjs/client": { - "types": "./build/runtime-cjs/client/index.d.ts", - "import": "./build/runtime-cjs/client/index.js" - }, - "./runtime-cjs/client/plugins": { - "types": "./build/runtime-cjs/client/plugins/index.d.ts", - "import": "./build/runtime-cjs/client/plugins/index.js" - }, - "./runtime-cjs/client/utils": { - "types": "./build/runtime-cjs/client/utils/index.d.ts", - "import": "./build/runtime-cjs/client/utils/index.js" - }, - "./runtime-cjs/lib": { - "types": "./build/runtime-cjs/lib/index.d.ts", - "import": "./build/runtime-cjs/lib/index.js" - }, - "./runtime-cjs/public": { - "types": "./build/runtime-cjs/public/index.d.ts", - "import": "./build/runtime-cjs/public/index.js" - }, - "./runtime-cjs/server": { - "types": "./build/runtime-cjs/server/index.d.ts", - "import": "./build/runtime-cjs/server/index.js" - }, - "./runtime-esm": { - "types": "./build/runtime-esm/index.d.ts", - "import": "./build/runtime-esm/index.js" - }, - "./runtime-esm/cache": { - "types": "./build/runtime-esm/cache/index.d.ts", - "import": "./build/runtime-esm/cache/index.js" - }, - "./runtime-esm/client": { - "types": "./build/runtime-esm/client/index.d.ts", - "import": "./build/runtime-esm/client/index.js" - }, - "./runtime-esm/client/plugins": { - "types": "./build/runtime-esm/client/plugins/index.d.ts", - "import": "./build/runtime-esm/client/plugins/index.js" - }, - "./runtime-esm/client/utils": { - "types": "./build/runtime-esm/client/utils/index.d.ts", - "import": "./build/runtime-esm/client/utils/index.js" - }, - "./runtime-esm/lib": { - "types": "./build/runtime-esm/lib/index.d.ts", - "import": "./build/runtime-esm/lib/index.js" - }, - "./runtime-esm/public": { - "types": "./build/runtime-esm/public/index.d.ts", - "import": "./build/runtime-esm/public/index.js" - }, - "./runtime-esm/server": { - "types": "./build/runtime-esm/server/index.d.ts", - "import": "./build/runtime-esm/server/index.js" - }, "./test": { "types": "./build/test/index.d.ts", "import": "./build/test/index.js" @@ -246,14 +150,6 @@ "types": "./build/test/*.d.ts", "import": "./build/test/*.js" }, - "./test-cjs": { - "types": "./build/test-cjs/index.d.ts", - "import": "./build/test-cjs/index.js" - }, - "./test-esm": { - "types": "./build/test-esm/index.d.ts", - "import": "./build/test-esm/index.js" - }, "./vite": { "types": "./build/vite/index.d.ts", "import": "./build/vite/index.js" @@ -261,14 +157,6 @@ "./vite/*": { "types": "./build/vite/*.d.ts", "import": "./build/vite/*.js" - }, - "./vite-cjs": { - "types": "./build/vite-cjs/index.d.ts", - "import": "./build/vite-cjs/index.js" - }, - "./vite-esm": { - "types": "./build/vite-esm/index.d.ts", - "import": "./build/vite-esm/index.js" } }, "typesVersions": { @@ -298,4 +186,4 @@ }, "bin": "./build/cmd/index.js", "types": "./build/lib/index.d.ts" -} +} \ No newline at end of file diff --git a/packages/houdini/src/runtime/documentStore.ts b/packages/houdini/src/runtime/documentStore.ts index a68ff7730a..48b96a172d 100644 --- a/packages/houdini/src/runtime/documentStore.ts +++ b/packages/houdini/src/runtime/documentStore.ts @@ -1,6 +1,6 @@ import type { ConfigFile } from 'houdini' -,mport type { HoudiniClient } from './index.js' +import type { HoudiniClient } from './index.js' import type { Layer } from './cache/storage.js' import { deepEquals } from './deepEquals.js' import { marshalInputs } from './scalars.js' From f9bd5b3b1f5aea13757b92acefe77a8f2dadab3e Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 18:14:22 +0530 Subject: [PATCH 06/22] Squash merge main into 611-devtool-react --- .changeset/anchor-typed-hrefs.md | 5 + .changeset/cold-poems-wear.md | 5 + .changeset/error-extensions.md | 7 + .changeset/fix-addmany-field-visibility.md | 5 + .changeset/fix-atomic-pipeline-writes.md | 6 + .../fix-conditional-spread-null-cascade.md | 5 + .changeset/fix-fragment-pagination.md | 8 + .changeset/fix-fragment-rerenders.md | 6 + .changeset/fix-hmr-new-routes.md | 6 + .../fix-list-filter-object-variables.md | 6 + .changeset/fix-mutation-order.md | 6 + .changeset/fix-pagination-dedupe.md | 6 + .changeset/fix-pagination-sibling-fields.md | 5 + .changeset/fix-plugin-bin-missing-error.md | 5 + .changeset/fix-refetch-cache-links-leak.md | 5 + .changeset/hip-rockets-stick.md | 5 + .changeset/lemon-files-follow.md | 5 + .changeset/pre.json | 23 + .changeset/react-routing-errors.md | 5 + .changeset/refresh-cache-record.md | 6 + .changeset/sharp-banks-check.md | 5 + .changeset/silver-baboons-open.md | 5 + .changeset/upsert-list-operation.md | 5 + .github/workflows/benchmarks.yml | 76 + .github/workflows/release.yml | 3 +- .github/workflows/tests.yml | 35 +- .github/workflows/trigger-docs-rebuild.yml | 2 +- .gitignore | 5 + CLAUDE.md | 8 +- .../00-your-first-app/00-getting-started.mdx | 10 +- docs/react/00-your-first-app/01-queries.mdx | 30 +- docs/react/00-your-first-app/02-fragments.mdx | 77 +- docs/react/00-your-first-app/03-mutations.mdx | 103 +- .../react/00-your-first-app/04-pagination.mdx | 104 +- docs/react/02-routing/02-navigation.mdx | 109 +- docs/react/02-routing/04-error-boundaries.mdx | 130 +- docs/react/03-loading-data/01-queries.mdx | 27 +- docs/react/03-loading-data/02-fragments.mdx | 6 +- .../03-loading-data/03-loading-states.mdx | 29 +- docs/react/04-updating-data/01-mutations.mdx | 6 +- .../02-optimistic-updates.mdx | 2 +- docs/react/05-guides/04-file-uploads.mdx | 2 +- docs/react/06-api-reference/01-forbidden.mdx | 10 + docs/react/06-api-reference/02-httpError.mdx | 16 + docs/react/06-api-reference/03-isPending.mdx | 39 + docs/react/06-api-reference/04-Link.mdx | 38 + docs/react/06-api-reference/05-notFound.mdx | 16 + docs/react/06-api-reference/06-redirect.mdx | 16 + .../06-api-reference/07-RoutingError.mdx | 24 + .../06-api-reference/08-unauthorized.mdx | 10 + .../09-useCurrentVariables.mdx | 23 + .../react/06-api-reference/10-useFragment.mdx | 38 + .../06-api-reference/11-useFragmentHandle.mdx | 42 + .../react/06-api-reference/12-useLocation.mdx | 31 + .../react/06-api-reference/13-useMutation.mdx | 47 + docs/react/06-api-reference/14-useQuery.mdx | 50 + .../06-api-reference/15-useQueryHandle.mdx | 67 + docs/react/06-api-reference/16-useRoute.mdx | 22 + docs/react/06-api-reference/17-useSession.mdx | 28 + .../06-api-reference/18-useSubscription.mdx | 35 + .../{01-reference => 01-core}/01-config.mdx | 2 +- .../{01-reference => 01-core}/02-cli.mdx | 0 .../03-vite-plugin.mdx | 0 .../{01-reference => 01-core}/04-client.mdx | 0 .../{01-reference => 01-core}/06-cache.mdx | 35 +- docs/shared/01-core/07-architecture.mdx | 97 + docs/shared/01-reference/07-architecture.mdx | 110 - .../01-client-plugins.mdx | 6 - .../02-codegen-plugins-golang.mdx | 36 + .../04-codegen-plugins-node.mdx | 32 + docs/shared/03-meta/03-contributing.mdx | 88 +- docs/shared/_partials/error-handling.mdx | 15 + docs/shared/_partials/list-operations.mdx | 61 +- docs/shared/_partials/loading-states.mdx | 13 +- .../_partials/plugin-vite-submodule.mdx | 31 + .../01-your-first-app/00-getting-started.mdx | 2 +- docs/svelte/01-your-first-app/01-queries.mdx | 187 +- .../svelte/01-your-first-app/02-fragments.mdx | 64 +- .../svelte/01-your-first-app/03-mutations.mdx | 94 +- .../01-your-first-app/04-pagination.mdx | 50 +- docs/svelte/02-setup/01-project-setup.mdx | 9 +- docs/svelte/03-loading-data/02-fragments.mdx | 16 +- docs/svelte/05-guides/02-error-handling.mdx | 14 +- e2e/_api/graphql.mjs | 16 + e2e/_api/schema.graphql | 10 + e2e/kit/src/lib/utils/routes.ts | 5 + .../bug/refetch-cache-links-leak/+page.svelte | 47 + .../bug/refetch-cache-links-leak/spec.ts | 19 + e2e/kit/src/routes/cache/refresh/+page.svelte | 33 + .../routes/cache/refresh/UserDetails.svelte | 16 + e2e/kit/src/routes/cache/refresh/spec.ts | 21 + .../conditional-fragment-spread/+page.svelte | 22 + .../conditional-fragment-spread/+page.ts | 21 + .../conditional-fragment-spread/spec.ts | 11 + .../forward-cursor-singlepage/+page.svelte | 42 + .../forward-cursor-singlepage/+page.ts | 18 + .../forward-cursor-singlepage/spec.ts | 39 + .../bidirectional-cursor-single-page/spec.ts | 11 +- e2e/react/eslint.config.js | 3 + e2e/react/package.json | 3 +- e2e/react/playwright.config.ts | 2 +- e2e/react/src/+index.jsx | 4 - e2e/react/src/anchor-types.types.tsx | 62 + e2e/react/src/api/+schema.js | 10 + e2e/react/src/routes/+layout.tsx | 4 +- e2e/react/src/routes/error-loop/+error.tsx | 8 + e2e/react/src/routes/error-loop/+layout.tsx | 5 + e2e/react/src/routes/error-loop/+page.tsx | 3 + e2e/react/src/routes/error-loop/test.ts | 8 + e2e/react/src/routes/list-id/+page.gql | 8 + e2e/react/src/routes/list-id/+page.tsx | 44 + e2e/react/src/routes/list-id/test.ts | 25 + .../routes/list-operations/update/+page.gql | 6 + .../routes/list-operations/update/+page.tsx | 59 + .../src/routes/list-operations/update/test.ts | 35 + .../routes/list-operations/upsert/+page.gql | 6 + .../routes/list-operations/upsert/+page.tsx | 59 + .../src/routes/list-operations/upsert/test.ts | 35 + .../src/routes/optimistic-keys/+page.tsx | 4 +- .../connection-backwards-singlepage/+page.gql | 5 + .../connection-backwards-singlepage/+page.tsx | 45 + .../connection-backwards-singlepage/test.ts | 41 + .../fragment/connection-backwards/+page.gql | 5 + .../fragment/connection-backwards/+page.tsx | 38 + .../fragment/connection-backwards/test.ts | 21 + .../+page.gql | 5 + .../+page.tsx | 42 + .../test.ts | 41 + .../connection-forwards-singlepage/+page.gql | 5 + .../connection-forwards-singlepage/+page.tsx | 45 + .../connection-forwards-singlepage/test.ts | 41 + .../fragment/connection-forwards/+page.gql | 5 + .../fragment/connection-forwards/+page.tsx | 38 + .../fragment/connection-forwards/test.ts | 21 + .../connection-backwards-singlepage/+page.gql | 9 + .../connection-backwards-singlepage/+page.tsx | 28 + .../connection-backwards-singlepage/test.ts | 34 + .../+page.gql | 14 + .../+page.tsx | 34 + .../test.ts | 45 + .../connection-forwards-singlepage/+page.gql | 9 + .../connection-forwards-singlepage/+page.tsx | 28 + .../connection-forwards-singlepage/test.ts | 34 + e2e/react/src/routes/route_params/+layout.tsx | 10 +- .../src/routes/routing-errors/+error.tsx | 8 + e2e/react/src/routes/routing-errors/+page.tsx | 3 + .../routes/routing-errors/not-found/+page.tsx | 5 + .../routes/routing-errors/not-found/test.ts | 14 + .../routing-errors/redirect-target/+page.tsx | 3 + .../routes/routing-errors/redirect/+page.tsx | 5 + .../routes/routing-errors/redirect/test.ts | 10 + e2e/react/src/utils/routes.ts | 17 + e2e/react/tsconfig.json | 5 +- go.mod | 11 +- go.sum | 19 + package.json | 4 + packages/_scripts/buildNode.js | 19 +- packages/adapter-auto/CHANGELOG.md | 7 + packages/adapter-auto/package.json | 2 +- packages/adapter-cloudflare/CHANGELOG.md | 7 + packages/adapter-cloudflare/package.json | 2 +- packages/adapter-node/CHANGELOG.md | 7 + packages/adapter-node/package.json | 2 +- packages/adapter-static/CHANGELOG.md | 7 + packages/adapter-static/package.json | 2 +- packages/houdini-core/CHANGELOG.md | 28 + packages/houdini-core/package.json | 2 +- .../plugin/documents/artifacts/merge.go | 154 +- .../plugin/documents/artifacts/print.go | 5 + .../plugin/documents/artifacts/selection.go | 167 +- .../artifacts/selection_conditional_test.go | 435 ++++ .../artifacts/selection_lists_test.go | 184 +- .../artifacts/selection_loading_test.go | 5 - .../artifacts/selection_operations_test.go | 205 +- .../artifacts/selection_pagination_test.go | 15 +- .../selection_requiredDirective_test.go | 4 - .../documents/artifacts/selection_test.go | 28 +- .../artifacts/typescript/documents.go | 170 +- .../artifacts/typescript/masking_test.go | 340 +++ .../plugin/documents/assignability.go | 97 + .../plugin/documents/assignability_test.go | 291 +++ .../plugin/documents/loadDocuments.go | 68 +- .../houdini-core/plugin/documents/validate.go | 557 +---- .../houdini-core/plugin/documents/walk.go | 16 +- .../plugin/documents/walk_test.go | 80 + .../plugin/fragmentArguments/validate.go | 231 +- .../houdini-core/plugin/generateRuntime.go | 6 - .../plugin/lists/insertOperations.go | 4 +- .../plugin/lists/paginationDocuments.go | 39 +- .../plugin/lists/paginationDocuments_test.go | 83 +- .../houdini-core/plugin/lists/validate.go | 59 +- .../plugin/runtime/imperativeCache.go | 37 +- .../plugin/runtime/imperativeCache_test.go | 398 ++-- .../plugin/runtime/pluginIndex.go | 2 +- .../plugin/runtime/runtimeIndex.go | 11 +- .../houdini-core/plugin/schema/arguments.go | 58 +- .../plugin/schema/generateDefinitions.go | 10 +- .../plugin/schema/generateDefinitions_test.go | 34 +- .../houdini-core/plugin/schema/typeRef.go | 128 ++ .../plugin/schema/typeRef_test.go | 171 ++ packages/houdini-core/plugin/schema/write.go | 43 + packages/houdini-core/plugin/validate.go | 3 +- packages/houdini-core/plugin/validate_test.go | 449 +++- packages/houdini-core/runtime/index.ts | 2 +- .../houdini-core/runtime/plugins/fragment.ts | 11 +- .../runtime/plugins/query.test.ts | 64 + .../houdini-core/runtime/plugins/query.ts | 26 +- .../runtime/plugins/subscription.ts | 3 +- .../houdini-core/runtime/public/list.test.ts | 110 + packages/houdini-core/runtime/public/list.ts | 13 + .../houdini-core/runtime/public/record.ts | 8 + .../runtime/public/tests/list.test.ts | 148 +- .../runtime/public/tests/record.test.ts | 6 +- .../runtime/public/tests/stale.test.ts | 2 +- .../houdini-core/runtime/public/tests/test.ts | 4 +- packages/houdini-react/CHANGELOG.md | 39 + packages/houdini-react/package.json | 2 +- packages/houdini-react/package/vite/index.ts | 77 +- packages/houdini-react/plugin/generate.go | 173 +- .../houdini-react/plugin/generate_test.go | 226 +- packages/houdini-react/plugin/manifest.go | 85 +- .../houdini-react/plugin/manifest_test.go | 285 ++- packages/houdini-react/plugin/runtime.go | 208 +- packages/houdini-react/plugin/runtime_test.go | 176 +- packages/houdini-react/runtime/Link.tsx | 81 + .../runtime/hooks/recycleNodesInto.test.ts | 61 + .../runtime/hooks/recycleNodesInto.ts | 60 + .../runtime/hooks/useDeepCompareEffect.ts | 8 +- .../runtime/hooks/useDocumentHandle.ts | 8 +- .../runtime/hooks/useDocumentStore.ts | 38 +- .../runtime/hooks/useDocumentSubscription.ts | 5 +- .../runtime/hooks/useFragment.ts | 82 +- .../runtime/hooks/useFragmentHandle.ts | 173 +- .../runtime/hooks/useMutation.ts | 4 +- .../houdini-react/runtime/hooks/useQuery.ts | 2 +- .../runtime/hooks/useQueryHandle.ts | 12 +- .../runtime/hooks/useSubscriptionHandle.ts | 9 +- packages/houdini-react/runtime/index.tsx | 19 +- .../runtime/resolve-href.test.ts | 52 + .../houdini-react/runtime/resolve-href.ts | 14 + .../houdini-react/runtime/routing/Router.tsx | 130 +- .../houdini-react/runtime/routing/cache.ts | 5 +- .../houdini-react/runtime/routing/errors.tsx | 145 ++ .../houdini-react/runtime/routing/index.ts | 15 + packages/houdini-react/runtime/tsconfig.json | 39 + packages/houdini-svelte/CHANGELOG.md | 9 + packages/houdini-svelte/package.json | 2 +- .../runtime/stores/pagination/fragment.ts | 129 +- packages/houdini/CHANGELOG.md | 55 + packages/houdini/package.json | 2 +- packages/houdini/src/lib/codegen.ts | 7 +- packages/houdini/src/lib/plugins.test.ts | 2 +- packages/houdini/src/lib/plugins.ts | 116 +- packages/houdini/src/node/index.test.ts | 7 +- packages/houdini/src/node/index.ts | 318 ++- packages/houdini/src/router/match.ts | 45 +- packages/houdini/src/router/server.ts | 11 +- packages/houdini/src/router/types.ts | 4 +- .../runtime/cache/benchmarks/cache.bench.ts | 1161 ++++++++++ packages/houdini/src/runtime/cache/gc.ts | 14 +- packages/houdini/src/runtime/cache/index.ts | 327 ++- packages/houdini/src/runtime/cache/lists.ts | 138 +- .../houdini/src/runtime/cache/staleManager.ts | 5 + packages/houdini/src/runtime/cache/storage.ts | 213 +- packages/houdini/src/runtime/cache/stuff.ts | 9 +- .../houdini/src/runtime/cache/subscription.ts | 219 +- .../runtime/cache/tests/availability.test.ts | 2 +- .../src/runtime/cache/tests/gc.test.ts | 77 +- .../src/runtime/cache/tests/keys.test.ts | 2 +- .../src/runtime/cache/tests/list.test.ts | 1726 +++++++++++--- .../src/runtime/cache/tests/refresh.test.ts | 465 ++++ .../src/runtime/cache/tests/reset.test.ts | 50 +- .../src/runtime/cache/tests/storage.test.ts | 102 + .../runtime/cache/tests/subscriptions.test.ts | 563 +++-- .../houdini/src/runtime/documentStore.test.ts | 42 + packages/houdini/src/runtime/documentStore.ts | 35 +- packages/houdini/src/runtime/pagination.ts | 143 +- packages/houdini/src/runtime/selection.ts | 24 + packages/houdini/src/runtime/types.ts | 70 +- packages/houdini/src/vite/hmr.ts | 59 +- packages/houdini/src/vite/houdini.ts | 62 +- perf/benchmark.json | 1974 +++++++++++++++++ perf/compare.js | 129 ++ perf/merge.js | 55 + perf/watch-bench.sh | 5 + plugins/fs.go | 30 +- plugins/graphql/conventions.go | 12 + plugins/handlers.go | 2 +- plugins/run.go | 27 +- plugins/stdio.go | 39 +- plugins/tests/run.go | 20 +- plugins/websocket.go | 19 +- test-results/.last-run.json | 4 + vite.config.ts | 14 +- 294 files changed, 16852 insertions(+), 2889 deletions(-) create mode 100644 .changeset/anchor-typed-hrefs.md create mode 100644 .changeset/cold-poems-wear.md create mode 100644 .changeset/error-extensions.md create mode 100644 .changeset/fix-addmany-field-visibility.md create mode 100644 .changeset/fix-atomic-pipeline-writes.md create mode 100644 .changeset/fix-conditional-spread-null-cascade.md create mode 100644 .changeset/fix-fragment-pagination.md create mode 100644 .changeset/fix-fragment-rerenders.md create mode 100644 .changeset/fix-hmr-new-routes.md create mode 100644 .changeset/fix-list-filter-object-variables.md create mode 100644 .changeset/fix-mutation-order.md create mode 100644 .changeset/fix-pagination-dedupe.md create mode 100644 .changeset/fix-pagination-sibling-fields.md create mode 100644 .changeset/fix-plugin-bin-missing-error.md create mode 100644 .changeset/fix-refetch-cache-links-leak.md create mode 100644 .changeset/hip-rockets-stick.md create mode 100644 .changeset/lemon-files-follow.md create mode 100644 .changeset/react-routing-errors.md create mode 100644 .changeset/refresh-cache-record.md create mode 100644 .changeset/sharp-banks-check.md create mode 100644 .changeset/silver-baboons-open.md create mode 100644 .changeset/upsert-list-operation.md create mode 100644 .github/workflows/benchmarks.yml create mode 100644 docs/react/06-api-reference/01-forbidden.mdx create mode 100644 docs/react/06-api-reference/02-httpError.mdx create mode 100644 docs/react/06-api-reference/03-isPending.mdx create mode 100644 docs/react/06-api-reference/04-Link.mdx create mode 100644 docs/react/06-api-reference/05-notFound.mdx create mode 100644 docs/react/06-api-reference/06-redirect.mdx create mode 100644 docs/react/06-api-reference/07-RoutingError.mdx create mode 100644 docs/react/06-api-reference/08-unauthorized.mdx create mode 100644 docs/react/06-api-reference/09-useCurrentVariables.mdx create mode 100644 docs/react/06-api-reference/10-useFragment.mdx create mode 100644 docs/react/06-api-reference/11-useFragmentHandle.mdx create mode 100644 docs/react/06-api-reference/12-useLocation.mdx create mode 100644 docs/react/06-api-reference/13-useMutation.mdx create mode 100644 docs/react/06-api-reference/14-useQuery.mdx create mode 100644 docs/react/06-api-reference/15-useQueryHandle.mdx create mode 100644 docs/react/06-api-reference/16-useRoute.mdx create mode 100644 docs/react/06-api-reference/17-useSession.mdx create mode 100644 docs/react/06-api-reference/18-useSubscription.mdx rename docs/shared/{01-reference => 01-core}/01-config.mdx (97%) rename docs/shared/{01-reference => 01-core}/02-cli.mdx (100%) rename docs/shared/{01-reference => 01-core}/03-vite-plugin.mdx (100%) rename docs/shared/{01-reference => 01-core}/04-client.mdx (100%) rename docs/shared/{01-reference => 01-core}/06-cache.mdx (92%) create mode 100644 docs/shared/01-core/07-architecture.mdx delete mode 100644 docs/shared/01-reference/07-architecture.mdx create mode 100644 docs/shared/_partials/plugin-vite-submodule.mdx create mode 100644 e2e/kit/src/routes/bug/refetch-cache-links-leak/+page.svelte create mode 100644 e2e/kit/src/routes/bug/refetch-cache-links-leak/spec.ts create mode 100644 e2e/kit/src/routes/cache/refresh/+page.svelte create mode 100644 e2e/kit/src/routes/cache/refresh/UserDetails.svelte create mode 100644 e2e/kit/src/routes/cache/refresh/spec.ts create mode 100644 e2e/kit/src/routes/conditional-fragment-spread/+page.svelte create mode 100644 e2e/kit/src/routes/conditional-fragment-spread/+page.ts create mode 100644 e2e/kit/src/routes/conditional-fragment-spread/spec.ts create mode 100644 e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte create mode 100644 e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts create mode 100644 e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts create mode 100644 e2e/react/src/anchor-types.types.tsx create mode 100644 e2e/react/src/routes/error-loop/+error.tsx create mode 100644 e2e/react/src/routes/error-loop/+layout.tsx create mode 100644 e2e/react/src/routes/error-loop/+page.tsx create mode 100644 e2e/react/src/routes/error-loop/test.ts create mode 100644 e2e/react/src/routes/list-id/+page.gql create mode 100644 e2e/react/src/routes/list-id/+page.tsx create mode 100644 e2e/react/src/routes/list-id/test.ts create mode 100644 e2e/react/src/routes/list-operations/update/+page.gql create mode 100644 e2e/react/src/routes/list-operations/update/+page.tsx create mode 100644 e2e/react/src/routes/list-operations/update/test.ts create mode 100644 e2e/react/src/routes/list-operations/upsert/+page.gql create mode 100644 e2e/react/src/routes/list-operations/upsert/+page.tsx create mode 100644 e2e/react/src/routes/list-operations/upsert/test.ts create mode 100644 e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql create mode 100644 e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx create mode 100644 e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts create mode 100644 e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql create mode 100644 e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx create mode 100644 e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts create mode 100644 e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql create mode 100644 e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx create mode 100644 e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts create mode 100644 e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql create mode 100644 e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx create mode 100644 e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts create mode 100644 e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql create mode 100644 e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx create mode 100644 e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts create mode 100644 e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.gql create mode 100644 e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.tsx create mode 100644 e2e/react/src/routes/pagination/query/connection-backwards-singlepage/test.ts create mode 100644 e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.gql create mode 100644 e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.tsx create mode 100644 e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts create mode 100644 e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.gql create mode 100644 e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.tsx create mode 100644 e2e/react/src/routes/pagination/query/connection-forwards-singlepage/test.ts create mode 100644 e2e/react/src/routes/routing-errors/+error.tsx create mode 100644 e2e/react/src/routes/routing-errors/+page.tsx create mode 100644 e2e/react/src/routes/routing-errors/not-found/+page.tsx create mode 100644 e2e/react/src/routes/routing-errors/not-found/test.ts create mode 100644 e2e/react/src/routes/routing-errors/redirect-target/+page.tsx create mode 100644 e2e/react/src/routes/routing-errors/redirect/+page.tsx create mode 100644 e2e/react/src/routes/routing-errors/redirect/test.ts create mode 100644 packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go create mode 100644 packages/houdini-core/plugin/documents/artifacts/typescript/masking_test.go create mode 100644 packages/houdini-core/plugin/documents/assignability.go create mode 100644 packages/houdini-core/plugin/documents/assignability_test.go create mode 100644 packages/houdini-core/plugin/documents/walk_test.go create mode 100644 packages/houdini-core/plugin/schema/typeRef.go create mode 100644 packages/houdini-core/plugin/schema/typeRef_test.go create mode 100644 packages/houdini-core/runtime/public/list.test.ts create mode 100644 packages/houdini-react/runtime/Link.tsx create mode 100644 packages/houdini-react/runtime/hooks/recycleNodesInto.test.ts create mode 100644 packages/houdini-react/runtime/hooks/recycleNodesInto.ts create mode 100644 packages/houdini-react/runtime/resolve-href.test.ts create mode 100644 packages/houdini-react/runtime/resolve-href.ts create mode 100644 packages/houdini-react/runtime/routing/errors.tsx create mode 100644 packages/houdini-react/runtime/tsconfig.json create mode 100644 packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts create mode 100644 packages/houdini/src/runtime/cache/tests/refresh.test.ts create mode 100644 perf/benchmark.json create mode 100644 perf/compare.js create mode 100644 perf/merge.js create mode 100755 perf/watch-bench.sh create mode 100644 test-results/.last-run.json diff --git a/.changeset/anchor-typed-hrefs.md b/.changeset/anchor-typed-hrefs.md new file mode 100644 index 0000000000..3618157662 --- /dev/null +++ b/.changeset/anchor-typed-hrefs.md @@ -0,0 +1,5 @@ +--- +'houdini-react': minor +--- + +Add a `` component with a typed `to` prop checked at compile time against your app's route manifest, with `params` interpolation and custom scalar support. diff --git a/.changeset/cold-poems-wear.md b/.changeset/cold-poems-wear.md new file mode 100644 index 0000000000..79e1e5319c --- /dev/null +++ b/.changeset/cold-poems-wear.md @@ -0,0 +1,5 @@ +--- +"houdini": patch +--- + +Fix document change count in hmr diff --git a/.changeset/error-extensions.md b/.changeset/error-extensions.md new file mode 100644 index 0000000000..45d0bde4c0 --- /dev/null +++ b/.changeset/error-extensions.md @@ -0,0 +1,7 @@ +--- +'houdini': patch +'houdini-react': patch +'houdini-svelte': patch +--- + +GraphQL errors now expose `locations`, `path`, and `extensions` per the spec; augment `App.GraphQLErrorExtensions` to type your server's extensions. diff --git a/.changeset/fix-addmany-field-visibility.md b/.changeset/fix-addmany-field-visibility.md new file mode 100644 index 0000000000..0a845f6f56 --- /dev/null +++ b/.changeset/fix-addmany-field-visibility.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +fix addMany ignoring field visibility when subscribing, preventing hidden fields from leaking into list updates diff --git a/.changeset/fix-atomic-pipeline-writes.md b/.changeset/fix-atomic-pipeline-writes.md new file mode 100644 index 0000000000..2f8c55965c --- /dev/null +++ b/.changeset/fix-atomic-pipeline-writes.md @@ -0,0 +1,6 @@ +--- +'houdini': patch +'houdini-react': patch +--- + +write generated files atomically to prevent partial-read parse errors when Vite loads a module mid-pipeline diff --git a/.changeset/fix-conditional-spread-null-cascade.md b/.changeset/fix-conditional-spread-null-cascade.md new file mode 100644 index 0000000000..46bb787565 --- /dev/null +++ b/.changeset/fix-conditional-spread-null-cascade.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +fix null cascade when combining @mask_disable with @include/@skip (#1550), and restore correct runtime masking behavior in artifacts diff --git a/.changeset/fix-fragment-pagination.md b/.changeset/fix-fragment-pagination.md new file mode 100644 index 0000000000..d5f284f92c --- /dev/null +++ b/.changeset/fix-fragment-pagination.md @@ -0,0 +1,8 @@ +--- +'houdini': patch +'houdini-react': patch +'houdini-svelte': patch +'houdini-core': patch +--- + +fixed fragment pagination diff --git a/.changeset/fix-fragment-rerenders.md b/.changeset/fix-fragment-rerenders.md new file mode 100644 index 0000000000..d9604cf53b --- /dev/null +++ b/.changeset/fix-fragment-rerenders.md @@ -0,0 +1,6 @@ +--- +'houdini-react': patch +'houdini': patch +--- + +prevent unnecessary re-renders on fragments by stabilizing returned values and skipping subscription updates when data hasn't changed diff --git a/.changeset/fix-hmr-new-routes.md b/.changeset/fix-hmr-new-routes.md new file mode 100644 index 0000000000..498603481c --- /dev/null +++ b/.changeset/fix-hmr-new-routes.md @@ -0,0 +1,6 @@ +--- +'houdini': patch +'houdini-react': patch +--- + +fix HMR not regenerating the router manifest when a new `+page` or `+layout` file is added; invalidate component fields cache after each HMR cycle diff --git a/.changeset/fix-list-filter-object-variables.md b/.changeset/fix-list-filter-object-variables.md new file mode 100644 index 0000000000..e4e9321750 --- /dev/null +++ b/.changeset/fix-list-filter-object-variables.md @@ -0,0 +1,6 @@ +--- +'houdini-core': patch +'houdini': patch +--- + +fix list filters and @when conditions that contain object values or variable references nested inside objects diff --git a/.changeset/fix-mutation-order.md b/.changeset/fix-mutation-order.md new file mode 100644 index 0000000000..db44a75913 --- /dev/null +++ b/.changeset/fix-mutation-order.md @@ -0,0 +1,6 @@ +--- +'houdini-react': patch +'houdini': patch +--- + +Fix `useMutation` to return `[mutate, pending]` instead of `[pending, mutate]`, and fix list toggle operations accumulating across resolved optimistic mutation layers causing subsequent toggles to appear stuck. diff --git a/.changeset/fix-pagination-dedupe.md b/.changeset/fix-pagination-dedupe.md new file mode 100644 index 0000000000..050e77d42f --- /dev/null +++ b/.changeset/fix-pagination-dedupe.md @@ -0,0 +1,6 @@ +--- +'houdini-react': patch +'houdini': patch +--- + +fix gaps in pagination request deduplication: stale inflight entries no longer block new requests, and ssr_signals now covers client-side concurrent renders to prevent duplicate observer/send pairs diff --git a/.changeset/fix-pagination-sibling-fields.md b/.changeset/fix-pagination-sibling-fields.md new file mode 100644 index 0000000000..c3a27e88d7 --- /dev/null +++ b/.changeset/fix-pagination-sibling-fields.md @@ -0,0 +1,5 @@ +--- +'houdini-core': patch +--- + +strip sibling fields from generated pagination query documents so only the paginated field is included diff --git a/.changeset/fix-plugin-bin-missing-error.md b/.changeset/fix-plugin-bin-missing-error.md new file mode 100644 index 0000000000..2949b0511f --- /dev/null +++ b/.changeset/fix-plugin-bin-missing-error.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +show a clear error when a plugin is found but has no bin field, calling out local monorepo packages as the likely cause diff --git a/.changeset/fix-refetch-cache-links-leak.md b/.changeset/fix-refetch-cache-links-leak.md new file mode 100644 index 0000000000..fa1fec609f --- /dev/null +++ b/.changeset/fix-refetch-cache-links-leak.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +Fix cache link leak when refetching connections — embedded edge records now reuse their existing keys on write instead of generating new ones, and records that fall out of the list are cleaned up immediately. diff --git a/.changeset/hip-rockets-stick.md b/.changeset/hip-rockets-stick.md new file mode 100644 index 0000000000..6db58e414b --- /dev/null +++ b/.changeset/hip-rockets-stick.md @@ -0,0 +1,5 @@ +--- +"houdini": patch +--- + +Prevent panic in the presence of concurrent writes to dev server websocket diff --git a/.changeset/lemon-files-follow.md b/.changeset/lemon-files-follow.md new file mode 100644 index 0000000000..6689ba025a --- /dev/null +++ b/.changeset/lemon-files-follow.md @@ -0,0 +1,5 @@ +--- +"houdini-core": patch +--- + +Add support for @includeListID directive diff --git a/.changeset/pre.json b/.changeset/pre.json index f5f952201f..e8f1a45b79 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -20,11 +20,13 @@ }, "changesets": [ "abort-controller", + "anchor-typed-hrefs", "angry-zoos-talk", "auth-header-fix", "clean-socks-explain", "cloudflare-adapter-fix", "cold-carrots-wink", + "cold-poems-wear", "curly-olives-flash", "cyan-sheep-compare", "dep-bump-adapter-auto", @@ -35,33 +37,54 @@ "dep-bump-houdini-svelte", "dep-bump-houdini", "eleven-parents-rest", + "error-extensions", + "fix-addmany-field-visibility", + "fix-atomic-pipeline-writes", "fix-bin-object-plugin-resolution", + "fix-conditional-spread-null-cascade", "fix-create-houdini-version-resolution", + "fix-cursor-pagination", "fix-dev-fouc-css", "fix-fragment-abstract-types", "fix-fragment-handle-ts-type", + "fix-fragment-rerenders", "fix-hmr-gql-deletions", + "fix-hmr-new-routes", + "fix-hmr-pipeline", "fix-injected-plugins-null-config", + "fix-list-filter-object-variables", + "fix-mutation-order", + "fix-pageinfo-updates-direction", "fix-paginated-connection-updates", + "fix-pagination-dedupe", + "fix-pagination-sibling-fields", + "fix-plugin-bin-missing-error", "fix-react-vite-ssr-fouc", + "fix-refetch-cache-links-leak", "gitignore-schema-path", "go-compiler-features", "go-rewrite", "graphql-peer-dep", "hip-fishes-end", + "hip-rockets-stick", "large-countries-fetch", + "lemon-files-follow", "many-geese-admire", "mean-clocks-care", "mutation-error-handling", "nasty-tables-fix", "peer-dep-adapters", "publish-wasm-packages", + "refresh-cache-record", "session-transform-ts-annotations", + "sharp-banks-check", + "silver-baboons-open", "small-falcons-grow", "stale-lizards-warn", "stale-trainers-enjoy", "svelte-async-component-query", "tsconfig-stub-on-startup", + "upsert-list-operation", "write-polled-schema", "yellow-dancers-start" ] diff --git a/.changeset/react-routing-errors.md b/.changeset/react-routing-errors.md new file mode 100644 index 0000000000..78135bd7e7 --- /dev/null +++ b/.changeset/react-routing-errors.md @@ -0,0 +1,5 @@ +--- +"houdini-react": minor +--- + +Add `+error.tsx` route-level error boundaries and a full routing error toolkit (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`, `httpError()`, `isRoutingError`, `isApiError`) for the React adapter. diff --git a/.changeset/refresh-cache-record.md b/.changeset/refresh-cache-record.md new file mode 100644 index 0000000000..3f0ba81bd7 --- /dev/null +++ b/.changeset/refresh-cache-record.md @@ -0,0 +1,6 @@ +--- +'houdini': minor +'houdini-core': minor +--- + +Add `record.refresh()` to refetch every document that contains a given cache record, including those that reference it only through a fragment spread. diff --git a/.changeset/sharp-banks-check.md b/.changeset/sharp-banks-check.md new file mode 100644 index 0000000000..9f0763a430 --- /dev/null +++ b/.changeset/sharp-banks-check.md @@ -0,0 +1,5 @@ +--- +'houdini-core': patch +--- + +Rework argument type validation to follow the GraphQL spec, fixing coercions, `@with` checks, and unknown type/enum reporting ([#1645](https://github.com/HoudiniGraphql/houdini/issues/1645)). diff --git a/.changeset/silver-baboons-open.md b/.changeset/silver-baboons-open.md new file mode 100644 index 0000000000..b832ef366c --- /dev/null +++ b/.changeset/silver-baboons-open.md @@ -0,0 +1,5 @@ +--- +"houdini-react": patch +--- + +Fix preload conflicting with navigations diff --git a/.changeset/upsert-list-operation.md b/.changeset/upsert-list-operation.md new file mode 100644 index 0000000000..cf72d7f8d4 --- /dev/null +++ b/.changeset/upsert-list-operation.md @@ -0,0 +1,5 @@ +--- +'houdini': minor +--- + +add `_upsert` list operation (insert if absent, update in place if present) and `_update` fragment (write field values to an existing cached record without affecting list membership) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000000..3373419ae2 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,76 @@ +name: Cache Benchmarks + +on: + pull_request: + paths: + - 'packages/houdini/src/runtime/cache/**' + +jobs: + benchmark: + name: Benchmark + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 24.9.0 + + - uses: pnpm/action-setup@v4.1.0 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.dir }} + key: pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm- + + # ── PR branch ──────────────────────────────────────────────────── + - run: pnpm install --frozen-lockfile --prefer-offline + + - name: Benchmark PR branch (3 runs) + run: | + for i in 1 2 3; do + BENCH_MAX_N=1000 npx vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson /tmp/benchmark.current.$i.json + done + node perf/merge.js /tmp/benchmark.current.1.json /tmp/benchmark.current.2.json /tmp/benchmark.current.3.json > /tmp/benchmark.current.json + + # ── Base branch ────────────────────────────────────────────────── + - name: Save benchmark suite and merge script from PR + run: | + cp -r packages/houdini/src/runtime/cache/benchmarks /tmp/houdini-benchmarks + cp perf/merge.js /tmp/houdini-merge.js + + - name: Checkout base branch + run: git checkout ${{ github.base_ref }} + + - name: Restore benchmark suite onto base branch + run: | + mkdir -p packages/houdini/src/runtime/cache/benchmarks + cp -r /tmp/houdini-benchmarks/. packages/houdini/src/runtime/cache/benchmarks/ + + - run: pnpm install --frozen-lockfile --prefer-offline + + - name: Benchmark base branch (3 runs) + run: | + for i in 1 2 3; do + BENCH_MAX_N=1000 npx vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson /tmp/benchmark.baseline.$i.json + done + node /tmp/houdini-merge.js /tmp/benchmark.baseline.1.json /tmp/benchmark.baseline.2.json /tmp/benchmark.baseline.3.json > /tmp/benchmark.baseline.json + + # ── Compare ────────────────────────────────────────────────────── + - name: Checkout PR branch + run: | + git stash --include-untracked + git checkout ${{ github.head_ref }} + + - name: Compare + run: node perf/compare.js /tmp/benchmark.baseline.json /tmp/benchmark.current.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c394ae3bc..47653ac8d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,11 @@ name: Release on: push: branches: - - houdini-2.0 - - go - main env: CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: publish: name: Publish Release Version diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c974f556f..cf557b6deb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,6 @@ on: push: branches: - 'main' - - 'houdini-2.0' env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/ms-playwright @@ -53,8 +52,12 @@ jobs: tests: name: Unit Tests runs-on: ubuntu-latest + needs: [format] + if: always() steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} - uses: actions/setup-node@v3 with: node-version: 24.9.0 @@ -88,8 +91,12 @@ jobs: verify_init: name: Verify Init runs-on: ubuntu-latest + needs: [format] + if: always() steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} - uses: actions/setup-node@v3 with: node-version: 24.9.0 @@ -115,11 +122,15 @@ jobs: e2e_tests: name: End-to-End Tests runs-on: ubuntu-latest + needs: [format] + if: always() strategy: matrix: framework: [e2e-kit, e2e-react] steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} - uses: actions/setup-node@v3 with: node-version: 24.9.0 @@ -159,11 +170,15 @@ jobs: verify_generate: name: Verify Generate runs-on: ubuntu-latest + needs: [format] + if: always() strategy: matrix: framework: [e2e-react, e2e-kit] steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} - uses: actions/setup-node@v3 with: node-version: 24.9.0 @@ -188,11 +203,15 @@ jobs: verify_protocols: name: Verify Plugin Protocols runs-on: ubuntu-latest + needs: [format] + if: always() strategy: matrix: protocol: [websocket, stdio] steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} - uses: actions/setup-node@v3 with: node-version: 24.9.0 @@ -220,8 +239,15 @@ jobs: linter: name: Linter & Type Check runs-on: ubuntu-latest + needs: [format] + if: always() + strategy: + matrix: + framework: [e2e-kit, e2e-react] steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} - uses: actions/setup-node@v3 with: node-version: 24.9.0 @@ -241,7 +267,6 @@ jobs: - run: pnpm install --frozen-lockfile --prefer-offline - run: pnpm run build - run: find ./packages -path "*/bin/houdini-*" -type f | xargs chmod +x 2>/dev/null || true - - run: pnpm --filter e2e-kit run build - - run: pnpm --filter e2e-kit run lint - - run: pnpm --filter e2e-kit run check - - run: pnpm --filter e2e-react run lint + - run: pnpm --filter ${{ matrix.framework }} run build + - run: pnpm --filter ${{ matrix.framework }} run lint + - run: pnpm --filter ${{ matrix.framework }} run check diff --git a/.github/workflows/trigger-docs-rebuild.yml b/.github/workflows/trigger-docs-rebuild.yml index e0690ce7bc..461ca81593 100644 --- a/.github/workflows/trigger-docs-rebuild.yml +++ b/.github/workflows/trigger-docs-rebuild.yml @@ -2,7 +2,7 @@ name: Trigger docs rebuild on: push: - branches: [houdini-2.0] + branches: [main] tags: - 'svelte-v*' - 'react-v*' diff --git a/.gitignore b/.gitignore index 907df3f4ec..d2a195ee72 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ bin .pnp.* __tests__ +test-results/ .svelte-kit functions @@ -27,3 +28,7 @@ vite.config.*.timestamp-* .netlify mise.toml + +# Benchmark outputs — only benchmark.json (the full baseline) is committed +perf/benchmark.*.json +perf/benchmark.quick.json diff --git a/CLAUDE.md b/CLAUDE.md index 893ea85456..b9e7bab552 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,12 @@ When making changes, update the relevant doc pages alongside the code. This incl - Changed behavior or API shape → update any pages that describe it - New adapters or plugins → add an entry to the relevant reference page +**Mandatory check**: before finishing any code task, run `grep -rn /docs` to find pages that reference it and verify they reflect the change. Do not skip this step. + +**Internal links**: always use `~/path` (not `/path`) for cross-links between doc pages. Example: `[custom scalars](~/guides/custom-scalars)`. + The marketing site at `../marketing` symlinks directly into these directories, so doc changes are reflected immediately in the local dev server. -## Tutorial sync +## Changesets -Fixes to tutorial shim/Go files must also update the houdini source templates: `shim.cjs`, `postInstall.js`, `db_ncruces.go`. No automated check enforces this. +Every non-documentation change needs a changeset. Doc-only changes do not need one. Each branch should have exactly one changeset. Keep the description to one or two sentences — no bullet lists. diff --git a/docs/react/00-your-first-app/00-getting-started.mdx b/docs/react/00-your-first-app/00-getting-started.mdx index f242e9aa8e..d81ac2429d 100644 --- a/docs/react/00-your-first-app/00-getting-started.mdx +++ b/docs/react/00-your-first-app/00-getting-started.mdx @@ -28,11 +28,17 @@ Instead of the usual todo list, we're going to build a little app to explore the We're going to start off with a project that's already been configured so we can jump right to the fun stuff. You can pull down the demo by executing the following commands in your terminal: ```bash -npx degit houdinigraphql/intro-react hello-houdini +npx degit houdinigraphql/intro-react#react hello-houdini cd hello-houdini -npm i +npm install ``` If you look inside of this directory, you'll see a barebones Houdini React application with a few extra config files as well as some components we'll use to lay out our Pokédex. Don't worry too much about the extra bits right now - we'll highlight the important things as we work through this guide. When you're ready to set up your own application, head over to the [Setup](~/setup) guide. Once you're ready to go, navigate to the project directory and start the dev server with `npm run dev`. + +If you ever want to check your work against the finished product, the completed version of everything we're going to build is available on the `react-final` branch: + +```bash +npx degit houdinigraphql/intro-react#react-final hello-houdini-final +``` diff --git a/docs/react/00-your-first-app/01-queries.mdx b/docs/react/00-your-first-app/01-queries.mdx index 83cbd6f505..e09539156f 100644 --- a/docs/react/00-your-first-app/01-queries.mdx +++ b/docs/react/00-your-first-app/01-queries.mdx @@ -11,7 +11,7 @@ Create two files inside of your `src/routes` directory, `+page.gql` and `+page.t ```graphql title="src/routes/+page.gql" query Info { species(id: 1) { - id + pokedexNumber name flavor_text sprites { @@ -31,7 +31,7 @@ export default function Page({ Info }: PageProps) { {Info.species.name} - no.{Info.species.id} + no.{Info.species.pokedexNumber} + A GraphQL query is a string that describes what information you want from the API. For example, the following defines a query named `QueryUserInfo`. In Houdini, all documents like queries must be named for reasons that will become more clear later. @@ -123,7 +123,7 @@ cd src/routes && mkdir "[[id]]" && mv +page.* "./[[id]]" The double braces mark an optional parameter so we can render the same view for both `/` and `/1`. - + All of the queries we've seen so far have had static arguments. However, most of the time you will need to give an argument a dynamic value based on something in your application. @@ -158,6 +158,7 @@ a variable. Doing this is relatively simple, just update the query inside of `+p ```graphql title="src/routes/[[id]]/+page.gql" query Info($id: Int! = 1) { species(id: $id) { + id name flavor_text sprites { @@ -173,17 +174,26 @@ and took care of all of the wiring for you. Pretty cool, huh? You should be able to navigate to `/6` and see Charizard's information. If you then navigate back to `/`, there is no value for the `[[id]]` portion of the url and the query uses its default value of `1`. -For completeness, let's quickly add some buttons to navigate between the different species. Copy and paste this block as the last child of the `Container` component. Don't worry if you see an error when you click on them, we'll fix that next. +For completeness, let's quickly add some buttons to navigate between the different species. First add `Link` to your `$houdini` import: + +```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" +import { Link } from '$houdini' +``` + +Then copy and paste this block as the last child of the `Container` component: ```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" ``` diff --git a/docs/react/00-your-first-app/02-fragments.mdx b/docs/react/00-your-first-app/02-fragments.mdx index 35c5c316e6..68f54aeda4 100644 --- a/docs/react/00-your-first-app/02-fragments.mdx +++ b/docs/react/00-your-first-app/02-fragments.mdx @@ -3,6 +3,8 @@ title: Reusing Parts of a Query description: The second part of the Houdini intro focusing on how to reuse parts of a query --- +import AsideAccordion from "@components/docs/AsideAccordion.astro"; + As you've seen, we can get pretty far by just using GraphQL queries to define our route's data requirements. However, as our application grows these would quickly become unmanageable. To illustrate this point, look at how we used the `Sprite` component earlier (copied below without the unrelated bits): ```graphql title="src/routes/[[id]]/+page.gql" @@ -28,8 +30,7 @@ At first it might not be clear what the problem is. `Sprite` defines some props Wouldn't it be nice if we had some way of defining these requirements inside of `Sprite` so we didn't have to worry about the exact details and could ensure that the query included whatever information `Sprite` needs? Well, that's where GraphQL fragments come to the rescue. -:::note[GraphQL: What Are Fragments?] - + It's safe to skip this section if you are familiar with GraphQL Fragments. @@ -44,8 +45,7 @@ fragment MyFragment on Species { This fragment acts as a reusable bit of query so anywhere we have a field in our document that returns a `Species` we can ask for this data by appending `...` to the fragment name in a selection. - -::: + ## Using Fragments @@ -55,7 +55,15 @@ Defining a fragment inside of your component looks a lot like the query from bef import { useFragment, graphql } from '$houdini' import type { SpriteInfo } from '$houdini' -export function Sprite({ species, ...props }: { species: SpriteInfo }) { +export function Sprite({ + species, + id, + className, +}: { + species: SpriteInfo | null + id?: string + className?: string +}) { const info = useFragment(species, graphql(` fragment SpriteInfo on Species { name @@ -65,13 +73,11 @@ export function Sprite({ species, ...props }: { species: SpriteInfo }) { } `)) + if (!info) return null + return ( -
- {`${info.name} +
+ {`${info.name}
) } @@ -82,9 +88,9 @@ Next we have to go back to the route and put this fragment to use. Update the qu ```graphql title="src/routes/[[id]]/+page.gql" query Info($id: Int! = 1) { species(id: $id) { + id name flavor_text - ...SpriteInfo } } @@ -119,26 +125,30 @@ It's worth mentioning explicitly that you are free to mix and match fragments ho Before we add anything to our route, let's update the component defined in `src/components/SpeciesPreview` to use the new fragment we just added to `Sprite`. You might want to give it a try without looking ahead but either way, here's what it should look like now: ```tsx title="src/components/SpeciesPreview.tsx&typescriptToggle=true" -import { useFragment, graphql } from '$houdini' +import { useFragment, graphql, Link } from '$houdini' +import type { SpeciesPreview as SpeciesPreviewFragment } from '$houdini' import { Sprite, Display } from '.' -import type { SpeciesPreview } from '$houdini' +import { SpeciesPreviewNumber } from './SpeciesPreviewNumber' -export function SpeciesPreview({ species, number }: { species: SpeciesPreview, number: number }) { - const preview = useFragment(species, graphql(` +export function SpeciesPreview({ species, number }: { species: SpeciesPreviewFragment, number: number }) { + const data = useFragment(species, graphql(` fragment SpeciesPreview on Species { name id + pokedexNumber ...SpriteInfo } `)) + if (!data) return null + return ( - - - - {preview.name} - + + + + {data.name} + ) } ``` @@ -148,28 +158,37 @@ Once that's done, go back to the route we've been working with and update the qu ```graphql title="src/routes/[[id]]/+page.gql" query Info($id: Int! = 1) { species(id: $id) { + id name flavor_text + ...SpriteInfo evolution_chain { + id ...SpeciesPreview } - ...SpriteInfo } } ``` -Next, copy and paste the following code above the `nav` in the right panel. You'll also want to add imports for `SpeciesPreview` and `SpeciesPreviewPlaceholder` from the component directory. +Next, add imports for `SpeciesPreview` and `SpeciesPreviewPlaceholder` from the component directory, then add the two `const` declarations at the top of your component function and place the `div` above the `nav` in the right panel: ```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" +// add these at the top of the Page function body +const evolutionChain = Info.species?.evolution_chain ?? [] +const placeholderCount = Math.max(0, 3 - evolutionChain.length) + +// ... + +{/* add this above the nav in the right panel */}
- {Info.species.evolution_chain.map((form, i) => ( - + {evolutionChain.map((s, i) => ( + ))} - {/* if there are less than three species in the chain, leave a placeholder behind */} - {Array.from({ length: 3 - Info.species.evolution_chain.length }).map((_, i) => ( + {/* if there are fewer than three species in the chain, leave a placeholder */} + {Array.from({ length: placeholderCount }).map((_, i) => ( ))}
diff --git a/docs/react/00-your-first-app/03-mutations.mdx b/docs/react/00-your-first-app/03-mutations.mdx index 016d7f07d5..70f3b538a2 100644 --- a/docs/react/00-your-first-app/03-mutations.mdx +++ b/docs/react/00-your-first-app/03-mutations.mdx @@ -3,12 +3,13 @@ title: Handling Updates description: The third part of the Houdini intro focusing on how to update the client side cache --- +import AsideAccordion from "@components/docs/AsideAccordion.astro"; + Thanks for making it this far! We really appreciate the time you are spending to learn Houdini. So far we've only covered how to read data from our server. Clearly this is only one part of the picture since most projects need to be able to update the server's state. -:::note[GraphQL: Mutations] - + In GraphQL, requests to update the server are defined by a different document type than `query` and are known as "mutations". They look something like this: @@ -24,8 +25,7 @@ mutation SetFavorite { This example defines a mutation document named `SetFavorite` that invokes the `toggleFavorite` mutation on the server to flag Bulbasaur as one of our favorites. Every mutation in GraphQL has a return type that we can use to look up the values that have been updated in response to the mutation. Since we know that this mutation updates the `favorite` field we made sure to ask for the `favorite` field of the species. - -::: + ## Updating Field Values @@ -33,15 +33,17 @@ Before we explain how to use mutations in Houdini, we need a way to visualize if ```graphql title="src/routes/[[id]]/+page.gql" query Info($id: Int! = 1) { - species(id: $id) { - name - flavor_text - favorite - evolution_chain { - ...SpeciesPreview - } - ...SpriteInfo - } + species(id: $id) { + id + name + flavor_text + favorite + ...SpriteInfo + evolution_chain { + id + ...SpeciesPreview + } + } } ``` @@ -51,18 +53,20 @@ Once you've added the field, add an import for `Icon` from the component directo import { Icon } from '~/components' ``` -With that in place we can now define a function that will invoke the `toggleFavorite` mutation and pass it to our button. Add the `useMutation` call shown below to your component: +With that in place we can now define a function that will invoke the `toggleFavorite` mutation and pass it to our button. Add the following above the component definition, then add the `useMutation` call inside: ```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" import { graphql, useMutation } from '$houdini' -// ... everything from before - -const [, toggleFavorite] = useMutation(graphql(` +const toggleFavoriteMutation = graphql(` mutation ToggleFavorite($id: Int!) { toggleFavorite(id: $id) { species { @@ -71,15 +75,23 @@ const [, toggleFavorite] = useMutation(graphql(` } } } -`)) +`) + +export default function Page({ Info }: PageProps) { + const [toggleFavorite, pending] = useMutation(toggleFavoriteMutation) + + // ... everything from before +} ``` -`useMutation` returns a tuple of `[pending, mutate]`. `pending` is `true` while the server request is in flight; `mutate` is the function you call to execute the mutation. With that in place, we can now configure the button we added earlier: +`useMutation` returns a tuple of `[mutate, pending]`. `mutate` is the function you call to execute the mutation; `pending` is `true` while the server request is in flight. With that in place, we can now configure the button we added earlier: ```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" - + + ) +} ``` + +`useLocation` also exposes `pathname` and `params` if you need to read the current URL — for example, to mark a link as active: + +```tsx +import { useLocation } from '$houdini' + +export function NavLink({ href, label }: { href: string; label: string }) { + const { pathname } = useLocation() + return ( + + {label} + + ) +} +``` + +See [`useLocation`](~/api-reference/useLocation) for the full API. diff --git a/docs/react/02-routing/04-error-boundaries.mdx b/docs/react/02-routing/04-error-boundaries.mdx index f2dc8251ce..58ae31c734 100644 --- a/docs/react/02-routing/04-error-boundaries.mdx +++ b/docs/react/02-routing/04-error-boundaries.mdx @@ -3,37 +3,133 @@ title: Error Boundaries description: Handling errors at the route level in Houdini's React framework --- -Houdini uses standard React error boundaries for route-level error handling. A `+layout.jsx` is just a React component, so wrapping its children in an error boundary works exactly as you'd expect: +Houdini provides a dedicated `+error.tsx` file for route-level error handling. When present alongside a `+page.tsx`, Houdini automatically wraps the page in an error boundary that catches errors thrown by the page, its queries, and any child routes nested below that directory. -```jsx title="src/routes/+layout.tsx&typescriptToggle=true" -import { ErrorBoundary } from 'react-error-boundary' -import type { LayoutProps } from './$types' +```tsx title="src/routes/dashboard/+error.tsx&typescriptToggle=true" +import type { ErrorProps } from './$types' -export default function RootLayout({ children }: LayoutProps) { +export default function DashboardError({ errors, children }: ErrorProps) { return ( - Something went wrong.

}> - {children} -
+
+

Something went wrong

+

{errors[0].message}

+
) } ``` -We recommend [react-error-boundary](https://github.com/bvaughn/react-error-boundary) for the extra utilities it provides — reset keys, error logging callbacks, and a `useErrorBoundary` hook for throwing errors imperatively from event handlers. +The `errors` prop is `Array` — it will contain whichever error was thrown, whether that's a network failure, a GraphQL response error, or anything else thrown inside the page tree. -## Placement +## The `children` prop -Because layouts nest, we can have boundaries at multiple levels. A boundary in the root layout catches everything. A boundary in a section layout catches only that section. Nothing stops us from having both. +The error component also receives `children` — the page component that failed. You can render it if you want to show partial content alongside the error, but in most cases you won't. It's there when you need it. + +## Query data + +A `+error.tsx` has access to the same layout queries that are in scope at its directory level, since those queries succeeded for the boundary to even render. These are passed as props just like they would be to `+page.tsx`. The type for all available props is `ErrorProps`, exported from `./$types`. + +```tsx title="src/routes/+error.tsx&typescriptToggle=true" +import type { ErrorProps } from './$types' + +// RootQuery results are available because the root layout query succeeded +export default function RootError({ RootQuery, errors }: ErrorProps) { + return ( +
+

Welcome, {RootQuery.viewer.name} — but something went wrong.

+

{errors[0].message}

+
+ ) +} +``` + +## Scope + +Error boundaries nest the same way layouts do. A `+error.tsx` catches errors from its sibling `+page.tsx` and all routes below it. A layout query error at the same level bubbles up and would be caught by an error boundary at the parent directory instead. ``` src/routes/ - +layout.jsx ← catches everything + +error.tsx ← catches errors from the root page and all child routes + +layout.gql dashboard/ - +layout.jsx ← catches only dashboard routes - +page.jsx + +error.tsx ← catches errors from +page.tsx and child routes within dashboard/ + +page.tsx + +page.gql +``` + +## GraphQL errors + +By default Houdini throws GraphQL response errors so they're catchable by the error boundary. The thrown value is unwrapped into the `errors` array, so each entry is a `GraphQLError` object with at least a `message` field (and optionally `path`, `locations`, and `extensions`). [Learn how to type the `extensions` field.](~/guides/error-handling/#typing-error-extensions) + +### SSR and query errors + +Error boundaries catch GraphQL errors correctly during client-side navigation. Direct page loads (SSR) are different: Houdini streams the page shell to the browser immediately while queries are in flight. Components waiting on data are suspended and retried asynchronously once the response arrives. If a query returns an error during that retry, React is already mid-stream and cannot route the error to a class-based error boundary — the server returns a 500 and the browser shows a blank error page. + +Routing errors (`notFound()`, `redirect()`, etc.) are not affected because they throw synchronously before any suspension happens. + +The recommended fix is to add a `@loading` directive to your page query. This tells Houdini to generate a Suspense boundary in the right place — between the error boundary and the suspended component — so that React can hand off errors correctly during SSR. It also gives the browser something to show while the query is in flight. We can't exactly tell you where to put it in your app, that depends on how error-prone certain fields are in your API. But you should try to put some kind of loading state high up in your component tree so you load the minimal amount of information for a reasonable experience. + +```graphql title="src/routes/dashboard/+page.gql" +query DashboardQuery @loading { + viewer { + name + } +} ``` -This is more control than a single `+error.jsx` per route, and it composes naturally with any third-party error reporting setup. +With `@loading` in place, Houdini generates a loading state component from your `+page.tsx` (see the [loading states guide](~/routing/loading-states) for how to define it). React sends that loading state in the initial HTML, holds the stream open until the query resolves, and either replaces it with the page content or lets the error reach the nearest `+error.tsx`. It's important to note that when you do this, your application will return a status code 200 even if an error is bubbled up to the error state. This is just how React's current implementation of streaming SSR works. There's no way around it. -## Query Errors +Client-side navigations don't need any of this — the error boundary catches query errors there regardless. -By default, query errors are returned as data rather than thrown — so error boundaries won't catch them unless you opt in. The `throwOnError` option on your queries controls this. See [Error Handling](~/guides/error-handling) for the full picture. +## Routing utilities + +Houdini exports a set of functions you can call from any page component to signal a specific HTTP outcome. Each one throws an error that is caught by the nearest `+error.tsx` boundary. + +```tsx +import { notFound, unauthorized, forbidden, httpError, redirect } from '$houdini' +``` + +| Function | Throws | HTTP status | +|---|---|---| +| `notFound()` | `RoutingError(404)` | 404 | +| `unauthorized()` | `RoutingError(401)` | 401 | +| `forbidden()` | `RoutingError(403)` | 403 | +| `httpError(status)` | `RoutingError(status)` | any status | +| `redirect(status, url)` | `RedirectError` | 3xx | + +The `errors` prop in your `+error.tsx` will contain the thrown instance. Use `isRoutingError` and `isApiError` to branch on error type: + +```tsx title="src/routes/show/[id]/+error.tsx&typescriptToggle=true" +import type { ErrorProps } from './$types' +import { isRoutingError, isApiError } from '$houdini' + +export default function ShowError({ errors }: ErrorProps) { + const routing = errors.find(isRoutingError) + if (routing?.status === 404) return

Show not found.

+ if (routing?.status === 401) return

Please log in.

+ + const api = errors.find(isApiError) + if (api) return

{api.graphqlErrors[0].message}

+ + return

Something went wrong.

+} +``` + +### 404 and static URL matching + +When no exact route matches the incoming URL, Houdini automatically finds the deepest layout whose static URL prefix matches, renders its `+error.tsx` as the 404 page, and returns HTTP 404 before streaming begins — no catch-all route needed. + +### redirect() + +`redirect()` works on both the server and the client. On an initial SSR response where the redirect can be detected before streaming begins, Houdini returns an HTTP 3xx response with the `Location` header set. On the client, or when the redirect is detected mid-stream, it triggers a router navigation to the target URL. + +```tsx title="src/routes/dashboard/+page.tsx&typescriptToggle=true" +import { redirect } from '$houdini' +import type { PageProps } from './$types' + +export default function DashboardPage({ ViewerQuery }: PageProps) { + if (!ViewerQuery.viewer) { + redirect(302, '/login') + } + return
Welcome, {ViewerQuery.viewer.name}
+} +``` diff --git a/docs/react/03-loading-data/01-queries.mdx b/docs/react/03-loading-data/01-queries.mdx index 60d52e9836..7f749da6ea 100644 --- a/docs/react/03-loading-data/01-queries.mdx +++ b/docs/react/03-loading-data/01-queries.mdx @@ -5,6 +5,7 @@ description: Loading data in Houdini's React framework import Dedupe from '@shared/_partials/dedupe.mdx' import RuntimeScalars from '@shared/_partials/runtime-scalars.mdx' +import AsideAccordion from "@components/docs/AsideAccordion.astro" The core idea behind Houdini's data loading model is that queries live next to the routes that use them. A `+page.gql` file defines what data a route needs, and Houdini handles the fetching, caching, and prop-threading before your component ever renders. @@ -39,6 +40,16 @@ export default function ShowsPage({ ShowList }) { The prop name matches the query name exactly — `ShowList` not `data` or `props.ShowList`. Houdini enforces this through static analysis, so it needs to appear in the destructuring pattern in the function signature. + + +The prevailing wisdom is that queries should live inside the components that use them. Houdini agrees with the principle (we still colocate fragments in our component source, after all) but disagrees about where the boundary should sit. + +The problem is timing. By the time JavaScript is loaded and runs, it's too late to start a network request without a waterfall: navigate → download JS → parse → run component → discover query → fetch → render. The query has to live somewhere the router can find it before any JavaScript runs — outside the component tree entirely. Once you're inside a JavaScript runtime, it's too late. + +The `.gql` file still lives next to the `.jsx` file. This is just colocation by directory rather than file. + + + ## Layout Queries A `+layout.gql` works the same way as `+page.gql`, but its results are available to the layout component and every route nested beneath it. @@ -80,20 +91,16 @@ query ShowDetail($id: ID!) { The `$id` variable is populated from the `[id]` segment with no extra configuration. -## Runtime Scalars - - - ## Imperative Handles -Sometimes you need to do more than display the initial data — trigger a refetch, load the next page, or respond to user input. For these cases, destructure the handle alongside the query result: +Sometimes you need to do more than display the initial data — trigger a refetch, load the next page, or respond to user input. The `$handle` prop gives you an imperative handle for the query: ```jsx title="src/routes/shows/+page.jsx" -export default function ShowsPage({ ShowList, ShowList$handle }) { +export default function ShowsPage({ ShowList$handle }) { return (
    - {ShowList.shows.map((show) => ( + {ShowList$handle.data.shows.map((show) => (
  • {show.title}
  • ))}
@@ -103,6 +110,8 @@ export default function ShowsPage({ ShowList, ShowList$handle }) { } ``` +If you'd rather avoid the `.data` accessor, destructure both props and use them side by side. + The methods available on the handle depend on the query's directives. A query with `@paginate` will also have `loadNextPage` and `loadPreviousPage`. See [Pagination](~/loading-data/pagination) for details. ## TypeScript @@ -138,6 +147,10 @@ export default function RootLayout({ AppShell, children }: LayoutProps) { } ``` +## Runtime Scalars + + + ## Deduplication diff --git a/docs/react/03-loading-data/02-fragments.mdx b/docs/react/03-loading-data/02-fragments.mdx index 659b2f15f3..2314eb95d7 100644 --- a/docs/react/03-loading-data/02-fragments.mdx +++ b/docs/react/03-loading-data/02-fragments.mdx @@ -3,6 +3,8 @@ title: Fragments description: Reusable data shapes in Houdini --- +import Notice from '@components/docs/Notice.astro' + Fragments let each component declare exactly the data it needs, without coupling it to the query that fetches it. The parent query spreads the fragment; the component reads it back through `useFragment`. ## Basic Usage @@ -85,7 +87,9 @@ query AllUsers { } ``` -> If you use fragment arguments on a field that is also marked for list operations, you must pass the variable value when performing the operation. + + If you use fragment arguments on a field that is also marked for list operations, you must pass the variable value when performing the operation. + ## Fragment Masking diff --git a/docs/react/03-loading-data/03-loading-states.mdx b/docs/react/03-loading-data/03-loading-states.mdx index 4c9c639790..a5251432b1 100644 --- a/docs/react/03-loading-data/03-loading-states.mdx +++ b/docs/react/03-loading-data/03-loading-states.mdx @@ -5,17 +5,36 @@ description: Building loading states in Houdini's React framework import LoadingStates from '@shared/_partials/loading-states.mdx' -The naive approach to loading states is a top-level check: if the query is fetching, render a skeleton; otherwise render the real UI. The problem is that you end up building your layout twice — and any component that owns its own loading shape has to expose that structure to its parent. +The naive approach to loading states is a top-level check: if the query is fetching, render a skeleton; otherwise render the real UI. The problem is that you end up building your layout twice, and any component that owns its own loading shape has to expose that structure to its parent. Houdini solves this with the `@loading` directive. Rather than a separate skeleton branch, the data object itself carries pending placeholders. Components can check their own fields and render accordingly, without the route knowing anything about their internals. +Adding `@loading` to a query also changes how the page is delivered. The response splits at the query boundary: everything above it (layouts, navigation, any ancestor shell) is flushed in the initial HTML. The component that renders the query result streams in over the same HTTP connection as data resolves. Users see the page structure immediately rather than staring at a blank screen until all queries finish. + ## The @loading Directive +## Streaming Boundaries + +When a query includes `@loading`, Houdini wraps the route component in a React Suspense boundary and streams the result to the browser using React's built-in streaming support. The split happens at the query: + +``` +initial HTML flush +├── root layout ← rendered immediately +│ ├── nav ← rendered immediately +│ └── +│ └── ShowsPage ← streams in when data resolves +│ └── ShowCard (pending titles, posters...) +``` + +The layout's HTML (navigation, sidebars, any shell chrome) arrives in the first network chunk. The Suspense boundary holds the page content until the query resolves, at which point React streams the rendered HTML and hydrates it in place. The browser shows a real, interactive shell while the data-heavy content loads. + +There is nothing to configure. The boundary is created automatically when `@loading` appears on a query, and torn down once the data is ready. + ## Checking for Pending Values in React -Use `isPending` from `$houdini` to check whether a value is a placeholder. Do not compare directly against `PendingValue` — that pattern is incompatible with React 18's concurrent rendering: +Use `isPending` from `$houdini` to check whether a value is a placeholder. Do not compare directly against `PendingValue` (that pattern is incompatible with React 18's concurrent rendering): ```jsx meta="typescriptToggle=true" import { graphql, useFragment, isPending } from '$houdini' @@ -38,8 +57,4 @@ export function ShowCard({ show }: { show: ShowCard_show }) { } ``` -`isPending` is a type guard — TypeScript narrows the type correctly on both branches. - -## Suspense - -When a query includes `@loading`, Houdini automatically wraps the view in a Suspense boundary. There's nothing to configure — the pending values are available as soon as the component renders. +`isPending` is a type guard, so TypeScript narrows the type correctly on both branches. diff --git a/docs/react/04-updating-data/01-mutations.mdx b/docs/react/04-updating-data/01-mutations.mdx index b7740a5966..dba5c1e4de 100644 --- a/docs/react/04-updating-data/01-mutations.mdx +++ b/docs/react/04-updating-data/01-mutations.mdx @@ -5,13 +5,13 @@ description: Sending mutations in Houdini's React framework import Dedupe from '@shared/_partials/dedupe.mdx' -The `useMutation` hook wraps a GraphQL mutation and returns a tuple of `[pending, mutate]`. Call `mutate` with your variables to execute it. +The `useMutation` hook wraps a GraphQL mutation and returns a tuple of `[mutate, pending]`. Call `mutate` with your variables to execute it. ```jsx meta="typescriptToggle=true" import { graphql, useMutation } from '$houdini' export function AddCommentForm({ postId }: { postId: string }) { - const [pending, mutate] = useMutation(graphql(` + const [mutate, pending] = useMutation(graphql(` mutation AddComment($postId: ID!, $body: String!) { addComment(postId: $postId, body: $body) { id @@ -47,7 +47,7 @@ Mutations always throw on error — unlike queries, there's no opt-in. Wrap call ```jsx import { graphql, useMutation, RuntimeGraphQLError } from '$houdini' -const [pending, mutate] = useMutation(graphql(`...`)) +const [mutate, pending] = useMutation(graphql(`...`)) try { await mutate({ variables: { ... } }) diff --git a/docs/react/04-updating-data/02-optimistic-updates.mdx b/docs/react/04-updating-data/02-optimistic-updates.mdx index 74a11c6e3d..68eb2e98f8 100644 --- a/docs/react/04-updating-data/02-optimistic-updates.mdx +++ b/docs/react/04-updating-data/02-optimistic-updates.mdx @@ -10,7 +10,7 @@ An optimistic update applies an immediate change to the cache before the server Pass `optimisticResponse` to `mutate` with the shape you expect back: ```jsx -const [pending, mutate] = useMutation(graphql(` +const [mutate, pending] = useMutation(graphql(` mutation ToggleLike($id: ID!) { toggleLike(id: $id) { id diff --git a/docs/react/05-guides/04-file-uploads.mdx b/docs/react/05-guides/04-file-uploads.mdx index 7679fd11d1..bf22044b35 100644 --- a/docs/react/05-guides/04-file-uploads.mdx +++ b/docs/react/05-guides/04-file-uploads.mdx @@ -15,7 +15,7 @@ Pass a `File` directly as a mutation variable. Houdini detects it and switches t import { graphql, useMutation } from '$houdini' export function UploadForm() { - const [pending, mutate] = useMutation(graphql(` + const [mutate, pending] = useMutation(graphql(` mutation UploadFile($file: File!) { uploadImage(file: $file) } diff --git a/docs/react/06-api-reference/01-forbidden.mdx b/docs/react/06-api-reference/01-forbidden.mdx new file mode 100644 index 0000000000..1503a97d58 --- /dev/null +++ b/docs/react/06-api-reference/01-forbidden.mdx @@ -0,0 +1,10 @@ +--- +title: forbidden +description: Throw a 403 RoutingError from a route loader or component. +--- + +Throws a `RoutingError(403)`, caught by the nearest `+error.tsx`. Signals that the authenticated user does not have permission. + +```ts +function forbidden(): never +``` diff --git a/docs/react/06-api-reference/02-httpError.mdx b/docs/react/06-api-reference/02-httpError.mdx new file mode 100644 index 0000000000..cf42869826 --- /dev/null +++ b/docs/react/06-api-reference/02-httpError.mdx @@ -0,0 +1,16 @@ +--- +title: httpError +description: Throw a RoutingError with any HTTP status code. +--- + +Throws a `RoutingError(status)` with any HTTP status code. Use this for status codes not covered by the named helpers. + +```ts +function httpError(status: number): never +``` + +```tsx +import { httpError } from '$houdini' + +if (!data.resource) httpError(422) +``` diff --git a/docs/react/06-api-reference/03-isPending.mdx b/docs/react/06-api-reference/03-isPending.mdx new file mode 100644 index 0000000000..f5de451571 --- /dev/null +++ b/docs/react/06-api-reference/03-isPending.mdx @@ -0,0 +1,39 @@ +--- +title: isPending +description: Type guard for @loading placeholder values. +--- + +import Notice from '@components/docs/Notice.astro' + +A type guard that returns `true` if a value is a loading placeholder produced by `@loading`. Use this instead of comparing directly against `PendingValue`, which is not safe under React 18's concurrent rendering. + +```tsx +import { graphql, useFragment, isPending } from '$houdini' +import type { ShowCard_show } from '$houdini' + +export function ShowCard({ show }: { show: ShowCard_show }) { + const data = useFragment(show, graphql(` + fragment ShowCard_show on Show { + title @loading + posterUrl @loading + } + `)) + + return ( +
+ +

{isPending(data.title) ? : data.title}

+
+ ) +} +``` + +**Signature** + +```ts +function isPending(value: any): value is LoadingType +``` + +Returns `true` when `value` is the internal pending symbol inserted by the `@loading` directive. TypeScript narrows the type on both branches of the condition. + +Import from `$houdini` alongside the other runtime utilities. See [Loading States](~/loading-data/loading-states) for the full picture on building loading UI with `@loading`. diff --git a/docs/react/06-api-reference/04-Link.mdx b/docs/react/06-api-reference/04-Link.mdx new file mode 100644 index 0000000000..942d9043ea --- /dev/null +++ b/docs/react/06-api-reference/04-Link.mdx @@ -0,0 +1,38 @@ +--- +title: Link +description: Type-safe navigation link component. +--- + +A type-safe navigation link. The `to` prop is checked against your app's route manifest at compile time, and `params` is required (and typed) whenever the route contains dynamic segments. + +```tsx +import { Link } from '$houdini' + +export function Nav() { + return ( + + ) +} +``` + +**Props** + +| Prop | Type | Description | +|---|---|---| +| `to` | route href | The destination route. Must be a known route in your manifest. | +| `params` | object | Required when `to` contains dynamic segments. Typed per-route. | +| `disabled` | `boolean` | When true, renders without an `href` (effectively inert). | +| `preload` | `boolean \| 'data' \| 'component' \| 'page'` | Start loading on hover. `true` is equivalent to `'page'`. | + +All standard `` attributes are also accepted (except `href`, which is derived from `to` and `params`). + +**External links** + +For links outside your route manifest, pass the full URL as a string. TypeScript will accept it as an external href. + +```tsx +External +``` diff --git a/docs/react/06-api-reference/05-notFound.mdx b/docs/react/06-api-reference/05-notFound.mdx new file mode 100644 index 0000000000..9fb52afd16 --- /dev/null +++ b/docs/react/06-api-reference/05-notFound.mdx @@ -0,0 +1,16 @@ +--- +title: notFound +description: Throw a 404 RoutingError from a route loader or component. +--- + +Throws a `RoutingError(404)`, caught by the nearest `+error.tsx`. On an initial SSR response, Houdini returns HTTP 404 before streaming begins. + +```ts +function notFound(): never +``` + +```tsx +import { notFound } from '$houdini' + +if (!data.show) notFound() +``` diff --git a/docs/react/06-api-reference/06-redirect.mdx b/docs/react/06-api-reference/06-redirect.mdx new file mode 100644 index 0000000000..2de2dcd1cd --- /dev/null +++ b/docs/react/06-api-reference/06-redirect.mdx @@ -0,0 +1,16 @@ +--- +title: redirect +description: Trigger a navigation or HTTP redirect from a route loader or component. +--- + +Triggers a navigation to `url` with the given HTTP status code. On an initial SSR response where the redirect can be detected before streaming begins, Houdini returns an HTTP 3xx response. On the client, or when the redirect occurs mid-stream, it triggers a router navigation. + +```ts +function redirect(status: 300 | 301 | 302 | 303 | 307 | 308, url: string): never +``` + +```tsx +import { redirect } from '$houdini' + +if (!viewer) redirect(302, '/login') +``` diff --git a/docs/react/06-api-reference/07-RoutingError.mdx b/docs/react/06-api-reference/07-RoutingError.mdx new file mode 100644 index 0000000000..32dc1baa32 --- /dev/null +++ b/docs/react/06-api-reference/07-RoutingError.mdx @@ -0,0 +1,24 @@ +--- +title: RoutingError +description: Error class thrown by notFound, unauthorized, forbidden, and httpError. +--- + +The error class thrown by `notFound()`, `unauthorized()`, `forbidden()`, and `httpError()`. Check for it in `+error.tsx` to distinguish routing errors from GraphQL errors. + +```ts +class RoutingError extends Error { + status: number +} +``` + +```tsx +import { RoutingError } from '$houdini' +import type { ErrorProps } from './$types' + +export default function PageError({ errors }: ErrorProps) { + const routing = errors.find((e) => e instanceof RoutingError) as RoutingError | undefined + if (routing?.status === 404) return

Not found.

+ if (routing?.status === 401) return

Please log in.

+ return

Something went wrong.

+} +``` diff --git a/docs/react/06-api-reference/08-unauthorized.mdx b/docs/react/06-api-reference/08-unauthorized.mdx new file mode 100644 index 0000000000..ae2129ee6f --- /dev/null +++ b/docs/react/06-api-reference/08-unauthorized.mdx @@ -0,0 +1,10 @@ +--- +title: unauthorized +description: Throw a 401 RoutingError from a route loader or component. +--- + +Throws a `RoutingError(401)`, caught by the nearest `+error.tsx`. Signals that the user must authenticate to access the resource. + +```ts +function unauthorized(): never +``` diff --git a/docs/react/06-api-reference/09-useCurrentVariables.mdx b/docs/react/06-api-reference/09-useCurrentVariables.mdx new file mode 100644 index 0000000000..260a11edd3 --- /dev/null +++ b/docs/react/06-api-reference/09-useCurrentVariables.mdx @@ -0,0 +1,23 @@ +--- +title: useCurrentVariables +description: Read the query variables used to load the current route. +--- + +Returns the query variables that were used to load the current route. Useful inside deeply nested components that need access to route-level variables without prop-drilling. + +```tsx +import { useCurrentVariables } from '$houdini' + +export function DebugPanel() { + const variables = useCurrentVariables() + return
{JSON.stringify(variables, null, 2)}
+} +``` + +**Signature** + +```ts +function useCurrentVariables(): GraphQLVariables +``` + +Returns the variables object for the query that loaded the current page. Returns `null` outside a route context. diff --git a/docs/react/06-api-reference/10-useFragment.mdx b/docs/react/06-api-reference/10-useFragment.mdx new file mode 100644 index 0000000000..990d8a51d8 --- /dev/null +++ b/docs/react/06-api-reference/10-useFragment.mdx @@ -0,0 +1,38 @@ +--- +title: useFragment +description: Read a fragment reference into typed, reactive data. +--- + +import Notice from '@components/docs/Notice.astro' + +Reads a fragment reference into typed data. The component re-renders whenever the underlying cache record changes. + +```tsx +import { graphql, useFragment } from '$houdini' +import type { UserAvatar_user } from '$houdini' + +export function UserAvatar({ user }: { user: UserAvatar_user }) { + const data = useFragment(user, graphql(` + fragment UserAvatar_user on User { + name + avatarUrl + } + `)) + + if (!data) return null + return {data.name} +} +``` + +**Signature** + +```ts +function useFragment(reference, document): data | null +``` + +| Parameter | Type | Description | +|---|---|---| +| `reference` | fragment prop | The masked reference passed from the parent | +| `document` | `graphql()` result | The fragment document | + +Returns the fragment data, or `null` if the reference is null. diff --git a/docs/react/06-api-reference/11-useFragmentHandle.mdx b/docs/react/06-api-reference/11-useFragmentHandle.mdx new file mode 100644 index 0000000000..dd3a477f9d --- /dev/null +++ b/docs/react/06-api-reference/11-useFragmentHandle.mdx @@ -0,0 +1,42 @@ +--- +title: useFragmentHandle +description: Read a fragment reference with an imperative handle for pagination and refetch. +--- + +Like `useFragment`, but also returns an imperative handle. Use this when the fragment is paginated or you need to trigger a refetch. + +```tsx +import { graphql, useFragmentHandle } from '$houdini' +import type { ShowList_show } from '$houdini' + +export function ShowList({ show }: { show: ShowList_show }) { + const { data, loadNext, loadNextPending, pageInfo } = useFragmentHandle(show, graphql(` + fragment ShowList_show on Show @paginate { + episodes(first: 10) @paginate { + edges { + node { title } + } + } + } + `)) + + return ( + <> + {data?.episodes.edges.map(({ node }) =>
{node.title}
)} + {pageInfo?.hasNextPage && ( + + )} + + ) +} +``` + +**Signature** + +```ts +function useFragmentHandle(reference, document): handle +``` + +Returns the same `DocumentHandle` shape as [`useQueryHandle`](~/api-reference/useQueryHandle) — `data`, `fetch`, `variables`, plus pagination methods if the fragment uses `@paginate`. diff --git a/docs/react/06-api-reference/12-useLocation.mdx b/docs/react/06-api-reference/12-useLocation.mdx new file mode 100644 index 0000000000..8c656310d2 --- /dev/null +++ b/docs/react/06-api-reference/12-useLocation.mdx @@ -0,0 +1,31 @@ +--- +title: useLocation +description: Read the current URL pathname and navigate imperatively. +--- + +Returns the current location. Use this to read the pathname or navigate imperatively. + +```tsx +import { useLocation } from '$houdini' + +export function ActiveLink({ href, label }: { href: string; label: string }) { + const { pathname } = useLocation() + return ( +
+ {label} + + ) +} +``` + +**Signature** + +```ts +function useLocation(): { pathname: string; params: Record; goto: (url: string) => void } +``` + +| Field | Type | Description | +|---|---|---| +| `pathname` | `string` | The current URL path | +| `params` | `Record` | The current route params (untyped) | +| `goto` | `(url: string) => void` | Navigate to a URL imperatively | diff --git a/docs/react/06-api-reference/13-useMutation.mdx b/docs/react/06-api-reference/13-useMutation.mdx new file mode 100644 index 0000000000..3806f490e1 --- /dev/null +++ b/docs/react/06-api-reference/13-useMutation.mdx @@ -0,0 +1,47 @@ +--- +title: useMutation +description: Send a GraphQL mutation from a component. +--- + +Returns a `[mutate, pending]` tuple. Call `mutate` to send the mutation; `pending` is true while it is in flight. + +```tsx +import { graphql, useMutation } from '$houdini' + +export function FollowButton({ userId }: { userId: string }) { + const [follow, pending] = useMutation(graphql(` + mutation FollowUser($userId: ID!) { + followUser(userId: $userId) { + success + } + } + `)) + + return ( + + ) +} +``` + +**Signature** + +```ts +function useMutation(document): [mutate, pending] +``` + +The `mutate` function accepts: + +| Option | Type | Description | +|---|---|---| +| `variables` | object | The mutation input variables | +| `optimisticResponse` | object | Optional optimistic update applied immediately | +| `metadata` | `App.Metadata` | Passed through to client plugins | +| `fetch` | `typeof fetch` | Override the fetch implementation | +| `abortController` | `AbortController` | Cancel the in-flight request | + +`mutate` throws a `RuntimeGraphQLError` if the server returns any `errors`. diff --git a/docs/react/06-api-reference/14-useQuery.mdx b/docs/react/06-api-reference/14-useQuery.mdx new file mode 100644 index 0000000000..bc6da09240 --- /dev/null +++ b/docs/react/06-api-reference/14-useQuery.mdx @@ -0,0 +1,50 @@ +--- +title: useQuery +description: Fetch a query and suspend until data is available. +--- + +In most cases query data arrives as a prop from the route file rather than from a hook directly. `useQuery` exists for cases where you need to issue a query imperatively from inside a component. + +Fetches a query and returns the data. Suspends until the result is available. + +```tsx +import { graphql, useQuery } from '$houdini' + +export function UserProfile({ userId }: { userId: string }) { + const data = useQuery( + graphql(` + query UserProfile($userId: ID!) { + user(id: $userId) { + name + bio + } + } + `), + { userId } + ) + + return

{data.user.bio}

+} +``` + +**Signature** + +```ts +function useQuery(document, variables?, config?): data +``` + +| Parameter | Type | Description | +|---|---|---| +| `document` | `graphql()` result | The query document | +| `variables` | object | Query variables | +| `config` | `UseQueryConfig` | Optional config (see below) | + +Returns the query data directly. For access to the imperative handle, use [`useQueryHandle`](~/api-reference/useQueryHandle) instead. + +**`UseQueryConfig`** + +| Option | Type | Description | +|---|---|---| +| `policy` | `CachePolicy` | Override the cache policy for this query | +| `metadata` | `App.Metadata` | Passed through to client plugins | +| `fetchKey` | any | Change this value to force a refetch | diff --git a/docs/react/06-api-reference/15-useQueryHandle.mdx b/docs/react/06-api-reference/15-useQueryHandle.mdx new file mode 100644 index 0000000000..8ce83671cd --- /dev/null +++ b/docs/react/06-api-reference/15-useQueryHandle.mdx @@ -0,0 +1,67 @@ +--- +title: useQueryHandle +description: Fetch a query and return a full handle with refetch and pagination methods. +--- + +Like [`useQuery`](~/api-reference/useQuery), but returns a full `DocumentHandle` with methods for refetching and pagination. + +```tsx +import { graphql, useQueryHandle } from '$houdini' + +export function ShowList() { + const { data, loadNext, loadNextPending, pageInfo, fetch } = useQueryHandle( + graphql(` + query AllShows { + shows @paginate(limit: 10) { + id + title + } + } + `) + ) + + return ( + <> + {data.shows.map((show) =>
{show.title}
)} + {pageInfo?.hasNextPage && ( + + )} + + + ) +} +``` + +**Signature** + +```ts +function useQueryHandle(document, variables?, config?): DocumentHandle +``` + +**`UseQueryConfig`** + +| Option | Type | Description | +|---|---|---| +| `policy` | `CachePolicy` | Override the cache policy for this query | +| `metadata` | `App.Metadata` | Passed through to client plugins | +| `fetchKey` | any | Change this value to force a refetch | + +**`DocumentHandle`** + +| Field | Type | Description | +|---|---|---| +| `data` | query data | The current result | +| `fetching` | `boolean` | True while a network request is in flight | +| `errors` | `GraphQLError[] \| null` | Any errors from the last response | +| `partial` | `boolean` | True if the result was served partially from cache | +| `variables` | object | The variables used for the current result | +| `fetch` | function | Trigger a refetch, optionally with new variables | +| `loadNext` | function | Load the next page (cursor or offset pagination) | +| `loadNextPending` | `boolean` | True while `loadNext` is in flight | +| `loadPrevious` | function | Load the previous page (cursor pagination only) | +| `loadPreviousPending` | `boolean` | True while `loadPrevious` is in flight | +| `pageInfo` | `PageInfo` | Cursor pagination metadata | + +Pagination fields are only present when the query uses `@paginate`. diff --git a/docs/react/06-api-reference/16-useRoute.mdx b/docs/react/06-api-reference/16-useRoute.mdx new file mode 100644 index 0000000000..140fba3eff --- /dev/null +++ b/docs/react/06-api-reference/16-useRoute.mdx @@ -0,0 +1,22 @@ +--- +title: useRoute +description: Read typed route params for the current page. +--- + +Returns the typed params for the current route. The type parameter should be the `PageProps` type generated for the current route file. + +```tsx +import type { PageProps } from './$types' +import { useRoute } from '$houdini' + +export function ShowBreadcrumb() { + const { params } = useRoute() + return Show #{params.id} +} +``` + +**Signature** + +```ts +function useRoute(): { params: PageProps['Params'] } +``` diff --git a/docs/react/06-api-reference/17-useSession.mdx b/docs/react/06-api-reference/17-useSession.mdx new file mode 100644 index 0000000000..90644caf55 --- /dev/null +++ b/docs/react/06-api-reference/17-useSession.mdx @@ -0,0 +1,28 @@ +--- +title: useSession +description: Read and update the current session from any component. +--- + +Returns the current session and an updater function. Calling the updater patches the local session and syncs the change to the server. + +```tsx +import { useSession } from '$houdini' + +export function LogoutButton() { + const [session, updateSession] = useSession() + + return ( + + ) +} +``` + +**Signature** + +```ts +function useSession(): [App.Session, (patch: Partial) => void] +``` + +The updater sends a `POST` request to the session endpoint defined in your router config, clears the data cache, and triggers a re-fetch of any active queries. The session type is defined by your `App.Session` declaration. diff --git a/docs/react/06-api-reference/18-useSubscription.mdx b/docs/react/06-api-reference/18-useSubscription.mdx new file mode 100644 index 0000000000..86790df6a6 --- /dev/null +++ b/docs/react/06-api-reference/18-useSubscription.mdx @@ -0,0 +1,35 @@ +--- +title: useSubscription +description: Subscribe to a live GraphQL subscription document. +--- + +Subscribes to a live subscription document and returns the latest data. The subscription starts when the component mounts and stops when it unmounts. + +```tsx +import { graphql, useSubscription } from '$houdini' + +export function LiveScore({ matchId }: { matchId: string }) { + const data = useSubscription( + graphql(` + subscription MatchScore($matchId: ID!) { + matchScore(matchId: $matchId) { + home + away + } + } + `), + { matchId } + ) + + if (!data) return Waiting... + return {data.matchScore.home} – {data.matchScore.away} +} +``` + +**Signature** + +```ts +function useSubscription(document, variables): data | null +``` + +Returns the latest subscription payload, or `null` before the first message arrives. diff --git a/docs/shared/01-reference/01-config.mdx b/docs/shared/01-core/01-config.mdx similarity index 97% rename from docs/shared/01-reference/01-config.mdx rename to docs/shared/01-core/01-config.mdx index 5d46b8b1aa..50ac3764d0 100644 --- a/docs/shared/01-reference/01-config.mdx +++ b/docs/shared/01-core/01-config.mdx @@ -48,7 +48,7 @@ By default, your config file can contain the following values: - `defaultListTarget` (optional): Can be set to `"all"` for all list operations to ignore parent ID and affect all lists with the name. - `defaultPaginateMode` (optional, default: `"Infinite"`): The default mode for pagination. One of `"Infinite"` or `"SinglePage"`. - `defaultListPosition` (optional, default: "first"): One of `"first"` or `"last"` to indicate the default location for list operations. -- `plugins` (optional): An object containing the set of plugins you want to add to your houdini application. The keys are plugin names, the values are plugin-specific configuration. The actual plugin API is undocumented and considered unstable while we try out various things internally. For an overview of your framework plugin's specific configuration, see below. +- `plugins` (optional): An object containing the set of plugins you want to add to your houdini application. The keys are plugin names, the values are plugin-specific configuration. For an overview of your framework plugin's specific configuration, see below. - `supressPaginationDeduplication` (optional, default `false): Prevents the runtime from deduplicating pagination requests - `runtimeDir` (optional, default: `'.houdini`): The name of the directory used to output the generated Houdini runtime, relative to `projectDir`. diff --git a/docs/shared/01-reference/02-cli.mdx b/docs/shared/01-core/02-cli.mdx similarity index 100% rename from docs/shared/01-reference/02-cli.mdx rename to docs/shared/01-core/02-cli.mdx diff --git a/docs/shared/01-reference/03-vite-plugin.mdx b/docs/shared/01-core/03-vite-plugin.mdx similarity index 100% rename from docs/shared/01-reference/03-vite-plugin.mdx rename to docs/shared/01-core/03-vite-plugin.mdx diff --git a/docs/shared/01-reference/04-client.mdx b/docs/shared/01-core/04-client.mdx similarity index 100% rename from docs/shared/01-reference/04-client.mdx rename to docs/shared/01-core/04-client.mdx diff --git a/docs/shared/01-reference/06-cache.mdx b/docs/shared/01-core/06-cache.mdx similarity index 92% rename from docs/shared/01-reference/06-cache.mdx rename to docs/shared/01-core/06-cache.mdx index 65ed6cae09..57ba408441 100644 --- a/docs/shared/01-reference/06-cache.mdx +++ b/docs/shared/01-core/06-cache.mdx @@ -13,25 +13,6 @@ please open an issue or discussion on GitHub so we can try to figure out if ther is something that Houdini could be doing better. This should be considered an advanced escape hatch for one-off situations. -## Enabling the API - -This API is currently considered experimental and while we refine it, we might need -to dramatically change it overall shape. Until it's ready, we want to reserve the ability -to break its API on any minor version. We understand this is not technically semantic versioning -but ultimately it will let us refine the API against real applications and lead to a better solution -faster. - -In order to acknowledge this, you will need to enable the `imperativeCache` flag in your config file: - -```javascript title="houdini.config.js" -export default { - // ... - features: { - imperativeCache: true, - } -} -``` - ## Records The primary unit of the cache api is the `Record`. It acts as a proxy for interacting with entities in Houdini's cache @@ -258,6 +239,22 @@ const user = cache.get('User', { id: '1' }) user.delete() ``` +## Refreshing Records + +If you want to reload a record's values from your API (for example, after a mutation +that you know changed data on the server), you can use the `refresh` method. Every +document whose data contains the record will refetch itself over the network — +including documents that only include the record through a fragment spread: + +```typescript +const user = cache.get('User', { id: '1' }) + +user.refresh() +``` + +Unlike `markStale` (which waits for the next fetch to reload the data), `refresh` +triggers the network requests immediately. + ## Lists Another primitive provided by the `cache` instance is `List` and it provide a programatic diff --git a/docs/shared/01-core/07-architecture.mdx b/docs/shared/01-core/07-architecture.mdx new file mode 100644 index 0000000000..16db45c9fd --- /dev/null +++ b/docs/shared/01-core/07-architecture.mdx @@ -0,0 +1,97 @@ +--- +title: Houdini's Architecture +description: A high-level overview of Houdini's compiler, runtime client, generated artifacts, and plugin system. +--- + +Houdini is built around a compiler-first model. We write GraphQL documents in our app, and Houdini reads them at build time, validates them against our schema, and generates the runtime code and TypeScript types our framework integrations need. The goal is to move as much work as possible out of the browser and into code generation. + +## The three main pieces + +Houdini has three major parts: + +- **Houdini Client**: the runtime client used by our application. It manages network requests, normalized caching, optimistic updates, pagination, subscriptions, and document behavior. +- **Code generation**: the compiler pipeline that reads GraphQL documents, validates them, and writes generated files into `$houdini`. +- **Framework plugins**: integrations that turn generated artifacts into framework-native APIs. Each plugin implements the same codegen hooks and outputs whatever files its framework needs. + +The core compiler understands GraphQL and Houdini's runtime model. Framework plugins decide how that model should feel inside a given app. + +## Compile-time work + +When we write a GraphQL document, Houdini can know a lot before our app ever runs: + +- the operation name +- the result shape +- required variables +- fragment dependencies +- cache selection data +- generated TypeScript types +- framework-specific wrappers + +That lets Houdini generate strongly typed code instead of shipping a large GraphQL interpretation layer to the browser. + +For example, in a Svelte component we might write: + +```svelte + + +{$UserList.data?.users?.map((user) => user.name).join(', ')} +``` + +From that single tagged template literal, Houdini generates the `UserList` result and variable types, a store for loading the query, and cache metadata for every selected field. The important idea is that the document is the source of truth — everything else is derived. + +## How the compiler works + +This is where things get interesting. The compiler has to satisfy two constraints that pull in different directions: it's deeply integrated with Vite, which means there will always be a Node.js layer involved. But parsing and validating thousands of GraphQL documents in a hot-reload loop is the kind of work that really wants a compiled language. + +The solution is a process-per-plugin model with a Node.js orchestrator. Node handles Vite integration, pipeline sequencing, and spawning. The heavy pipeline work — extraction, validation, codegen — runs in Go plugins (or any compiled binary). + +### Plugins as long-running processes + +When the compiler starts, it spawns each plugin as a child process and keeps it alive. During `houdini generate`, that means the processes persist for the duration of one run. During `vite dev`, they persist for the entire dev session — startup cost is paid once, and each incremental build is just the orchestrator sending hook invocations to already-running processes. + +Each plugin registers itself with its name, the hooks it implements, and how it wants to be ordered relative to other plugins. After that, the orchestrator knows exactly which processes to call for each stage of the pipeline. + +### WebSocket communication + +The orchestrator and plugins communicate over WebSockets. The persistent connection means there's no per-call handshake overhead — we connect once and send hook invocations over the open socket for as long as the session runs. When the orchestrator goes away (Vite restarts, process killed, `generate` finishes), the connection close propagates to every plugin and they exit cleanly. No orphaned processes to hunt down. + +### SQLite as shared memory + +Plugin processes need to share data — schema definitions, extracted documents, artifact metadata — across process boundaries and across language runtimes. We use a single SQLite database file for this. The path is passed to every plugin as a flag at startup, and each plugin connects directly. + +SQLite in WAL mode supports concurrent reads without blocking, which matters for hooks like `Validate` and `GenerateDocuments` that are independent of each other and can run in parallel. More broadly, the database schema is the contract between the orchestrator and every plugin. A plugin written in Go, Rust, or anything else just needs to open the same file — no serialization layer, no bespoke protocol for state transfer. + +### Vite's role during dev + +During development, Vite drives the compiler. When source files change, Vite's HMR pipeline calls the orchestrator, which triggers an incremental pipeline run starting from the appropriate hook. Because plugins are already running and the database already has the previous build's state, only the work that's actually stale gets redone. The orchestrator also serializes concurrent triggers — if a schema watcher and a file watcher fire at the same time, they queue rather than racing. + +During `houdini generate`, the same pipeline runs end-to-end once and exits. + +## Why this architecture matters + +The process model gives us a few things that would be difficult to get any other way: + +- **Speed**: the heavy pipeline work runs in compiled code, not in Node's event loop +- **Extensibility**: any language that can open a SQLite file and speak WebSocket can be a plugin +- **Resilience**: liveness connections mean clean shutdown instead of orphaned processes +- **Parallelism**: independent hooks read from a shared database concurrently without coordination overhead + +## Related docs + +- [Configuration](/api/config) +- [CLI](/api/cli) +- [Client Plugins](/extending-houdini/client-plugins) +- [Codegen Plugins (Go)](/extending-houdini/codegen-plugins-golang) +- [Codegen Plugins (Node)](/extending-houdini/codegen-plugins-node) diff --git a/docs/shared/01-reference/07-architecture.mdx b/docs/shared/01-reference/07-architecture.mdx deleted file mode 100644 index 54010e7f34..0000000000 --- a/docs/shared/01-reference/07-architecture.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: Houdini's Architecture -description: A high-level overview of Houdini's compiler, runtime client, generated artifacts, and framework plugins. ---- - -import OutOfDate from '@shared/_partials/out-of-date.mdx' - - - -Houdini is built around a compiler-first model. You write GraphQL documents in your app, Houdini reads them at build time, validates them against your schema, and generates the runtime code and TypeScript types your framework integration needs. - -The goal is to move as much work as possible out of the browser and into code generation. - -## The three main pieces - -Houdini has three major parts: - -- **Houdini Client**: the runtime client used by your application. It manages network requests, normalized caching, optimistic updates, pagination, subscriptions, and document behavior. -- **Code generation**: the compiler pipeline that reads GraphQL documents, validates them, and writes generated files into `$houdini`. -- **Framework plugins**: integrations such as Svelte/SvelteKit that turn generated artifacts into framework-native APIs. - -The core compiler understands GraphQL and Houdini's runtime model. The framework plugin decides how that model should feel inside your app. - -## Compile-time work - -When you write a GraphQL document, Houdini can know a lot before your app ever runs: - -- the operation name -- the result shape -- required variables -- fragment dependencies -- cache selection data -- generated TypeScript types -- framework-specific wrappers - -That lets Houdini generate strongly typed code instead of shipping a large GraphQL interpretation layer to the browser. - -## Example - -In a Svelte component you might write: - -```svelte - - -{$UserList.data?.users?.map((user) => user.name).join(', ')} -``` - -This looks small, but Houdini uses it to do several things: - -- parse and validate the `UserList` query -- generate the `UserList` result and variable types -- generate a store for loading the query -- create cache metadata for the selected fields -- transform framework code where needed - -A simplified generated version looks like this: - -```svelte - - -{$UserList.data?.users?.map((user) => user.name).join(', ')} -``` - -The real generated output is more involved, but the important idea is that Houdini turns GraphQL documents into typed framework APIs. - -## What the Svelte/SvelteKit plugin does - -The Svelte/SvelteKit integration is responsible for making Houdini feel native in Svelte apps. It can: - -- generate Svelte stores -- wire route queries into SvelteKit load behavior -- provide `$houdini` types for route data -- transform inline `graphql` calls -- support Svelte 5 runes-specific patterns - -The Houdini core still handles the GraphQL document model, cache, network behavior, and generated metadata. The plugin wraps that core in Svelte conventions. - -## Why this architecture matters - -This split gives Houdini a few important properties: - -- **small runtime**: expensive GraphQL analysis happens during generation -- **typed APIs**: generated types match your schema and documents -- **framework-native APIs**: each integration can expose the right mental model -- **plugin extensibility**: codegen and runtime behavior can be extended - -## Related docs - -- [GraphQL Documents](/svelte/graphql-documents/) -- [Configuration](/svelte/configuration/) -- [Client Plugins](/svelte/api/client-plugins/) -- [Codegen Plugins](/svelte/api/codegen-plugins/) diff --git a/docs/shared/02-extending-houdini/01-client-plugins.mdx b/docs/shared/02-extending-houdini/01-client-plugins.mdx index f9c6b4d67d..d1d24f569b 100644 --- a/docs/shared/02-extending-houdini/01-client-plugins.mdx +++ b/docs/shared/02-extending-houdini/01-client-plugins.mdx @@ -5,12 +5,6 @@ description: How to write custom plugins that hook into Houdini's request pipeli Client plugins let us customize the runtime behavior of our application's documents — integrating with a logging service, adding retry logic, or even adding support for entirely new network capabilities like [Live Queries](https://the-guild.dev/blog/subscriptions-and-live-queries-real-time-with-graphql). -:::caution[Unstable API] - -The client plugin API is still considered unstable. We reserve the ability to change its structure with any minor version update. By building a plugin, you acknowledge this and accept the responsibility of not breaking your users' projects. - -::: - ## Overview Every document in a Houdini app is backed by an observable value called a "Document Store". The store holds the latest value of the document and sends new queries to update its state. Client plugins modify this structure by hooking into five phases of the request pipeline: diff --git a/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx b/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx index beacffd217..513bf694d5 100644 --- a/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx +++ b/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx @@ -3,6 +3,8 @@ title: Codegen Plugins (Golang) description: Writing Houdini codegen plugins in Go --- +import ViteSubmodule from '@shared/_partials/plugin-vite-submodule.mdx' + Houdini's codegen pipeline is built in Go, and you can extend it by writing your own Go plugin. A plugin is a standalone binary that registers itself with the pipeline and responds to lifecycle hooks — schema modifications, validation, document generation, and more. ## Defining a Plugin @@ -122,6 +124,40 @@ The root package's `bin` field points to a small Node.js shim rather than a bina The shim also supports a `HOUDINI_PLATFORM` environment variable, which lets callers force a specific platform — useful in CI environments where the host architecture doesn't match the target. +## Static Runtimes + +The `StaticRuntime` interface lets a plugin copy a directory of files into the project during the `afterLoad` phase — before document discovery and codegen run. + +```go +func (p *MyPlugin) StaticRuntime(ctx context.Context) (string, error) { + return filepath.Join(p.PluginDirectory(ctx), "static"), nil +} +``` + +Because the copy happens before document discovery, any `.graphql` files in that directory are treated as project documents. That makes `StaticRuntime` the right place to ship fragments or queries that users can reference directly in their own operations: + +```graphql title="static/UserFields.graphql" +fragment UserFields on User { + id + name + email +} +``` + +A user can then spread `...UserFields` in their own queries without defining the fragment themselves. The plugin owns the definition; the project just consumes it. + +If you need to rewrite file contents during the copy (to inject the project's schema URL, for example), implement `TransformStaticRuntime` alongside it: + +```go +func (p *MyPlugin) TransformStaticRuntime(ctx context.Context, source string, content string) (string, error) { + return strings.ReplaceAll(content, "__SCHEMA_URL__", p.config.SchemaURL), nil +} +``` + +The main distinction from `IncludeRuntime` is timing: `IncludeRuntime` is copied during the `generateRuntime` phase (after documents are already collected), so its `.graphql` files arrive too late to be discovered. Use `StaticRuntime` whenever the content needs to participate in the document graph, and `IncludeRuntime` for TypeScript runtime code that doesn't. + + + ### Houdini's Implementation Rather than maintaining these files by hand, Houdini generates everything through a build script. The templates and tooling live at: diff --git a/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx b/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx index 1c42a2df27..976d8d1a07 100644 --- a/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx +++ b/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx @@ -3,6 +3,8 @@ title: Codegen Plugins (Node) description: Writing Houdini codegen plugins in Node.js --- +import ViteSubmodule from '@shared/_partials/plugin-vite-submodule.mdx' + Node plugins let you hook into Houdini's codegen pipeline from JavaScript or TypeScript. They follow the same lifecycle as [Go plugins](~/extending-houdini/codegen-plugins-golang) but are written as plain Node.js scripts and distributed as normal npm packages. ## Defining a Plugin @@ -139,6 +141,7 @@ async validate(ctx, payload) { The plugin config accepts a few additional fields for plugins that need to ship runtime code: - `includeRuntime` — a path (relative to your plugin entry point) to a directory that Houdini will copy into the project's generated runtime +- `staticRuntime` — a path (relative to your plugin entry point) to a directory whose contents are copied **before codegen runs** (see below) - `configModule` — a path to a JavaScript module that exports config values to merge into the project config - `clientPlugins` — an object of client-side plugins to inject into the user's `HoudiniClient` @@ -153,3 +156,32 @@ plugin({ hooks: { /* ... */ }, }) ``` + +## Static Runtimes + +`staticRuntime` points to a directory (relative to your plugin entry point) whose contents are copied into the project during the `afterLoad` phase — before document discovery and codegen run. + +```typescript +plugin({ + name: 'my-plugin', + order: 'after', + staticRuntime: './static', + hooks: { /* ... */ }, +}) +``` + +Because the copy happens before document discovery, any `.graphql` files in that directory are treated as project documents. That makes `staticRuntime` the right place to ship GraphQL fragments or queries that users can reference directly in their own operations: + +```graphql title="static/UserFields.graphql" +fragment UserFields on User { + id + name + email +} +``` + +A user can then spread `...UserFields` in their own queries without defining the fragment themselves. The plugin owns the definition; the project just consumes it. + +The main distinction from `includeRuntime` is timing: `includeRuntime` is copied during the `generateRuntime` phase (after documents are already collected), so its `.graphql` files arrive too late to be discovered. Use `staticRuntime` whenever the content needs to participate in the document graph, and `includeRuntime` for TypeScript runtime code that doesn't. + + diff --git a/docs/shared/03-meta/03-contributing.mdx b/docs/shared/03-meta/03-contributing.mdx index 67e69fc0ef..c6145b0757 100644 --- a/docs/shared/03-meta/03-contributing.mdx +++ b/docs/shared/03-meta/03-contributing.mdx @@ -3,15 +3,17 @@ title: Contributing description: A guide for contributing to Houdini. --- -import OutOfDate from '@shared/_partials/out-of-date.mdx' - - - First off, thanks for the interest in contributing to Houdini. This document should provide some guidance for working on the project, including tips for local development and an introduction to the internal architecture and relevant files. -**Note**: this document contains links to files and sometimes specific lines of code that could easily be invalidated by future work. If you run into a broken link, please open a PR to fix it — keeping documentation up to date is as important as any bug fix or new feature. +**Note**: this document contains links to files that could easily be invalidated by future work. If you run into a broken link, please open a PR to fix it — keeping documentation up to date is as important as any bug fix or new feature. + +Before diving in, the [architecture guide](/api/architecture) is worth a read — it explains how the compiler's process model works, how plugins communicate, and how the shared database fits together. That context makes the rest of this document a lot easier to follow. + +## General Introduction + +At a high level, Houdini is broken up into a few parts. The core compiler pipeline lives in [packages/houdini-core](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-core) and handles document extraction, validation, and artifact generation. The shared plugin runtime library lives in [packages/houdini](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini) and provides the cache, Vite plugin, and utilities for building extensions. Apart from that, Houdini has framework-specific packages that take the generated artifacts and deliver an experience tailored to each framework. The Svelte bindings live in [packages/houdini-svelte](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-svelte) and the React bindings in [packages/houdini-react](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-react). ## Local Development @@ -23,13 +25,9 @@ Make sure you're using Node.js and pnpm versions compatible with the `engines` c - [mise-en-place](https://mise.jdx.dev/) - [nix](https://nixos.org/) -## General Introduction - -At a high level, Houdini is broken up into a few parts. The core project lives in [packages/houdini](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini) and provides the artifact generation pipeline, cache runtime, Vite plugin, and utilities for building extensions. Apart from that, Houdini has framework-specific bindings that take the generated artifacts and deliver an experience tailored to each framework. The Svelte bindings live in [packages/houdini-svelte](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-svelte) and the React bindings in [packages/houdini-react](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-react). - ## Code Generation -Houdini's code generation pipeline is written in Go and lives in the [plugins/](https://github.com/HoudiniGraphQL/houdini/tree/main/plugins) directory. It is ultimately responsible for generating the artifacts that describe every document in a project — those artifacts save the runtime from parsing user documents and enable features like compiling fragments and queries into a single string sent to the API. +Houdini's code generation pipeline is written in Go. The core pipeline logic lives in [packages/houdini-core](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-core), with framework-specific codegen in the corresponding package (e.g. [packages/houdini-react](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-react)). The shared library for building plugins lives in [plugins/](https://github.com/HoudiniGraphQL/houdini/tree/main/plugins). Each plugin runs as a long-running process and communicates with the Node.js orchestrator over WebSocket, sharing state through a common SQLite database — see the [architecture guide](/api/architecture) for the full picture. The pipeline tasks fall into three categories: @@ -61,6 +59,76 @@ Subscriptions keep stores up to date as values change. The cache walks a given s The best way to verify a feature is to write a test that simulates the user's experience. These live in the [e2e/](https://github.com/HoudiniGraphQL/houdini/tree/main/e2e) directory at the root of the repository. The general pattern is to create a route in one of the test applications that showcases a specific behavior, then use [Playwright](https://playwright.dev/) to simulate a user interacting with the UI. +## Test Scripts + +There are three layers of testing, each with its own set of scripts. + +### TypeScript unit tests (root) + +Run from the repo root using [Vitest](https://vitest.dev/): + +```sh +pnpm tests # run all TypeScript unit tests +pnpm tests:ui # run with the Vitest browser UI + coverage report +``` + +These cover the runtime cache, client plugins, and other TypeScript-only logic that doesn't require a full pipeline run. + +### Go pipeline tests (per package) + +The Go pipeline tests live in `plugin/` subdirectories of each package (e.g. `packages/houdini-core/plugin/`, `packages/houdini-react/plugin/`). Run them with the standard Go toolchain from the package root: + +```sh +go test ./... +``` + +Each test uses `tests.RunTable` from `plugins/tests/` — it spins up an in-memory SQLite database, runs the pipeline steps (extract → validate → generate), and asserts on the resulting artifacts. These are the right tests to write when you change Go plugin logic. + +### Playwright e2e tests (e2e apps) + +The `e2e/kit/` (SvelteKit) and `e2e/react/` apps each have a family of scripts for running the full Playwright suite. The key ones: + +| Script | What it does | +|---|---| +| `build:` | Rebuilds all packages from the repo root (`pnpm run build`) | +| `build:test` | `build:` then runs Playwright (`pnpm test`) — use this to verify changes against a fresh build | +| `build:build` | `build:` then builds the e2e app itself (produces a static output) | +| `build:tests` | `build:build` then runs Playwright — use this when you also need the app compiled | +| `build:dev` | `build:` then starts the dev server | +| `build:generate` | `build:` then runs `houdini generate` | +| `tests` / `test` | Playwright only, no rebuild — use when packages are already built | + +For faster iteration when only one package changed, the `compile:*` variants recompile a single package without doing a full root build: + +```sh +pnpm compile:core # recompile houdini-core only +pnpm compile:houdini # recompile houdini only +pnpm compile:svelte # recompile houdini-svelte only (kit app) +pnpm compile:react # recompile houdini-react only (react app) +``` + +Each of these has `:dev`, `:generate`, and `:dev` suffixes that chain into the next step after compiling. + +### Cache benchmarks + +The cache has a benchmark suite in `packages/houdini/src/runtime/cache/benchmarks/`. Benchmarks are grouped into categories (`core`, `subscriptions`, `lists`, `multi-doc`, `optimistic`, `gc`, `ssr`) and can be run selectively. + +For active work on the cache, `watch-bench` re-runs a category in fast mode (minimal iterations, just enough to catch regressions) every time a file changes: + +```sh +pnpm watch-bench gc # re-run gc suite on every save +pnpm watch-bench core # re-run core suite on every save +``` + +To run the full suite at full statistical precision: + +```sh +pnpm bench # all categories, full run +BENCH=core pnpm bench # single category, full run +``` + +A CI job runs automatically on any PR that touches `packages/houdini/src/runtime/cache/`. It benchmarks the base branch and the PR branch on the same runner and flags any benchmark that regressed beyond its noise band. + ## Piecing It All Together When thinking about adding a feature, a few questions help frame the work: diff --git a/docs/shared/_partials/error-handling.mdx b/docs/shared/_partials/error-handling.mdx index cceb0a2127..108f6091b4 100644 --- a/docs/shared/_partials/error-handling.mdx +++ b/docs/shared/_partials/error-handling.mdx @@ -22,3 +22,18 @@ export default new HoudiniClient({ ``` Note that mutations always throw on error regardless of the `throwOnError` configuration. The thrown value is a `RuntimeGraphQLError` with a `.raw` field containing the full error payload from the server. + +## Typing Error Extensions + +The GraphQL spec allows servers to include an `extensions` field on errors with arbitrary data (error codes, stack traces, etc.). Houdini passes these through at runtime, and you can type them by augmenting `App.GraphQLErrorExtensions` in your app's type declarations: + +```typescript title="src/app.d.ts" +declare namespace App { + interface GraphQLErrorExtensions { + code: string + timestamp: string + } +} +``` + +Once declared, `errors[n].extensions` will be typed as `App.GraphQLErrorExtensions` everywhere Houdini exposes errors — query results, mutation responses, subscriptions, and the `throwOnError` callback. diff --git a/docs/shared/_partials/list-operations.mdx b/docs/shared/_partials/list-operations.mdx index f356101058..90ce027a47 100644 --- a/docs/shared/_partials/list-operations.mdx +++ b/docs/shared/_partials/list-operations.mdx @@ -32,9 +32,44 @@ mutation AddFriend($friendID: ID!, $userID: ID!) { The `value` argument must be the ID of the parent record that owns the list. Without it, Houdini will update every instance of that list in the cache. +## `@listID` and `@includeListID` + +`@parentID` requires knowing the parent record's ID at mutation time, but that isn't always possible — the parent type may not expose an ID field, or the ID may not appear anywhere in the query document. `@includeListID` and `@listID` solve this by letting the cache identify the list for you. + +Add `@includeListID` to the list field alongside `@list` or `@paginate`. The cache will stamp an opaque `__id` value directly onto the returned list, and the generated TypeScript type will include `__id: string` so you can read it without any casting: + +```graphql +query AllItems { + userNodes { + items @list(name: "All_Items") @includeListID { + id + } + } +} +``` + +```tsx +const items = data?.userNodes.items +const listId = items?.__id // string | undefined — typed by codegen +``` + +Then pass that value to `@listID` on the mutation fragment spread instead of `@parentID`: + +```graphql +mutation NewItem($input: AddItemInput!, $listId: ID!) { + addItem(input: $input) { + item { + ...All_Items_insert @listID(value: $listId) + } + } +} +``` + +Unlike `@parentID`, `@listID` works even when the parent has no usable ID in the document — the opaque key encodes everything the cache needs to find the right list instance. + ## Operations -Once a list is tagged, Houdini generates a set of fragments named after the list — `All_Items_insert`, `All_Items_remove`, `All_Items_toggle` — that you spread into mutation responses to tell the cache what to do. The cache updates immediately on the client without waiting for a refetch. +Once a list is tagged, Houdini generates a set of fragments named after the list — `All_Items_insert`, `All_Items_remove`, `All_Items_toggle`, `All_Items_upsert`, `All_Items_update` — that you spread into mutation responses to tell the cache what to do. The cache updates immediately on the client without waiting for a refetch. ### Inserting a record @@ -92,6 +127,30 @@ mutation ToggleItem($input: ToggleItemInput!) { } ``` +### Updating a record + +Use `_update` when you only want to write new field values to an existing cached record without touching list membership. This is useful when an update comes in for a record you know is already in the cache. + +```graphql +mutation UpdateMessage($id: ID!) { + updateMessage(id: $id) { + ...All_Messages_update + } +} +``` + +### Upserting a record + +Use `_upsert` when you want to insert a record if it isn't already in the list, or update its data in place if it is. This avoids duplicates when the same update can apply to both new and existing records. + +```graphql +mutation UpsertMessage($input: MessageInput!) { + upsertMessage(input: $input) { + ...All_Messages_upsert + } +} +``` + ### Deleting a record Sometimes it can be tedious to remove a record from every single list that mentions it. diff --git a/docs/shared/_partials/loading-states.mdx b/docs/shared/_partials/loading-states.mdx index 79b9284db5..66615da5c9 100644 --- a/docs/shared/_partials/loading-states.mdx +++ b/docs/shared/_partials/loading-states.mdx @@ -21,7 +21,7 @@ query ShowList { } ``` -In this case `shows` is always an array and `title` is the pending value — so we can safely iterate over `shows` and check `title` to know whether the data has arrived. +In this case `shows` is always an array and `title` is the pending value, so we can safely iterate over `shows` and check `title` to know whether the data has arrived. ### List Placeholders @@ -73,4 +73,13 @@ query ShowList { } ``` -The fragment then handles its own pending checks internally. The route stays decoupled from the loading structure of its children. +The fragment then defines its own loading shape independently: + +```graphql +fragment ShowCard_show on Show { + title @loading + posterUrl @loading +} +``` + +The route stays decoupled from the loading structure of its children. diff --git a/docs/shared/_partials/plugin-vite-submodule.mdx b/docs/shared/_partials/plugin-vite-submodule.mdx new file mode 100644 index 0000000000..1be028a62e --- /dev/null +++ b/docs/shared/_partials/plugin-vite-submodule.mdx @@ -0,0 +1,31 @@ +## Vite Integration + +If your plugin needs to add transforms to the user's Vite build, export a `/vite` sub-module from your npm package. Houdini picks it up automatically and includes it in the project's Vite config — no manual wiring required on the user's end. + +The sub-module just needs to export a default function that returns a Vite plugin: + +```typescript title="src/vite.ts" +import type { Plugin } from 'vite' + +export default function myPlugin(): Plugin { + return { + name: 'my-plugin', + transform(code, id) { + // ... + }, + } +} +``` + +Wire it up in `package.json`'s `exports` field: + +```json title="package.json" +{ + "exports": { + ".": "./dist/index.js", + "./vite": "./dist/vite.js" + } +} +``` + +Any project that installs your plugin will get the Vite integration automatically. diff --git a/docs/svelte/01-your-first-app/00-getting-started.mdx b/docs/svelte/01-your-first-app/00-getting-started.mdx index e875fabb42..7c79b0b280 100644 --- a/docs/svelte/01-your-first-app/00-getting-started.mdx +++ b/docs/svelte/01-your-first-app/00-getting-started.mdx @@ -30,7 +30,7 @@ Instead of the usual todo list, we're going to build a little app to explore the We’re going to start off with a project that’s already been configured so we can jump right to the fun stuff. You can pull down the demo by executing the following commands in your terminal: ```bash -npx degit houdinigraphql/intro hello-houdini +npx degit houdinigraphql/intro#svelte hello-houdini cd hello-houdini npm i ``` diff --git a/docs/svelte/01-your-first-app/01-queries.mdx b/docs/svelte/01-your-first-app/01-queries.mdx index bf7950e8d2..b6e6f8bfa0 100644 --- a/docs/svelte/01-your-first-app/01-queries.mdx +++ b/docs/svelte/01-your-first-app/01-queries.mdx @@ -6,12 +6,12 @@ description: The first part of the Houdini intro focusing on how to fetch data import AsideAccordion from "@components/docs/AsideAccordion.astro"; Before we do anything _too_ complicated, lets start with some static content pulled from our GraphQL API. -Create two files inside of your `src/routes` directory, `+page.gql` and `+page.svelte`: +Create two files inside of your `src/routes` directory, `Info.gql` and `+page.svelte`: -```graphql title="src/routes/+page.gql" +```graphql title="src/routes/Info.gql" query Info { species(id: 1) { - id + pokedexNumber name flavor_text sprites { @@ -21,41 +21,38 @@ query Info { } ``` -```svelte title="src/routes/+page.svelte&typescriptToggle=true" - - - - {$Info.data.species.name} - no.{$Info.data.species.id} - - - - {$Info.data.species.flavor_text} - - + {#snippet left()} + + + {$Info.data.species.name} + no.{$Info.data.species.pokedexNumber} + + + + {$Info.data.species.flavor_text} + + + {/snippet} ``` -You're already starting to see some of the very exciting things Houdini offers. Just like you might define a route's `load` function in a standard -SvelteKit application, you can use `+page.gql` to define the query for your route. - -The data for the query is passed as a store that's available in a key that matches its name (in this case its `Info`). We used the store to render some basic information about Bulbasaur using some components that -were provided in the project's `component` directory. +You're already starting to see some of the very exciting things Houdini offers. Houdini picked up your `Info.gql` file and generated an `InfoStore` class that you can import and use to fetch data. The store's value is reactive — any time the data changes, your component updates automatically. - + A GraphQL query is a string that describes what information you want from the API. For example, the following defines a query named `QueryUserInfo`. In Houdini, all documents like queries must be named for reasons that will become more clear later. @@ -113,45 +110,22 @@ your queries so you can catch errors as quickly as possible. Anyway, now that you have the necessary files, you should see Bulbasaur's description. If you are still running into issues, please reach out to us on the svelte discord and we'd be happy to help. - - -If you were looking carefully, you might have noticed that we didn't define a `load` function as described in the [SvelteKit docs](https://kit.svelte.dev/docs/loading). Don't worry, this route is still rendered on the server thanks to the vite plugin. One of its responsibilities is moving the actual fetch into a `load`. You can think of the block at the top of this section as equivalent to: - -```svelte title="src/routes/+page.svelte" -``` - -```typescript title="src/routes/+page.ts&typescriptToggle=true" -import { InfoStore } from '$houdini/stores/Info' -import type { PageLoad } from './$types' - -export const load: PageLoad = async ({ event }) => { - const store = new InfoStore() - - await InfoStore.fetch({ event }) - - return { - Info: store, - } -} -``` - - ## Query Variables This is a good start but we will need to be able to show information for more species than just Bulbasaur. Let's set up our application to take look at the url for the id of the species we are interested in. -To do that, add a directory named `[[id]]` and move both `+page.gql` and `+page.svelte` inside of it: +To do that, add a directory named `[[id]]` and move both `Info.gql` and `+page.svelte` inside of it: ```bash ## This needs to be run at the root of the project -cd src/routes && mkdir "[[id]]" && mv +page.* "./[[id]]" +cd src/routes && mkdir "[[id]]" && mv Info.gql +page.svelte "./[[id]]" ``` The double braces might seem strange but that will let us have an optional parameter in the url so we can render the same view for both `/` and `/1`. - + All of the queries we've seen so far have had static arguments. However, most of the time you will need to want to give an argument a dynamic value based on something in your application. @@ -181,11 +155,12 @@ query MyQuery($variable1: Boolean, variable2: String!) { Now that we have the actual route defined, we will have to change our query so that it can accept -a variable. Doing this is relatively simple, just update the query inside of `+page.gql` to look like the following: +a variable. Update `Info.gql` to look like the following: -```graphql title="src/routes/[[id]]/+page.gql" +```graphql title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { + pokedexNumber name flavor_text sprites { @@ -195,27 +170,58 @@ query Info($id: Int! = 1) { } ``` -And that's it! Notice that the route parameter matched the name of the query input? Houdini detected that -and took care of the all of the wiring for you. Pretty cool, huh? There is also a way to perform -custom logic to compute your query inputs when you need it but we'll keep things simple for now. -For more information, check out the [query api docs](/svelte/api/query#query-variables). +Next, create a `+page.js` file that reads the `id` from the URL and passes it to the page component: -You should be able to navigate to `/6` and see Charizard's information. If you then navigate back to `/`, -there is no value for the `[[id]]` portion of the url and the query uses its default value of `1`. +```typescript title="src/routes/[[id]]/+page.ts&typescriptToggle=true" +import { error } from '@sveltejs/kit' + +export function load({ params }) { + const id = params.id ? parseInt(params.id) : 1 -For completeness, let's quickly add some buttons to navigate between the different species. Copy and paste this block of code as the last child of the `Container` component. Don't worry if you see an error when you click on them, we'll fix that next. + if (id < 1 || id > 151) { + throw error(400, 'id must be between 1 and 151') + } + + return { id } +} +``` + +Then update `+page.svelte` to receive `id` from the load function and pass it to the store: ```svelte title="src/routes/[[id]]/+page.svelte" - - - + +``` + +Whenever `data.id` changes (because the user navigated to a different URL), `$effect` re-runs and fetches the new species. You should be able to navigate to `/6` and see Charizard's information. If you then navigate back to `/`, +there is no value for the `[[id]]` portion of the url and the load function uses its default value of `1`. + +For completeness, let's quickly add some buttons to navigate between the different species. Copy and paste this block of code as the last child of the `Container` component: + +```svelte title="src/routes/[[id]]/+page.svelte" +{#snippet right()} + + + +{/snippet} ``` ## Loading State @@ -224,38 +230,21 @@ If you've already clicked on those links you probably saw a scary message about to handle the loading state for our view. Let's just do something quick and dirty: ```svelte title="src/routes/[[id]]/+page.svelte" -{#if $Info.fetching} - +{#if $Info.fetching || !$Info.data} + + {#snippet left()}{/snippet} + {#snippet right()}{/snippet} + {:else} {/if} ``` -## Error Handling - -So far so good! There is one slight problem: there are only 151 species in the first generation of Pokémon. The buttons we added in the last section prevent the user from going beyond those bounds, but if we navigate to `/152` directly we will get an error since `$Info.data.species` is null. Go ahead, give it a try. - -In order to prevent this, we need to check if `id` is between `1` and `151` and if not, render an error for the user. The best way to do this is to use a load hook to check the value before the load fires. Load hooks belong in `+page.js` files so create a -file at `src/routes/[[id]]/+page.js` that looks like the following: - -```typescript title="src/routes/[[id]]/+page.ts&typescriptToggle=true" -import { error } from '@sveltejs/kit' -import type { BeforeLoadEvent } from './$houdini' - -export function _houdini_beforeLoad({ params }: BeforeLoadEvent) { - // if we were given an id, convert the string to a number - const id = params.id ? parseInt(params.id) : 1 +We check both `$Info.fetching` and `!$Info.data` so the loading shell also shows on the very first render before any fetch has completed. - // check that the id falls between 1 and 151 - if (id < 1 || id > 151) { - // return a status code 400 along with the error - throw error(400, 'id must be between 1 and 151') - } -} -``` +## Error Handling -Load hooks in houdini all begin with `_houdini_` and there are a lot more than just `beforeLoad`. For an overview of what hooks you can -define, check out the [query api docs](/svelte/api/query#hooks). +The `+page.js` we created earlier already handles the out-of-bounds case by throwing a 400 error before the fetch fires. If you navigate to `/152` you should see SvelteKit's error page. ## What's Next? diff --git a/docs/svelte/01-your-first-app/02-fragments.mdx b/docs/svelte/01-your-first-app/02-fragments.mdx index e748d508a8..d30c79df24 100644 --- a/docs/svelte/01-your-first-app/02-fragments.mdx +++ b/docs/svelte/01-your-first-app/02-fragments.mdx @@ -3,9 +3,11 @@ title: Reusing Parts of a Query description: The second part of the Houdini intro focusing how to reuse parts of a query --- +import AsideAccordion from "@components/docs/AsideAccordion.astro"; + As you've seen, we can get pretty far by just using GraphQL queries to define our route's data requirements. However, as our application grows these would quickly become unmanageable. To illustrate this point, look at how we used the `Sprite` component earlier (copied below without the unrelated bits): -```graphql title="src/routes/[[id]]/+page.gql" +```graphql title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { name @@ -28,10 +30,9 @@ At first it might not be clear what the problem is. `Sprite` defines some props Wouldn't it be nice if we had some way of defining these requirements inside of `Sprite` so we didn't have to worry about the exact details and could ensure that the query included whatever information `Sprite` needs? Well, that's where GraphQL fragments come to the rescue. -:::note[GraphQL: What Are Fragments?] - + -It's safe to skip this section if you familiar with GraphQL Fragments. +It's safe to skip this section if you are familiar with GraphQL Fragments. Fragments are a super powerful tool in GraphQL that is commonly overlooked. In short, they allow us to describe a selection of fields of a given type without having a concrete instance of that type. They look like this: @@ -42,32 +43,21 @@ fragment MyFragment on Species { } ``` -This fragment acts as a reusable bit of query so anywhere we have a field in our document that returns a `Species` we can ask for this data by appending `...` to the fragment name in a selection: - -
- -

- → -

- -
- +This fragment acts as a reusable bit of query so anywhere we have a field in our document that returns a `Species` we can ask for this data by appending `...` to the fragment name in a selection. -::: +
## Using Fragments Defining a fragment inside of your component looks a lot like the query from before. Let's see this in action by updating the `Sprite` component to look like this: -```svelte title="src/components/Sprite.svelte&typescriptToggle=true" - -
+
{`${$info.name}
``` -Next we have to go back to the route and put this fragment to use. Update the query inside of `src/routes/[[id]]/+page.gql` to look like: +Next we have to go back to the route and put this fragment to use. Update the query inside of `src/routes/[[id]]/Info.gql` to look like: -```graphql title="src/routes/[[id]]/+page.gql" +```graphql title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { name @@ -127,39 +117,35 @@ It's worth mentioning explicitly that you are free to mix and match fragments ho Before we add anything to our route, let's update the component defined in `src/components/SpeciesPreview` to use the new fragment we just added to `Sprite`. You might want to give it a try without looking ahead but either way, here's what it should look like now: -```svelte title="src/components/SpeciesPreview.svelte&typescriptToggle=true" - - + - - - {$preview.name} - + + {$data.name} ``` Once that's done, go back to the route we've been working with and update the query to look like this: -```graphql title="src/routes/[[id]]/+page.gql" +```graphql title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { name diff --git a/docs/svelte/01-your-first-app/03-mutations.mdx b/docs/svelte/01-your-first-app/03-mutations.mdx index 08fa0f29ca..9ffe18f058 100644 --- a/docs/svelte/01-your-first-app/03-mutations.mdx +++ b/docs/svelte/01-your-first-app/03-mutations.mdx @@ -3,14 +3,15 @@ title: Handling Updates description: The third part of the Houdini intro focusing on how to update the client side cache --- +import AsideAccordion from "@components/docs/AsideAccordion.astro"; + Thanks for making it this far! We really appreciate the time you are spending to learn Houdini. So far we've only covered how to read data from our server. Clearly this is only one part of the picture since most projects need to be able to update the server's state. -:::note[GraphQL: Mutations] - + -In GraphQL, requests to update the server are defined by a different document type than `query` and are knowns as "mutations". They look something like this: +In GraphQL, requests to update the server are defined by a different document type than `query` and are known as "mutations". They look something like this: ```graphql mutation SetFavorite { @@ -24,14 +25,13 @@ mutation SetFavorite { This example defines a mutation document named `SetFavorite` that invokes the `toggleFavorite` mutation on the server to flag Bulbasaur as one of our favorites. Every mutation in GraphQL has a return type that we can use to look up the values that have been updated in response to the mutation. Since we know that this mutation updates the `favorite` field we made sure to ask for the `favorite` field of the species. - -::: + ## Updating Field Values Before we explain how to use mutations in Houdini, we need a way to visualize if a species is one of our favorites. To start, add the `favorite` to the route's query. It should now look something like: -```graphql title="src/routes/[[id]]/+page.gql" +```graphql title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { name @@ -47,41 +47,47 @@ query Info($id: Int! = 1) { Once you've added the field, add an import for `Icon` from the component directory and drop the following block of code at the bottom of the left panel: -```svelte title="src/routes/[[id]]/+page.svelte&typescriptToggle=true" - ``` -With that in place we can now define a function that will invoke the `toggleFavorite` mutation and pass it to our button. Add the declaration for `toggleFavorite` shown below to your `script` tag: +With that in place we can now define the mutation. Just like queries, mutations live in their own `.gql` file next to the route. Create `src/routes/[[id]]/ToggleFavorite.gql`: + +```graphql title="src/routes/[[id]]/ToggleFavorite.gql" +mutation ToggleFavorite($id: Int!) { + toggleFavorite(id: $id) { + species { + id + favorite + } + } +} +``` + +Houdini will generate a `ToggleFavoriteStore` class you can import and instantiate. Add it to your `script` tag: + +```svelte title="src/routes/[[id]]/+page.svelte" + ``` -A mutation store provides a `mutate` method to trigger your mutation. It takes an object with fields to match the mutation's input and returns a promise that will resolve with the response from the mutation. With that in place, we can now configure the button we added earlier to call this function: +A mutation store provides a `mutate` method to trigger your mutation. With that in place, we can now wire up the button to call it and use the `favorite` field to drive the icon color: ```svelte title="src/routes/[[id]]/+page.svelte" - ``` Now, try clicking on the grey star for any species. It should flip between gold and grey every time you click it. @@ -105,7 +111,7 @@ It wouldn't be possible to look up this list's new value in the payload of `togg Before we get too far, let's add a place in our UI to show us the list of favorites. First, open up the `+page.gql` file and add the following block: -```diff title="src/routes/[[id]]/+page.gql" +```diff title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { id @@ -117,34 +123,38 @@ Before we get too far, let's add a place in our UI to show us the list of favori } ...SpriteInfo } -+ favorites @list(name:"FavoriteSpecies") { ++ favorites @list(name: "FavoriteSpecies") { + ...FavoritePreview + } } ``` -Then, open up `src/routes/[[id]]/+page.svelte`, add imports for `FavoritePreview` and `FavoritesContainer`, and some components to visualize the list: +Then, open up `src/routes/[[id]]/+page.svelte`, add imports for `FavoritePreview` and `FavoritesContainer`, and add the list just before the `Container`. Also update the loading branch to include `` so the layout doesn't shift: -```svelte title="src/routes/[[id]]/+page.svelte&typescriptToggle=true" - - - - {#each $Info.data.favorites as favorite} - - {:else} -

- No Favorites Selected -

- {/each} -
+{#if $Info.fetching || !$Info.data} + + +{:else} + + {#each $Info.data.favorites as favorite} + + {:else} +

No Favorites Selected

+ {/each} +
+ + +{/if} ``` Don't worry about the `@list` directive just yet - we'll explain what it does in a bit. For now, just confirm that you have to refresh your browser in order to see the effect of clicking the star on the section at the top. Hopefully that's not too surprising since we haven't told Houdini how to update our view in response to the mutation. Connecting those dots just requires updating the mutation to look like this: -```graphql title="src/routes/[[id]]/+page.svelte" +```graphql title="src/routes/[[id]]/ToggleFavorite.gql" mutation ToggleFavorite($id: Int!) { toggleFavorite(id: $id) { species { diff --git a/docs/svelte/01-your-first-app/04-pagination.mdx b/docs/svelte/01-your-first-app/04-pagination.mdx index 689b4ae8f3..36a73a1db9 100644 --- a/docs/svelte/01-your-first-app/04-pagination.mdx +++ b/docs/svelte/01-your-first-app/04-pagination.mdx @@ -3,6 +3,8 @@ title: Rendering Long Lists description: The fourth part of the Houdini intro focusing on how to render long lists of data --- +import AsideAccordion from "@components/docs/AsideAccordion.astro"; + You'll often run into the situation where you want to render a long list of data. It's also common for that list to be much too long to query or even render on the screen. In order to address this, most APIs will accept a set of arguments on the field designed to window @@ -13,8 +15,7 @@ into two categories: cursor-based pagination and offset-based pagination. Houdini supports both but since our API relies on cursor-based pagination that's what we're going to show here. If you want to read more about pagination, head over to the [pagination guide](/svelte/pagination/). -:::note[GraphQL: Cursor-Based Pagination] - + If you are familiar with cursor-based pagination you can safely skip this section. @@ -159,15 +160,14 @@ query { For more information on the Connection model, check out [this blog post](https://www.apollographql.com/blog/graphql/explaining-graphql-connections/). - -::: + ## Paginated Queries Interacting with a paginated query is pretty similar to everything we've been doing so far. -Just decorate the paginated field with the `@paginate` directive. Go ahead and change the `+page.gql` file to look like this: +Just decorate the paginated field with the `@paginate` directive. Go ahead and change the `Info.gql` file to look like this: -```graphql title="src/routes/[[id]]/+page.gql" +```graphql title="src/routes/[[id]]/Info.gql" query Info($id: Int! = 1) { species(id: $id) { name @@ -184,10 +184,16 @@ query Info($id: Int! = 1) { ...MoveDisplay } } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } } } - favorites @list(name:"FavoriteSpecies") { + favorites @list(name: "FavoriteSpecies") { ...FavoritePreview } } @@ -195,8 +201,8 @@ query Info($id: Int! = 1) { At the surface, a paginated query store is basically the same thing as a normal query except for a few extra utilities. They're pretty self-explanatory but just in case there's any confusion: `loadNextPage` is an async function that will load the next -page and append the result of the field tagged with `@paginate` to the existing value in our cache and -there is a `pageInfo` value on the store that contains an object with meta data about the current page. +page and append the result of the field tagged with `@paginate` to the existing value in our cache. +The `pageInfo` object lives at `$Info.data.species.moves.pageInfo` and contains meta data about the current page. For a more in-depth summary of what you can do with `@paginate`, you can check out the [Pagination Guide](/svelte/pagination/). It's time to add some visuals. Add an import for the `MoveDisplay` component and copy the following block @@ -209,8 +215,8 @@ as the second child in the right panel (between `div#species-evolution-chain` an Houdini supports a few different approachs for pagination but we won't go too deep just yet. In our situation, since we want to display 1 move at a time, we need to pass `SinglePage` as the `mode` for `@paginate`: -```graphql title="src/routes/[[id]]/+page.gql" -query Info($id: Int = 1) { +```graphql title="src/routes/[[id]]/Info.gql" +query Info($id: Int! = 1) { # ... @@ -220,6 +226,12 @@ query Info($id: Int = 1) { ...MoveDisplay } } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } } # ... @@ -227,22 +239,24 @@ query Info($id: Int = 1) { ``` -```svelte title="src/routes/[[id]]/+page.svelte&typescriptToggle=true" -
- + {#key $Info.data.species.moves.pageInfo.startCursor} + + {/key}
await Info.loadPreviousPage()} + disabled={!$Info.data.species.moves.pageInfo.hasPreviousPage} + onclick={() => Info.loadPreviousPage()} /> await Info.loadNextPage()} + disabled={!$Info.data.species.moves.pageInfo.hasNextPage} + onclick={() => Info.loadNextPage()} />
diff --git a/docs/svelte/02-setup/01-project-setup.mdx b/docs/svelte/02-setup/01-project-setup.mdx index c7dc1c4846..5986f3da93 100644 --- a/docs/svelte/02-setup/01-project-setup.mdx +++ b/docs/svelte/02-setup/01-project-setup.mdx @@ -3,6 +3,8 @@ title: Setting Up Your Project description: A guide for setting up your Houdini project --- +import Notice from '@components/docs/Notice.astro' + This guide contains two different approaches to add Houdini to your project. The first one uses a script that will configure everything for you. The second is more manual, explaining all of the necessary steps. @@ -17,10 +19,9 @@ configure everything for you (setup url, get schema, prepare project files, etc) npx houdini@latest init ``` -> This will send a request to your API to download your schema definition. If you need -> headers to authenticate this request, you can pass them in with the `--headers` -> flag (abbreviated `-h`). For example, -> `npx houdini init -h Authorization="Bearer MyToken"`. + + This will send a request to your API to download your schema definition. If you need headers to authenticate this request, you can pass them in with the `--headers` flag (abbreviated `-h`). For example, `npx houdini init -h Authorization="Bearer MyToken"`. + And that's it! You should be all set with everything you need to work with Houdini. diff --git a/docs/svelte/03-loading-data/02-fragments.mdx b/docs/svelte/03-loading-data/02-fragments.mdx index 810996ce35..cbddf19063 100644 --- a/docs/svelte/03-loading-data/02-fragments.mdx +++ b/docs/svelte/03-loading-data/02-fragments.mdx @@ -3,8 +3,19 @@ title: Fragments description: Fragments in Houdini --- +import AsideAccordion from "@components/docs/AsideAccordion.astro"; +import Notice from '@components/docs/Notice.astro' + Specify the data requirements for a component. + + +Rather than leaving it up to coincidence that the right fields happen to be in the route's query, we give each component its own fragment — and the parent query just spreads them in. As a component library grows, this keeps the coupling loose: adding a field to `UserAvatar` means updating `UserAvatar`'s fragment, not hunting down every query that renders it. + +Think of fragments as the primary way to describe component data requirements in Houdini. A `UserAvatar` that starts out needing just initials can quietly grow to need an email and a favorite color without touching any of its callers. + + + ## Basic Usage ```svelte @@ -88,8 +99,9 @@ query AllUsers { } ``` -> Keep in mind, if you are using fragment variables inside of a field flagged for -> list operations, you'll have to pass a value for the variable when performing the operation + + If you are using fragment variables inside of a field flagged for list operations, you'll have to pass a value for the variable when performing the operation. + For paginated fragments, see the [Pagination](~/loading-data/pagination) guide. diff --git a/docs/svelte/05-guides/02-error-handling.mdx b/docs/svelte/05-guides/02-error-handling.mdx index 006ff674e5..4e03bc57b9 100644 --- a/docs/svelte/05-guides/02-error-handling.mdx +++ b/docs/svelte/05-guides/02-error-handling.mdx @@ -3,12 +3,13 @@ title: "Error Handling" description: "Handle GraphQL and network errors." --- -There are 2 ways you can handle errors in GraphQL: you can either have documents throw an exception if an -`error` key is located in the payload (and is a list with at least one member) or they can fail silently -and rely on application level code to function. +import ErrorHandling from '@shared/_partials/error-handling.mdx' -By default, Houdini will behave "silently" and not throw any exceptions if an error is the query response is -seen. If you want to turn on exceptions, you can specify the details in the `throwOnError` field: + + +## SvelteKit Integration + +In SvelteKit, use the `error` helper from `@sveltejs/kit` inside `throwOnError` to produce proper HTTP error responses: ```typescript title="src/client.ts&typescriptToggle=true" import { error } from '@sveltejs/kit' @@ -16,10 +17,7 @@ import { error } from '@sveltejs/kit' export default new HoudiniClient({ url: '...', throwOnError: { - // can be any combination of - // query, mutation, subscription, and all operations: ['all'], - // the function to call error: (errors, ctx) => error(500, `(${ctx.artifact.name}): ` + errors.map((err) => err.message).join('. ') + '.' diff --git a/e2e/_api/graphql.mjs b/e2e/_api/graphql.mjs index 6a3a5c4d78..e7293dbc19 100644 --- a/e2e/_api/graphql.mjs +++ b/e2e/_api/graphql.mjs @@ -127,6 +127,16 @@ export const typeDefs = /* GraphQL */ ` delay: Int snapshot: String! ): UserConnection! + usersConnectionForwardOnly( + after: String + first: Int + snapshot: String! + ): UserConnection! + usersConnectionBackwardOnly( + before: String + last: Int + snapshot: String! + ): UserConnection! usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! userNodes(limit: Int = 4, offset: Int, snapshot: String!): UserNodes! userSearch(filter: UserNameFilter!, snapshot: String!): [User!]! @@ -468,6 +478,12 @@ export const resolvers = { return connectionFromArray(getUserSnapshot(args.snapshot), args) }, + usersConnectionForwardOnly: (_, args) => { + return connectionFromArray(getUserSnapshot(args.snapshot), args) + }, + usersConnectionBackwardOnly: (_, args) => { + return connectionFromArray(getUserSnapshot(args.snapshot), args) + }, user: async (_, args) => { // simulate network delay if (args.delay) { diff --git a/e2e/_api/schema.graphql b/e2e/_api/schema.graphql index 82ad18459c..ee58599fea 100644 --- a/e2e/_api/schema.graphql +++ b/e2e/_api/schema.graphql @@ -114,6 +114,16 @@ type Query { delay: Int snapshot: String! ): UserConnection! + usersConnectionForwardOnly( + after: String + first: Int + snapshot: String! + ): UserConnection! + usersConnectionBackwardOnly( + before: String + last: Int + snapshot: String! + ): UserConnection! usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! userNodes(limit: Int = 4, offset: Int, snapshot: String!): UserNodes! userSearch(filter: UserNameFilter!, snapshot: String!): [User!]! diff --git a/e2e/kit/src/lib/utils/routes.ts b/e2e/kit/src/lib/utils/routes.ts index cfebf4d594..cce63066c4 100644 --- a/e2e/kit/src/lib/utils/routes.ts +++ b/e2e/kit/src/lib/utils/routes.ts @@ -11,8 +11,10 @@ export const routes = { abstractFragments: '/abstract-fragments', abstractFragments_nestedConnection: '/abstract-fragments/nested-connection', fragment_masking_partial: '/fragment-masking-partial', + conditional_fragment_spread: '/conditional-fragment-spread', loading_state: '/loading-state', required_field: '/required-field', + Cache_Refresh: '/cache/refresh', Lists_fragment: '/lists/fragment', Lists_mutation_insert: '/lists/mutation-insert', @@ -67,6 +69,7 @@ export const routes = { Pagination_fragment_bidirectional_cursor: '/pagination/fragment/bidirectional-cursor', Pagination_fragment_offset: '/pagination/fragment/offset', Pagination_fragment_required_arguments: '/pagination/fragment/required-arguments', + Pagination_fragment_forward_cursor_singlepage: '/pagination/fragment/forward-cursor-singlepage', nested_argument_fragments: '/nested-argument-fragments', nested_argument_fragments_masking: '/nested-argument-fragments-masking', @@ -79,6 +82,8 @@ export const routes = { Stores_Layouts: '/layouts', Stores_Layouts_page2: '/layouts/page2', + Bug_RefetchCacheLinksLeak: '/bug/refetch-cache-links-leak', + Svelte5_Runes_Simple_SSR: '/svelte5-runes/simple-ssr', Svelte5_Runes_Pagination: '/svelte5-runes/pagination', Svelte5_Runes_Fragment: '/svelte5-runes/fragment', diff --git a/e2e/kit/src/routes/bug/refetch-cache-links-leak/+page.svelte b/e2e/kit/src/routes/bug/refetch-cache-links-leak/+page.svelte new file mode 100644 index 0000000000..8d1627f69d --- /dev/null +++ b/e2e/kit/src/routes/bug/refetch-cache-links-leak/+page.svelte @@ -0,0 +1,47 @@ + + + + +{#if $store.fetching} +

Loading...

+{:else} +
{JSON.stringify($store.data)}
+{/if} + + diff --git a/e2e/kit/src/routes/bug/refetch-cache-links-leak/spec.ts b/e2e/kit/src/routes/bug/refetch-cache-links-leak/spec.ts new file mode 100644 index 0000000000..3d49b07c15 --- /dev/null +++ b/e2e/kit/src/routes/bug/refetch-cache-links-leak/spec.ts @@ -0,0 +1,19 @@ +import { routes } from '../../../lib/utils/routes.js' +import { expect_1_gql, goto_expect_n_gql } from '../../../lib/utils/testsHelper.js' +import { test, expect } from '@playwright/test' + +test.describe('bug/refetch-cache-links-leak', () => { + test('refetching a connection does not duplicate embedded cache links', async ({ page }) => { + await goto_expect_n_gql(page, routes.Bug_RefetchCacheLinksLeak, 1) + + const countEl = page.locator('div[id="edge-link-count"]') + + const initialCount = parseInt((await countEl.textContent()) ?? '0') + expect(initialCount).toBeGreaterThan(0) + + await expect_1_gql(page, 'button[id="refetch"]') + + const afterCount = parseInt((await countEl.textContent()) ?? '0') + expect(afterCount).toBe(initialCount) + }) +}) diff --git a/e2e/kit/src/routes/cache/refresh/+page.svelte b/e2e/kit/src/routes/cache/refresh/+page.svelte new file mode 100644 index 0000000000..44a65bda76 --- /dev/null +++ b/e2e/kit/src/routes/cache/refresh/+page.svelte @@ -0,0 +1,33 @@ + + +

Cache Refresh

+ +{#if $store.data} + +{/if} + + diff --git a/e2e/kit/src/routes/cache/refresh/UserDetails.svelte b/e2e/kit/src/routes/cache/refresh/UserDetails.svelte new file mode 100644 index 0000000000..8b49116b5b --- /dev/null +++ b/e2e/kit/src/routes/cache/refresh/UserDetails.svelte @@ -0,0 +1,16 @@ + + +
{$data?.name}
diff --git a/e2e/kit/src/routes/cache/refresh/spec.ts b/e2e/kit/src/routes/cache/refresh/spec.ts new file mode 100644 index 0000000000..71a449b824 --- /dev/null +++ b/e2e/kit/src/routes/cache/refresh/spec.ts @@ -0,0 +1,21 @@ +import { test } from '@playwright/test' +import { routes } from '../../../lib/utils/routes.js' +import { expect_1_gql, expect_to_be, goto_expect_n_gql } from '../../../lib/utils/testsHelper.js' + +test.describe('cache refresh', () => { + test('refreshing a record refetches the query that contains it', async ({ page }) => { + // load the page and wait for the initial query + await goto_expect_n_gql(page, routes.Cache_Refresh, 1) + + // the fragment renders the user's name. the query only contains the + // user behind the fragment's mask + await expect_to_be(page, 'Bruce Willis', 'div[id=user-name]') + + // refreshing the user record triggers exactly one network request: + // the query that contains it refetches itself + await expect_1_gql(page, 'button[id=refresh]') + + // and the data is still rendered after the round trip + await expect_to_be(page, 'Bruce Willis', 'div[id=user-name]') + }) +}) diff --git a/e2e/kit/src/routes/conditional-fragment-spread/+page.svelte b/e2e/kit/src/routes/conditional-fragment-spread/+page.svelte new file mode 100644 index 0000000000..58a9261264 --- /dev/null +++ b/e2e/kit/src/routes/conditional-fragment-spread/+page.svelte @@ -0,0 +1,22 @@ + + +{#if $store.data} +
{$store.data.user.id}:{$store.data.user.name}
+{:else} +
no data
+{/if} diff --git a/e2e/kit/src/routes/conditional-fragment-spread/+page.ts b/e2e/kit/src/routes/conditional-fragment-spread/+page.ts new file mode 100644 index 0000000000..30a0ea865a --- /dev/null +++ b/e2e/kit/src/routes/conditional-fragment-spread/+page.ts @@ -0,0 +1,21 @@ +import { graphql } from '$houdini' +import type { PageLoad } from './$types' + +const store = graphql(` + query ConditionalFragmentSpreadQuery { + user(id: "1", snapshot: "conditional-fragment-spread") { + id + name + + ...ConditionalFragmentSpreadDetails @mask_disable @include(if: false) + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + ConditionalFragmentSpreadQuery: store, + } +} diff --git a/e2e/kit/src/routes/conditional-fragment-spread/spec.ts b/e2e/kit/src/routes/conditional-fragment-spread/spec.ts new file mode 100644 index 0000000000..e081f2c74e --- /dev/null +++ b/e2e/kit/src/routes/conditional-fragment-spread/spec.ts @@ -0,0 +1,11 @@ +import { test } from '@playwright/test' +import { routes } from '../../lib/utils/routes.js' +import { expect_to_be, goto } from '../../lib/utils/testsHelper.js' + +test.describe('@include on a fragment spread', () => { + test('a falsy condition does not bubble null up to the parent', async ({ page }) => { + await goto(page, routes.conditional_fragment_spread) + + await expect_to_be(page, 'conditional-fragment-spread:1:Bruce Willis') + }) +}) diff --git a/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte new file mode 100644 index 0000000000..5c52115139 --- /dev/null +++ b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.svelte @@ -0,0 +1,42 @@ + + +
+ {$fragmentResult.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')} +
+ +
+ {stringify($fragmentResult.pageInfo)} +
+ + + diff --git a/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts new file mode 100644 index 0000000000..23b3508065 --- /dev/null +++ b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/+page.ts @@ -0,0 +1,18 @@ +import type { PageLoad } from './$types' +import { graphql } from '$houdini' + +const store = graphql(` + query UserFragmentForwardsCursorSinglePageQuery { + user(id: "1", snapshot: "pagination-fragment-forwards-cursor-singlepage-svelte") { + ...ForwardsCursorSinglePageFragment + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + UserFragmentForwardsCursorSinglePageQuery: store, + } +} diff --git a/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts new file mode 100644 index 0000000000..ae911d8ea2 --- /dev/null +++ b/e2e/kit/src/routes/pagination/fragment/forward-cursor-singlepage/spec.ts @@ -0,0 +1,39 @@ +import { test } from '@playwright/test' +import { routes } from '../../../../lib/utils/routes.js' +import { + expect_0_gql, + expect_1_gql, + expect_to_be, + expectToContain, + goto, +} from '../../../../lib/utils/testsHelper.js' + +test.describe('forwards cursor fragment single page', () => { + test('loadNextPage replaces data', async ({ page }) => { + await goto(page, routes.Pagination_fragment_forward_cursor_singlepage) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + }) +}) diff --git a/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts b/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts index 9aa7cace45..185a1c70f9 100644 --- a/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts +++ b/e2e/kit/src/routes/pagination/query/bidirectional-cursor-single-page/spec.ts @@ -3,6 +3,7 @@ import { routes } from '../../../../lib/utils/routes.js' import { expect_to_be, expectToContain, + expect_0_gql, expect_1_gql, goto, stringify, @@ -29,8 +30,8 @@ test.describe('bidirectional cursor single page paginated query', () => { /// Click on the next button - // load the next page and wait for the response - await expect_1_gql(page, 'button[id=next]') + // page 2 was the initial load — cache hit, no network request + await expect_0_gql(page, 'button[id=next]') // there should be no previous page await expectToContain(page, `"hasPreviousPage":true`) @@ -87,8 +88,8 @@ test.describe('bidirectional cursor single page paginated query', () => { /// Click on the previous button - // load the previous page and wait for the response - await expect_1_gql(page, 'button[id=previous]') + // page 2 was the initial load — cache hit, no network request + await expect_0_gql(page, 'button[id=previous]') // make sure we got the new content await expect_to_be(page, 'Morgan Freeman, Tom Hanks') @@ -100,7 +101,7 @@ test.describe('bidirectional cursor single page paginated query', () => { /// Click on the previous button - // load the previous page and wait for the response + // previousCursors now empty — use before cursor to fetch page 1 await expect_1_gql(page, 'button[id=previous]') // make sure we got the new content diff --git a/e2e/react/eslint.config.js b/e2e/react/eslint.config.js index 48a64b180e..4214dfebd5 100644 --- a/e2e/react/eslint.config.js +++ b/e2e/react/eslint.config.js @@ -2,6 +2,9 @@ import tsPlugin from '@typescript-eslint/eslint-plugin' import tsParser from '@typescript-eslint/parser' export default [ + { + ignores: ['.houdini/**', 'build/**', 'node_modules/**'], + }, { files: ['src/**/*.{ts,tsx}'], plugins: { '@typescript-eslint': tsPlugin }, diff --git a/e2e/react/package.json b/e2e/react/package.json index 9810bdce84..2f2caaf3b5 100644 --- a/e2e/react/package.json +++ b/e2e/react/package.json @@ -29,7 +29,8 @@ "tw": "npx tailwindcss -i ./src/styles.css -o ./public/assets/output.css --watch", "preview": "vite dev", "preinstall": "node preInstall.js", - "lint": "eslint ." + "lint": "eslint .", + "check": "tsc --noEmit" }, "dependencies": { "@cloudflare/workers-types": "^4.20260605.1", diff --git a/e2e/react/playwright.config.ts b/e2e/react/playwright.config.ts index 43771e6483..a61c6ddfa4 100644 --- a/e2e/react/playwright.config.ts +++ b/e2e/react/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ testMatch: 'test.ts', webServer: { - command: 'PORT=3008 node build/index.js', + command: 'NODE_ENV=production PORT=3008 node build/index.js', port: 3008, timeout: 120 * 1000, reuseExistingServer: !process.env.CI, diff --git a/e2e/react/src/+index.jsx b/e2e/react/src/+index.jsx index 27fad7b2d6..b79ef3dddc 100644 --- a/e2e/react/src/+index.jsx +++ b/e2e/react/src/+index.jsx @@ -26,10 +26,6 @@ class ErrorBoundary extends React.Component { return { hasError: true } } - componentDidCatch(error, info) { - console.error('ErrorBoundary caught an error:', error, info) - } - render() { if (this.state.hasError) { return

Something went wrong.

diff --git a/e2e/react/src/anchor-types.types.tsx b/e2e/react/src/anchor-types.types.tsx new file mode 100644 index 0000000000..de2846c615 --- /dev/null +++ b/e2e/react/src/anchor-types.types.tsx @@ -0,0 +1,62 @@ +// Compile-time type assertions for prop typing. +// Verified by `tsc --noEmit` — not a Playwright test. + +import { Link } from '$houdini' + +export {} + +// ── valid usages ───────────────────────────────────────────────────────────── + +// known static routes +const _s1 = Home +const _s2 = Hello +// external links +const _s3 = HTTPS +const _s4 = Email +const _s5 = Fragment +const _s6 = Relative +// parameterized route — GQL ID accepts string or number +const _s7 = User +const _s7b = User +// parameterized route with number id +const _s8 = User +// preload prop on a known route +const _s9 = Preload +const _s9b = Preload +// key prop works (from ClassAttributes via DetailedHTMLProps) +const _s10 = Nav +// other standard attributes still work +const _s11 = New tab + +// ── invalid usages ──────────────────────────────────────────────────────────── + +// to must be a string +const _e1 = + // @ts-expect-error -- number is not a valid route + Bad + +// unknown route is rejected +const _e4 = + // @ts-expect-error -- /not-a-real-route is not in the manifest + Bad + +// params must be a plain object, not a primitive +const _e2 = + // @ts-expect-error -- boolean is not a valid params value + Bad + +// params must be a plain object, not a string +const _e3 = + // @ts-expect-error -- string is not a valid params object + Bad + +// wrong param key for a known route +const _e5 = + // @ts-expect-error -- userId is not a valid param for /route_params/[id] + Bad + +// missing params entirely for a parameterized route +const _e7 = + // @ts-expect-error -- params required for parameterized route + Bad + diff --git a/e2e/react/src/api/+schema.js b/e2e/react/src/api/+schema.js index 59f82e6504..115a856fbd 100644 --- a/e2e/react/src/api/+schema.js +++ b/e2e/react/src/api/+schema.js @@ -119,6 +119,16 @@ export const typeDefs = /* GraphQL */ ` delay: Int snapshot: String! ): UserConnection! + usersConnectionForwardOnly( + after: String + first: Int + snapshot: String! + ): UserConnection! + usersConnectionBackwardOnly( + before: String + last: Int + snapshot: String! + ): UserConnection! usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! userNodes(limit: Int = 4, offset: Int, snapshot: String!): UserNodes! userSearch(filter: UserNameFilter!, snapshot: String!): [User!]! diff --git a/e2e/react/src/routes/+layout.tsx b/e2e/react/src/routes/+layout.tsx index d301d10120..e05f7dc882 100644 --- a/e2e/react/src/routes/+layout.tsx +++ b/e2e/react/src/routes/+layout.tsx @@ -1,4 +1,4 @@ -import { useCache } from '$houdini/plugins/houdini-react/runtime/routing' +import { useCache } from '$houdini' import React from 'react' import { routes } from '~/utils/routes' @@ -17,7 +17,7 @@ export default function ({ children }: LayoutProps) { return ( <>
- {Object.entries(routes).map(([route, url]) => { + {Object.entries(routes).map(([route, url]: [string, string]) => { return ( routing-error: {routing.status}
+ return
{errors[0]?.message}
+} diff --git a/e2e/react/src/routes/error-loop/+layout.tsx b/e2e/react/src/routes/error-loop/+layout.tsx new file mode 100644 index 0000000000..8724cfbe60 --- /dev/null +++ b/e2e/react/src/routes/error-loop/+layout.tsx @@ -0,0 +1,5 @@ +import { notFound } from '$houdini' + +export default function ErrorLoopLayout() { + return notFound() +} diff --git a/e2e/react/src/routes/error-loop/+page.tsx b/e2e/react/src/routes/error-loop/+page.tsx new file mode 100644 index 0000000000..52c6689a82 --- /dev/null +++ b/e2e/react/src/routes/error-loop/+page.tsx @@ -0,0 +1,3 @@ +export default function ErrorLoopPage() { + return
error-loop page
+} diff --git a/e2e/react/src/routes/error-loop/test.ts b/e2e/react/src/routes/error-loop/test.ts new file mode 100644 index 0000000000..9d8706e304 --- /dev/null +++ b/e2e/react/src/routes/error-loop/test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('layout that throws notFound() returns 404 without looping', async ({ page }) => { + const response = await goto(page, routes.error_loop) + expect(response?.status()).toBe(404) +}) diff --git a/e2e/react/src/routes/list-id/+page.gql b/e2e/react/src/routes/list-id/+page.gql new file mode 100644 index 0000000000..797e74d98b --- /dev/null +++ b/e2e/react/src/routes/list-id/+page.gql @@ -0,0 +1,8 @@ +query ListIDTest { + userNodes(snapshot: "ListIDTest") { + nodes @list(name: "ListID_Users") @includeListID { + id + name + } + } +} diff --git a/e2e/react/src/routes/list-id/+page.tsx b/e2e/react/src/routes/list-id/+page.tsx new file mode 100644 index 0000000000..d18e5418bd --- /dev/null +++ b/e2e/react/src/routes/list-id/+page.tsx @@ -0,0 +1,44 @@ +import { useMutation, graphql } from '$houdini' +import { PageProps } from './$types' + +export default function ListIDTestView({ ListIDTest }: PageProps) { + const nodes = ListIDTest?.userNodes.nodes + const listId = nodes?.__id + + const [addUser] = useMutation( + graphql(` + mutation ListIDAddUser($name: String!, $birthDate: DateTime!, $listId: ID!) { + addUser(snapshot: "ListIDTest", name: $name, birthDate: $birthDate) { + ...ListID_Users_insert @listID(value: $listId) + } + } + `) + ) + + return ( + <> +
    + {nodes?.map((user) => ( +
  • + {user.name} +
  • + ))} +
+ + + ) +} diff --git a/e2e/react/src/routes/list-id/test.ts b/e2e/react/src/routes/list-id/test.ts new file mode 100644 index 0000000000..d5018da99f --- /dev/null +++ b/e2e/react/src/routes/list-id/test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { sleep } from '~/utils/sleep' +import { goto } from '~/utils/testsHelper.js' + +test('@listID inserts into an embedded list with no natural parent ID', async ({ page }) => { + await goto(page, routes.list_id) + + const rows = page.getByTestId('user-row') + const initialCount = await rows.count() + expect(initialCount).toBeGreaterThan(0) + + await page.click('[data-test-action="add"]') + await sleep(300) + + expect(await rows.count()).toBe(initialCount + 1) + expect(await rows.last().textContent()).toContain('New User') + + // second insert: __id must survive the re-render caused by the first insert + await page.click('[data-test-action="add"]') + await sleep(300) + + expect(await rows.count()).toBe(initialCount + 2) + expect(await rows.last().textContent()).toContain('New User') +}) diff --git a/e2e/react/src/routes/list-operations/update/+page.gql b/e2e/react/src/routes/list-operations/update/+page.gql new file mode 100644 index 0000000000..bb6a343e41 --- /dev/null +++ b/e2e/react/src/routes/list-operations/update/+page.gql @@ -0,0 +1,6 @@ +query UpdateFragmentTest { + usersList(snapshot: "UpdateFragmentTest", limit: 1) @list(name: "UpdateFragmentTest") { + id + name + } +} diff --git a/e2e/react/src/routes/list-operations/update/+page.tsx b/e2e/react/src/routes/list-operations/update/+page.tsx new file mode 100644 index 0000000000..32046f97b4 --- /dev/null +++ b/e2e/react/src/routes/list-operations/update/+page.tsx @@ -0,0 +1,59 @@ +import { useMutation, graphql } from '$houdini' + +import { PageProps } from './$types' + +export default function UpdateFragmentTestView({ UpdateFragmentTest }: PageProps) { + const users = UpdateFragmentTest?.usersList ?? [] + const firstUserId = users[0]?.id + + const [updateExisting] = useMutation( + graphql(` + mutation UpdateExisting($id: ID!, $name: String!) { + updateUserByID(id: $id, snapshot: "UpdateFragmentTest", name: $name) { + ...UpdateFragmentTest_update + } + } + `) + ) + + const [updateNonMember] = useMutation( + graphql(` + mutation UpdateNonMember($name: String!, $birthDate: DateTime!) { + addUser(snapshot: "UpdateFragmentTest", name: $name, birthDate: $birthDate) { + ...UpdateFragmentTest_update + } + } + `) + ) + + return ( + <> +
    + {users.map((user) => ( +
  • + {user.name} +
  • + ))} +
+ + + + ) +} diff --git a/e2e/react/src/routes/list-operations/update/test.ts b/e2e/react/src/routes/list-operations/update/test.ts new file mode 100644 index 0000000000..0dd084fc2c --- /dev/null +++ b/e2e/react/src/routes/list-operations/update/test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +import { routes } from '~/utils/routes' +import { sleep } from '~/utils/sleep' +import { goto } from '~/utils/testsHelper.js' + +test('_update updates existing list member in place', async ({ page }) => { + await goto(page, routes.list_operations_update) + + const rows = page.getByTestId('user-row') + const initialCount = await rows.count() + expect(initialCount).toBeGreaterThan(0) + + const initialFirstName = await rows.first().textContent() + + await page.click('[data-test-action="update-existing"]') + await sleep(300) + + expect(await rows.count()).toBe(initialCount) + expect(await rows.first().textContent()).toBe('updated name') + expect(await rows.first().textContent()).not.toBe(initialFirstName) +}) + +test('_update does not insert records that are not in the list', async ({ page }) => { + await goto(page, routes.list_operations_update) + + const rows = page.getByTestId('user-row') + const initialCount = await rows.count() + + await page.click('[data-test-action="update-non-member"]') + await sleep(300) + + expect(await rows.count()).toBe(initialCount) + expect(page.getByText('Should Not Appear')).not.toBeVisible() +}) diff --git a/e2e/react/src/routes/list-operations/upsert/+page.gql b/e2e/react/src/routes/list-operations/upsert/+page.gql new file mode 100644 index 0000000000..9d9b0ac43b --- /dev/null +++ b/e2e/react/src/routes/list-operations/upsert/+page.gql @@ -0,0 +1,6 @@ +query UpsertFragmentTest { + usersList(snapshot: "UpsertFragmentTest") @list(name: "UpsertFragmentTest") { + id + name + } +} diff --git a/e2e/react/src/routes/list-operations/upsert/+page.tsx b/e2e/react/src/routes/list-operations/upsert/+page.tsx new file mode 100644 index 0000000000..c0a6e938cc --- /dev/null +++ b/e2e/react/src/routes/list-operations/upsert/+page.tsx @@ -0,0 +1,59 @@ +import { useMutation, graphql } from '$houdini' + +import { PageProps } from './$types' + +export default function UpsertFragmentTestView({ UpsertFragmentTest }: PageProps) { + const users = UpsertFragmentTest?.usersList ?? [] + const firstUserId = users[0]?.id + + const [updateExisting] = useMutation( + graphql(` + mutation UpsertExisting($id: ID!, $name: String!) { + updateUserByID(id: $id, snapshot: "UpsertFragmentTest", name: $name) { + ...UpsertFragmentTest_upsert + } + } + `) + ) + + const [addNew] = useMutation( + graphql(` + mutation UpsertNew($name: String!, $birthDate: DateTime!) { + addUser(snapshot: "UpsertFragmentTest", name: $name, birthDate: $birthDate) { + ...UpsertFragmentTest_upsert @prepend + } + } + `) + ) + + return ( + <> +
    + {users.map((user) => ( +
  • + {user.name} +
  • + ))} +
+ + + + ) +} diff --git a/e2e/react/src/routes/list-operations/upsert/test.ts b/e2e/react/src/routes/list-operations/upsert/test.ts new file mode 100644 index 0000000000..54b183f464 --- /dev/null +++ b/e2e/react/src/routes/list-operations/upsert/test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +import { routes } from '~/utils/routes' +import { sleep } from '~/utils/sleep' +import { goto } from '~/utils/testsHelper.js' + +test('_upsert updates existing list member without duplicating', async ({ page }) => { + await goto(page, routes.list_operations_upsert) + + const rows = page.getByTestId('user-row') + const initialCount = await rows.count() + expect(initialCount).toBeGreaterThan(0) + + const initialFirstName = await rows.first().textContent() + + await page.click('[data-test-action="update-existing"]') + await sleep(300) + + expect(await rows.count()).toBe(initialCount) + expect(await rows.first().textContent()).toBe('updated name') + expect(await rows.first().textContent()).not.toBe(initialFirstName) +}) + +test('_upsert inserts new record when not already in list', async ({ page }) => { + await goto(page, routes.list_operations_upsert) + + const rows = page.getByTestId('user-row') + const initialCount = await rows.count() + + await page.click('[data-test-action="add-new"]') + await sleep(300) + + expect(await rows.count()).toBe(initialCount + 1) + expect(await rows.first().textContent()).toBe('Brand New User') +}) diff --git a/e2e/react/src/routes/optimistic-keys/+page.tsx b/e2e/react/src/routes/optimistic-keys/+page.tsx index 7b179eceec..95b0c025bc 100644 --- a/e2e/react/src/routes/optimistic-keys/+page.tsx +++ b/e2e/react/src/routes/optimistic-keys/+page.tsx @@ -6,7 +6,7 @@ import { PageProps } from './$types' export default function OptimisticKeyTestView({ OptimisticKeyTest }: PageProps) { const [error, setError] = React.useState('') - const [_, update] = useMutation( + const [update, _] = useMutation( graphql(` mutation OptimisticKeyTestUpdateMutation($id: ID!, $avatarURL: String!) { updateUserByID( @@ -22,7 +22,7 @@ export default function OptimisticKeyTestView({ OptimisticKeyTest }: PageProps) `) ) - const [__, create] = useMutation( + const [create, __] = useMutation( graphql(` mutation OptimisticKeyTestCreateMutation($name: String!, $birthDate: DateTime!) { addUser( diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql new file mode 100644 index 0000000000..1581915050 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.gql @@ -0,0 +1,5 @@ +query FragmentCursorBackwardsSinglePageQuery { + user(id: "1", snapshot: "pagination-fragment-cursor-backwards-singlepage") { + ...FragmentCursorBackwardsSinglePageFragment + } +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx new file mode 100644 index 0000000000..f411d613c0 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/+page.tsx @@ -0,0 +1,45 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment FragmentCursorBackwardsSinglePageFragment on User { + usersConnectionSnapshot( + snapshot: "pagination-fragment-cursor-backwards-singlepage" + last: 2 + ) @paginate(mode: SinglePage) { + edges { + node { + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`) + +export default function ({ FragmentCursorBackwardsSinglePageQuery }: PageProps) { + const handle = useFragmentHandle(FragmentCursorBackwardsSinglePageQuery.user, fragment) + + return ( + <> +
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + + + ) +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts new file mode 100644 index 0000000000..01f088896d --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-backwards-singlepage/test.ts @@ -0,0 +1,41 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { + expect_to_be, + expectToContain, + expect_0_gql, + expect_1_gql, + goto, +} from '~/utils/testsHelper.js' + +test.describe('backwards cursor fragment single page paginated query', () => { + test('loadPreviousPage replaces data then loadNextPage navigates forward', async ({ page }) => { + await goto(page, routes.pagination_fragment_cursor_backwards_singlepage) + + await expect_to_be(page, 'Eddie Murphy, Clint Eastwood') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":false`) + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // "Will Smith, Harrison Ford" was fetched on the way back — served from cache. + await expect_0_gql(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // Page 4 was the initial load — cache hit, no network request. + await expect_0_gql(page, 'button[id=next]') + await expect_to_be(page, 'Eddie Murphy, Clint Eastwood') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":false`) + }) +}) diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql new file mode 100644 index 0000000000..5bce57040b --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.gql @@ -0,0 +1,5 @@ +query FragmentCursorBackwardsQuery { + user(id: "1", snapshot: "pagination-fragment-cursor-backwards") { + ...FragmentCursorBackwardsFragment + } +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx new file mode 100644 index 0000000000..106826f0b2 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-backwards/+page.tsx @@ -0,0 +1,38 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment FragmentCursorBackwardsFragment on User { + usersConnectionSnapshot(snapshot: "pagination-fragment-cursor-backwards", last: 2) @paginate { + edges { + node { + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`) + +export default function ({ FragmentCursorBackwardsQuery }: PageProps) { + const handle = useFragmentHandle(FragmentCursorBackwardsQuery.user, fragment) + + return ( + <> +
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + ) +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts b/e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts new file mode 100644 index 0000000000..778a37e201 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-backwards/test.ts @@ -0,0 +1,21 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, expectToContain, expect_1_gql, goto } from '~/utils/testsHelper.js' + +test.describe('backwards cursor fragment paginated query', () => { + test('loadPreviousPage prepends data', async ({ page }) => { + await goto(page, routes.pagination_fragment_cursor_backwards) + + await expect_to_be(page, 'Eddie Murphy, Clint Eastwood') + await expectToContain(page, `"hasPreviousPage":true`) + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Will Smith, Harrison Ford, Eddie Murphy, Clint Eastwood') + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be( + page, + 'Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford, Eddie Murphy, Clint Eastwood' + ) + }) +}) diff --git a/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql new file mode 100644 index 0000000000..2497d4ee17 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.gql @@ -0,0 +1,5 @@ +query FragmentSinglePageQuery { + user(id: "1", snapshot: "pagination-fragment-bidirectional-singlepage") { + ...UserConnectionSinglePageFragment + } +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx new file mode 100644 index 0000000000..765c9e0903 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/+page.tsx @@ -0,0 +1,42 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment UserConnectionSinglePageFragment on User { + usersConnectionSnapshot(snapshot: "pagination-fragment-bidirectional-singlepage", first: 2) @paginate(mode: SinglePage) { + edges { + node { + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`) + +export default function ({ FragmentSinglePageQuery }: PageProps) { + const handle = useFragmentHandle(FragmentSinglePageQuery.user, fragment) + + return ( + <> +
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + + + ) +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts new file mode 100644 index 0000000000..638e0c1e6b --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-bidirectional-singlepage/test.ts @@ -0,0 +1,41 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { + expect_to_be, + expectToContain, + expect_0_gql, + expect_1_gql, + goto, +} from '~/utils/testsHelper.js' + +test.describe('bidirectional cursor fragment single page paginated query', () => { + test('loadNextPage replaces data', async ({ page }) => { + await goto(page, routes.pagination_fragment_bidirectional_cursor_singlepage) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // Page 2 was fetched on the way forward — the cache serves it without a network request. + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // Page 1 was the initial load — cache hit, no network request. + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + }) +}) diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql new file mode 100644 index 0000000000..279a646ead --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.gql @@ -0,0 +1,5 @@ +query FragmentCursorForwardsSinglePageQuery { + user(id: "1", snapshot: "pagination-fragment-cursor-forwards-singlepage") { + ...FragmentCursorForwardsSinglePageFragment + } +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx new file mode 100644 index 0000000000..27d06e3af7 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/+page.tsx @@ -0,0 +1,45 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment FragmentCursorForwardsSinglePageFragment on User { + usersConnectionSnapshot( + snapshot: "pagination-fragment-cursor-forwards-singlepage" + first: 2 + ) @paginate(mode: SinglePage) { + edges { + node { + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`) + +export default function ({ FragmentCursorForwardsSinglePageQuery }: PageProps) { + const handle = useFragmentHandle(FragmentCursorForwardsSinglePageQuery.user, fragment) + + return ( + <> +
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + + + ) +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts new file mode 100644 index 0000000000..d6c27e39d1 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-forwards-singlepage/test.ts @@ -0,0 +1,41 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { + expect_to_be, + expectToContain, + expect_0_gql, + expect_1_gql, + goto, +} from '~/utils/testsHelper.js' + +test.describe('forwards cursor fragment single page paginated query', () => { + test('loadNextPage replaces data', async ({ page }) => { + await goto(page, routes.pagination_fragment_cursor_forwards_singlepage) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // Page 2 was fetched on the way forward — the cache serves it without a network request. + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // Page 1 was the initial load — cache hit, no network request. + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + }) +}) diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql new file mode 100644 index 0000000000..d338bcba71 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.gql @@ -0,0 +1,5 @@ +query FragmentCursorForwardsQuery { + user(id: "1", snapshot: "pagination-fragment-cursor-forwards") { + ...FragmentCursorForwardsFragment + } +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx new file mode 100644 index 0000000000..1664652e61 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-forwards/+page.tsx @@ -0,0 +1,38 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment FragmentCursorForwardsFragment on User { + usersConnectionSnapshot(snapshot: "pagination-fragment-cursor-forwards", first: 2) @paginate { + edges { + node { + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`) + +export default function ({ FragmentCursorForwardsQuery }: PageProps) { + const handle = useFragmentHandle(FragmentCursorForwardsQuery.user, fragment) + + return ( + <> +
+ {handle.data?.usersConnectionSnapshot.edges.map(({ node }) => node?.name).join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + ) +} diff --git a/e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts b/e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts new file mode 100644 index 0000000000..3a57caa246 --- /dev/null +++ b/e2e/react/src/routes/pagination/fragment/connection-forwards/test.ts @@ -0,0 +1,21 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, expectToContain, expect_1_gql, goto } from '~/utils/testsHelper.js' + +test.describe('forwards cursor fragment paginated query', () => { + test('loadNextPage appends data', async ({ page }) => { + await goto(page, routes.pagination_fragment_cursor_forwards) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be( + page, + 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford' + ) + }) +}) diff --git a/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.gql b/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.gql new file mode 100644 index 0000000000..b5951339f7 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.gql @@ -0,0 +1,9 @@ +query BackwardsCursorSinglePagePaginationQuery { + usersConnectionBackwardOnly(last: 2, snapshot: "pagination-query-backwards-cursor-singlepage") @paginate(mode: SinglePage) { + edges { + node { + name + } + } + } +} diff --git a/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.tsx b/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.tsx new file mode 100644 index 0000000000..0f5be35a64 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/+page.tsx @@ -0,0 +1,28 @@ +import type { PageProps } from './$types' + +export default function ({ + BackwardsCursorSinglePagePaginationQuery, + BackwardsCursorSinglePagePaginationQuery$handle, +}: PageProps) { + const handle = BackwardsCursorSinglePagePaginationQuery$handle + + return ( + <> +
+ {BackwardsCursorSinglePagePaginationQuery.usersConnectionBackwardOnly.edges + .map(({ node }) => node?.name) + .join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + + + ) +} diff --git a/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/test.ts b/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/test.ts new file mode 100644 index 0000000000..1cae0e0610 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-backwards-singlepage/test.ts @@ -0,0 +1,34 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, expectToContain, expect_1_gql, locator_click, goto } from '~/utils/testsHelper.js' + +test.describe('backwards-only cursor single page paginated query', () => { + test('loadNextPage via cursor stack (no forwards API support)', async ({ page }) => { + await goto(page, routes.pagination_query_backwards_cursor_singlepage) + + await expect_to_be(page, 'Eddie Murphy, Clint Eastwood') + await expectToContain(page, `"hasNextPage":false`) + await expectToContain(page, `"hasPreviousPage":true`) + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasNextPage":true`) + await expectToContain(page, `"hasPreviousPage":true`) + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasNextPage":true`) + await expectToContain(page, `"hasPreviousPage":true`) + + // Cursor stack re-issues a backward query with the prior cursor + await locator_click(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasNextPage":true`) + await expectToContain(page, `"hasPreviousPage":true`) + + await locator_click(page, 'button[id=next]') + await expect_to_be(page, 'Eddie Murphy, Clint Eastwood') + await expectToContain(page, `"hasNextPage":false`) + await expectToContain(page, `"hasPreviousPage":true`) + }) +}) diff --git a/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.gql b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.gql new file mode 100644 index 0000000000..fb010fea14 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.gql @@ -0,0 +1,14 @@ +query BidirectionalCursorSinglePagePaginationQuery { + user(id: "1", snapshot: "nested-bidirectional-cursor-single-page-user") { + usersConnectionSnapshot( + first: 2 + snapshot: "nested-bidirectional-cursor-single-page" + ) @paginate(mode: SinglePage) { + edges { + node { + name + } + } + } + } +} diff --git a/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.tsx b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.tsx new file mode 100644 index 0000000000..388888322e --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/+page.tsx @@ -0,0 +1,34 @@ +import { CachePolicy } from '$houdini' + +import type { PageProps } from './$types' + +export default function ({ + BidirectionalCursorSinglePagePaginationQuery, + BidirectionalCursorSinglePagePaginationQuery$handle, +}: PageProps) { + const handle = BidirectionalCursorSinglePagePaginationQuery$handle + + return ( + <> +
+ {BidirectionalCursorSinglePagePaginationQuery.user?.usersConnectionSnapshot.edges + .map(({ node }) => node?.name) + .join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + + + + + ) +} diff --git a/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts new file mode 100644 index 0000000000..2f8fd451e7 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-bidirectional-singlepage/test.ts @@ -0,0 +1,45 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, expectToContain, expect_0_gql, expect_1_gql, goto } from '~/utils/testsHelper.js' + +test.describe('bidirectional cursor single page paginated query', () => { + test('forwards three times then backwards three times', async ({ page }) => { + await goto(page, routes.pagination_query_bidirectional_cursor_singlepage) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Eddie Murphy, Clint Eastwood') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":false`) + + // Pages 3 and 2 were fetched going forward — the cache serves them without a network + // request. Page 1 came from the initial query (different cache key), so it needs one. + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_0_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=previous]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + }) +}) diff --git a/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.gql b/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.gql new file mode 100644 index 0000000000..b00082cce5 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.gql @@ -0,0 +1,9 @@ +query ForwardsCursorSinglePagePaginationQuery { + usersConnectionForwardOnly(first: 2, snapshot: "pagination-query-forwards-cursor-singlepage") @paginate(mode: SinglePage) { + edges { + node { + name + } + } + } +} diff --git a/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.tsx b/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.tsx new file mode 100644 index 0000000000..4825bc1cff --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/+page.tsx @@ -0,0 +1,28 @@ +import type { PageProps } from './$types' + +export default function ({ + ForwardsCursorSinglePagePaginationQuery, + ForwardsCursorSinglePagePaginationQuery$handle, +}: PageProps) { + const handle = ForwardsCursorSinglePagePaginationQuery$handle + + return ( + <> +
+ {ForwardsCursorSinglePagePaginationQuery.usersConnectionForwardOnly.edges + .map(({ node }) => node?.name) + .join(', ')} +
+ +
{JSON.stringify(handle.pageInfo)}
+ + + + + + ) +} diff --git a/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/test.ts b/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/test.ts new file mode 100644 index 0000000000..0adcee474c --- /dev/null +++ b/e2e/react/src/routes/pagination/query/connection-forwards-singlepage/test.ts @@ -0,0 +1,34 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, expectToContain, expect_1_gql, locator_click, goto } from '~/utils/testsHelper.js' + +test.describe('forwards-only cursor single page paginated query', () => { + test('loadPreviousPage via cursor stack (no backwards API support)', async ({ page }) => { + await goto(page, routes.pagination_query_forwards_cursor_singlepage) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Will Smith, Harrison Ford') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + // Cursor stack re-issues a forward query with the prior cursor + await locator_click(page, 'button[id=previous]') + await expect_to_be(page, 'Morgan Freeman, Tom Hanks') + await expectToContain(page, `"hasPreviousPage":true`) + await expectToContain(page, `"hasNextPage":true`) + + await locator_click(page, 'button[id=previous]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + await expectToContain(page, `"hasPreviousPage":false`) + await expectToContain(page, `"hasNextPage":true`) + }) +}) diff --git a/e2e/react/src/routes/route_params/+layout.tsx b/e2e/react/src/routes/route_params/+layout.tsx index d562c39095..0950bf092b 100644 --- a/e2e/react/src/routes/route_params/+layout.tsx +++ b/e2e/react/src/routes/route_params/+layout.tsx @@ -1,15 +1,17 @@ +import { Link } from '$houdini' + import { LayoutProps } from './$types' export default ({ children }: LayoutProps) => { return (
diff --git a/e2e/react/src/routes/routing-errors/+error.tsx b/e2e/react/src/routes/routing-errors/+error.tsx new file mode 100644 index 0000000000..79ead79271 --- /dev/null +++ b/e2e/react/src/routes/routing-errors/+error.tsx @@ -0,0 +1,8 @@ +import { isRoutingError } from '$houdini' +import type { ErrorProps } from './$types' + +export default function RoutingErrorBoundary({ errors }: ErrorProps) { + const routing = errors.find(isRoutingError) + if (routing) return
routing-error: {routing.status}
+ return
{errors[0]?.message}
+} diff --git a/e2e/react/src/routes/routing-errors/+page.tsx b/e2e/react/src/routes/routing-errors/+page.tsx new file mode 100644 index 0000000000..900f79c1b5 --- /dev/null +++ b/e2e/react/src/routes/routing-errors/+page.tsx @@ -0,0 +1,3 @@ +export default function RoutingErrorsIndex() { + return
routing errors index
+} diff --git a/e2e/react/src/routes/routing-errors/not-found/+page.tsx b/e2e/react/src/routes/routing-errors/not-found/+page.tsx new file mode 100644 index 0000000000..5ac4f5f77f --- /dev/null +++ b/e2e/react/src/routes/routing-errors/not-found/+page.tsx @@ -0,0 +1,5 @@ +import { notFound } from '$houdini' + +export default function NotFoundPage() { + return notFound() +} diff --git a/e2e/react/src/routes/routing-errors/not-found/test.ts b/e2e/react/src/routes/routing-errors/not-found/test.ts new file mode 100644 index 0000000000..ece2783d42 --- /dev/null +++ b/e2e/react/src/routes/routing-errors/not-found/test.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto, locator_click } from '~/utils/testsHelper.js' + +test('notFound() triggers the error boundary with 404 status', async ({ page }) => { + await goto(page, routes.routing_errors) + await locator_click(page, 'a[href="/routing-errors/not-found"]') + await expect(page.locator('#error-message')).toHaveText('routing-error: 404') +}) + +test('unmatched URL shows 404 via static prefix matching', async ({ page }) => { + await goto(page, routes.routing_errors_static_404) + await expect(page.locator('#error-message')).toHaveText('routing-error: 404') +}) diff --git a/e2e/react/src/routes/routing-errors/redirect-target/+page.tsx b/e2e/react/src/routes/routing-errors/redirect-target/+page.tsx new file mode 100644 index 0000000000..00f2bbb2e8 --- /dev/null +++ b/e2e/react/src/routes/routing-errors/redirect-target/+page.tsx @@ -0,0 +1,3 @@ +export default function RedirectTarget() { + return
redirect target reached
+} diff --git a/e2e/react/src/routes/routing-errors/redirect/+page.tsx b/e2e/react/src/routes/routing-errors/redirect/+page.tsx new file mode 100644 index 0000000000..d7c60ebd79 --- /dev/null +++ b/e2e/react/src/routes/routing-errors/redirect/+page.tsx @@ -0,0 +1,5 @@ +import { redirect } from '$houdini' + +export default function RedirectPage() { + return redirect(302, '/routing-errors/redirect-target') +} diff --git a/e2e/react/src/routes/routing-errors/redirect/test.ts b/e2e/react/src/routes/routing-errors/redirect/test.ts new file mode 100644 index 0000000000..c84dabe16a --- /dev/null +++ b/e2e/react/src/routes/routing-errors/redirect/test.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto, locator_click } from '~/utils/testsHelper.js' + +test('redirect() navigates to the target URL', async ({ page }) => { + await goto(page, routes.routing_errors) + await locator_click(page, 'a[href="/routing-errors/redirect"]') + await expect(page).toHaveURL(routes.routing_errors_redirect_target) + await expect(page.locator('#result')).toHaveText('redirect target reached') +}) diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts index 6c1282bddb..cc0446499a 100644 --- a/e2e/react/src/utils/routes.ts +++ b/e2e/react/src/utils/routes.ts @@ -14,7 +14,24 @@ export const routes = { pagination_query_offset: '/pagination/query/offset', pagination_dedupe: '/pagination/query/dedupe', pagination_query_offset_singlepage: '/pagination/query/offset-singlepage', + pagination_query_bidirectional_cursor_singlepage: '/pagination/query/connection-bidirectional-singlepage', + pagination_query_forwards_cursor_singlepage: '/pagination/query/connection-forwards-singlepage', + pagination_query_backwards_cursor_singlepage: '/pagination/query/connection-backwards-singlepage', + pagination_fragment_bidirectional_cursor_singlepage: '/pagination/fragment/connection-bidirectional-singlepage', + pagination_fragment_cursor_forwards: '/pagination/fragment/connection-forwards', + pagination_fragment_cursor_backwards: '/pagination/fragment/connection-backwards', + pagination_fragment_cursor_forwards_singlepage: '/pagination/fragment/connection-forwards-singlepage', + pagination_fragment_cursor_backwards_singlepage: '/pagination/fragment/connection-backwards-singlepage', pagination_query_offset_variable: '/pagination/query/offset-variable/2', optimistic_keys: '/optimistic-keys', node_plugin: '/node-plugin', + list_id: '/list-id', + list_operations_upsert: '/list-operations/upsert', + list_operations_update: '/list-operations/update', + routing_errors: '/routing-errors', + routing_errors_not_found: '/routing-errors/not-found', + routing_errors_redirect: '/routing-errors/redirect', + routing_errors_redirect_target: '/routing-errors/redirect-target', + routing_errors_static_404: '/routing-errors/does-not-exist', + error_loop: '/error-loop/doesnt-exist', } as const diff --git a/e2e/react/tsconfig.json b/e2e/react/tsconfig.json index 09acc8ec34..96afda5c35 100644 --- a/e2e/react/tsconfig.json +++ b/e2e/react/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "./.houdini/tsconfig.json" + "extends": "./.houdini/tsconfig.json", + "compilerOptions": { + "ignoreDeprecations": "6.0" + } } diff --git a/go.mod b/go.mod index 496c415e3f..c2dc664f63 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/vektah/gqlparser/v2 v2.5.22 golang.org/x/sync v0.20.0 - zombiezen.com/go/sqlite v1.4.0 + zombiezen.com/go/sqlite v1.4.2 ) require ( @@ -24,11 +24,12 @@ require ( github.com/ncruces/julianday v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.33.1 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect ) diff --git a/go.sum b/go.sum index 9a4946b651..d49c50b32c 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vektah/gqlparser/v2 v2.5.22 h1:yaaeJ0fu+nv1vUMW0Hl+aS1eiv1vMfapBNjpffAda1I= github.com/vektah/gqlparser/v2 v2.5.22/go.mod h1:xMl+ta8a5M1Yo1A1Iwt/k7gSpscwSnHZdw7tfhEGfTM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -57,27 +59,44 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= +zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= +zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= diff --git a/package.json b/package.json index a2f1467e44..20c6d1575a 100755 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "tests": "vitest", "tests:ui": "vitest --ui --coverage", "test": "pnpm run tests", + "bench": "vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson perf/benchmark.json", + "bench:check": "vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson perf/benchmark.current.json && node perf/compare.js", + "bench:quick": "BENCH_QUICK=1 vitest bench packages/houdini/src/runtime/cache/benchmarks/ --outputJson perf/benchmark.quick.json", + "watch-bench": "sh perf/watch-bench.sh", "build:all": "turbo build", "build": "turbo run build --filter=\"./packages/*\"", "dev": "turbo dev --filter=\"./packages/*\"", diff --git a/packages/_scripts/buildNode.js b/packages/_scripts/buildNode.js index 48715a4c02..a8c9963d2f 100644 --- a/packages/_scripts/buildNode.js +++ b/packages/_scripts/buildNode.js @@ -280,11 +280,20 @@ 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 and .tsx files in the runtime directory, excluding test files - const files = await glob(path.join(source, '**/*.ts*').replaceAll('\\', '/'), { - nodir: true, - ignore: ['**/*.test.*', '**/test.ts'], - }) + // find all .ts, .tsx, 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([ + glob(path.join(source, '**/*.ts*').replaceAll('\\', '/'), { + nodir: true, + ignore: ['**/*.test.*', '**/test.ts'], + }), + glob(path.join(source, '**/*.json').replaceAll('\\', '/'), { + nodir: true, + ignore: ['**/package.json'], + }), + ]) + ).flat() // where we will put everything const target_dir = path.join(outDir, path.basename(source)) diff --git a/packages/adapter-auto/CHANGELOG.md b/packages/adapter-auto/CHANGELOG.md index 2df260f779..bae36511b6 100644 --- a/packages/adapter-auto/CHANGELOG.md +++ b/packages/adapter-auto/CHANGELOG.md @@ -1,5 +1,12 @@ # houdini-adapter-auto +## 2.0.0-next.31 + +### Patch Changes + +- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: + - houdini@2.0.0-next.34 + ## 2.0.0-next.30 ### Patch Changes diff --git a/packages/adapter-auto/package.json b/packages/adapter-auto/package.json index dd6bf82ac4..f03cca36c0 100644 --- a/packages/adapter-auto/package.json +++ b/packages/adapter-auto/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-auto", - "version": "2.0.0-next.30", + "version": "2.0.0-next.31", "description": "An adapter for deploying your Houdini application according to the build environment ", "keywords": [ "houdini", diff --git a/packages/adapter-cloudflare/CHANGELOG.md b/packages/adapter-cloudflare/CHANGELOG.md index 3ad89aa5fa..a973a51d73 100644 --- a/packages/adapter-cloudflare/CHANGELOG.md +++ b/packages/adapter-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # houdini-adapter-cloudflare +## 2.0.0-next.30 + +### Patch Changes + +- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: + - houdini@2.0.0-next.34 + ## 2.0.0-next.29 ### Patch Changes diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index e18713cac9..f48b8b96fd 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-cloudflare", - "version": "2.0.0-next.29", + "version": "2.0.0-next.30", "description": "The adapter for deploying your Houdini application to Cloudflare Pages", "keywords": [ "houdini", diff --git a/packages/adapter-node/CHANGELOG.md b/packages/adapter-node/CHANGELOG.md index 47f37da2e6..90f5e80668 100644 --- a/packages/adapter-node/CHANGELOG.md +++ b/packages/adapter-node/CHANGELOG.md @@ -1,5 +1,12 @@ # houdini-adapter-node +## 2.0.0-next.30 + +### Patch Changes + +- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: + - houdini@2.0.0-next.34 + ## 2.0.0-next.29 ### Patch Changes diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 95d892a7b6..92ad7216ee 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-node", - "version": "2.0.0-next.29", + "version": "2.0.0-next.30", "description": "The adapter for deploying your Houdini application as a standalone node server", "keywords": [ "houdini", diff --git a/packages/adapter-static/CHANGELOG.md b/packages/adapter-static/CHANGELOG.md index 3ea164adae..bca75ea25c 100644 --- a/packages/adapter-static/CHANGELOG.md +++ b/packages/adapter-static/CHANGELOG.md @@ -1,5 +1,12 @@ # houdini-adapter-static +## 2.0.0-next.31 + +### Patch Changes + +- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: + - houdini@2.0.0-next.34 + ## 2.0.0-next.30 ### Patch Changes diff --git a/packages/adapter-static/package.json b/packages/adapter-static/package.json index df7ed6f1ca..192be68bf7 100644 --- a/packages/adapter-static/package.json +++ b/packages/adapter-static/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-static", - "version": "2.0.0-next.30", + "version": "2.0.0-next.31", "description": "The adapter for deploying your Houdini application as a single-page application without a server component", "keywords": [ "houdini", diff --git a/packages/houdini-core/CHANGELOG.md b/packages/houdini-core/CHANGELOG.md index 5bfa39a4e3..acd575fc29 100644 --- a/packages/houdini-core/CHANGELOG.md +++ b/packages/houdini-core/CHANGELOG.md @@ -1,5 +1,33 @@ # houdini-core +## 2.0.0-next.22 + +### Minor Changes + +- [#1646](https://github.com/HoudiniGraphql/houdini/pull/1646) [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add `record.refresh()` to refetch every document that contains a given cache record, including those that reference it only through a fragment spread. + +### Patch Changes + +- [#1649](https://github.com/HoudiniGraphql/houdini/pull/1649) [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix list filters and @when conditions that contain object values or variable references nested inside objects + +- [#1644](https://github.com/HoudiniGraphql/houdini/pull/1644) [`f40e510`](https://github.com/HoudiniGraphql/houdini/commit/f40e510e0e67cd4ecc444f01662e3163fe45e736) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add support for @includeListID directive + +- [#1648](https://github.com/HoudiniGraphql/houdini/pull/1648) [`5f3fd63`](https://github.com/HoudiniGraphql/houdini/commit/5f3fd635199681ef36ecb90a16df2e109a354c22) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rework argument type validation to follow the GraphQL spec, fixing coercions, `@with` checks, and unknown type/enum reporting ([#1645](https://github.com/HoudiniGraphql/houdini/issues/1645)). + +## 2.0.0-next.21 + +### Patch Changes + +- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - strip sibling fields from generated pagination query documents so only the paginated field is included + +## 2.0.0-next.20 + +### Patch Changes + +- [#1639](https://github.com/HoudiniGraphql/houdini/pull/1639) [`b3798cd`](https://github.com/HoudiniGraphql/houdini/commit/b3798cde406da0f4160ee64e6026817162e61959) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix cursor pagination: @paginate path now wins over @list, listPaginated and direction are correctly computed for bidirectional cursor fields + +- [#1639](https://github.com/HoudiniGraphql/houdini/pull/1639) [`b3798cd`](https://github.com/HoudiniGraphql/houdini/commit/b3798cde406da0f4160ee64e6026817162e61959) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - encode per-field pagination direction in pageInfo updates arrays; runtime now drives cache behavior from the artifact instead of hardcoded field names + ## 2.0.0-next.19 ### Patch Changes diff --git a/packages/houdini-core/package.json b/packages/houdini-core/package.json index 3fe251d0da..d98ea6b920 100644 --- a/packages/houdini-core/package.json +++ b/packages/houdini-core/package.json @@ -1,6 +1,6 @@ { "name": "houdini-core", - "version": "2.0.0-next.19", + "version": "2.0.0-next.22", "description": "The core GraphQL client for Houdini", "keywords": [ "graphql", diff --git a/packages/houdini-core/plugin/documents/artifacts/merge.go b/packages/houdini-core/plugin/documents/artifacts/merge.go index 70afbe20dc..2c02d72754 100644 --- a/packages/houdini-core/plugin/documents/artifacts/merge.go +++ b/packages/houdini-core/plugin/documents/artifacts/merge.go @@ -63,6 +63,9 @@ type fieldCollectionField struct { Field *collected.Selection Selection *fieldCollection Directives []*collected.Directive + // true once the field has been requested through at least one path that isn't + // guarded by a conditional directive inherited from a fragment spread + Unconditional bool } type fieldCollection struct { @@ -118,6 +121,16 @@ func (c *fieldCollection) Add( } } + // figure out if this occurrence of the field is guarded by a conditional + // directive inherited from a fragment spread + unconditional := true + for _, dir := range selection.Directives { + if isPropagatedConditional(dir) { + unconditional = false + break + } + } + // if we've seen the field before then we need to make sure some metadata // overlaps correctly if sel, ok := c.Fields[*selection.Alias]; ok { @@ -125,12 +138,23 @@ func (c *fieldCollection) Add( sel.Visible = true } + // an occurrence that isn't guarded by a spread conditional means the field + // is always requested so drop any conditionals inherited from other spreads + if unconditional && !sel.Unconditional { + sel.Unconditional = true + sel.Directives = withoutPropagatedConditionals(sel.Directives) + sel.Field.Directives = withoutPropagatedConditionals(sel.Field.Directives) + } + // only append directives we haven't seen yet // but skip @required directive when merging from external fragments that are masked // to prevent it from affecting parent query for _, dir := range selection.Directives { shouldSkipRequired := external && dir.Name == graphql.RequiredDirective && hidden - if !containsDirective(sel.Field.Directives, dir) && !shouldSkipRequired { + // an inherited conditional can't override a field that is requested unconditionally + shouldSkipConditional := sel.Unconditional && isPropagatedConditional(dir) + if !containsDirective(sel.Field.Directives, dir) && !shouldSkipRequired && + !shouldSkipConditional { sel.Directives = append(sel.Field.Directives, dir) } } @@ -155,7 +179,8 @@ func (c *fieldCollection) Add( selection.FieldType, c.SortKeys, ), - Visible: !hidden, + Visible: !hidden, + Unconditional: unconditional, } } @@ -209,12 +234,48 @@ func (c *fieldCollection) Add( return plugins.WrapError(errors.New("fragment not found")) } + // figure out if the fragment's fields are masked in this document. explicit + // mask directives on the spread win, otherwise we fall back to the project's + // defaultFragmentMasking setting + childHidden := c.DefaultMask + explicitlyUnmasked := false + for _, directive := range selection.Directives { + if directive.Name == graphql.DisableMaskDirective { + childHidden = false + explicitlyUnmasked = true + break + } + if directive.Name == graphql.EnableMaskDirective { + childHidden = true + break + } + } + // a spread that is itself in a hidden context (eg inside a masked fragment) + // keeps its fields hidden no matter what, unless the spread explicitly opts out + // of masking via @mask_disable (e.g. generated pagination queries for fragments). + if (external || selection.Internal) && !explicitlyUnmasked { + childHidden = true + } + + // if the spread is guarded by @include or @skip then the fields it inlines are + // conditional too. clone the fragment's selections and push the condition onto + // them so the runtime doesn't treat a missing value as a broken non-null field + fragmentSelections := definition.Selections + if conditionals := spreadConditionals(selection.Directives); len(conditionals) > 0 { + fragmentSelections = make([]*collected.Selection, len(definition.Selections)) + for i, sel := range definition.Selections { + clone := sel.Clone(true) + attachConditionals(clone, conditionals) + fragmentSelections[i] = clone + } + } + _, abstractParent := c.CollectedDocuments.PossibleTypes[c.ParentType] // if the selections parent type is the same as the fragment type condition then // we should just add every field directly if definition.TypeCondition == c.ParentType || !abstractParent { - for _, sel := range definition.Selections { - err := c.Add(sel, true, visibilityMask) + for _, sel := range fragmentSelections { + err := c.Add(sel, childHidden, visibilityMask) if err != nil { return err } @@ -229,11 +290,11 @@ func (c *fieldCollection) Add( Kind: "inline_fragment", FieldName: definition.TypeCondition, FieldType: definition.TypeCondition, - Children: definition.Selections, + Children: fragmentSelections, Visible: !hidden, } - return c.Add(inlineFragment, true, visibilityMask) + return c.Add(inlineFragment, childHidden, visibilityMask) } // its a field we don't recognize, we're done @@ -420,9 +481,9 @@ func (c *fieldCollection) ToSelectionSet() []*collected.Selection { if f.Selection != nil { field.Children = f.Selection.ToSelectionSet() } - if f.Visible { - field.Visible = true - } + // the visibility computed while flattening is authoritative — the flag on + // the source selection only says whether a user wrote the field somewhere + field.Visible = f.Visible result = append(result, field) } @@ -433,7 +494,7 @@ func (c *fieldCollection) ToSelectionSet() []*collected.Selection { } for _, f := range c.FragmentSpreads { - field := f.Field + field := f.Field.Clone(false) if f.Visible { field.Visible = true } @@ -453,9 +514,9 @@ func (c *fieldCollection) ToSelectionSet() []*collected.Selection { if field.Selection != nil { selectionField.Children = field.Selection.ToSelectionSet() } - if field.Visible { - selectionField.Visible = true - } + // the visibility computed while flattening is authoritative — the flag on + // the source selection only says whether a user wrote the field somewhere + selectionField.Visible = field.Visible result = append(result, selectionField) } @@ -516,3 +577,70 @@ func containsDirective(list []*collected.Directive, dir *collected.Directive) bo } return false } + +// spreadConditionals extracts the @include and @skip directives applied to a +// fragment spread. the copies are marked internal so that merging can tell them +// apart from conditionals the user wrote on a field directly +func spreadConditionals(directives []*collected.Directive) []*collected.Directive { + result := []*collected.Directive{} + for _, directive := range directives { + if directive.Name == graphql.IncludeDirective || directive.Name == graphql.SkipDirective { + clone := *directive + clone.Internal = 1 + result = append(result, &clone) + } + } + return result +} + +// isPropagatedConditional returns true if the directive is an @include or @skip +// that was inherited from a fragment spread rather than written on the field +func isPropagatedConditional(directive *collected.Directive) bool { + return directive.Internal == 1 && + (directive.Name == graphql.IncludeDirective || directive.Name == graphql.SkipDirective) +} + +// withoutPropagatedConditionals filters out conditionals inherited from fragment +// spreads. the original slice is left untouched since it might be shared with the +// collected document +func withoutPropagatedConditionals( + directives []*collected.Directive, +) []*collected.Directive { + propagated := false + for _, dir := range directives { + if isPropagatedConditional(dir) { + propagated = true + break + } + } + if !propagated { + return directives + } + + result := make([]*collected.Directive, 0, len(directives)) + for _, dir := range directives { + if !isPropagatedConditional(dir) { + result = append(result, dir) + } + } + return result +} + +// attachConditionals records the conditional directives from a fragment spread on +// every selection it inlines so the runtime knows the fields might be missing from +// the response. fields and nested spreads carry the condition directly while inline +// fragments pass it through to their children +func attachConditionals(selection *collected.Selection, conditionals []*collected.Directive) { + switch selection.Kind { + case "field", "fragment": + for _, directive := range conditionals { + if !containsDirective(selection.Directives, directive) { + selection.Directives = append(selection.Directives, directive) + } + } + case "inline_fragment": + for _, child := range selection.Children { + attachConditionals(child, conditionals) + } + } +} diff --git a/packages/houdini-core/plugin/documents/artifacts/print.go b/packages/houdini-core/plugin/documents/artifacts/print.go index 846f6e91df..824f78c236 100644 --- a/packages/houdini-core/plugin/documents/artifacts/print.go +++ b/packages/houdini-core/plugin/documents/artifacts/print.go @@ -289,6 +289,11 @@ func printSelection( var resultBuilder strings.Builder for _, selection := range selections { + // __id is a synthetic field resolved by the cache; never send it to the server + if selection.Kind == "field" && selection.FieldName == "__id" { + continue + } + // before we print children and directives we need // to handle the specific selection type switch selection.Kind { diff --git a/packages/houdini-core/plugin/documents/artifacts/selection.go b/packages/houdini-core/plugin/documents/artifacts/selection.go index c5d0f5fba9..e6be098e04 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection.go @@ -58,7 +58,7 @@ func writeSelectionDocument( } // write the file to disk - err = afero.WriteFile(fs, artifactPath, []byte(artifact), 0644) + err = plugins.WriteFile(fs, artifactPath, []byte(artifact), 0644) if err != nil { return "", err } @@ -877,17 +877,23 @@ func stringifySelection( %s}`, resultBuilder.String(), indent) } -func keyField(field *collected.Selection, paginatedMode *string) string { +func keyField(field *collected.Selection, paginatedMode *string, _ string) string { + // Pagination args are stripped and ::paginated suffix applied only for Infinite mode, + // where all pages accumulate under one stable key. + // SinglePage pagination (both query and fragment) uses distinct per-cursor cache keys + // so the cache can serve back-navigation without a network request. + useStableKey := paginatedMode != nil && *paginatedMode == graphql.PaginationModeInfinite + isSinglePage := paginatedMode != nil && *paginatedMode == graphql.PaginationModeSinglePage + if len(field.Arguments) == 0 { paginationSuffix := "" - if paginatedMode != nil { + if useStableKey { paginationSuffix = "::paginated" } return `"` + *field.Alias + paginationSuffix + `"` } - // if we are generating the key for a paginated field then we need to strip away - // the pagination arguments + // strip pagination args when using a stable key args := []*collected.Argument{} for _, arg := range field.Arguments { paginationArgs := map[string]bool{ @@ -898,18 +904,52 @@ func keyField(field *collected.Selection, paginatedMode *string) string { "limit": true, "offset": true, } - if _, ok := paginationArgs[arg.Name]; ok && paginatedMode != nil && - *paginatedMode == graphql.PaginationModeInfinite { + if _, ok := paginationArgs[arg.Name]; ok && useStableKey { continue } - // if we got this far then we can add the arg a := *arg args = append(args, &a) } + // For SinglePage, the initial parent query and the pagination query must share the same + // cache key for the first page so backward navigation finds the cached data. + // The pagination query always includes all four cursor args; expand the parent query's + // key to match by adding any missing cursor args as null in canonical order + // (first, after, last, before) followed by the remaining user-defined args. + if isSinglePage { + cursorNames := []string{"first", "after", "last", "before"} + existing := map[string]*collected.Argument{} + for _, arg := range args { + existing[arg.Name] = arg + } + + var ordered []*collected.Argument + for _, name := range cursorNames { + if arg, ok := existing[name]; ok { + ordered = append(ordered, arg) + } else { + // missing cursor arg — add it as null so the key matches the pagination query + ordered = append(ordered, &collected.Argument{Name: name, Value: nil}) + } + } + for _, arg := range args { + isCursor := false + for _, name := range cursorNames { + if arg.Name == name { + isCursor = true + break + } + } + if !isCursor { + ordered = append(ordered, arg) + } + } + args = ordered + } + paginationSuffix := "" - if paginatedMode != nil { + if useStableKey { paginationSuffix = "::paginated" } @@ -941,16 +981,21 @@ func stringifyFieldSelection( // figure out the pagination state var paginatedMode *string + paginatedTargetType := "Query" if selection.List != nil { if selection.List.Paginated { paginatedMode = &selection.List.Mode + paginatedTargetType = selection.List.TargetType } updates = []string{} - if selection.List.SupportsForward { - updates = append(updates, "append") - } - if selection.List.SupportsBackward { - updates = append(updates, "prepend") + // SinglePage pagination uses replace semantics — never accumulate edges + if !selection.List.Paginated || selection.List.Mode != graphql.PaginationModeSinglePage { + if selection.List.SupportsForward { + updates = append(updates, "append") + } + if selection.List.SupportsBackward { + updates = append(updates, "prepend") + } } // @paginate always wins over @list when both appear in the same document @@ -1004,9 +1049,13 @@ func stringifyFieldSelection( hasRequiredDirective := false hasLoading := forceLoading loadingCount := 3 + includeListID := false for _, directive := range selection.Directives { switch directive.Name { + case graphql.IncludeListIDDirective: + includeListID = true + continue case graphql.OptimisticKeyDirective: optimisticKey = fmt.Sprintf(` %s"optimisticKey": true,`, indent4) @@ -1208,12 +1257,17 @@ func stringifyFieldSelection( list := "" filters := "" if selection.List != nil && selection.List.Name != "" { + includeListIDStr := "" + if includeListID { + includeListIDStr = fmt.Sprintf(`, +%s"includeListID": true`, indent5) + } // we need to record the list specification list = fmt.Sprintf(` %s"list": { %s"name": "%s", %s"connection": %v, -%s"type": "%s" +%s"type": "%s"%s %s},`, indent4, indent5, @@ -1222,20 +1276,14 @@ func stringifyFieldSelection( selection.List.Connection, indent5, selection.List.Type, + includeListIDStr, indent4, ) // we also need to record which filters are currently being applied to the list field for _, arg := range selection.Arguments { - value := stringifyValue(arg.Value, map[string]bool{}) - if arg.Value.Kind == "Variable" { - value = `"` + arg.Value.Raw + `"` - } filters += fmt.Sprintf(` -%s"%s": { -%s"kind": "%s", -%s"value": %s -%s},`, indent5, arg.Name, indent6, arg.Value.Kind, indent6, value, indent5) +%s"%s": %s,`, indent5, arg.Name, serializeListFilter(arg.Value, level+4)) } if filters != "" { @@ -1278,7 +1326,7 @@ func stringifyFieldSelection( indent4, selection.FieldType, indent4, - keyField(selection, paginatedMode), + keyField(selection, paginatedMode, paginatedTargetType), updateStr, nullable, directives, @@ -1407,11 +1455,17 @@ func stringifyOperations( %s"parentID": %s`, indent5, operation.ParentID) } + listID := "" + if operation.ListID != "" { + listID = fmt.Sprintf(`, + +%s"listID": %s`, indent5, operation.ListID) + } fmt.Fprintf(&operationStringBuilder, `{ -%s"action": "%s"%s%s%s%s%s%s +%s"action": "%s"%s%s%s%s%s%s%s %s}, -`, indent5, operation.Action, list, typ, position, target, when, parentID, indent4) +`, indent5, operation.Action, list, typ, position, target, when, parentID, listID, indent4) } if operationStringBuilder.Len() > 0 { @@ -1436,6 +1490,8 @@ func extractOperation( when := "" // along with a parentID specification parentID := "" + // along with a listID specification (raw cache key, bypasses id(type,value) lookup) + listID := "" for _, directive := range selection.Directives { switch directive.Name { // if we encounter a when directive @@ -1448,7 +1504,7 @@ func extractOperation( %s"%s": %s,`, indent2, arg.Name, - stringifyValue(arg.Value, map[string]bool{}), + serializeListFilter(arg.Value, level+1), ) } when += fmt.Sprintf(` @@ -1465,7 +1521,7 @@ func extractOperation( %s"%s": %s,`, indent2, arg.Name, - stringifyValue(arg.Value, map[string]bool{}), + serializeListFilter(arg.Value, level+1), ) } when += fmt.Sprintf(` @@ -1475,6 +1531,9 @@ func extractOperation( // parentID directive case graphql.ParentIDDirective: parentID = serializeFragmentArgument(directive.Arguments[0].Value, level-1) + // listID directive — raw cache key, bypasses the id(type, value) lookup + case graphql.ListIDDirective: + listID = serializeFragmentArgument(directive.Arguments[0].Value, level-1) } } @@ -1492,6 +1551,7 @@ func extractOperation( Action: "delete", When: when, ParentID: parentID, + ListID: listID, } } } @@ -1522,6 +1582,9 @@ func extractOperation( case strings.Contains(selection.FieldName, graphql.ListOperationSuffixToggle): listName = stripSuffix(selection.FieldName, graphql.ListOperationSuffixToggle) action = "toggle" + case strings.Contains(selection.FieldName, graphql.ListOperationSuffixUpsert): + listName = stripSuffix(selection.FieldName, graphql.ListOperationSuffixUpsert) + action = "upsert" default: // the fragment doesn't end in one of the magic prefixes @@ -1552,6 +1615,7 @@ func extractOperation( Target: target, When: when, ParentID: parentID, + ListID: listID, } } @@ -1566,6 +1630,7 @@ type CollectedOperation struct { Type string When string ParentID string + ListID string } func stripSuffix(s string, suffix string) string { @@ -1575,6 +1640,52 @@ func stripSuffix(s string, suffix string) string { return s } +// serializeListFilter generates the {kind, value} entry for an argument applied +// to a @list field. object and list values nest their children as filter entries +// so that variable references stay structured instead of being printed as raw +// graphql text (which isn't valid javascript) +func serializeListFilter(value *collected.ArgumentValue, level int) string { + indent0 := strings.Repeat(spacing, level) + indent1 := strings.Repeat(spacing, level+1) + + serialized := "" + switch value.Kind { + case "Variable": + serialized = fmt.Sprintf("%q", value.Raw) + case "Object": + indent2 := strings.Repeat(spacing, level+2) + fields := "" + for i, child := range value.Children { + if i > 0 { + fields += "," + } + fields += fmt.Sprintf( + "\n%s%q: %s", + indent2, + child.Name, + serializeListFilter(child.Value, level+2), + ) + } + serialized = fmt.Sprintf("{%s\n%s}", fields, indent1) + case "List": + values := "" + for i, child := range value.Children { + if i > 0 { + values += ", " + } + values += serializeListFilter(child.Value, level+1) + } + serialized = "[" + values + "]" + default: + serialized = stringifyValue(value, map[string]bool{}) + } + + return fmt.Sprintf(`{ +%s"kind": "%s", +%s"value": %s +%s}`, indent1, value.Kind, indent1, serialized, indent0) +} + func serializeFragmentArgument(arg *collected.ArgumentValue, level int) string { indent0 := strings.Repeat(spacing, level) indent1 := strings.Repeat(spacing, level+1) diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go new file mode 100644 index 0000000000..1ac4cc2684 --- /dev/null +++ b/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go @@ -0,0 +1,435 @@ +package artifacts_test + +import ( + "testing" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-core/plugin" + "code.houdinigraphql.com/plugins/tests" +) + +// when a fragment spread is tagged with @include or @skip, the fields it inlines +// into the parent selection are conditional: the server might not return them. +// the condition has to be propagated onto those fields in the artifact so the +// runtime doesn't treat a missing non-null value as a reason to cascade null up +// the selection (https://github.com/HoudiniGraphql/houdini/issues/1550) +func TestConditionalFragmentSpread(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: ` + type Query { + user(id: ID): User! + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + firstName: String! + } + `, + PerformTest: performArtifactTest, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "include on a spread propagates to inlined fields", + Pass: true, + Input: []string{ + `query TestQuery { + user(id: "1") { + id + ...UserDetails @mask_disable @include(if: false) + } + }`, + `fragment UserDetails on User { + firstName + }`, + }, + Extra: map[string]any{ + "TestQuery": tests.Dedent(`const artifact = { + "name": "TestQuery", + "kind": "HoudiniQuery", + "hash": "c1028fc6e5213fd703e4e3f2138294735428be9437e68eab4d9854ba91f14c0c", + "raw": ` + "`" + `query TestQuery { + user(id: "1") { + id + ...UserDetails @include(if: false) + __typename + } +} + +fragment UserDetails on User { + firstName + __typename + id +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "user": { + "type": "User", + "keyRaw": "user(id: \"1\")", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "firstName": { + "type": "String", + "keyRaw": "firstName", + + "directives": [{ + "name": "include", + "arguments": { + "if": { + "kind": "BooleanValue", + "value": false + } + } + }], + + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + + "fragments": { + "UserDetails": { + "arguments": {} + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type TestQuery = { + readonly "input"?: TestQuery$input; + readonly "result": TestQuery$result | undefined; +}; + +export type TestQuery$result = { + readonly user: { + readonly id: string; + readonly firstName?: string; + readonly " $fragments": { + UserDetails: {}; + }; + }; +}; + +export type TestQuery$input = null | undefined; + +export type TestQuery$artifact = typeof artifact + +"HoudiniHash=c1028fc6e5213fd703e4e3f2138294735428be9437e68eab4d9854ba91f14c0c"`), + }, + }, + { + Name: "skip on a spread propagates through abstract selections", + Pass: true, + Input: []string{ + `query TestQuery($show: Boolean!) { + node(id: "1") { + id + ...UserDetails @mask_disable @skip(if: $show) + } + }`, + `fragment UserDetails on User { + firstName + }`, + }, + Extra: map[string]any{ + "TestQuery": tests.Dedent(`const artifact = { + "name": "TestQuery", + "kind": "HoudiniQuery", + "hash": "2bc145fc0631e7f8e71fa91c1b77afc0b2aba93ca8fe5f39dc4dbfeceb158063", + "raw": ` + "`" + `query TestQuery($show: Boolean!) { + node(id: "1") { + id + ...UserDetails @skip(if: $show) + __typename + } +} + +fragment UserDetails on User { + firstName + __typename + id +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "node": { + "type": "Node", + "keyRaw": "node(id: \"1\")", + "nullable": true, + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + "abstractFields": { + "fields": { + "User": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + "firstName": { + "type": "String", + "keyRaw": "firstName", + + "directives": [{ + "name": "skip", + "arguments": { + "if": { + "kind": "Variable", + name: { + "kind": "Name", + "value": "show", + }, + "value": "show" + } + } + }], + + "visible": true, + }, + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "typeMap": {}, + }, + + "fragments": { + "UserDetails": { + "arguments": {} + }, + }, + }, + + "abstract": true, + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "input": { + "fields": { + "show": "Boolean", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type TestQuery = { + readonly "input": TestQuery$input; + readonly "result": TestQuery$result | undefined; +}; + +export type TestQuery$result = { + readonly node: { + readonly id: string; + readonly " $fragments": { + UserDetails: {}; + }; + } | null; +}; + +export type TestQuery$input = { + show: boolean; +}; + +export type TestQuery$artifact = typeof artifact + +"HoudiniHash=2bc145fc0631e7f8e71fa91c1b77afc0b2aba93ca8fe5f39dc4dbfeceb158063"`), + }, + }, + { + Name: "masked spread with @include does not propagate condition to inlined fields", + Pass: true, + Input: []string{ + `query TestQuery($show: Boolean!) { + user(id: "1") { + id + ...UserDetails @include(if: $show) + } + }`, + `fragment UserDetails on User { + firstName + }`, + }, + Extra: map[string]any{ + "TestQuery": tests.Dedent(`const artifact = { + "name": "TestQuery", + "kind": "HoudiniQuery", + "hash": "4d664747270e4504bff250185fc0abd5a6778f75b203cbc5e8dca84aa8c36d93", + "raw": ` + "`" + `query TestQuery($show: Boolean!) { + user(id: "1") { + id + ...UserDetails @include(if: $show) + __typename + } +} + +fragment UserDetails on User { + firstName + __typename + id +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "user": { + "type": "User", + "keyRaw": "user(id: \"1\")", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "firstName": { + "type": "String", + "keyRaw": "firstName", + + "directives": [{ + "name": "include", + "arguments": { + "if": { + "kind": "Variable", + name: { + "kind": "Name", + "value": "show", + }, + "value": "show" + } + } + }], + + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + + "fragments": { + "UserDetails": { + "arguments": {} + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "input": { + "fields": { + "show": "Boolean", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type TestQuery = { + readonly "input": TestQuery$input; + readonly "result": TestQuery$result | undefined; +}; + +export type TestQuery$result = { + readonly user: { + readonly id: string; + readonly " $fragments": { + UserDetails: {}; + }; + }; +}; + +export type TestQuery$input = { + show: boolean; +}; + +export type TestQuery$artifact = typeof artifact + +"HoudiniHash=4d664747270e4504bff250185fc0abd5a6778f75b203cbc5e8dca84aa8c36d93"`), + }, + }, + }, + }) +} diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go index 4bbf4a93c3..36313a7e2e 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go @@ -751,7 +751,7 @@ export type TestQuery$artifact = typeof artifact Pass: true, Input: []string{ `query TestQuery { - usersByCursor(first: 10) @paginate(name: "All_Users", mode: "SinglePage") { + usersByCursor(first: 10) @paginate(name: "All_Users", mode: SinglePage) { edges { node { firstName @@ -806,7 +806,7 @@ export type TestQuery$artifact = typeof artifact "fields": { "usersByCursor": { "type": "UserConnection", - "keyRaw": "usersByCursor(after: $after, before: $before, first: $first, last: $last)::paginated", + "keyRaw": "usersByCursor(first: $first, after: $after, last: $last, before: $before)", "directives": [{ "name": "paginate", @@ -816,7 +816,7 @@ export type TestQuery$artifact = typeof artifact "value": "All_Users" }, "mode": { - "kind": "StringValue", + "kind": "EnumValue", "value": "SinglePage" } } @@ -838,7 +838,6 @@ export type TestQuery$artifact = typeof artifact "edges": { "type": "UserEdge", "keyRaw": "edges", - "updates": ["append", "prepend"], "selection": { "fields": { @@ -895,7 +894,6 @@ export type TestQuery$artifact = typeof artifact "endCursor": { "type": "String", "keyRaw": "endCursor", - "updates": ["append"], "nullable": true, "visible": true, }, @@ -903,21 +901,18 @@ export type TestQuery$artifact = typeof artifact "hasNextPage": { "type": "Boolean", "keyRaw": "hasNextPage", - "updates": ["append"], "visible": true, }, "hasPreviousPage": { "type": "Boolean", "keyRaw": "hasPreviousPage", - "updates": ["prepend"], "visible": true, }, "startCursor": { "type": "String", "keyRaw": "startCursor", - "updates": ["prepend"], "nullable": true, "visible": true, }, @@ -1202,7 +1197,6 @@ fragment MonkeyList on MonkeyConnection { "hasBanana": { "type": "Boolean", "keyRaw": "hasBanana", - "visible": true, }, "id": { @@ -1213,7 +1207,6 @@ fragment MonkeyList on MonkeyConnection { "name": { "type": "String", "keyRaw": "name", - "visible": true, }, }, @@ -1224,12 +1217,10 @@ fragment MonkeyList on MonkeyConnection { }, }, - "visible": true, }, }, }, - "visible": true, }, "pageInfo": { "type": "PageInfo", @@ -1448,17 +1439,14 @@ fragment MonkeyFragment on Monkey { "hasBanana": { "type": "Boolean", "keyRaw": "hasBanana", - "visible": true, }, "id": { "type": "ID", "keyRaw": "id", - "visible": true, }, "name": { "type": "String", "keyRaw": "name", - "visible": true, }, }, }, @@ -1474,13 +1462,11 @@ fragment MonkeyFragment on Monkey { }, "abstract": true, - "visible": true, }, }, }, "abstract": true, - "visible": true, }, }, @@ -1817,7 +1803,6 @@ fragment UserTest on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -1927,6 +1912,169 @@ export type TestQuery$artifact = typeof artifact }) } +func TestIncludeListID(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: ` + type Query { + users: [User!]! + usersByCursor(first: Int, last: Int, after: String, before: String): UserConnection! + } + + type User implements Node { + id: ID! + name: String! + } + + type UserConnection { + edges: [UserEdge!]! + pageInfo: PageInfo! + } + + type UserEdge { + node: User + cursor: String! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + interface Node { + id: ID! + } + `, + PerformTest: func(t *testing.T, p *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + performArtifactTest(t, p, test) + + if !test.Pass { + return + } + + projectConfig, err := p.DB.ProjectConfig(t.Context()) + require.NoError(t, err) + + artifactPath := projectConfig.ArtifactPath("TestQuery") + file, err := p.Fs.Open(artifactPath) + require.NoError(t, err) + content, err := afero.ReadAll(file) + require.NoError(t, err) + + artifact := string(content) + require.True(t, strings.Contains(artifact, `"includeListID": true`), + "artifact should contain includeListID: true, got:\n%s", artifact) + // @includeListID is an internal directive; it must be stripped from the raw query + // sent to the server — only the selection metadata should carry it + require.NotContains(t, artifact, "@includeListID", + "@includeListID should be stripped from the raw query, got:\n%s", artifact) + }, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "non-connection list with @includeListID emits includeListID in artifact", + Pass: true, + Input: []string{`query TestQuery { + users @list(name: "All_Users") @includeListID { + id + name + } +}`}, + }, + { + Name: "connection list with @paginate and @includeListID emits includeListID in artifact", + Pass: true, + Input: []string{`query TestQuery { + usersByCursor(first: 10) @paginate(name: "All_Users") @includeListID { + edges { + node { + id + name + } + } + } +}`}, + }, + { + Name: "@includeListID without @list or @paginate fails validation", + Pass: false, + Input: []string{`query TestQuery { + users @includeListID { + id + name + } +}`}, + }, + }, + }) +} + +// TestListIDMutation verifies that @listID on a fragment spread is serialized into the mutation +// artifact's operations so the runtime can resolve the list via the opaque key from __id. +func TestListIDMutation(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: ` + type Mutation { + addUser(name: String!): User + } + + type Query { + users: [User!]! + } + + type User implements Node { + id: ID! + name: String! + } + + interface Node { + id: ID! + } + `, + PerformTest: func(t *testing.T, p *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + performArtifactTest(t, p, test) + if !test.Pass { + return + } + + projectConfig, err := p.DB.ProjectConfig(t.Context()) + require.NoError(t, err) + + artifactPath := projectConfig.ArtifactPath("TestMutation") + file, err := p.Fs.Open(artifactPath) + require.NoError(t, err) + content, err := afero.ReadAll(file) + require.NoError(t, err) + + artifact := string(content) + require.Contains(t, artifact, `"listID"`, + "mutation artifact should contain listID in operations, got:\n%s", artifact) + // @listID is internal; it should not appear in the raw mutation sent to the server + require.NotContains(t, artifact, "@listID", + "@listID should be stripped from the raw query, got:\n%s", artifact) + }, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "@listID on fragment spread produces listID field in mutation artifact operations", + Pass: true, + Input: []string{ + `query TestQuery { + users @list(name: "All_Users") @includeListID { + id + name + } +}`, + `mutation TestMutation($listId: ID!) { + addUser(name: "Test") { + ...All_Users_insert @listID(value: $listId) + } +}`, + }, + }, + }, + }) +} + // TestPaginatePathWinsOverListPath verifies that when a document contains both a @paginate // field and a @list field, the refetch path points to the @paginate field. func TestPaginatePathWinsOverListPath(t *testing.T) { diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go index 99d4f5b1b9..983814e0f2 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go @@ -191,25 +191,21 @@ query MonkeyListQuery { "id": { "type": "ID", "keyRaw": "id", - "visible": true, }, "name": { "type": "String", "keyRaw": "name", - "visible": true, }, }, }, "abstract": true, - "visible": true, }, }, }, "abstract": true, - "visible": true, }, "pageInfo": { @@ -820,7 +816,6 @@ query Query { "loading": { "kind": "value", }, - "visible": true, }, "id": { "type": "ID", diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go index cc984510b2..59dc7d2149 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go @@ -225,7 +225,6 @@ fragment All_Users_insert on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -365,13 +364,11 @@ fragment All_Users_insert_kVR6H on User { "type": "String", "keyRaw": "field(filter: \"Hello World\")", "nullable": true, - "visible": true, }, "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -438,6 +435,166 @@ export type A$artifact = typeof artifact "HoudiniHash=5c4e7db84da4cc870dab20430a5f4a1895573dbbbd3f7568caee055770ad0370"`), }, }, + { + Name: "Insert operations with variable condition", + Pass: true, + Input: []string{ + `mutation B($name: String!) { + addFriend { + friend { + ...All_Users_insert @when(stringValue: $name) + } + } + }`, + `query TestQuery { + users @list(name: "All_Users") { + firstName + } + }`, + }, + Extra: map[string]any{ + "B": tests.Dedent(`const artifact = { + "name": "B", + "kind": "HoudiniMutation", + "hash": "680082591789d4a74f7136909b1e6349e6561f44b777de23138d5dda947e4150", + "raw": ` + "`" + `fragment All_Users_insert on User { + firstName + __typename + id +} + +mutation B { + addFriend { + friend { + ...All_Users_insert + __typename + id + } + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": ["name"] as Array, + + "selection": { + "fields": { + "addFriend": { + "type": "AddFriendOutput", + "keyRaw": "addFriend", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "friend": { + "type": "User", + "keyRaw": "friend", + + "operations": [{ + "action": "insert", + "list": "All_Users", + "position": "last", + + "when": { + "must": { + "stringValue": { + "kind": "Variable", + "value": "name" + }, + }, + }, + }], + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "firstName": { + "type": "String", + "keyRaw": "firstName", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + + "fragments": { + "All_Users_insert": { + "arguments": {} + }, + }, + }, + + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "input": { + "fields": { + "name": "String", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + +} as const + +export default artifact + +export type B = { + readonly "input": B$input; + readonly "result": B$result; +}; + +export type B$result = { + readonly addFriend: { + readonly friend: { + readonly " $fragments": { + All_Users_insert: {}; + }; + }; + }; +}; + +export type B$input = { + name: string; +}; + +export type B$optimistic = { + readonly addFriend?: { + readonly friend?: { + readonly firstName?: string; + }; + }; +}; + +export type B$artifact = typeof artifact + +"HoudiniHash=680082591789d4a74f7136909b1e6349e6561f44b777de23138d5dda947e4150"`), + }, + }, { Name: "Insert operations with condition", Pass: true, @@ -505,7 +662,10 @@ fragment All_Users_insert on User { "when": { "must": { - "stringValue": "foo", + "stringValue": { + "kind": "String", + "value": "foo" + }, }, }, }], @@ -520,7 +680,6 @@ fragment All_Users_insert on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -648,7 +807,10 @@ fragment All_Users_insert on User { "when": { "must_not": { - "stringValue": "foo", + "stringValue": { + "kind": "String", + "value": "foo" + }, }, }, }], @@ -663,7 +825,6 @@ fragment All_Users_insert on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -791,10 +952,16 @@ fragment All_Users_insert on User { "when": { "must": { - "stringValue": "foo", + "stringValue": { + "kind": "String", + "value": "foo" + }, }, "must_not": { - "a": "foo", + "a": { + "kind": "String", + "value": "foo" + }, }, }, }], @@ -809,7 +976,6 @@ fragment All_Users_insert on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -950,13 +1116,11 @@ fragment All_Users_insert_kVR6H on User { "type": "String", "keyRaw": "field(filter: \"Hello World\")", "nullable": true, - "visible": true, }, "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -1212,7 +1376,6 @@ fragment All_Users_insert on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -1478,7 +1641,6 @@ fragment All_Users_toggle on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -1619,13 +1781,11 @@ fragment All_Users_toggle_kVR6H on User { "type": "String", "keyRaw": "field(filter: \"Hello World\")", "nullable": true, - "visible": true, }, "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -1772,7 +1932,6 @@ fragment All_Users_toggle on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -1912,7 +2071,6 @@ fragment All_Users_toggle on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -2267,7 +2425,10 @@ export type A$artifact = typeof artifact "when": { "must": { - "stringValue": "foo", + "stringValue": { + "kind": "String", + "value": "foo" + }, }, }, }], @@ -2379,7 +2540,10 @@ export type A$artifact = typeof artifact "when": { "must_not": { - "stringValue": "foo", + "stringValue": { + "kind": "String", + "value": "foo" + }, }, }, }], @@ -2503,7 +2667,6 @@ fragment All_Users_insert on User { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go index 26b5d1d149..bac95a2757 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go @@ -330,7 +330,7 @@ export type PaginatedFragment$artifact = typeof artifact }, }, { - Name: "pagination arguments stays in key as its a SinglePage Mode", + Name: "pagination arguments included in key for SinglePage Mode (per-cursor keys, no ::paginated)", Input: []string{ ` fragment PaginatedFragment on User { @@ -398,7 +398,7 @@ export type PaginatedFragment$artifact = typeof artifact "friendsByCursor": { "type": "UserConnection", - "keyRaw": "friendsByCursor(filter: \"hello\", first: 10)::paginated", + "keyRaw": "friendsByCursor(first: 10, after: null, last: null, before: null, filter: \"hello\")", "nullable": true, "directives": [{ @@ -422,7 +422,6 @@ export type PaginatedFragment$artifact = typeof artifact "edges": { "type": "UserEdge", "keyRaw": "edges", - "updates": ["append", "prepend"], "selection": { "fields": { @@ -474,7 +473,6 @@ export type PaginatedFragment$artifact = typeof artifact "endCursor": { "type": "String", "keyRaw": "endCursor", - "updates": ["append"], "nullable": true, "visible": true, }, @@ -482,21 +480,18 @@ export type PaginatedFragment$artifact = typeof artifact "hasNextPage": { "type": "Boolean", "keyRaw": "hasNextPage", - "updates": ["append"], "visible": true, }, "hasPreviousPage": { "type": "Boolean", "keyRaw": "hasPreviousPage", - "updates": ["prepend"], "visible": true, }, "startCursor": { "type": "String", "keyRaw": "startCursor", - "updates": ["prepend"], "nullable": true, "visible": true, }, @@ -1840,7 +1835,7 @@ export type TestQuery$artifact = typeof artifact "moves": { "type": "SpeciesMoveConnection", - "keyRaw": "moves(after: $after, first: $first)::paginated", + "keyRaw": "moves(first: $first, after: $after, last: null, before: null)", "directives": [{ "name": "paginate", @@ -1863,7 +1858,6 @@ export type TestQuery$artifact = typeof artifact "edges": { "type": "SpeciesMoveEdge", "keyRaw": "edges", - "updates": ["append"], "selection": { "fields": { @@ -1920,7 +1914,6 @@ export type TestQuery$artifact = typeof artifact "endCursor": { "type": "String", "keyRaw": "endCursor", - "updates": ["append"], "nullable": true, "visible": true, }, @@ -1928,14 +1921,12 @@ export type TestQuery$artifact = typeof artifact "hasNextPage": { "type": "Boolean", "keyRaw": "hasNextPage", - "updates": ["append"], "visible": true, }, "hasPreviousPage": { "type": "Boolean", "keyRaw": "hasPreviousPage", - "updates": ["prepend"], "visible": true, }, diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go index 76ed51ee12..9264f11085 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go @@ -162,19 +162,16 @@ query TestQuery($id: ID!) { "type": "String", "keyRaw": "name", "nullable": true, - "visible": true, }, }, }, "abstract": true, - "visible": true, }, "name": { "type": "String", "keyRaw": "name", "nullable": true, - "visible": true, }, }, "Legend": { @@ -190,7 +187,6 @@ query TestQuery($id: ID!) { "type": "String", "keyRaw": "name", "nullable": true, - "visible": true, }, }, }, diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_test.go index 063d03a14a..758df818a9 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_test.go @@ -300,7 +300,6 @@ query TestQuery { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -813,12 +812,10 @@ query TestQuery { "lastName": { "type": "String", "keyRaw": "lastName", - "visible": true, }, }, }, - "visible": true, }, "id": { "type": "ID", @@ -1326,6 +1323,7 @@ export type Friends$artifact = typeof artifact boolValue: true, floatValue: 1.2, intValue: 1, + filter: { name: $value } ) @list(name: "All_Users") { firstName } @@ -1335,7 +1333,7 @@ export type Friends$artifact = typeof artifact "TestQuery": tests.Dedent(`const artifact = { "name": "TestQuery", "kind": "HoudiniQuery", - "hash": "dc502dd533f31553a3c311a7aaa782d82f81d7f7a8816d5095f96584a7600004", + "hash": "cf9a1b37522817318bc0893e797a289dc9ff66bee13544171839bd1b685ad514", "refetch": { "path": ["users"], @@ -1349,7 +1347,7 @@ export type Friends$artifact = typeof artifact }, "raw": ` + "`" + `query TestQuery($value: String!) { - users(boolValue: true, floatValue: 1.2, intValue: 1, stringValue: $value) { + users(boolValue: true, filter: {name: $value}, floatValue: 1.2, intValue: 1, stringValue: $value) { firstName __typename id @@ -1364,7 +1362,7 @@ export type Friends$artifact = typeof artifact "fields": { "users": { "type": "User", - "keyRaw": "users(boolValue: true, floatValue: 1.2, intValue: 1, stringValue: $value)", + "keyRaw": "users(boolValue: true, filter: {name: $value}, floatValue: 1.2, intValue: 1, stringValue: $value)", "directives": [{ "name": "list", @@ -1407,6 +1405,15 @@ export type Friends$artifact = typeof artifact "kind": "Boolean", "value": true }, + "filter": { + "kind": "Object", + "value": { + "name": { + "kind": "Variable", + "value": "value" + } + } + }, "floatValue": { "kind": "Float", "value": 1.2 @@ -1462,7 +1469,7 @@ export type TestQuery$input = { export type TestQuery$artifact = typeof artifact -"HoudiniHash=dc502dd533f31553a3c311a7aaa782d82f81d7f7a8816d5095f96584a7600004"`), +"HoudiniHash=cf9a1b37522817318bc0893e797a289dc9ff66bee13544171839bd1b685ad514"`), }, }, { @@ -1751,7 +1758,6 @@ fragment UserThings on User { "name": { "type": "String", "keyRaw": "name", - "visible": true, }, }, }, @@ -2034,7 +2040,6 @@ query TestQuery() { "type": "String", "keyRaw": "field(filter: \"Foo\")", "nullable": true, - "visible": true, }, "id": { "type": "ID", @@ -3460,7 +3465,6 @@ query EntityList { "name": { "type": "String", "keyRaw": "name", - "visible": true, }, }, "User": { @@ -3471,7 +3475,6 @@ query EntityList { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { "type": "ID", @@ -3590,7 +3593,6 @@ query UserWithAvatar { "firstName": { "type": "String", "keyRaw": "firstName", - "visible": true, }, "id": { @@ -3743,7 +3745,6 @@ query UserWithAvatar { "type": "String", "keyRaw": "field", "nullable": true, - "visible": true, }, "id": { @@ -3754,7 +3755,6 @@ query UserWithAvatar { "name": { "type": "String", "keyRaw": "name", - "visible": true, }, }, diff --git a/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go b/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go index 59e2424aa1..6efcaaaaab 100644 --- a/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go +++ b/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go @@ -24,6 +24,124 @@ type DocumentContext struct { ScalarImports map[string]bool // full import statement → true } +// spreadDisablesMasking returns true when a fragment spread's fields should be +// inlined into the surrounding type. @mask_disable and @mask_enable on the spread +// take priority, otherwise we fall back to the project's defaultFragmentMasking +func spreadDisablesMasking(projectConfig plugins.ProjectConfig, spread *collected.Selection) bool { + for _, directive := range spread.Directives { + if directive.Name == graphql.DisableMaskDirective { + return true + } + if directive.Name == graphql.EnableMaskDirective { + return false + } + } + return !projectConfig.DefaultFragmentMasking +} + +// spreadIsConditional returns true when the spread carries @include or @skip, +// meaning the fields it inlines might be missing from the response +func spreadIsConditional(spread *collected.Selection) bool { + for _, directive := range spread.Directives { + if directive.Name == graphql.IncludeDirective || directive.Name == graphql.SkipDirective { + return true + } + } + return false +} + +// expandMaskedSpreads replaces fragment spreads that disable masking with the +// selections of the fragment definition. the spread node itself is kept so the +// " $fragments" marker is still generated. only spreads whose type condition is +// satisfied by every object of parentType are expanded — anything narrower needs +// a discriminated union to describe accurately so we leave those masked. +// +// the second return value marks the fields that were inlined through a spread +// guarded by @include or @skip — those might be missing from the response so +// they have to be typed as optional +func expandMaskedSpreads( + ctx *DocumentContext, + collectedDocs *collected.Documents, + selections []*collected.Selection, + parentType string, +) ([]*collected.Selection, map[*collected.Selection]bool) { + result := make([]*collected.Selection, 0, len(selections)) + optionalFields := map[*collected.Selection]bool{} + seenFields := map[string]*collected.Selection{} + seenFragments := map[string]bool{} + + // satisfied returns true when every object of parentType matches the condition + satisfied := func(condition string) bool { + return condition == "" || condition == parentType || + collectedDocs.PossibleTypes[condition][parentType] + } + + var walk func(sels []*collected.Selection, expanded bool, conditional bool) + walk = func(sels []*collected.Selection, expanded bool, conditional bool) { + for _, sel := range sels { + switch sel.Kind { + case "fragment": + // always keep the spread for the " $fragments" marker + result = append(result, sel) + + if !spreadDisablesMasking(ctx.ProjectConfig, sel) { + continue + } + // guard against revisiting a fragment (the spec forbids cycles but + // we don't want a malformed document to hang the generator) + if seenFragments[sel.FieldName] { + continue + } + seenFragments[sel.FieldName] = true + + definition, ok := collectedDocs.Selections[sel.FieldName] + if !ok || !satisfied(definition.TypeCondition) { + continue + } + + walk(definition.Selections, true, conditional || spreadIsConditional(sel)) + + case "inline_fragment": + // inline fragments pulled out of an expanded definition can't be + // passed through (the flat object type has no way to express them) + // so we inline the ones that always match and drop the rest + if !expanded { + result = append(result, sel) + } else if satisfied(sel.FieldName) { + walk(sel.Children, true, conditional) + } + + case "field": + // fields can reach us twice (selected directly and through an + // unmasked fragment) but the type can only list them once + name := sel.FieldName + if sel.Alias != nil { + name = *sel.Alias + } + if kept, ok := seenFields[name]; ok { + // an occurrence that is always present wins over a conditional one + if !conditional { + delete(optionalFields, kept) + } + continue + } + seenFields[name] = sel + if conditional { + optionalFields[sel] = true + } + + result = append(result, sel) + + default: + result = append(result, sel) + } + } + } + walk(selections, false, false) + + return result, optionalFields +} + func GenerateDocumentTypeDefs( projectConfig plugins.ProjectConfig, rootTypes *RootTypeNames, @@ -346,6 +464,9 @@ func generateSelectionType( return "{}", nil } + // inline the fields of any fragment spread that disables masking + selections, optionalFields := expandMaskedSpreads(ctx, collectedDocs, selections, parentType) + var fields []string var fragmentFields []string @@ -467,12 +588,26 @@ func generateSelectionType( fieldType = convertLeafType(ctx, selection.FieldType, selection.TypeModifiers, collectedDocs) } + // @includeListID attaches an opaque __id to the runtime value; reflect that in the type + for _, directive := range selection.Directives { + if directive.Name == graphql.IncludeListIDDirective { + fieldType = fieldType + " & { __id: string }" + break + } + } + // Add readonly modifier if needed readonlyPrefix := "" if readonly { readonlyPrefix = "readonly " } + // fields inlined through a conditionally included spread might be missing + // from the response so they are typed as optional + if optionalFields[selection] { + fieldName += "?" + } + // Add JSDoc comment if this field has a description var fieldDef string indent := strings.Repeat("\t", indentLevel+1) @@ -584,7 +719,8 @@ func generateInterfaceUnionTypeWithLoading( for concreteType := range possibleTypesMap { fragmentsByType[concreteType] = append(fragmentsByType[concreteType], child) concreteTypesSet[concreteType] = true - collectNamedFragments(concreteType, child.Children) + expanded, _ := expandMaskedSpreads(ctx, collectedDocs, child.Children, concreteType) + collectNamedFragments(concreteType, expanded) } // Also recursively process any nested inline fragments within this abstract fragment processInlineFragments(child.Children) @@ -593,7 +729,8 @@ func generateInterfaceUnionTypeWithLoading( if fieldPossibleTypes[fragmentTypeName] { fragmentsByType[fragmentTypeName] = append(fragmentsByType[fragmentTypeName], child) concreteTypesSet[fragmentTypeName] = true - collectNamedFragments(fragmentTypeName, child.Children) + expanded, _ := expandMaskedSpreads(ctx, collectedDocs, child.Children, fragmentTypeName) + collectNamedFragments(fragmentTypeName, expanded) } } } @@ -629,8 +766,10 @@ func generateInterfaceUnionTypeWithLoading( // Process all fragments that apply to this type for _, fragment := range fragmentsByType[typeName] { + // inline the fields of any fragment spread that disables masking + fragmentChildren, optionalFields := expandMaskedSpreads(ctx, collectedDocs, fragment.Children, typeName) // Include fields from this inline fragment - for _, fragmentChild := range fragment.Children { + for _, fragmentChild := range fragmentChildren { if fragmentChild.Kind == "field" && fragmentChild.FieldName != "__typename" { // Skip __typename fields from fragments - we'll add the discriminated version // Also skip if we've already added this field @@ -705,12 +844,20 @@ func generateInterfaceUnionTypeWithLoading( } } + // fields inlined through a conditionally included spread might be + // missing from the response so they are typed as optional + optional := "" + if optionalFields[fragmentChild] { + optional = "?" + } + fields = append( fields, fmt.Sprintf( - "\t\t%s%s: %s;", + "\t\t%s%s%s: %s;", readonlyPrefix, fragmentChild.FieldName, + optional, fieldType, ), ) @@ -856,7 +1003,20 @@ func generateOptimisticType( visibleSelections = append(visibleSelections, sel) if sel.FieldName != "__typename" { - explicitFieldCount++ + // @optimisticKey fields are server-generated; the caller can never provide them, + // so they don't count as "explicit" — without this, a selection like + // { id @optimisticKey ...Frag } would suppress fragment expansion and produce + // a broken `Frag?: null` optimistic type instead of inlining the fragment fields. + isOptimisticKey := false + for _, d := range sel.Directives { + if d.Name == graphql.OptimisticKeyDirective { + isOptimisticKey = true + break + } + } + if !isOptimisticKey { + explicitFieldCount++ + } } } diff --git a/packages/houdini-core/plugin/documents/artifacts/typescript/masking_test.go b/packages/houdini-core/plugin/documents/artifacts/typescript/masking_test.go new file mode 100644 index 0000000000..7f087399e3 --- /dev/null +++ b/packages/houdini-core/plugin/documents/artifacts/typescript/masking_test.go @@ -0,0 +1,340 @@ +package typescript_test + +import ( + "context" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-core/plugin" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/tests" +) + +// when masking is disabled for a fragment spread (either with @mask_disable or +// defaultFragmentMasking: 'disable') the fragment's fields are part of the data +// the runtime returns, so they have to show up in the generated types too +func TestTypescriptFragmentMasking(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: ` + type Query { + user(id: ID): User! + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + nickname: String + age: Int + } + + type Ghost implements Node { + id: ID! + aka: String! + } + `, + VerifyTest: func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + config, err := plugin.DB.ProjectConfig(context.Background()) + require.NoError(t, err) + + for docName, expected := range test.Extra { + typeDefs, err := afero.ReadFile(plugin.Fs, config.ArtifactTypePath(docName)) + require.NoError(t, err) + require.Contains(t, string(typeDefs), expected) + } + }, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "mask_disable inlines fragment fields", + Pass: true, + Input: []string{ + `query MaskQuery { + user(id: "1") { + id + ...MaskUserInfo @mask_disable + } + }`, + `fragment MaskUserInfo on User { + nickname + age + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + export type MaskQuery$result = { + readonly user: { + readonly id: string; + readonly nickname: string | null; + readonly age: number | null; + readonly " $fragments": { + MaskUserInfo: {}; + }; + }; + }; + `), + }, + }, + { + Name: "mask_disable dedupes fields selected directly", + Pass: true, + Input: []string{ + `query MaskQuery { + user(id: "1") { + id + nickname + ...MaskUserInfo @mask_disable + } + }`, + `fragment MaskUserInfo on User { + nickname + age + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + export type MaskQuery$result = { + readonly user: { + readonly id: string; + readonly nickname: string | null; + readonly age: number | null; + readonly " $fragments": { + MaskUserInfo: {}; + }; + }; + }; + `), + }, + }, + { + Name: "fragment on an implemented interface is inlined", + Pass: true, + Input: []string{ + `query MaskQuery { + user(id: "1") { + ...MaskNodeInfo @mask_disable + } + }`, + `fragment MaskNodeInfo on Node { + id + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + export type MaskQuery$result = { + readonly user: { + readonly id: string; + readonly " $fragments": { + MaskNodeInfo: {}; + }; + }; + }; + `), + }, + }, + { + Name: "narrower fragment on an abstract parent stays masked", + Pass: true, + Input: []string{ + `query MaskQuery { + node(id: "1") { + id + ...MaskUserInfo @mask_disable + } + }`, + `fragment MaskUserInfo on User { + nickname + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + export type MaskQuery$result = { + readonly node: { + readonly id: string; + readonly " $fragments": { + MaskUserInfo: {}; + }; + } | null; + }; + `), + }, + }, + { + Name: "spread inside an inline fragment is inlined into the branch", + Pass: true, + Input: []string{ + `query MaskQuery { + node(id: "1") { + ... on User { + id + ...MaskUserInfo @mask_disable + } + } + }`, + `fragment MaskUserInfo on User { + nickname + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + readonly nickname: string | null; + `), + }, + }, + { + Name: "nested spreads inline into fragment data types", + Pass: true, + Input: []string{ + `query MaskQuery { + user(id: "1") { + ...MaskUserInfo + } + }`, + `fragment MaskUserInfo on User { + nickname + ...MaskUserAge @mask_disable + }`, + `fragment MaskUserAge on User { + age + }`, + }, + Extra: map[string]any{ + "MaskUserInfo": tests.Dedent(` + export type MaskUserInfo$data = { + readonly nickname: string | null; + readonly age: number | null; + readonly " $fragments": { + MaskUserAge: {}; + }; + }; + `), + }, + }, + { + Name: "conditionally included spreads are typed optional", + Pass: true, + Input: []string{ + `query MaskQuery($show: Boolean!) { + user(id: "1") { + id + nickname + ...MaskUserInfo @mask_disable @include(if: $show) + } + }`, + `fragment MaskUserInfo on User { + nickname + age + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + export type MaskQuery$result = { + readonly user: { + readonly id: string; + readonly nickname: string | null; + readonly age?: number | null; + readonly " $fragments": { + MaskUserInfo: {}; + }; + }; + }; + `), + }, + }, + { + Name: "defaultFragmentMasking disable inlines without a directive", + Pass: true, + ProjectConfig: func(config *plugins.ProjectConfig) { + config.DefaultFragmentMasking = false + }, + Input: []string{ + `query MaskQuery { + user(id: "1") { + id + ...MaskUserInfo + ...MaskUserAge @mask_enable + } + }`, + `fragment MaskUserInfo on User { + nickname + }`, + `fragment MaskUserAge on User { + age + }`, + }, + Extra: map[string]any{ + "MaskQuery": tests.Dedent(` + export type MaskQuery$result = { + readonly user: { + readonly id: string; + readonly nickname: string | null; + readonly " $fragments": { + MaskUserInfo: {}; + MaskUserAge: {}; + }; + }; + }; + `), + }, + }, + }, + }) +} + +// @includeListID stamps an opaque __id onto the list value so the client can +// pass it back via @listID; the generated type must reflect that intersection +func TestTypescriptIncludeListID(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: ` + type Query { + users: [User!]! + } + + type User implements Node { + id: ID! + name: String! + } + + interface Node { + id: ID! + } + `, + VerifyTest: func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + config, err := plugin.DB.ProjectConfig(context.Background()) + require.NoError(t, err) + + for docName, expected := range test.Extra { + typeDefs, err := afero.ReadFile(plugin.Fs, config.ArtifactTypePath(docName)) + require.NoError(t, err) + require.Contains(t, string(typeDefs), expected) + } + }, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "@includeListID intersects __id onto the list type", + Pass: true, + Input: []string{`query TestQuery { + users @list(name: "All_Users") @includeListID { + id + name + } + }`}, + Extra: map[string]any{ + "TestQuery": tests.Dedent(` + export type TestQuery$result = { + readonly users: ({ + readonly id: string; + readonly name: string; + })[] & { __id: string }; + }; + `), + }, + }, + }, + }) +} diff --git a/packages/houdini-core/plugin/documents/assignability.go b/packages/houdini-core/plugin/documents/assignability.go new file mode 100644 index 0000000000..9ad2d7201f --- /dev/null +++ b/packages/houdini-core/plugin/documents/assignability.go @@ -0,0 +1,97 @@ +package documents + +import ( + "strings" + + "code.houdinigraphql.com/packages/houdini-core/plugin/schema" +) + +// argumentValueCheck carries everything needed to decide whether a single +// argument value row is assignable to its expected type. +type argumentValueCheck struct { + // Kind is the literal kind recorded at extraction: Int, Float, String, + // Block, Boolean, Null, Enum, List, Object, or Variable + Kind string + ExpectedType string + ExpectedModifiers string + // ExpectedTypeKind is types.kind for the expected base type ('' if unknown) + ExpectedTypeKind string + // ScalarInputOK is true when the expected type is a custom scalar whose + // configured input_types include this literal kind + ScalarInputOK bool + // EnumValueOK is true when the raw value matches one of the expected + // enum's values + EnumValueOK bool + + VariableDefined bool + VariableType string + VariableModifiers string + VariableHasNonNullDefault bool +} + +// validArgumentValue reports whether the value is assignable to its expected +// type per the spec's "Values of Correct Type" rule plus input coercion: a +// non-list value is accepted at a list location (single-value coercion), and a +// literal's own non-nullness satisfies any '!' wrappers, so for literals only +// the base types need to be compared. +func validArgumentValue(value argumentValueCheck) bool { + // values passed to @with and @arguments are typed against the synthetic + // __ArgumentSpecification type; the fragmentArguments plugin checks them + // against the fragment's declared argument types + if value.ExpectedType == schema.ArgumentSpecificationType { + return true + } + + // @when/@when_not arguments match against list filters, not schema types — + // there is nothing to validate them against + if value.ExpectedType == whenPassthroughType { + return true + } + + switch value.Kind { + case "Variable": + // undefined variables are reported by ValidateUndefinedVariables + if !value.VariableDefined { + return true + } + if value.VariableType != value.ExpectedType { + return false + } + return schema.VariableTypeCompatible( + schema.ParseTypeRef(value.VariableModifiers), + schema.ParseTypeRef(value.ExpectedModifiers), + value.VariableHasNonNullDefault, + ) + + case "Null": + return !strings.HasSuffix(value.ExpectedModifiers, "!") + + case "List": + return schema.ParseTypeRef(value.ExpectedModifiers).IsList + + case "Object": + // single-value coercion lets an object literal fill a list location of + // any depth, so only the base type matters + return value.ExpectedTypeKind == "INPUT" + + case "Enum": + return value.ExpectedTypeKind == "ENUM" && value.EnumValueOK + + default: + // scalar literals: Int, Float, String, Block, Boolean + switch value.ExpectedTypeKind { + case "SCALAR": + if assignable, known := schema.LiteralKindAssignable(value.ExpectedType, value.Kind); known { + return assignable + } + // custom scalars accept whatever their config allows + return value.ScalarInputOK + case "ENUM", "INPUT": + return false + default: + // output types used as inputs are reported by + // ValidateOutputTypeAsInput; unknown types by other rules + return true + } + } +} diff --git a/packages/houdini-core/plugin/documents/assignability_test.go b/packages/houdini-core/plugin/documents/assignability_test.go new file mode 100644 index 0000000000..47f5e54eb5 --- /dev/null +++ b/packages/houdini-core/plugin/documents/assignability_test.go @@ -0,0 +1,291 @@ +package documents + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidArgumentValue(t *testing.T) { + for _, tc := range []struct { + name string + check argumentValueCheck + ok bool + }{ + // scalar literals + { + "int for Int", + argumentValueCheck{Kind: "Int", ExpectedType: "Int", ExpectedTypeKind: "SCALAR"}, + true, + }, + { + "int for Float", + argumentValueCheck{Kind: "Int", ExpectedType: "Float", ExpectedTypeKind: "SCALAR"}, + true, + }, + { + "float for Int", + argumentValueCheck{Kind: "Float", ExpectedType: "Int", ExpectedTypeKind: "SCALAR"}, + false, + }, + { + "int for ID", + argumentValueCheck{Kind: "Int", ExpectedType: "ID", ExpectedTypeKind: "SCALAR"}, + true, + }, + { + "string for ID", + argumentValueCheck{Kind: "String", ExpectedType: "ID", ExpectedTypeKind: "SCALAR"}, + true, + }, + { + "block string for String", + argumentValueCheck{Kind: "Block", ExpectedType: "String", ExpectedTypeKind: "SCALAR"}, + true, + }, + { + "int for String", + argumentValueCheck{Kind: "Int", ExpectedType: "String", ExpectedTypeKind: "SCALAR"}, + false, + }, + { + "string for Boolean", + argumentValueCheck{Kind: "String", ExpectedType: "Boolean", ExpectedTypeKind: "SCALAR"}, + false, + }, + + // single-value list coercion: a literal can fill a list location + { + "int for [ID!]!", + argumentValueCheck{ + Kind: "Int", + ExpectedType: "ID", + ExpectedModifiers: "!]!", + ExpectedTypeKind: "SCALAR", + }, + true, + }, + + // custom scalars + { + "configured custom scalar", + argumentValueCheck{ + Kind: "String", + ExpectedType: "Date", + ExpectedTypeKind: "SCALAR", + ScalarInputOK: true, + }, + true, + }, + { + "unconfigured custom scalar", + argumentValueCheck{ + Kind: "String", + ExpectedType: "Date", + ExpectedTypeKind: "SCALAR", + ScalarInputOK: false, + }, + false, + }, + + // enums + { + "valid enum value", + argumentValueCheck{ + Kind: "Enum", + ExpectedType: "Role", + ExpectedTypeKind: "ENUM", + EnumValueOK: true, + }, + true, + }, + { + "unknown enum value", + argumentValueCheck{ + Kind: "Enum", + ExpectedType: "Role", + ExpectedTypeKind: "ENUM", + EnumValueOK: false, + }, + false, + }, + { + "string for enum", + argumentValueCheck{Kind: "String", ExpectedType: "Role", ExpectedTypeKind: "ENUM"}, + false, + }, + { + "enum for String", + argumentValueCheck{Kind: "Enum", ExpectedType: "String", ExpectedTypeKind: "SCALAR"}, + false, + }, + + // null + { + "null for nullable", + argumentValueCheck{Kind: "Null", ExpectedType: "ID", ExpectedTypeKind: "SCALAR"}, + true, + }, + { + "null for non-null", + argumentValueCheck{ + Kind: "Null", + ExpectedType: "ID", + ExpectedModifiers: "!", + ExpectedTypeKind: "SCALAR", + }, + false, + }, + { + "null for nullable list of non-null", + argumentValueCheck{ + Kind: "Null", + ExpectedType: "ID", + ExpectedModifiers: "!]", + ExpectedTypeKind: "SCALAR", + }, + true, + }, + + // lists + { + "list for nullable list", + argumentValueCheck{ + Kind: "List", + ExpectedType: "String", + ExpectedModifiers: "]", + ExpectedTypeKind: "SCALAR", + }, + true, + }, + { + "list for non-null list", + argumentValueCheck{ + Kind: "List", + ExpectedType: "ID", + ExpectedModifiers: "!]!", + ExpectedTypeKind: "SCALAR", + }, + true, + }, + { + "list for non-list", + argumentValueCheck{ + Kind: "List", + ExpectedType: "String", + ExpectedTypeKind: "SCALAR", + }, + false, + }, + { + "list for non-null scalar", + argumentValueCheck{ + Kind: "List", + ExpectedType: "String", + ExpectedModifiers: "!", + ExpectedTypeKind: "SCALAR", + }, + false, + }, + + // objects + { + "object for input", + argumentValueCheck{Kind: "Object", ExpectedType: "Filter", ExpectedTypeKind: "INPUT"}, + true, + }, + { + "object for input list", + argumentValueCheck{ + Kind: "Object", + ExpectedType: "Filter", + ExpectedModifiers: "]", + ExpectedTypeKind: "INPUT", + }, + true, + }, + { + "object for scalar", + argumentValueCheck{Kind: "Object", ExpectedType: "String", ExpectedTypeKind: "SCALAR"}, + false, + }, + + // variables + { + "variable exact match", + argumentValueCheck{ + Kind: "Variable", + ExpectedType: "ID", + ExpectedModifiers: "!", + ExpectedTypeKind: "SCALAR", + VariableDefined: true, + VariableType: "ID", + VariableModifiers: "!", + }, + true, + }, + { + "nullable variable for non-null location", + argumentValueCheck{ + Kind: "Variable", + ExpectedType: "ID", + ExpectedModifiers: "!", + ExpectedTypeKind: "SCALAR", + VariableDefined: true, + VariableType: "ID", + }, + false, + }, + { + "nullable variable with default for non-null location", + argumentValueCheck{ + Kind: "Variable", + ExpectedType: "ID", + ExpectedModifiers: "!", + ExpectedTypeKind: "SCALAR", + VariableDefined: true, + VariableType: "ID", + VariableHasNonNullDefault: true, + }, + true, + }, + { + "default does not forgive inner nullability", + argumentValueCheck{ + Kind: "Variable", + ExpectedType: "ID", + ExpectedModifiers: "!]!", + ExpectedTypeKind: "SCALAR", + VariableDefined: true, + VariableType: "ID", + VariableModifiers: "]", + VariableHasNonNullDefault: true, + }, + false, + }, + { + "variable base type mismatch", + argumentValueCheck{ + Kind: "Variable", + ExpectedType: "ID", + ExpectedTypeKind: "SCALAR", + VariableDefined: true, + VariableType: "String", + }, + false, + }, + { + "undefined variables are someone else's problem", + argumentValueCheck{ + Kind: "Variable", + ExpectedType: "ID", + ExpectedTypeKind: "SCALAR", + }, + true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.ok, validArgumentValue(tc.check)) + }) + } +} diff --git a/packages/houdini-core/plugin/documents/loadDocuments.go b/packages/houdini-core/plugin/documents/loadDocuments.go index 9295648997..19b87d2395 100644 --- a/packages/houdini-core/plugin/documents/loadDocuments.go +++ b/packages/houdini-core/plugin/documents/loadDocuments.go @@ -20,6 +20,13 @@ import ( "code.houdinigraphql.com/plugins/graphql" ) +// whenPassthroughType marks the expected_type of @when/@when_not arguments. +// those directives accept whatever filters the target @list field defines, so +// there is no schema type to validate their values against. the double +// underscore prefix is reserved by graphql for introspection, which guarantees +// the sentinel can never collide with a user-defined type +const whenPassthroughType = "__HOUDINI__PASSTHROUGH__" + func LoadDocuments( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], @@ -614,7 +621,15 @@ func LoadPendingQuery( docDirID := conn.LastInsertRowID() for _, arg := range directive.Arguments { // look for the type of the argument - argTypeWithModifiers, _ := typeCache.DirectiveArguments[fmt.Sprintf("%s.%s", directive.Name, arg.Name)] + argTypeWithModifiers, ok := typeCache.DirectiveArguments[fmt.Sprintf("%s.%s", directive.Name, arg.Name)] + // the top level of @with and @arguments can accept any argument + if !ok && (directive.Name == graphql.WithDirective || + directive.Name == graphql.ArgumentsDirective) { + argTypeWithModifiers = TypeWithModifiers{ + Type: schema.ArgumentSpecificationType, + Modifiers: "!", + } + } argType := argTypeWithModifiers.Type argTypeModifiers := argTypeWithModifiers.Modifiers @@ -737,7 +752,15 @@ func LoadPendingQuery( docDirID := conn.LastInsertRowID() for _, arg := range directive.Arguments { // look for the type of the argument - argTypeWithModifiers, _ := typeCache.DirectiveArguments[fmt.Sprintf("%s.%s", directive.Name, arg.Name)] + argTypeWithModifiers, ok := typeCache.DirectiveArguments[fmt.Sprintf("%s.%s", directive.Name, arg.Name)] + // the top level of @with and @arguments can accept any argument + if !ok && (directive.Name == graphql.WithDirective || + directive.Name == graphql.ArgumentsDirective) { + argTypeWithModifiers = TypeWithModifiers{ + Type: schema.ArgumentSpecificationType, + Modifiers: "!", + } + } argType := argTypeWithModifiers.Type argTypeModifiers := argTypeWithModifiers.Modifiers @@ -842,6 +865,7 @@ func LoadPendingQuery( // first, lets look for type information var argType string var argTypeModifiers string + var argTypeRaw string for _, field := range arg.Value.Children { if field.Name != "type" { continue @@ -860,7 +884,8 @@ func LoadPendingQuery( } } - argType, argTypeModifiers = schema.ParseFieldType(field.Value.Raw) + argTypeRaw = field.Value.Raw + argType, argTypeModifiers = schema.ParseFieldType(argTypeRaw) } if argType == "" { @@ -928,8 +953,8 @@ func LoadPendingQuery( } // before we insert the argument definition we need to confirm that the default value is valid - if argDefault != 0 { - match, err := schema.ValueMatchesType(argType, argDefaultValue) + if argDefaultValue != nil { + match, err := schema.ValueMatchesType(argTypeRaw, argDefaultValue) if err != nil { return plugins.WrapError(err) } @@ -1277,13 +1302,13 @@ func processDirectives[PluginConfig any]( if directive.Name == graphql.WithDirective || directive.Name == graphql.ArgumentsDirective { dArgType = TypeWithModifiers{ - Type: "ArgumentSpecification", + Type: schema.ArgumentSpecificationType, Modifiers: "!", } } else if directive.Name == graphql.WhenDirective || directive.Name == graphql.WhenNotDirective { dArgType = TypeWithModifiers{ - Type: "__HOUDINI__PASSTHROUGH__", + Type: whenPassthroughType, } } else { return &plugins.Error{ @@ -1417,23 +1442,12 @@ func processArgumentValue[PluginConfig any]( typ := expectedType typeModifier := expectedTypeModifiers - // list types retain their parents type + // list values keep the full expected modifiers; their items get whatever + // remains after stripping the outermost list wrapper + listItemModifiers := expectedTypeModifiers if valueKind == "List" { - typ = expectedType - listModifier := "" - if expectedTypeModifiers != "" { - if expectedTypeModifiers[len(expectedTypeModifiers)-1] == '!' { - listModifier = "!" - expectedTypeModifiers = expectedTypeModifiers[:len(expectedTypeModifiers)-1] - } - if expectedTypeModifiers != "" && - expectedTypeModifiers[len(expectedTypeModifiers)-1] == ']' { - listModifier = "]" + listModifier - expectedTypeModifiers = expectedTypeModifiers[:len(expectedTypeModifiers)-1] - } - } - - typeModifier = listModifier + listItemModifiers = strings.TrimSuffix(listItemModifiers, "!") + listItemModifiers = strings.TrimSuffix(listItemModifiers, "]") } line, column := 0, 0 if value.Position != nil { @@ -1497,14 +1511,8 @@ func processArgumentValue[PluginConfig any]( } - // if the type is a list then we need to strip away the outer brackets if valueKind == "List" { - if strings.HasSuffix(childModifiers, "!") { - childModifiers = childModifiers[:len(childModifiers)-1] - } - if strings.HasSuffix(childModifiers, "]") { - childModifiers = childModifiers[:len(childModifiers)-1] - } + childModifiers = listItemModifiers } // Recursively process the child value. diff --git a/packages/houdini-core/plugin/documents/validate.go b/packages/houdini-core/plugin/documents/validate.go index 3e4ab6e948..08855fb4a6 100644 --- a/packages/houdini-core/plugin/documents/validate.go +++ b/packages/houdini-core/plugin/documents/validate.go @@ -13,6 +13,7 @@ import ( "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-core/plugin/schema" "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/graphql" ) @@ -235,6 +236,51 @@ func ValidateFragmentOnScalar( } } +// ValidateUnknownVariableTypes makes sure that every operation variable and +// fragment argument declares a type that actually exists in the schema. without +// this, a typo'd type name would only surface as a confusing mismatch where the +// variable is used (or not at all if it's only passed through @with) +func ValidateUnknownVariableTypes( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + queryStr := ` + SELECT + document_variables.name, + document_variables.type, + raw_documents.filepath, + document_variables.row + raw_documents.offset_line, + document_variables.column + raw_documents.offset_column + FROM document_variables + JOIN documents ON document_variables.document = documents.id + JOIN raw_documents ON raw_documents.id = documents.raw_document + LEFT JOIN types ON document_variables.type = types.name + WHERE types.name IS NULL + AND (raw_documents.current_task = $task_id OR $task_id IS NULL) + ` + err := db.StepQuery(ctx, queryStr, nil, func(row plugins.Row) { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "Variable '$%s' uses unknown type '%s'", + row.ColumnText(0), + row.ColumnText(1), + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{ + { + Filepath: row.ColumnText(2), + Line: row.ColumnInt(3), + Column: row.ColumnInt(4), + }, + }, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } +} + func ValidateOutputTypeAsInput( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], @@ -918,91 +964,6 @@ func ValidateDuplicateArgumentInField( } } -func ValidateFieldArgumentIncompatibleType( - ctx context.Context, - db plugins.DatabasePool[config.PluginConfig], - errs *plugins.ErrorList, -) { - // This query retrieves each field argument variable usage along with: - // - The expected type and modifiers (from the field argument definition). - // - The provided type and modifiers (from the operation variable definition). - // We join through selection_refs (since selections don’t directly store a document id) - // and use the normalized argument value (argument_values) for variable references. - // A row is returned when the expected type (including non-null modifier) does not match - // the provided variable type. - query := ` - SELECT - sa.selection_id, - fad.name AS argName, - fad.type AS expectedType, - COALESCE(fad.type_modifiers, '') AS expectedModifiers, - opv.type AS providedTypeRaw, - COALESCE(opv.type_modifiers, '') AS providedModifiers, - rd.filepath, - json_group_array( - json_object('line', sa.row, 'column', sa.column) - ) AS locations, - av.raw AS varUsage - FROM selection_arguments sa - JOIN selections s ON sa.selection_id = s.id - JOIN type_fields tf ON s.type = tf.id - JOIN type_field_arguments fad ON fad.field = tf.id AND fad.name = sa.name - JOIN selection_refs sr ON sr.child_id = s.id - JOIN documents d ON d.id = sr.document - JOIN raw_documents rd ON rd.id = d.raw_document - JOIN argument_values av ON av.id = sa.value - JOIN document_variables opv ON d.id = opv.document AND opv.name = av.raw - WHERE (rd.current_task = $task_id OR $task_id IS NULL) - GROUP BY sa.selection_id, fad.name - HAVING ( - - ( - fad."type" != opv."type" - AND - fad.type_modifiers != opv.type_modifiers - ) - ) - ` - - err := db.StepQuery(ctx, query, nil, func(row plugins.Row) { - argName := row.ColumnText(1) - expectedType := row.ColumnText(2) - expectedModifiers := row.ColumnText(3) - providedTypeRaw := row.ColumnText(4) - providedModifiers := row.ColumnText(5) - filepath := row.ColumnText(6) - locationsRaw := row.ColumnText(7) - - var locations []*plugins.ErrorLocation - if err := json.Unmarshal([]byte(locationsRaw), &locations); err != nil { - errs.Append(&plugins.Error{ - Message: fmt.Sprintf("could not unmarshal locations for argument '%s'", argName), - Detail: err.Error(), - }) - return - } - for _, loc := range locations { - loc.Filepath = filepath - } - - errs.Append(&plugins.Error{ - Message: fmt.Sprintf( - "Variable used for argument '%s' is incompatible: expected type '%s%s' but got '%s%s'", - argName, - expectedType, - expectedModifiers, - providedTypeRaw, - providedModifiers, - ), - Kind: plugins.ErrorKindValidation, - Locations: locations, - }) - }) - if err != nil { - errs.Append(plugins.WrapError(err)) - } -} - func ValidateMissingRequiredArgument( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], @@ -1181,193 +1142,16 @@ func ValidateDuplicateKeysInInputObject( } } -// InputTypeDefinition describes an input object type. -type InputTypeDefinition struct { - Name string - Fields map[string]*InputFieldDefinition -} - -// InputFieldDefinition describes one field on an input object. -type InputFieldDefinition struct { - Name string - ExpectedType string // e.g. "String", "Int", etc. - Required bool // true if the field is non-null - IsList bool // true if the field is defined as a list (derived from type_modifiers) - // If the field itself is an input object, InputDef holds its definition. - InputDef *InputTypeDefinition -} - -// TypeModifiers represents the parsed structure of a modifier string. -// We assume the stored modifier string consists solely of closing brackets (']') -// and exclamation marks ('!'). For example, a modifier like "]!]]!]]" indicates -// five levels of nesting, with the outermost and third levels non-null. -type TypeModifiers struct { - ListDepth int - NonNullLevels []bool // one per level, in order from outermost to innermost -} - -// parseModifiers parses a modifier string (e.g. "]!]]!]]") into a structured form. -func parseModifiers(modifiers string) TypeModifiers { - var tm TypeModifiers - tm.NonNullLevels = []bool{} - i := 0 - for i < len(modifiers) { - if modifiers[i] == ']' { - tm.ListDepth++ - nonNull := false - if i+1 < len(modifiers) && modifiers[i+1] == '!' { - nonNull = true - i++ // Skip the '!' - } - tm.NonNullLevels = append(tm.NonNullLevels, nonNull) - } - i++ - } - return tm -} - -// loadUsedInputTypes loads only those input types that are used by structured arguments. -// It first queries for distinct expected input type names from structured arguments, then -// loads all matching type definitions (from the types table) and their fields (from type_fields). -func loadUsedInputTypes( - ctx context.Context, - db plugins.DatabasePool[config.PluginConfig], -) (map[string]*InputTypeDefinition, error) { - conn, err := db.Take(ctx) - if err != nil { - return nil, err - } - defer db.Put(conn) - - // Step 1: Get distinct input type names used by structured arguments. - distinctQuery := ` - SELECT DISTINCT fad.type AS expectedInputType - FROM selection_arguments sargs - JOIN type_field_arguments fad ON fad.field = sargs.selection_id AND fad.name = sargs.name - JOIN argument_values av ON av.id = sargs.value - WHERE av.kind = 'Object' - ` - stmt, err := conn.Prepare(distinctQuery) - if err != nil { - return nil, fmt.Errorf("failed to prepare distinct types query: %w", err) - } - defer stmt.Finalize() - - usedTypes := make(map[string]bool) - for { - hasData, err := stmt.Step() - if err != nil { - return nil, fmt.Errorf("error stepping distinct types query: %w", err) - } - if !hasData { - break - } - typ := stmt.ColumnText(0) - usedTypes[typ] = true - } - // If none are used, return an empty map. - if len(usedTypes) == 0 { - return make(map[string]*InputTypeDefinition), nil - } - - // Build an IN clause (e.g. "'TypeA','TypeB'") - typeNames := []string{} - for t := range usedTypes { - typeNames = append(typeNames, fmt.Sprintf("'%s'", t)) - } - inClause := strings.Join(typeNames, ",") - - // Step 2: Load input type definitions from the types table. - typesQuery := fmt.Sprintf(` - SELECT name FROM types - WHERE name IN (%s) AND kind IN ('INPUT','INPUT_OBJECT') - `, inClause) - typesStmt, err := conn.Prepare(typesQuery) - if err != nil { - return nil, fmt.Errorf("failed to prepare types query: %w", err) - } - defer typesStmt.Finalize() - - typeDefs := make(map[string]*InputTypeDefinition) - for { - hasData, err := typesStmt.Step() - if err != nil { - return nil, fmt.Errorf("error stepping types query: %w", err) - } - if !hasData { - break - } - name := typesStmt.ColumnText(0) - typeDefs[name] = &InputTypeDefinition{ - Name: name, - Fields: make(map[string]*InputFieldDefinition), - } - } - - // Step 3: Load all fields for these types. - fieldsQuery := fmt.Sprintf(` - SELECT parent, name, type, type_modifiers - FROM type_fields - WHERE parent IN (%s) - `, inClause) - fieldsStmt, err := conn.Prepare(fieldsQuery) - if err != nil { - return nil, fmt.Errorf("failed to prepare fields query: %w", err) - } - defer fieldsStmt.Finalize() - - for { - hasData, err := fieldsStmt.Step() - if err != nil { - return nil, fmt.Errorf("error stepping fields query: %w", err) - } - if !hasData { - break - } - parent := fieldsStmt.ColumnText(0) - fieldName := fieldsStmt.ColumnText(1) - fieldType := fieldsStmt.ColumnText(2) - modifiers := fieldsStmt.ColumnText(3) - - tm := parseModifiers(modifiers) - isList := tm.ListDepth > 0 - required := false - if len(tm.NonNullLevels) > 0 { - required = tm.NonNullLevels[0] - } - - fieldDef := &InputFieldDefinition{ - Name: fieldName, - ExpectedType: fieldType, - Required: required, - IsList: isList, - } - if parentDef, ok := typeDefs[parent]; ok { - parentDef.Fields[fieldName] = fieldDef - } - } - - // Step 4: For each field whose ExpectedType is itself an input type, set its InputDef. - for _, typeDef := range typeDefs { - for _, fieldDef := range typeDef.Fields { - if nested, ok := typeDefs[fieldDef.ExpectedType]; ok { - fieldDef.InputDef = nested - } - } - } - - return typeDefs, nil -} - func ValidateWrongTypesToArg( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], errs *plugins.ErrorList, ) { - // every argument value contains the type that it should be so we need to look at every scalar - // usage and make sure that it matches with the expectations + // every argument value was annotated with its expected type at extraction so + // this query just streams every value along with the context needed to decide + // assignability; the actual decision happens in validArgumentValue query := ` - WITH input_types as ( + WITH scalar_inputs as ( SELECT scalar.name, types.value AS input_type @@ -1380,20 +1164,26 @@ func ValidateWrongTypesToArg( raw_documents.offset_column, argument_values.row, argument_values.column, - selection_arguments.name, - selection_directive_arguments.name, - argument_value_children.name, COALESCE( - selection_arguments.name, - selection_directive_arguments.name, - argument_value_children.name - ) AS argument_name, + selection_arguments.name, + selection_directive_arguments.name, + argument_value_children.name + ) AS argument_name, + argument_value_children.name AS child_name, + parent_values.expected_type AS parent_expected_type, argument_values.expected_type, argument_values.expected_type_modifiers, - argument_values.kind, - input_types.name, - document_variables.type AS variable_type, - document_variables.type_modifiers AS variable_type_modifiers + argument_values.kind AS value_kind, + types.kind AS expected_type_kind, + scalar_inputs.name IS NOT NULL AS scalar_input_ok, + ev.value IS NOT NULL AS enum_value_ok, + document_variables.id IS NOT NULL AS variable_defined, + document_variables.type AS variable_type, + document_variables.type_modifiers AS variable_type_modifiers, + ( + document_variables.default_value IS NOT NULL + AND COALESCE(variable_defaults.kind, '') != 'Null' + ) AS variable_has_non_null_default FROM argument_values JOIN documents on argument_values."document" = documents.id JOIN raw_documents on documents.raw_document = raw_documents.id @@ -1403,192 +1193,87 @@ func ValidateWrongTypesToArg( ON argument_values.kind = 'Variable' AND argument_values.document = document_variables.document AND argument_values.raw = document_variables."name" + LEFT JOIN argument_values variable_defaults + ON document_variables.default_value = variable_defaults.id LEFT JOIN selection_arguments ON argument_values.id = selection_arguments.value LEFT JOIN selection_directive_arguments ON argument_values.id = selection_directive_arguments.value LEFT JOIN argument_value_children ON argument_values.id = argument_value_children.value + LEFT JOIN argument_values parent_values + ON argument_value_children.parent = parent_values.id LEFT JOIN enum_values ev ON argument_values.kind = 'Enum' AND argument_values.expected_type = ev.parent AND argument_values.raw = ev.value - LEFT JOIN input_types - ON argument_values.expected_type = input_types.name - AND argument_values.kind = input_types.input_type + LEFT JOIN scalar_inputs + ON argument_values.expected_type = scalar_inputs.name + AND scalar_inputs.input_type = ( + CASE WHEN argument_values.kind = 'Block' THEN 'String' ELSE argument_values.kind END + ) WHERE (raw_documents.current_task = $task_id OR $task_id IS NULL) - - AND ( - -- For non-variable, non-null kinds that are scalar or enum: - -- invalid if the expected_type_modifiers contains a ']' or the kind != - ( - argument_values.kind NOT IN ('Variable', 'Null', 'ENUM') - AND types.kind = 'SCALAR' - AND ( - argument_values.expected_type_modifiers LIKE '%]%' - OR ( - argument_values.kind <> argument_values.expected_type - AND NOT ( - argument_values.kind IN ('ID','String', 'Int') - AND argument_values.expected_type = 'ID' - ) - AND ( - types.built_in IS TRUE OR - (types.built_in IS FALSE and input_types.name is null) - ) - ) - ) - ) - - OR - - -- if the argument kind is an object but the expected type modifiers have a list in it, there's a problem - ( - argument_values.kind = 'Object' - AND argument_values.expected_type_modifiers LIKE '%]]%' - ) - - OR - - -- if the argument kind is a list and there are no list modifiers - - ( - argument_values.kind = 'List' - AND argument_values.expected_type_modifiers NOT LIKE '%]' - ) - - OR - - -- For enum kinds: invalid if the expected modifiers contain a ']' or if no matching enum value is found. - ( - argument_values.kind = 'Enum' - AND ( - argument_values.expected_type_modifiers LIKE '%]%' - OR ev.value IS NULL - ) - ) - OR - - -- For Null kinds: invalid if the expected_type_modifiers end with '!' - ( - argument_values.kind = 'Null' - AND argument_values.expected_type_modifiers LIKE '%!' - ) - - OR - - -- For Variable kinds: compare the variable's modifiers to the expected modifiers, - -- but allow cases where non-null modifiers are allowed on null fields. - -- Consult the table below for info. - ( - argument_values.kind = 'Variable' - AND NOT ( - document_variables."type" = argument_values.expected_type - AND ( - document_variables.type_modifiers = argument_values.expected_type_modifiers - - -- Any non-null input is assignable to its nullable variant - OR ( - document_variables.type_modifiers LIKE '%!%' - AND document_variables.default_value is null - AND REPLACE(document_variables.type_modifiers, '!', '') = argument_values.expected_type_modifiers - ) - -- Non-nullable list needs to have a non-null input - OR ( - argument_values.expected_type_modifiers = ']!' - AND document_variables.default_value is null - AND document_variables.type_modifiers in (']!', '!]!') - ) - -- Nullable list of non-null needs to have non-null type in list - OR ( - argument_values.expected_type_modifiers = '!]' - AND document_variables.default_value is null - AND document_variables.type_modifiers in ('!]', '!]!') - ) - -- Non-null list of non-null needs to match perfectly, so it's already handled. - - OR document_variables.default_value is not null - ) - ) - AND argument_values.expected_type is not 'ArgumentSpecification' - ) - ) ` - /* - GraphQL non-null types can be valid on null fields. - Check the spec for more information on nullability and lists: https://spec.graphql.org/October2021/#sec-Combining-List-and-Non-Null - - input | expected | matches - ------+-----------+-------- - single object modifiers: - | | true - ! | | true - ! | ! | true - | ! | false - - list modifiers: - ] | ] | true - ]! | ] | true - !] | ] | true - !]! | ] | true - - ] | ]! | false - ]! | ]! | true - !] | ]! | false - !]! | ]! | true - - ] | !] | false - ]! | !] | false - !] | !] | true - !]! | !] | true - - ] | !]! | false - ]! | !]! | false - !] | !]! | false - !]! | !]! | true - */ - err := db.StepQuery(ctx, query, nil, func(stmt plugins.Row) { - filepath := stmt.ColumnText(0) - offsetLine := stmt.ColumnInt(1) - offsetColumn := stmt.ColumnInt(2) - row := stmt.ColumnInt(3) + offsetLine - column := stmt.ColumnInt(4) + offsetColumn - argumentName := stmt.ColumnText(5) - kind := stmt.GetText("kind") + check := argumentValueCheck{ + Kind: stmt.GetText("value_kind"), + ExpectedType: stmt.GetText("expected_type"), + ExpectedModifiers: stmt.GetText("expected_type_modifiers"), + ExpectedTypeKind: stmt.GetText("expected_type_kind"), + ScalarInputOK: stmt.GetBool("scalar_input_ok"), + EnumValueOK: stmt.GetBool("enum_value_ok"), + VariableDefined: stmt.GetBool("variable_defined"), + VariableType: stmt.GetText("variable_type"), + VariableModifiers: stmt.GetText("variable_type_modifiers"), + VariableHasNonNullDefault: stmt.GetBool("variable_has_non_null_default"), + } + + argumentName := stmt.GetText("argument_name") + childName := stmt.GetText("child_name") // Create a single error location from the representative row/column. loc := &plugins.ErrorLocation{ - Filepath: filepath, - Line: row, - Column: column, + Filepath: stmt.ColumnText(0), + Line: stmt.ColumnInt(3) + stmt.ColumnInt(1), + Column: stmt.ColumnInt(4) + stmt.ColumnInt(2), } - // we want to show [[User!]!] instead of User!]!] - expectedType := stmt.GetText("expected_type") + stmt.GetText("expected_type_modifiers") - for range strings.Count(expectedType, "]") { - expectedType = "[" + expectedType + // a child row without an expected type is a field that doesn't exist on + // the input object, but only when the parent itself resolved to a real + // type: fields of @arguments specifications are checked by the + // fragmentArguments plugin and children of unknown values are covered + // by the error on their ancestor. other empty expected types (unknown + // arguments, unknown directives, etc) are reported by their own rules + if check.ExpectedType == "" { + parentExpectedType := stmt.GetText("parent_expected_type") + if childName != "" && + parentExpectedType != "" && + parentExpectedType != schema.ArgumentSpecificationType { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf("Unexpected field: %s", argumentName), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{loc}, + }) + } + return } - if expectedType == "" { - errs.Append(&plugins.Error{ - Message: fmt.Sprintf("Unexpected field: %s", argumentName), - Kind: plugins.ErrorKindValidation, - Locations: []*plugins.ErrorLocation{loc}, - }) + if validArgumentValue(check) { return } - valueKind := kind + // we want to show [[User!]!] instead of User!]!] + expectedType := check.ExpectedType + check.ExpectedModifiers + for range strings.Count(expectedType, "]") { + expectedType = "[" + expectedType + } + + valueKind := check.Kind if valueKind == "Variable" { - valueKind += " of kind " + stmt.GetText( - "variable_type", - ) + stmt.GetText( - "variable_type_modifiers", - ) + valueKind += " of kind " + check.VariableType + check.VariableModifiers } errs.Append(&plugins.Error{ diff --git a/packages/houdini-core/plugin/documents/walk.go b/packages/houdini-core/plugin/documents/walk.go index ca7e2c31b2..995f4a718d 100644 --- a/packages/houdini-core/plugin/documents/walk.go +++ b/packages/houdini-core/plugin/documents/walk.go @@ -57,14 +57,23 @@ func Walk[PluginConfig any]( defer pluginSearch.Finalize() err = db.StepStatement(ctx, pluginSearch, func() { name := pluginSearch.GetText("name") - err = walker.AddInclude(fmt.Sprintf("%s/**", config.PluginStaticRuntimeDirectory(name))) + // The walker computes file paths relative to config.ProjectRoot, so the + // include pattern must also be relative — not the absolute path returned + // by PluginStaticRuntimeDirectory. + relDir := filepath.ToSlash(filepath.Join(config.RuntimeDir, "plugins", name, "static")) + err = walker.AddInclude(relDir + "/**") }) if err != nil { return err } + // The walker returns paths relative to config.ProjectRoot, but ProcessFile opens them + // from the filesystem. Use a BasePathFs so that opening a relative path correctly + // resolves against the project root on any afero backend (including MemMapFs in tests). + rootedFs := afero.NewBasePathFs(fs, config.ProjectRoot) + // and extract the documents that the walker finds - return extractDocuments(ctx, db, fs, func(filePathsCh chan string) error { + return extractDocuments(ctx, db, rootedFs, func(filePathsCh chan string) error { return walker.Walk(ctx, fs, config.ProjectRoot, func(fp string) error { // in case the context is canceled, stop early. select { @@ -104,9 +113,10 @@ func ExtractFromFilepaths[PluginConfig any]( } root := config.ProjectRoot + rootedFs := afero.NewBasePathFs(fs, root) // and extract the documents that the walker finds - return extractDocuments(ctx, db, fs, func(filePathsCh chan string) error { + return extractDocuments(ctx, db, rootedFs, func(filePathsCh chan string) error { for _, fp := range files { rel, err := filepath.Rel(root, fp) if err != nil { diff --git a/packages/houdini-core/plugin/documents/walk_test.go b/packages/houdini-core/plugin/documents/walk_test.go new file mode 100644 index 0000000000..3c9f57464f --- /dev/null +++ b/packages/houdini-core/plugin/documents/walk_test.go @@ -0,0 +1,80 @@ +package documents_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-core/plugin/documents" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/tests" +) + +func TestWalk_staticRuntimeIsDiscovered(t *testing.T) { + ctx := context.Background() + + db, err := plugins.NewTestPool[config.PluginConfig]() + require.NoError(t, err) + defer db.Close() + + conn, err := db.Take(ctx) + require.NoError(t, err) + require.NoError(t, tests.WriteDatabaseSchema(conn)) + db.Put(conn) + + projectConfig := plugins.ProjectConfig{ + ProjectRoot: "/project", + RuntimeDir: ".houdini", + Include: []string{}, + Exclude: []string{}, + RuntimeScalars: map[string]string{}, + } + db.SetProjectConfig(projectConfig) + + // Register a plugin whose static runtime should be walked. + // The value of include_static_runtime just needs to be NOT NULL — the path + // is computed from the plugin name and project config, not from this column. + err = db.ExecQuery(ctx, + `INSERT INTO plugins (name, port, hooks, plugin_order, include_static_runtime) + VALUES ($name, $port, $hooks, $order, $static)`, + map[string]any{ + "name": "my-plugin", + "port": 0, + "hooks": "[]", + "order": "after", + "static": "static", + }, + ) + require.NoError(t, err) + + // Create a .graphql file at the conventional static runtime path. + fs := afero.NewMemMapFs() + staticDir := projectConfig.PluginStaticRuntimeDirectory("my-plugin") + require.NoError(t, fs.MkdirAll(staticDir, 0755)) + require.NoError(t, afero.WriteFile( + fs, + filepath.Join(staticDir, "ops.graphql"), + []byte("query Ping { ping }"), + 0644, + )) + + require.NoError(t, documents.Walk(ctx, db, fs)) + + // The file should have been inserted into raw_documents. + conn, err = db.Take(ctx) + require.NoError(t, err) + defer db.Put(conn) + + stmt, err := conn.Prepare("SELECT COUNT(*) FROM raw_documents WHERE filepath LIKE '%ops.graphql'") + require.NoError(t, err) + defer stmt.Finalize() + + hasRow, err := stmt.Step() + require.NoError(t, err) + require.True(t, hasRow) + require.Equal(t, 1, stmt.ColumnInt(0), "static runtime file should be discovered by Walk") +} diff --git a/packages/houdini-core/plugin/fragmentArguments/validate.go b/packages/houdini-core/plugin/fragmentArguments/validate.go index 6a73b55ea8..bfe48b7505 100644 --- a/packages/houdini-core/plugin/fragmentArguments/validate.go +++ b/packages/houdini-core/plugin/fragmentArguments/validate.go @@ -9,6 +9,7 @@ import ( "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-core/plugin/schema" "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/graphql" ) @@ -74,13 +75,14 @@ func ValidateFragmentArgumentValues( // --- STEP 1. Build a flat map of argument values for the 'with' directive --- flatNodes := make(map[int]*DirectiveArgValueNode) flatTreeQuery := ` - WITH RECURSIVE arg_tree(id, kind, raw, parent) AS ( + WITH RECURSIVE arg_tree(id, kind, raw, parent, name) AS ( -- Base case: argument_values directly referenced by a @with directive. SELECT av.id, av.kind, av.raw, - avc.parent + avc.parent, + avc.name FROM argument_values av JOIN selection_directive_arguments sda ON sda.value = av.id JOIN selection_directives sd ON sd.id = sda.parent @@ -93,12 +95,13 @@ func ValidateFragmentArgumentValues( child.id, child.kind, child.raw, - avc.parent + avc.parent, + avc.name FROM arg_tree JOIN argument_value_children avc ON avc.parent = arg_tree.id JOIN argument_values child ON child.id = avc.value ) - SELECT id, kind, raw, parent FROM arg_tree + SELECT id, kind, raw, parent, name FROM arg_tree ` bindings := map[string]any{"with_directive": graphql.WithDirective} @@ -117,6 +120,7 @@ func ValidateFragmentArgumentValues( Kind: kind, Raw: raw, Parent: parent, + Name: stmt.ColumnText(4), Children: []*DirectiveArgValueNode{}, } }) @@ -134,6 +138,18 @@ func ValidateFragmentArgumentValues( } } + // if there are values to check we need the schema context (enum values, + // input object fields, scalar config) to validate them against + var info *schemaInfo + if len(flatNodes) > 0 { + loaded, err := loadSchemaInfo(ctx, db) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + info = loaded + } + // --- STEP 2. Run the main query that returns fragment info and directive arguments --- // We now have directive arguments as JSON objects with fields "name", "argId", and "raw". mainQuery := ` @@ -210,7 +226,7 @@ func ValidateFragmentArgumentValues( documentVariables = []DocumentVariables{} } - if err := validateWithArguments(directiveArgs, documentVariables); err != nil { + if err := validateWithArguments(info, directiveArgs, documentVariables); err != nil { errs.Append(&plugins.Error{ Message: err.Error(), Kind: plugins.ErrorKindValidation, @@ -232,10 +248,12 @@ func ValidateFragmentArgumentValues( // DirectiveArgValueNode represents a node in the argument value tree. type DirectiveArgValueNode struct { - ID int `json:"id"` - Kind string `json:"kind"` - Raw string `json:"raw"` - Parent *int `json:"parent,omitempty"` + ID int `json:"id"` + Kind string `json:"kind"` + Raw string `json:"raw"` + Parent *int `json:"parent,omitempty"` + // Name is set when this node is a field of an input object literal + Name string `json:"name,omitempty"` Children []*DirectiveArgValueNode `json:"children"` } @@ -260,7 +278,11 @@ type DocumentVariables struct { // validateWithArguments loops through the directive arguments, validates // each one against its corresponding operation variable, and ensures that every // required argument is passed (i.e. every opVar whose TypeModifiers ends with '!') -func validateWithArguments(directiveArgs []DirectiveArgument, opVars []DocumentVariables) error { +func validateWithArguments( + info *schemaInfo, + directiveArgs []DirectiveArgument, + opVars []DocumentVariables, +) error { // Create a map of passed directive argument names. passedArgs := make(map[string]bool) @@ -282,7 +304,7 @@ func validateWithArguments(directiveArgs []DirectiveArgument, opVars []DocumentV } // Validate the argument's value against the expected type and type modifiers. - if !checkTypeCompatibility(arg.Value, opVar.Type, opVar.TypeModifiers) { + if !checkTypeCompatibility(info, arg.Value, opVar.Type, schema.ParseTypeRef(opVar.TypeModifiers)) { return fmt.Errorf("argument %s value does not match expected type %s with modifiers %s", arg.Name, opVar.Type, opVar.TypeModifiers) } @@ -301,59 +323,164 @@ func validateWithArguments(directiveArgs []DirectiveArgument, opVars []DocumentV return nil } -// checkTypeCompatibility recursively validates that the ArgNode value matches -// the expected type (as a string) and its type modifiers. -// For our purposes: -// - An empty modifier string indicates a scalar (no children, non-empty raw value). -// - If the modifiers contain a ']', we expect a list. -// - A trailing '!' indicates that the list (or scalar) is non-null. -// -// This function follows GraphQL type compatibility rules where non-null values -// can be passed to nullable parameters. -func checkTypeCompatibility(arg *DirectiveArgValueNode, expectedType, modifiers string) bool { - // Special case: if the argument is a variable reference, it's always compatible - // because the actual type compatibility will be validated elsewhere when the - // variable is used in the schema field. - if arg.Kind == "Variable" { - return true +// schemaInfo carries the schema lookups needed to validate literal values +// against fragment argument types +type schemaInfo struct { + // typeKinds maps a type name to its kind (SCALAR, ENUM, INPUT, ...) + typeKinds map[string]string + // enumValues maps an enum name to the set of its values + enumValues map[string]map[string]bool + // inputFields maps "Parent.field" to the field's type + inputFields map[string]inputFieldDef + // scalarInputs maps a custom scalar to the literal kinds its config allows + scalarInputs map[string]map[string]bool +} + +type inputFieldDef struct { + Type string + Modifiers string +} + +func loadSchemaInfo( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], +) (*schemaInfo, error) { + info := &schemaInfo{ + typeKinds: map[string]string{}, + enumValues: map[string]map[string]bool{}, + inputFields: map[string]inputFieldDef{}, + scalarInputs: map[string]map[string]bool{}, + } + + err := db.StepQuery(ctx, `SELECT name, kind FROM types`, nil, func(stmt plugins.Row) { + info.typeKinds[stmt.ColumnText(0)] = stmt.ColumnText(1) + }) + if err != nil { + return nil, err } - // No modifiers: expect a scalar value. - if modifiers == "" { - return len(arg.Children) == 0 && arg.Raw != "" + err = db.StepQuery(ctx, `SELECT parent, value FROM enum_values`, nil, func(stmt plugins.Row) { + parent := stmt.ColumnText(0) + if info.enumValues[parent] == nil { + info.enumValues[parent] = map[string]bool{} + } + info.enumValues[parent][stmt.ColumnText(1)] = true + }) + if err != nil { + return nil, err } - // If the modifier contains a ']', then we expect a list. - if strings.Contains(modifiers, "]") { - // If a non-null list is required (modifier ends with '!'), ensure the list is nonempty. - if strings.HasSuffix(modifiers, "!") && len(arg.Children) == 0 { + err = db.StepQuery(ctx, ` + SELECT type_fields.parent, type_fields.name, type_fields.type, type_fields.type_modifiers + FROM type_fields + JOIN types ON type_fields.parent = types.name + WHERE types.kind = 'INPUT' + `, nil, func(stmt plugins.Row) { + info.inputFields[stmt.ColumnText(0)+"."+stmt.ColumnText(1)] = inputFieldDef{ + Type: stmt.ColumnText(2), + Modifiers: stmt.ColumnText(3), + } + }) + if err != nil { + return nil, err + } + + err = db.StepQuery(ctx, ` + SELECT scalar_config.name, input_types.value + FROM scalar_config, json_each(scalar_config.input_types) AS input_types + `, nil, func(stmt plugins.Row) { + name := stmt.ColumnText(0) + if info.scalarInputs[name] == nil { + info.scalarInputs[name] = map[string]bool{} + } + info.scalarInputs[name][stmt.ColumnText(1)] = true + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// checkTypeCompatibility recursively validates that the ArgNode value matches +// the expected type and its type modifiers, following the spec's "Values of +// Correct Type" rule plus input coercion: a non-list value is accepted at a +// list location (single-value coercion), and a literal's own non-nullness +// satisfies any '!' wrappers, so for literals only the base types need to be +// compared. +func checkTypeCompatibility( + info *schemaInfo, + arg *DirectiveArgValueNode, + expectedType string, + ref *schema.TypeRef, +) bool { + switch arg.Kind { + case "Variable": + // variable usages are validated where the variable is applied to a + // schema field + return true + + case "Null": + return !ref.NonNull + + case "List": + if !ref.IsList { return false } - // Recursively validate each child with one layer of list notation stripped. - newModifiers := stripOneLayer(modifiers) for _, child := range arg.Children { - if !checkTypeCompatibility(child, expectedType, newModifiers) { + if !checkTypeCompatibility(info, child, expectedType, ref.Inner) { return false } } return true - } - // Fallback: treat as scalar. - return arg.Raw != "" -} + case "Object": + switch info.typeKinds[expectedType] { + case "INPUT": + for _, child := range arg.Children { + field, ok := info.inputFields[expectedType+"."+child.Name] + if !ok { + return false + } + if !checkTypeCompatibility(info, child, field.Type, schema.ParseTypeRef(field.Modifiers)) { + return false + } + } + return true + case "": + // the declared type isn't in the schema; that's someone else's error + return true + default: + return false + } -// stripOneLayer removes one layer of list notation from the modifiers string. -// For example, given a modifiers string like "]!]!", it will remove up to and including -// the first ']' and then, if the next character is '!', remove that as well. -func stripOneLayer(modifiers string) string { - idx := strings.Index(modifiers, "]") - if idx == -1 { - return modifiers - } - newStr := modifiers[idx+1:] - if strings.HasPrefix(newStr, "!") { - newStr = newStr[1:] + case "Enum": + if info.typeKinds[expectedType] == "" { + return true + } + return info.typeKinds[expectedType] == "ENUM" && info.enumValues[expectedType][arg.Raw] + + case "Int", "Float", "String", "Block", "Boolean": + if assignable, known := schema.LiteralKindAssignable(expectedType, arg.Kind); known { + return assignable + } + switch info.typeKinds[expectedType] { + case "SCALAR": + // custom scalars accept whatever their config allows + kind := arg.Kind + if kind == "Block" { + kind = "String" + } + return info.scalarInputs[expectedType][kind] + case "ENUM", "INPUT": + return false + default: + return true + } + + default: + // fallback nodes constructed from raw values don't carry a kind we can + // check + return true } - return newStr } diff --git a/packages/houdini-core/plugin/generateRuntime.go b/packages/houdini-core/plugin/generateRuntime.go index a9ac3442da..25813850c3 100644 --- a/packages/houdini-core/plugin/generateRuntime.go +++ b/packages/houdini-core/plugin/generateRuntime.go @@ -95,12 +95,6 @@ func (p *HoudiniCore) GenerateRuntime(ctx context.Context) ([]string, error) { return err } existingContent = string(existingContentByte) - - // we can delete the file now - err = p.Fs.Remove(targetPath) - if err != nil { - return err - } } err := runtime.GenerateRuntimeIndexFile(ctx, p.DB, p.Fs) diff --git a/packages/houdini-core/plugin/lists/insertOperations.go b/packages/houdini-core/plugin/lists/insertOperations.go index 2aaa8aedb9..78df439b9e 100644 --- a/packages/houdini-core/plugin/lists/insertOperations.go +++ b/packages/houdini-core/plugin/lists/insertOperations.go @@ -282,8 +282,8 @@ func InsertOperationDocuments( // let's collect the documents we inserted so we can copy the argument values over to both documents copyTargets := []int64{} - // _insert and _toggle both get the full selection set - for _, suffixes := range []string{graphql.ListOperationSuffixInsert, graphql.ListOperationSuffixToggle} { + // _insert, _toggle, _upsert, and _update all get the full selection set + for _, suffixes := range []string{graphql.ListOperationSuffixInsert, graphql.ListOperationSuffixToggle, graphql.ListOperationSuffixUpsert, graphql.ListOperationSuffixUpdate} { err := db.ExecStatement(insertDocument, map[string]any{ "name": fmt.Sprintf("%s%s", name, suffixes), "kind": "fragment", diff --git a/packages/houdini-core/plugin/lists/paginationDocuments.go b/packages/houdini-core/plugin/lists/paginationDocuments.go index 695d4d6a95..afa94b59fc 100644 --- a/packages/houdini-core/plugin/lists/paginationDocuments.go +++ b/packages/houdini-core/plugin/lists/paginationDocuments.go @@ -338,11 +338,37 @@ func PreparePaginationDocuments( defer insertDiscoveredLists.Finalize() // statements for fragment pagination processing + // + // Copy only the selections we need into the paginated fragment: + // 1. root-level internal (key) fields — needed for cache lookup + // 2. the paginated field's subtree (grandchildren+) — copyChildSelectionsQuery + // handles the direct children so we skip them here to avoid orphaned refs + // + // Siblings of the paginated field (and their descendants) are intentionally omitted + // because the pagination query only needs the path from the fragment root down to + // the paginated field, not unrelated sibling data. copySelectionsQuery, err := conn.Prepare(` + WITH RECURSIVE paginated_subtree AS ( + SELECT parent_id, child_id, row, column, path_index, internal + FROM selection_refs + WHERE document = $original_document AND parent_id = $paginated_field + UNION ALL + SELECT sr.parent_id, sr.child_id, sr.row, sr.column, sr.path_index, sr.internal + FROM selection_refs sr + JOIN paginated_subtree ps ON sr.parent_id = ps.child_id + WHERE sr.document = $original_document + ) INSERT INTO selection_refs (parent_id, child_id, document, row, column, path_index, internal) SELECT parent_id, child_id, $new_document, row, column, path_index, internal FROM selection_refs - WHERE document = $original_document AND child_id != $paginated_field + WHERE document = $original_document + AND parent_id IS NULL + AND internal = true + AND child_id != $paginated_field + UNION ALL + SELECT parent_id, child_id, $new_document, row, column, path_index, internal + FROM paginated_subtree + WHERE parent_id != $paginated_field `) if err != nil { return commit(plugins.WrapError(err)) @@ -885,6 +911,17 @@ func processFragmentPagination( return 0, err } + // mark the fragment spread with @mask_disable so the cache reader exposes its + // fields through the abstract type wrapper (node() returns an interface, and + // external fragment spreads are masked by default; we need them visible here). + err = ctx.db.ExecStatement(ctx.insertSelectionDirective, map[string]any{ + "selection": fragmentSpreadID, + "directive": graphql.DisableMaskDirective, + }) + if err != nil { + return 0, err + } + // add resolve query arguments (keys) for _, key := range list.Keys { // create variable value for resolve query argument diff --git a/packages/houdini-core/plugin/lists/paginationDocuments_test.go b/packages/houdini-core/plugin/lists/paginationDocuments_test.go index 6d9b298bf7..a883e146d1 100644 --- a/packages/houdini-core/plugin/lists/paginationDocuments_test.go +++ b/packages/houdini-core/plugin/lists/paginationDocuments_test.go @@ -549,7 +549,7 @@ func TestPaginationDocumentGeneration(t *testing.T) { fmt.Sprintf(` query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) { node(id: $id) { - ...Friends_paginated_c9Zhk @with(first: $first, after: $after, before: $before, last: $last) + ...Friends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last) __typename id } @@ -606,7 +606,7 @@ func TestPaginationDocumentGeneration(t *testing.T) { fmt.Sprintf(` query %s($limit: Int = 10, $offset: Int, $title: String!) @dedupe(match: Variables) { legend(title: $title) { - ...Believers_paginated_1uyQEt @with(limit: $limit, offset: $offset) + ...Believers_paginated_1uyQEt @mask_disable @with(limit: $limit, offset: $offset) __typename title } @@ -784,7 +784,7 @@ func TestPaginationDocumentGeneration(t *testing.T) { fmt.Sprintf(` query %s($first: Int = 2, $after: String, $before: String, $last: Int, $id: ID!, $snapshot: String!) @dedupe(match: Variables) { node(id: $id) { - ...UserFriends_paginated_SAvn1 @with(first: $first, after: $after, before: $before, last: $last, snapshot: $snapshot) + ...UserFriends_paginated_SAvn1 @mask_disable @with(first: $first, after: $after, before: $before, last: $last, snapshot: $snapshot) __typename id } @@ -794,6 +794,83 @@ func TestPaginationDocumentGeneration(t *testing.T) { )), }, }, + { + Name: "fragment with non-paginated siblings strips sibling fields from pagination query", + Pass: true, + Input: []string{ + ` + fragment Friends on User { + firstName + friends(first: 10) @paginate { + edges { + node { + firstName + } + } + } + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc(` + fragment Friends_paginated_c9Zhk on User { + __typename + friends(first: $first, after: $after, last: $last, before: $before) @paginate { + edges { + node { + firstName + __typename + id + } + cursor + __typename + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + __typename + } + id + } + `).WithVariables( + tests.ExpectedOperationVariable{ + Name: "first", + Type: "Int", + DefaultValue: &tests.ExpectedArgumentValue{ + Kind: "Int", + Raw: "10", + }, + }, + tests.ExpectedOperationVariable{ + Name: "after", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "last", + Type: "Int", + }, + tests.ExpectedOperationVariable{ + Name: "before", + Type: "String", + }, + ), + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) { + node(id: $id) { + ...Friends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last) + __typename + id + } + } + `, + graphql.FragmentPaginationQueryName("Friends"), + )), + }, + }, }, }) } diff --git a/packages/houdini-core/plugin/lists/validate.go b/packages/houdini-core/plugin/lists/validate.go index 4a559506aa..c372c73cba 100644 --- a/packages/houdini-core/plugin/lists/validate.go +++ b/packages/houdini-core/plugin/lists/validate.go @@ -63,6 +63,57 @@ func ValidateConflictingPrependAppend( } } +func ValidateIncludeListID( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + // @includeListID is only valid on fields that also carry @list or @paginate + query := ` + SELECT + raw_documents.filepath, + raw_documents.offset_line, + raw_documents.offset_column, + sd.row AS line, + sd.column, + documents.name AS documentName + FROM selection_directives sd + JOIN selection_refs ON selection_refs.child_id = sd.selection_id + JOIN documents ON documents.id = selection_refs.document + JOIN raw_documents ON raw_documents.id = documents.raw_document + LEFT JOIN selection_directives sd2 + ON sd2.selection_id = sd.selection_id + AND sd2.directive IN ($list, $paginate) + WHERE sd.directive = $includeListID + AND (raw_documents.current_task = $task_id OR $task_id IS NULL) + AND sd2.selection_id IS NULL + ` + bindings := map[string]any{ + "includeListID": graphql.IncludeListIDDirective, + "list": graphql.ListDirective, + "paginate": graphql.PaginationDirective, + } + err := db.StepQuery(ctx, query, bindings, func(stmt plugins.Row) { + filepath := stmt.ColumnText(0) + line := int(stmt.ColumnInt(1)) + int(stmt.ColumnInt(3)) + column := int(stmt.ColumnInt(2)) + int(stmt.ColumnInt(4)) + documentName := stmt.ColumnText(5) + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@includeListID can only be used on fields that also have @list or @paginate in document %q", + documentName, + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{ + {Filepath: filepath, Line: line, Column: column}, + }, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } +} + func ValidateConflictingParentIDAllLists( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], @@ -385,7 +436,7 @@ func ValidateParentID( -- Define a table of acceptable suffixes suffixes(sfx) AS ( - VALUES ($insert_prefix), ($toggle_prefix), ($remove_prefix) + VALUES ($insert_prefix), ($toggle_prefix), ($remove_prefix), ($upsert_prefix), ($update_prefix) ), -- precompute the list of operation names that could refer to a constrainted list @@ -419,6 +470,8 @@ func ValidateParentID( "insert_prefix": graphql.ListOperationSuffixInsert, "toggle_prefix": graphql.ListOperationSuffixToggle, "remove_prefix": graphql.ListOperationSuffixRemove, + "upsert_prefix": graphql.ListOperationSuffixUpsert, + "update_prefix": graphql.ListOperationSuffixUpdate, "parentID_directive": graphql.ParentIDDirective, "allLists_directive": graphql.AllListsDirective, } @@ -869,7 +922,7 @@ func validateFragmentSpreads( // we need a query that looks for references to fragments in selection that don't exist in the database query := ` WITH suffixes(sfx) AS ( - VALUES ($insert_prefix), ($remove_prefix), ($toggle_prefix) + VALUES ($insert_prefix), ($remove_prefix), ($toggle_prefix), ($upsert_prefix), ($update_prefix) ), discovered_fragments AS ( SELECT @@ -897,6 +950,8 @@ func validateFragmentSpreads( "insert_prefix": graphql.ListOperationSuffixInsert, "remove_prefix": graphql.ListOperationSuffixRemove, "toggle_prefix": graphql.ListOperationSuffixToggle, + "upsert_prefix": graphql.ListOperationSuffixUpsert, + "update_prefix": graphql.ListOperationSuffixUpdate, } err := db.StepQuery(ctx, query, bindings, func(stmt plugins.Row) { diff --git a/packages/houdini-core/plugin/runtime/imperativeCache.go b/packages/houdini-core/plugin/runtime/imperativeCache.go index 2604c762f1..09a085e406 100644 --- a/packages/houdini-core/plugin/runtime/imperativeCache.go +++ b/packages/houdini-core/plugin/runtime/imperativeCache.go @@ -443,7 +443,7 @@ func generateCacheTypeDef( content.WriteString("\t\t};\n") // Generate lists section - listsSection, err := generateListsSection(ctx, db) + listsSection, err := generateListsSection(ctx, db, projectConfig) if err != nil { return "", err } @@ -768,6 +768,7 @@ func getFragmentsByType( func generateListsSection( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], + projectConfig plugins.ProjectConfig, ) (string, error) { var content strings.Builder @@ -803,7 +804,7 @@ func generateListsSection( } // Generate filters from pre-loaded data - filters := generateListFiltersFromData(list.FilterArgs) + filters := generateListFiltersFromData(projectConfig, list.FilterArgs) content.WriteString(fmt.Sprintf("\t\t\t\tfilters: %s;\n", filters)) content.WriteString("\t\t\t};\n") @@ -832,10 +833,10 @@ func getDiscoveredListsWithFilters( // First get the basic list info with possible types err := db.StepQuery(ctx, ` - SELECT DISTINCT dl.name, dl.list_field, dl.target_type, - COALESCE(pt.member, dl.target_type) as possible_type + SELECT DISTINCT dl.name, dl.list_field, dl.node_type, + COALESCE(pt.member, dl.node_type) as possible_type FROM discovered_lists dl - LEFT JOIN possible_types pt ON dl.target_type = pt.type + LEFT JOIN possible_types pt ON dl.node_type = pt.type WHERE dl.name IS NOT NULL ORDER BY dl.name, possible_type `, nil, func(stmt plugins.Row) { @@ -862,11 +863,15 @@ func getDiscoveredListsWithFilters( return nil, err } - // Now get the field arguments for each list + // Now get the field arguments for each list. list_field points at the + // selection the @list was found on, so we have to go through selections + // to land on the schema field that defines the arguments err = db.StepQuery(ctx, ` - SELECT DISTINCT dl.name, tfa.name as arg_name, tfa.type, tfa.type_modifiers + SELECT DISTINCT dl.name, tfa.name as arg_name, tfa.type, tfa.type_modifiers, types.kind FROM discovered_lists dl - JOIN type_field_arguments tfa ON dl.list_field = tfa.field + JOIN selections s ON dl.list_field = s.id + JOIN type_field_arguments tfa ON s.type = tfa.field + LEFT JOIN types ON tfa.type = types.name WHERE dl.name IS NOT NULL ORDER BY dl.name, tfa.name `, nil, func(stmt plugins.Row) { @@ -876,6 +881,7 @@ func getDiscoveredListsWithFilters( arg := FieldArgument{ Name: stmt.ColumnText(1), Type: stmt.ColumnText(2), + Kind: stmt.ColumnText(4), } if stmt.ColumnType(3) == plugins.ColumnKindText { arg.TypeModifiers = stmt.ColumnText(3) @@ -888,20 +894,23 @@ func getDiscoveredListsWithFilters( return listsWithFilters, err } -func generateListFiltersFromData(args []FieldArgument) string { +func generateListFiltersFromData(projectConfig plugins.ProjectConfig, args []FieldArgument) string { if len(args) == 0 { return "never" } var argStrings []string for _, arg := range args { - // Convert to TypeScript type using the exported function - baseType := typescript.ConvertScalarType(plugins.ProjectConfig{}, arg.Type, false) - tsType := typescript.ApplyTypeModifiers( - baseType, + tsType, err := typescript.ConvertToTypeScriptType( + projectConfig, + arg.Kind, + arg.Type, arg.TypeModifiers, true, - ) // Input type (filter argument) + ) + if err != nil { + continue + } // All filter arguments are optional argStrings = append(argStrings, fmt.Sprintf("\n\t\t\t\t\t%s?: %s;", arg.Name, tsType)) diff --git a/packages/houdini-core/plugin/runtime/imperativeCache_test.go b/packages/houdini-core/plugin/runtime/imperativeCache_test.go index e9d77057eb..eda2f79bbb 100644 --- a/packages/houdini-core/plugin/runtime/imperativeCache_test.go +++ b/packages/houdini-core/plugin/runtime/imperativeCache_test.go @@ -3,6 +3,7 @@ package runtime_test import ( "context" "path/filepath" + "strings" "testing" "code.houdinigraphql.com/packages/houdini-core/config" @@ -108,6 +109,7 @@ func TestGenerateImperativeCacheTypeDefs(t *testing.T) { Tests: []tests.Test[config.PluginConfig]{ { Name: "happy path", + Pass: true, Input: []string{ `query TestQuery { users( @@ -146,193 +148,245 @@ func TestGenerateImperativeCacheTypeDefs(t *testing.T) { expected := tests.Dedent(` import type { Record } from "./public/record"; - import { TestQueryNoArgs$result, TestQueryNoArgs$input } from "../artifacts/TestQueryNoArgs"; - import { TestQuery$result, TestQuery$input } from "../artifacts/TestQuery"; - import type { ValueOf } from "$houdini/runtime/lib/types"; - import type { MyEnum } from "$houdini/graphql/enums"; - import { UserInfoWithArguments$input } from "../artifacts/UserInfoWithArguments"; - import { UserInfoWithArguments$data } from "../artifacts/UserInfoWithArguments"; - import { UserInfo$data } from "../artifacts/UserInfo"; - - type NestedUserFilter = { - id: string; - firstName: string; - admin?: boolean | null | undefined; - age?: number | null | undefined; - weight?: number | null | undefined; - }; - - type UserFilter = { - middle?: NestedUserFilter | null | undefined; - listRequired: (string)[]; - nullList?: (string | null | undefined)[] | null | undefined; - recursive?: UserFilter | null | undefined; - enum?: ValueOf | null | undefined; - }; + import type { TestQuery$result, TestQuery$input } from "../artifacts/TestQuery"; + import type { TestQueryNoArgs$result, TestQueryNoArgs$input } from "../artifacts/TestQueryNoArgs"; + import type { MyEnum$options } from "$houdini/graphql/enums"; + import type { NestedUserFilter } from "$houdini/graphql/inputs"; + import type { UserFilter } from "$houdini/graphql/inputs"; + import type { UserInfo$data } from "../artifacts/UserInfo"; + import type { UserInfoWithArguments$input } from "../artifacts/UserInfoWithArguments"; + import type { UserInfoWithArguments$data } from "../artifacts/UserInfoWithArguments"; export declare type CacheTypeDef = { types: { - __ROOT__: { - idFields: {}; - fields: { - user: { - type: Record | null; - args: { - id?: string | null | undefined; - filter?: UserFilter | null | undefined; - filterList?: (UserFilter)[] | null | undefined; - enumArg?: ValueOf | null | undefined; - }; - }; - users: { - type: ((Record | null))[] | null; - args: { - filter?: UserFilter | null | undefined; - list: (UserFilter)[]; - id: string; - firstName: string; - admin?: boolean | null | undefined; - age?: number | null | undefined; - weight?: number | null | undefined; - }; - }; - nodes: { - type: (Record | Record | Record)[]; - args: never; - }; - entities: { - type: ((Record | Record | null))[] | null; - args: never; - }; - entity: { - type: Record | Record; - args: never; - }; - listOfLists: { - type: ((((Record | null))[] | null))[]; - args: never; - }; - node: { - type: Record | Record | Record | null; - args: { - id: string; - }; - }; + __ROOT__: { + idFields: {}; + fields: { + __typename: { + type: string; + args: never; + }; + entities: { + type: (Record | Record | null)[] | null; + args: never; + }; + entity: { + type: Record | Record; + args: never; + }; + listOfLists: { + type: ((Record | null)[] | null)[]; + args: never; + }; + node: { + type: Record | Record | Record | null; + args: { + id: string | number; }; - fragments: []; - }; - Cat: { - idFields: { - id: string; + }; + nodes: { + type: (Record | Record | Record)[]; + args: never; + }; + user: { + type: Record | null; + args: { + enumArg?: MyEnum$options | null | undefined; + filter?: UserFilter | null | undefined; + filterList?: (UserFilter)[] | null | undefined; + id?: string | number | null | undefined; }; - fields: { - id: { - type: string; - args: never; - }; - kitty: { - type: boolean; - args: never; - }; - isAnimal: { - type: boolean; - args: never; - }; - names: { - type: ((string | null))[]; - args: never; - }; + }; + users: { + type: (Record | null)[] | null; + args: { + admin?: boolean | null | undefined; + age?: number | null | undefined; + filter?: UserFilter | null | undefined; + firstName: string; + id: string | number; + list: (UserFilter)[]; + weight?: number | null | undefined; }; - fragments: []; + }; }; - Ghost: { - idFields: { - name: string; - aka: string; - }; - fields: { - id: { - type: string; - args: never; - }; - aka: { - type: string; - args: never; - }; - name: { - type: string; - args: never; - }; - }; - fragments: []; + fragments: []; + }; + Animal: { + idFields: never; + fields: { + __typename: { + type: string; + args: never; + }; + isAnimal: { + type: boolean; + args: never; + }; + }; + fragments: []; + }; + Cat: { + idFields: { + id: any; + }; + fields: { + __typename: { + type: string; + args: never; + }; + id: { + type: string; + args: never; + }; + isAnimal: { + type: boolean; + args: never; + }; + kitty: { + type: boolean; + args: never; + }; + names: { + type: (string | null)[]; + args: never; + }; }; - User: { - idFields: { - id: string; + fragments: []; + }; + Ghost: { + idFields: { + id: any; + }; + fields: { + __typename: { + type: string; + args: never; + }; + aka: { + type: string; + args: never; + }; + id: { + type: string; + args: never; + }; + name: { + type: string; + args: never; + }; + }; + fragments: []; + }; + Mutation: { + idFields: never; + fields: { + __typename: { + type: string; + args: never; + }; + doThing: { + type: Record | null; + args: { + admin?: boolean | null | undefined; + age?: number | null | undefined; + filter?: UserFilter | null | undefined; + firstName: string; + id: string | number; + list: (UserFilter)[]; + weight?: number | null | undefined; }; - fields: { - id: { - type: string; - args: never; - }; - firstName: { - type: string; - args: { - pattern?: string | null | undefined; - }; - }; - nickname: { - type: string | null; - args: never; - }; - parent: { - type: Record | null; - args: never; - }; - friends: { - type: ((Record | null))[] | null; - args: never; - }; - enumValue: { - type: MyEnum | null; - args: never; - }; - admin: { - type: boolean | null; - args: never; - }; - age: { - type: number | null; - args: never; - }; - weight: { - type: number | null; - args: never; - }; + }; + }; + fragments: []; + }; + Node: { + idFields: { + id: any; + }; + fields: { + __typename: { + type: string; + args: never; + }; + id: { + type: string; + args: never; + }; + }; + fragments: []; + }; + User: { + idFields: { + id: any; + }; + fields: { + __typename: { + type: string; + args: never; + }; + admin: { + type: boolean | null; + args: never; + }; + age: { + type: number | null; + args: never; + }; + enumValue: { + type: MyEnum$options | null; + args: never; + }; + firstName: { + type: string; + args: { + pattern?: string | null | undefined; }; - fragments: [[any, UserInfo$data, never], [any, UserInfoWithArguments$data, UserInfoWithArguments$input]]; + }; + friends: { + type: (Record | null)[] | null; + args: never; + }; + id: { + type: string; + args: never; + }; + nickname: { + type: string | null; + args: never; + }; + parent: { + type: Record | null; + args: never; + }; + weight: { + type: number | null; + args: never; + }; }; + fragments: [[any, UserInfo$data, never], [any, UserInfoWithArguments$data, UserInfoWithArguments$input]]; + }; }; lists: { - All_Users: { - types: "User"; - filters: { - filter?: UserFilter | null | undefined; - list?: (UserFilter)[]; - id?: string; - firstName?: string; - admin?: boolean | null | undefined; - age?: number | null | undefined; - weight?: number | null | undefined; - }; - }; - NoArgs: { - types: "User" | "Cat"; - filters: never; + All_Users: { + types: "User"; + filters: { + admin?: boolean | null | undefined; + age?: number | null | undefined; + filter?: UserFilter | null | undefined; + firstName?: string; + id?: string | number; + list?: (UserFilter)[]; + weight?: number | null | undefined; }; + }; + NoArgs: { + types: "Cat" | "User"; + filters: never; + }; }; queries: [[any, TestQuery$result, TestQuery$input], [any, TestQueryNoArgs$result, TestQueryNoArgs$input]]; - scalars: number | boolean | string + scalars: number | boolean | string; }; `) @@ -341,7 +395,7 @@ func TestGenerateImperativeCacheTypeDefs(t *testing.T) { filepath.Join(config.ProjectRoot, config.RuntimeDir, "runtime", "generated.ts"), ) require.NoError(t, err) - require.Equal(t, expected, contents) + require.Equal(t, expected, strings.TrimSpace(string(contents))) }, }) } diff --git a/packages/houdini-core/plugin/runtime/pluginIndex.go b/packages/houdini-core/plugin/runtime/pluginIndex.go index bc40ea34f1..1c3b8ce124 100644 --- a/packages/houdini-core/plugin/runtime/pluginIndex.go +++ b/packages/houdini-core/plugin/runtime/pluginIndex.go @@ -32,7 +32,7 @@ func GeneratePluginIndex( } // write the file contents - err = afero.WriteFile(fs, indexPath, []byte(content), 0o644) + err = plugins.WriteFile(fs, indexPath, []byte(content), 0o644) if err != nil { return err } diff --git a/packages/houdini-core/plugin/runtime/runtimeIndex.go b/packages/houdini-core/plugin/runtime/runtimeIndex.go index 3556ef592d..1a8f96765b 100644 --- a/packages/houdini-core/plugin/runtime/runtimeIndex.go +++ b/packages/houdini-core/plugin/runtime/runtimeIndex.go @@ -25,7 +25,6 @@ func GenerateRuntimeIndexFile( // we are going to populate the runtime index indexPath := filepath.Join(config.ProjectRoot, config.RuntimeDir, "index.ts") - _ = fs.Remove(indexPath) definitionsRelative, err := filepath.Rel(config.RuntimeDir, config.DefinitionsDirectory()) if err != nil { @@ -43,11 +42,11 @@ func GenerateRuntimeIndexFile( defer db.Put(conn) documentSearch, err := conn.Prepare(` - SELECT - name - FROM documents + SELECT + name + FROM documents JOIN raw_documents ON documents.raw_document = raw_documents.id - WHERE printed IS NOT NULL and internal = 0 + WHERE internal = 0 ORDER BY name ASC `) if err != nil { @@ -107,7 +106,7 @@ export * from './%s' ) // if we got this far then we need to update the file - err = afero.WriteFile(fs, indexPath, []byte(indexContent), 0644) + err = plugins.WriteFile(fs, indexPath, []byte(indexContent), 0644) if err != nil { return err } diff --git a/packages/houdini-core/plugin/schema/arguments.go b/packages/houdini-core/plugin/schema/arguments.go index 62f823f62b..41c1ea4189 100644 --- a/packages/houdini-core/plugin/schema/arguments.go +++ b/packages/houdini-core/plugin/schema/arguments.go @@ -122,9 +122,11 @@ func (p *typeParser) parseType() (*ast.Type, error) { // validateValue recursively validates an AST value against a parsed GraphQL type. // It handles non-null, list, and named types. func validateValue(t *ast.Type, val *ast.Value) (bool, error) { - // If the type is non-null, the value must be non-nil. + isNull := val == nil || val.Kind == ast.NullValue + + // If the type is non-null, the value must be non-null. if t.NonNull { - if val == nil { + if isNull { return false, nil } // Remove the non-null requirement for nested validation. @@ -133,16 +135,16 @@ func validateValue(t *ast.Type, val *ast.Value) (bool, error) { return validateValue(&tCopy, val) } - // For nullable types, a nil value (GraphQL null) is valid. - if val == nil { + // For nullable types, a null value is valid. + if isNull { return true, nil } // Handle list types. if t.Elem != nil { - // The value must be a list. + // a single value is coerced to a one-element list if val.Kind != ast.ListValue { - return false, nil + return validateValue(t.Elem, val) } // Recursively validate each element of the list. for _, child := range val.Children { @@ -157,25 +159,35 @@ func validateValue(t *ast.Type, val *ast.Value) (bool, error) { return true, nil } - // For named types, determine the expected AST value kind. - expectedKind, err := expectedKindForNamedType(t.NamedType) - if err != nil { - return false, err - } - return val.Kind == expectedKind, nil + return valueKindMatches(t.NamedType, val.Kind), nil } -// expectedKindForNamedType maps a GraphQL named type (e.g. "Int", "String", "Boolean") -// to its expected AST value kind. -func expectedKindForNamedType(name string) (ast.ValueKind, error) { - switch name { - case "Int": - return ast.IntValue, nil - case "String": - return ast.StringValue, nil - case "Boolean": - return ast.BooleanValue, nil +// valueKindMatches reports whether a literal of the given AST kind can coerce +// to the named type, following the spec's input coercion rules +func valueKindMatches(name string, kind ast.ValueKind) bool { + var literal string + switch kind { + case ast.IntValue: + literal = "Int" + case ast.FloatValue: + literal = "Float" + case ast.StringValue: + literal = "String" + case ast.BlockValue: + literal = "Block" + case ast.BooleanValue: + literal = "Boolean" + case ast.EnumValue: + literal = "Enum" default: - return ast.StringValue, fmt.Errorf("unknown named type: %s", name) + literal = "Object" + } + + assignable, known := LiteralKindAssignable(name, literal) + if !known { + // enums, custom scalars, and input objects can't be checked from the + // type name alone so trust the value + return true } + return assignable } diff --git a/packages/houdini-core/plugin/schema/generateDefinitions.go b/packages/houdini-core/plugin/schema/generateDefinitions.go index b1157fbf5a..501fabda04 100644 --- a/packages/houdini-core/plugin/schema/generateDefinitions.go +++ b/packages/houdini-core/plugin/schema/generateDefinitions.go @@ -236,7 +236,7 @@ func generateSchemaFile( return plugins.WrapError(err) } - err = afero.WriteFile(fs, schemaFileLocation, []byte(schemaString.String()), 0o644) + err = plugins.WriteFile(fs, schemaFileLocation, []byte(schemaString.String()), 0o644) if err != nil { return plugins.WrapError(err) } @@ -263,6 +263,8 @@ func generateDocumentsFile( WHERE d.name = dl.name || '_insert' OR d.name = dl.name || '_toggle' OR d.name = dl.name || '_remove' + OR d.name = dl.name || '_upsert' + OR d.name = dl.name || '_update' ) ORDER BY d.name `, nil, func(stmt plugins.Row) { @@ -284,7 +286,7 @@ func generateDocumentsFile( return plugins.WrapError(err) } - err = afero.WriteFile(fs, documentsFileLocation, []byte(documentString.String()), 0o644) + err = plugins.WriteFile(fs, documentsFileLocation, []byte(documentString.String()), 0o644) if err != nil { return plugins.WrapError(err) } @@ -397,7 +399,7 @@ func generateEnumFiles( return plugins.WrapError(err) } - err = afero.WriteFile(fs, enumsFileLocation, []byte(enumString.String()), 0o644) + err = plugins.WriteFile(fs, enumsFileLocation, []byte(enumString.String()), 0o644) if err != nil { return plugins.WrapError(err) } @@ -405,7 +407,7 @@ func generateEnumFiles( indexJsContent := "\nexport * from './enums.js'\n\n" indexJsLocation := projectConfig.DefinitionsIndexJs() - err = afero.WriteFile(fs, indexJsLocation, []byte(indexJsContent), 0o644) + err = plugins.WriteFile(fs, indexJsLocation, []byte(indexJsContent), 0o644) if err != nil { return plugins.WrapError(err) } diff --git a/packages/houdini-core/plugin/schema/generateDefinitions_test.go b/packages/houdini-core/plugin/schema/generateDefinitions_test.go index bfda997bde..39690b4f2a 100644 --- a/packages/houdini-core/plugin/schema/generateDefinitions_test.go +++ b/packages/houdini-core/plugin/schema/generateDefinitions_test.go @@ -87,6 +87,12 @@ directive @allLists on FRAGMENT_SPREAD """@parentID is used to provide a parentID without specifying position or in situations where it doesn't make sense (eg when deleting a node.)""" directive @parentID(value: ID!) on FRAGMENT_SPREAD +"""@includeListID exposes an opaque __id value on the list that can be passed to @listID on a mutation to target a specific list instance.""" +directive @includeListID on FIELD + +"""@listID is used to identify a list by the opaque key obtained from __id (via @includeListID).""" +directive @listID(value: ID!) on FRAGMENT_SPREAD + """@when is used to provide a conditional or in situations where it doesn't make sense (eg when removing or deleting a node.)""" directive @when on FRAGMENT_SPREAD @@ -164,6 +170,14 @@ fragment Friends_toggle on User { id } +fragment Friends_update on User { + id +} + +fragment Friends_upsert on User { + id +} + `, }, }, @@ -198,6 +212,14 @@ fragment Friends_toggle on User { id } +fragment Friends_update on User { + id +} + +fragment Friends_upsert on User { + id +} + fragment theList_insert on CustomIdType { foo bar @@ -213,6 +235,16 @@ fragment theList_toggle on CustomIdType { bar } +fragment theList_update on CustomIdType { + foo + bar +} + +fragment theList_upsert on CustomIdType { + foo + bar +} + `, }, }, @@ -224,7 +256,7 @@ fragment theList_toggle on CustomIdType { }, Extra: map[string]any{ "runGenerationTwice": true, - "directiveCount": 1, + "directiveCount": 2, // @list and @listID both match "directive @list" }, }, }, diff --git a/packages/houdini-core/plugin/schema/typeRef.go b/packages/houdini-core/plugin/schema/typeRef.go new file mode 100644 index 0000000000..e893861f20 --- /dev/null +++ b/packages/houdini-core/plugin/schema/typeRef.go @@ -0,0 +1,128 @@ +package schema + +// Type modifiers are stored as strings read inner→outer: a leading '!' marks the +// base type non-null, and every ']' opens a list level whose own non-null flag is +// the '!' immediately following it. For example: +// +// type | modifiers +// ---------+---------- +// ID | `` +// ID! | `!` +// [ID] | `]` +// [ID]! | `]!` +// [ID!] | `!]` +// [ID!]! | `!]!` +// [[ID]!] | `]!]` +// +// TypeRef decodes that encoding into one node per level, outermost first, so +// assignability rules can walk the type the way the spec describes them. +type TypeRef struct { + NonNull bool + IsList bool + Inner *TypeRef // nil for the base type +} + +func ParseTypeRef(modifiers string) *TypeRef { + ref := &TypeRef{} + i := 0 + if i < len(modifiers) && modifiers[i] == '!' { + ref.NonNull = true + i++ + } + for ; i < len(modifiers); i++ { + if modifiers[i] != ']' { + continue + } + wrapper := &TypeRef{IsList: true, Inner: ref} + if i+1 < len(modifiers) && modifiers[i+1] == '!' { + wrapper.NonNull = true + i++ + } + ref = wrapper + } + return ref +} + +// TypeCompatible reports whether a value of the variable's shape can flow into +// the location's shape, assuming the base types already match. It implements the +// spec's AreTypesCompatible: at every level a non-null variable satisfies a +// nullable location but not the other way around, and list depths must line up. +// +// variable | location | compatible +// ---------+----------+----------- +// | | true +// ! | | true +// ! | ! | true +// | ! | false +// ] | ] | true +// ]! | ] | true +// !] | ] | true +// !]! | ] | true +// ] | ]! | false +// ]! | ]! | true +// !] | ]! | false +// !]! | ]! | true +// ] | !] | false +// ]! | !] | false +// !] | !] | true +// !]! | !] | true +// ] | !]! | false +// ]! | !]! | false +// !] | !]! | false +// !]! | !]! | true +func TypeCompatible(variable, location *TypeRef) bool { + for { + if location.NonNull && !variable.NonNull { + return false + } + if location.IsList != variable.IsList { + return false + } + if !location.IsList { + return true + } + variable, location = variable.Inner, location.Inner + } +} + +// VariableTypeCompatible implements the spec's IsVariableUsageAllowed: a nullable +// variable can fill a non-null location if the variable declares a non-null +// default value, but only at the outermost level. +func VariableTypeCompatible(variable, location *TypeRef, hasNonNullDefault bool) bool { + if location.NonNull && !variable.NonNull { + if !hasNonNullDefault { + return false + } + unwrapped := *location + unwrapped.NonNull = false + location = &unwrapped + } + return TypeCompatible(variable, location) +} + +// LiteralKindAssignable reports whether a literal of the given kind (the kinds +// recorded in argument_values: Int, Float, String, Block, Boolean, ...) can +// coerce to the named type, following the spec's input coercion rules. known is +// false when the type isn't a built-in scalar and the answer requires schema +// information (enums, custom scalars, input objects). +func LiteralKindAssignable(typeName, literalKind string) (assignable bool, known bool) { + if literalKind == "Block" { + literalKind = "String" + } + switch typeName { + case "Int": + return literalKind == "Int", true + case "Float": + // the spec coerces integer literals to Float + return literalKind == "Int" || literalKind == "Float", true + case "String": + return literalKind == "String", true + case "Boolean": + return literalKind == "Boolean", true + case "ID": + // the spec coerces both strings and integers to ID + return literalKind == "String" || literalKind == "Int", true + default: + return false, false + } +} diff --git a/packages/houdini-core/plugin/schema/typeRef_test.go b/packages/houdini-core/plugin/schema/typeRef_test.go new file mode 100644 index 0000000000..1005676827 --- /dev/null +++ b/packages/houdini-core/plugin/schema/typeRef_test.go @@ -0,0 +1,171 @@ +package schema_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "code.houdinigraphql.com/packages/houdini-core/plugin/schema" +) + +func TestParseTypeRef(t *testing.T) { + for _, tc := range []struct { + modifiers string + expected string + }{ + {"", "T"}, + {"!", "T!"}, + {"]", "[T]"}, + {"]!", "[T]!"}, + {"!]", "[T!]"}, + {"!]!", "[T!]!"}, + {"]]", "[[T]]"}, + {"]!]", "[[T]!]"}, + {"!]!]!", "[[T!]!]!"}, + } { + t.Run(tc.expected, func(t *testing.T) { + require.Equal(t, tc.expected, printTypeRef(schema.ParseTypeRef(tc.modifiers))) + }) + } +} + +// printTypeRef renders a TypeRef back to GraphQL syntax with a T base type so +// the parse tests read naturally +func printTypeRef(ref *schema.TypeRef) string { + if ref == nil { + return "T" + } + result := "T" + if !ref.IsList { + if ref.NonNull { + result += "!" + } + return result + } + result = "[" + printTypeRef(ref.Inner) + "]" + if ref.NonNull { + result += "!" + } + return result +} + +func TestVariableTypeCompatible(t *testing.T) { + // the full truth table for variable usage without default values + for _, tc := range []struct { + variable string + location string + ok bool + }{ + // single values + {"", "", true}, + {"!", "", true}, + {"!", "!", true}, + {"", "!", false}, + + // lists + {"]", "]", true}, + {"]!", "]", true}, + {"!]", "]", true}, + {"!]!", "]", true}, + + {"]", "]!", false}, + {"]!", "]!", true}, + {"!]", "]!", false}, + {"!]!", "]!", true}, + + {"]", "!]", false}, + {"]!", "!]", false}, + {"!]", "!]", true}, + {"!]!", "!]", true}, + + {"]", "!]!", false}, + {"]!", "!]!", false}, + {"!]", "!]!", false}, + {"!]!", "!]!", true}, + + // list depth must match exactly + {"", "]", false}, + {"]", "", false}, + {"]]", "]", false}, + {"]", "]]", false}, + } { + name := printTypeRef(schema.ParseTypeRef(tc.variable)) + " -> " + printTypeRef( + schema.ParseTypeRef(tc.location), + ) + t.Run(name, func(t *testing.T) { + require.Equal( + t, + tc.ok, + schema.VariableTypeCompatible( + schema.ParseTypeRef(tc.variable), + schema.ParseTypeRef(tc.location), + false, + ), + ) + }) + } +} + +func TestVariableTypeCompatibleWithDefault(t *testing.T) { + // a non-null default forgives a nullable variable at the outermost level only + for _, tc := range []struct { + variable string + location string + ok bool + }{ + {"", "!", true}, + {"]", "]!", true}, + // the default does not forgive inner levels + {"]", "!]!", false}, + {"!]", "!]!", true}, + // base types still have to line up in depth + {"", "]", false}, + } { + name := printTypeRef(schema.ParseTypeRef(tc.variable)) + " -> " + printTypeRef( + schema.ParseTypeRef(tc.location), + ) + " (default)" + t.Run(name, func(t *testing.T) { + require.Equal( + t, + tc.ok, + schema.VariableTypeCompatible( + schema.ParseTypeRef(tc.variable), + schema.ParseTypeRef(tc.location), + true, + ), + ) + }) + } +} + +func TestLiteralKindAssignable(t *testing.T) { + for _, tc := range []struct { + typeName string + kind string + assignable bool + known bool + }{ + {"Int", "Int", true, true}, + {"Int", "Float", false, true}, + {"Float", "Int", true, true}, + {"Float", "Float", true, true}, + {"String", "String", true, true}, + {"String", "Block", true, true}, + {"String", "Int", false, true}, + {"Boolean", "Boolean", true, true}, + {"Boolean", "String", false, true}, + {"ID", "String", true, true}, + {"ID", "Int", true, true}, + {"ID", "Boolean", false, true}, + {"Date", "String", false, false}, + {"Role", "Enum", false, false}, + } { + t.Run(tc.typeName+" <- "+tc.kind, func(t *testing.T) { + assignable, known := schema.LiteralKindAssignable(tc.typeName, tc.kind) + require.Equal(t, tc.known, known) + if known { + require.Equal(t, tc.assignable, assignable) + } + }) + } +} diff --git a/packages/houdini-core/plugin/schema/write.go b/packages/houdini-core/plugin/schema/write.go index ddbe7a661a..a840b8166f 100644 --- a/packages/houdini-core/plugin/schema/write.go +++ b/packages/houdini-core/plugin/schema/write.go @@ -753,6 +753,49 @@ then the request will never be deduplicated.`, return err } + // @includeListID on FIELD_DEFINITION — exposes __id on the list for use with @listID + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.IncludeListIDDirective, + "description": "@includeListID exposes an opaque __id value on the list that can be passed to @listID on a mutation to target a specific list instance.", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.IncludeListIDDirective, + "location": "FIELD", + }) + if err != nil { + return err + } + + // @listID(value: ID!) on FRAGMENT_SPREAD + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.ListIDDirective, + "description": "@listID is used to identify a list by the opaque key obtained from __id (via @includeListID).", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.ListIDDirective, + "location": "FRAGMENT_SPREAD", + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveArgument, map[string]any{ + "directive": graphql.ListIDDirective, + "name": "value", + "type": "ID", + "type_modifiers": "!", + }) + if err != nil { + return err + } + // @when on FRAGMENT_SPREAD err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ "name": graphql.WhenDirective, diff --git a/packages/houdini-core/plugin/validate.go b/packages/houdini-core/plugin/validate.go index 6286693e5f..fe9c37f992 100644 --- a/packages/houdini-core/plugin/validate.go +++ b/packages/houdini-core/plugin/validate.go @@ -23,6 +23,7 @@ func (p *HoudiniCore) Validate(ctx context.Context) error { documents.ValidateFragmentUnknownType, documents.ValidateFragmentOnScalar, documents.ValidateOutputTypeAsInput, + documents.ValidateUnknownVariableTypes, documents.ValidateScalarWithSelection, documents.ValidateUnknownField, documents.ValidateIncompatibleFragmentSpread, @@ -35,7 +36,6 @@ func (p *HoudiniCore) Validate(ctx context.Context) error { documents.ValidateDuplicateArgumentInField, documents.ValidateWrongTypesToArg, documents.ValidateMissingRequiredArgument, - documents.ValidateFieldArgumentIncompatibleType, documents.ValidateConflictingSelections, documents.ValidateDuplicateKeysInInputObject, // Houdini-specific validation rules @@ -49,6 +49,7 @@ func (p *HoudiniCore) Validate(ctx context.Context) error { lists.DiscoverListsThenValidate, lists.ValidateConflictingParentIDAllLists, lists.ValidateConflictingPrependAppend, + lists.ValidateIncludeListID, lists.ValidatePaginateTypeCondition, lists.ValidateSinglePaginateDirective, lists.ValidateParentID, diff --git a/packages/houdini-core/plugin/validate_test.go b/packages/houdini-core/plugin/validate_test.go index 9933e11ff8..4f144332a0 100644 --- a/packages/houdini-core/plugin/validate_test.go +++ b/packages/houdini-core/plugin/validate_test.go @@ -62,10 +62,16 @@ func TestValidate_Houdini(t *testing.T) { directive @repeatable repeatable on FIELD + enum UserRole { + ADMIN + USER + } + type Query { rootScalar: String user(name: String! birthday: Date) : User users(filters: [UserFilter], filter: UserFilter, limit: Int, offset: Int): [User!]! + usersByRole(role: UserRole, roles: [UserRole!], minWeight: Float): [User!]! nodes(ids: [ID!]!): [Node!]! entitiesByCursor(first: Int, after: String, last: Int, before: String): EntityConnection! node(id: ID!): Node @@ -665,6 +671,443 @@ func TestValidate_Houdini(t *testing.T) { }`, }, }, + { + Name: "List arguments can be passed as static values", + Pass: true, + Input: []string{ + `query Test { + nodes(ids: [1, 2, 3]) { + id + } + }`, + }, + }, + { + Name: "List arguments with static values of the wrong element type", + Pass: false, + Input: []string{ + `query Test { + nodes(ids: [true, false]) { + id + } + }`, + }, + }, + { + Name: "Static lists cannot contain null when the element type is non-null", + Pass: false, + Input: []string{ + `query Test { + nodes(ids: [null]) { + id + } + }`, + }, + }, + { + Name: "Static lists cannot be passed to non-list arguments", + Pass: false, + Input: []string{ + `query Test { + user(name: ["foo"]) { + firstName + } + }`, + }, + }, + { + Name: "Nested static lists of input objects", + Pass: true, + Input: []string{ + `query Test { + users(filters: [{ and: [{ firstName: "foo" }] }]) { + id + } + }`, + }, + }, + { + Name: "Int literals coerce to Float", + Pass: true, + Input: []string{ + `query Test { + usersByRole(minWeight: 2) { + id + } + }`, + }, + }, + { + Name: "Float literals do not coerce to Int", + Pass: false, + Input: []string{ + `query Test { + users(limit: 1.5) { + id + } + }`, + }, + }, + { + Name: "Block strings are strings", + Pass: true, + Input: []string{ + `query Test { + user(name: """foo""") { + id + } + }`, + }, + }, + { + Name: "Single values coerce to lists", + Pass: true, + Input: []string{ + `query Test { + nodes(ids: "1") { + id + } + }`, + }, + }, + { + Name: "Enum values can be passed to enum arguments", + Pass: true, + Input: []string{ + `query Test { + usersByRole(role: ADMIN) { + id + } + }`, + }, + }, + { + Name: "Unknown enum values are rejected", + Pass: false, + Input: []string{ + `query Test { + usersByRole(role: SUPERADMIN) { + id + } + }`, + }, + }, + { + Name: "Strings cannot be passed to enum arguments", + Pass: false, + Input: []string{ + `query Test { + usersByRole(role: "ADMIN") { + id + } + }`, + }, + }, + { + Name: "Enum values inside static lists", + Pass: true, + Input: []string{ + `query Test { + usersByRole(roles: [ADMIN, USER]) { + id + } + }`, + }, + }, + { + Name: "Nullable variables with defaults satisfy non-null arguments", + Pass: true, + Input: []string{ + `query Test($id: ID = "1") { + node(id: $id) { + id + } + }`, + }, + }, + { + Name: "Variable defaults do not forgive inner nullability", + Pass: false, + Input: []string{ + `query Test($ids: [ID] = ["1"]) { + nodes(ids: $ids) { + id + } + }`, + }, + }, + { + Name: "Unknown fields on input objects", + Pass: false, + Input: []string{ + `mutation Test { + update(input: { unknown: 1 }) + }`, + }, + }, + { + Name: "Fragment arguments with list defaults", + Pass: true, + Input: []string{ + `fragment Fragment on Query @arguments( + ids: { type: "[ID!]!", default: ["1"] } + ) { + nodes(ids: $ids) { + id + } + }`, + }, + }, + { + Name: "@with values must match the fragment argument type", + Pass: false, + Input: []string{ + `fragment Fragment on User @arguments( + offset: { type: "Int" } + ) { + friends(offset: $offset) { + id + } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(offset: "five") + } + }`, + }, + }, + { + Name: "@with values matching the fragment argument type", + Pass: true, + Input: []string{ + `fragment Fragment on User @arguments( + offset: { type: "Int" } + ) { + friends(offset: $offset) { + id + } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(offset: 5) + } + }`, + }, + }, + { + Name: "@with cannot pass null to non-null fragment arguments", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + ids: { type: "[ID!]!" } + ) { + nodes(ids: $ids) { + id + } + }`, + `query Test { + ...Fragment @with(ids: null) + }`, + }, + }, + { + Name: "@with can pass empty lists to non-null list arguments", + Pass: true, + Input: []string{ + `fragment Fragment on Query @arguments( + ids: { type: "[ID!]!" } + ) { + nodes(ids: $ids) { + id + } + }`, + `query Test { + ...Fragment @with(ids: []) + }`, + }, + }, + { + Name: "@with accepts known enum values", + Pass: true, + Input: []string{ + `fragment Fragment on Query @arguments( + role: { type: "UserRole" } + ) { + usersByRole(role: $role) { + id + } + }`, + `query Test { + ...Fragment @with(role: ADMIN) + }`, + }, + }, + { + Name: "@with rejects unknown enum values", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + role: { type: "UserRole" } + ) { + usersByRole(role: $role) { + id + } + }`, + `query Test { + ...Fragment @with(role: SUPERADMIN) + }`, + }, + }, + { + Name: "@with rejects strings for enum arguments", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + role: { type: "UserRole" } + ) { + usersByRole(role: $role) { + id + } + }`, + `query Test { + ...Fragment @with(role: "ADMIN") + }`, + }, + }, + { + Name: "@with accepts valid input objects", + Pass: true, + Input: []string{ + `fragment Fragment on Query @arguments( + filter: { type: "UserFilter" } + ) { + users(filter: $filter) { + id + } + }`, + `query Test { + ...Fragment @with(filter: { and: [{ firstName: "x" }] }) + }`, + }, + }, + { + Name: "@with rejects unknown fields on input objects", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + filter: { type: "UserFilter" } + ) { + users(filter: $filter) { + id + } + }`, + `query Test { + ...Fragment @with(filter: { bogus: "x" }) + }`, + }, + }, + { + Name: "@with rejects mistyped fields nested in input objects", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + filter: { type: "UserFilter" } + ) { + users(filter: $filter) { + id + } + }`, + `query Test { + ...Fragment @with(filter: { and: [{ firstName: 1 }] }) + }`, + }, + }, + { + Name: "@with accepts configured custom scalar inputs", + Pass: true, + Input: []string{ + `fragment Fragment on Query @arguments( + birthday: { type: "Date" } + ) { + user(name: "foo", birthday: $birthday) { + id + } + }`, + `query Test { + ...Fragment @with(birthday: "2024-01-01") + }`, + }, + }, + { + Name: "@with rejects custom scalar inputs outside the config", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + birthday: { type: "Date" } + ) { + user(name: "foo", birthday: $birthday) { + id + } + }`, + `query Test { + ...Fragment @with(birthday: true) + }`, + }, + }, + { + Name: "Variables cannot use unknown types", + Pass: false, + Input: []string{ + `query Test($name: NotARealType!) { + user(name: $name) { + id + } + }`, + }, + }, + { + Name: "Fragment arguments cannot use unknown types", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + ids: { type: "NotARealType" } + ) { + nodes(ids: $ids) { + id + } + }`, + `query Test { + ...Fragment @with(ids: "A") + }`, + }, + }, + { + Name: "Variables with unknown types are caught even when only passed through @with", + Pass: false, + Input: []string{ + `fragment Fragment on Query @arguments( + ids: { type: "[ID!]!" } + ) { + nodes(ids: $ids) { + id + } + }`, + `query Test($ids: NotARealType) { + ...Fragment @with(ids: $ids) + }`, + }, + }, + { + Name: "Variables can use runtime scalar types", + Pass: true, + Input: []string{ + `query Test($id: ViewerIDFromSession!) { + node(id: $id) { + id + } + }`, + }, + }, { Name: "No aliases for default keys", Pass: false, @@ -1420,12 +1863,12 @@ func TestValidate_Houdini(t *testing.T) { }, }, { - Name: "must pass list to list fragment arguments", - Pass: false, + Name: "single values passed to list fragment arguments are coerced", + Pass: true, Input: []string{ ` fragment Fragment on Query @arguments( - ids: { type: "[String]" } + ids: { type: "[ID!]!" } ) { nodes(ids: $ids) { id diff --git a/packages/houdini-core/runtime/index.ts b/packages/houdini-core/runtime/index.ts index 65fd220f15..51c365fb18 100644 --- a/packages/houdini-core/runtime/index.ts +++ b/packages/houdini-core/runtime/index.ts @@ -4,7 +4,7 @@ import _cache from './cache.js' import type { CacheTypeDef } from './generated.js' import { Cache } from './public/index.js' -export { CachePolicy, PendingValue } from 'houdini/runtime' +export { CachePolicy, PendingValue, isPending } from 'houdini/runtime' export type { QueryArtifact, GraphQLVariables } from 'houdini/runtime' export * from './client.js' diff --git a/packages/houdini-core/runtime/plugins/fragment.ts b/packages/houdini-core/runtime/plugins/fragment.ts index d39fa15104..27e996f0e2 100644 --- a/packages/houdini-core/runtime/plugins/fragment.ts +++ b/packages/houdini-core/runtime/plugins/fragment.ts @@ -48,9 +48,16 @@ export const fragment = (cache: Cache) => selection: ctx.artifact.selection, variables: () => variables, parentID: ctx.stuff.parentID, - set: (newValue) => { + onMessage: (message) => { + // fragments can't issue network requests. refetch messages are + // handled by the query that owns this data — it holds its own + // masked parent subscription on the same records + if (message.kind !== 'update') { + return + } + resolve(ctx, { - data: newValue, + data: message.data, errors: null, fetching: false, partial: false, diff --git a/packages/houdini-core/runtime/plugins/query.test.ts b/packages/houdini-core/runtime/plugins/query.test.ts index fd130bf159..82c721aa4d 100644 --- a/packages/houdini-core/runtime/plugins/query.test.ts +++ b/packages/houdini-core/runtime/plugins/query.test.ts @@ -1,4 +1,5 @@ import { Cache } from 'houdini/runtime/cache' +import { CachePolicy } from 'houdini/runtime/types' import { beforeEach, expect, test, vi } from 'vitest' import { testConfigFile } from '../../test' @@ -11,6 +12,69 @@ beforeEach(async () => { setMockConfig(config) }) +test('refetch triggered by cache.refresh uses the most recent session, not the subscription-time session', async () => { + const cache = new Cache() + + // write a record so the subscription has something to attach to + const selection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + } + + cache._internal_unstable.write({ + selection, + data: { viewer: { id: '1', firstName: 'bob' } }, + }) + + // spy to capture every network request and the session it carries + const fetchSpy = vi.fn() + + const store = createStore({ + artifact: { + kind: 'HoudiniQuery', + hash: '7777', + raw: 'RAW_TEXT', + name: 'TestArtifact', + rootType: 'Query', + pluginData: {}, + stripVariables: [], + selection, + }, + pipeline: [query(cache), fakeFetch({ spy: fetchSpy })], + }) + + // first send — establishes the cache subscription with session 'old' + await store.send({ session: { token: 'old' }, variables: {} }) + + // second send with the same variables but a new session — no new subscription is created + // but lastSession in the closure must be updated to 'new' + await store.send({ session: { token: 'new' }, variables: {} }) + + // reset the spy so we only see the refetch request + fetchSpy.mockClear() + + // trigger a refetch via the cache + cache._internal_unstable.refresh('User:1') + + // give the async send a tick to run + await new Promise((r) => setTimeout(r, 0)) + + // the refetch must carry the new session, not the stale one from subscription time + expect(fetchSpy).toHaveBeenCalledOnce() + expect(fetchSpy.mock.calls[0][0].session).toEqual({ token: 'new' }) +}) + test('query plugin evaluates runtime scalars', async () => { const fetchSpy = vi.fn() diff --git a/packages/houdini-core/runtime/plugins/query.ts b/packages/houdini-core/runtime/plugins/query.ts index 1e632966d9..46913fe0e4 100644 --- a/packages/houdini-core/runtime/plugins/query.ts +++ b/packages/houdini-core/runtime/plugins/query.ts @@ -1,6 +1,6 @@ import type { RuntimeScalarPayload } from 'houdini' import type { Cache } from 'houdini/runtime/cache' -import { type SubscriptionSpec, ArtifactKind, DataSource } from 'houdini/runtime/types' +import { type SubscriptionSpec, ArtifactKind, CachePolicy, DataSource } from 'houdini/runtime/types' import { documentPlugin } from './utils/index.js' @@ -12,6 +12,11 @@ export const query = (cache: Cache) => // remember the last variables we were called with let lastVariables: Record | null = null + // track the most recent session so that refetch requests triggered by + // record.refresh() use the current auth token, not the one from when the + // subscription was first created + let lastSession: App.Session | null | undefined = null + // the function to call when a query is sent return { start(ctx, { next }) { @@ -46,6 +51,10 @@ export const query = (cache: Cache) => // patch subscriptions on the way out so that we don't get a cache update // before the promise resolves end(ctx, { resolve, marshalVariables, variablesChanged }) { + // always keep the session current so that a later record.refresh() call + // uses the auth token from the most recent send(), not from subscription time + lastSession = ctx.session + // if the variables have changed we need to setup a new subscription with the cache if (variablesChanged(ctx) && !ctx.cacheParams?.disableSubscriptions) { // if the variables changed we need to unsubscribe from the old fields and @@ -63,9 +72,20 @@ export const query = (cache: Cache) => rootType: ctx.artifact.rootType, selection: ctx.artifact.selection, variables: () => variables, - set: (newValue) => { + onMessage: (message) => { + // if the cache asked us to refetch, kick off a brand new request + // through the full pipeline so the document reloads from the API + if (message.kind === 'refetch') { + ctx.documentStore.send({ + policy: CachePolicy.NetworkOnly, + session: lastSession, + metadata: ctx.metadata, + }) + return + } + resolve(ctx, { - data: newValue, + data: message.data, errors: null, fetching: false, partial: false, diff --git a/packages/houdini-core/runtime/plugins/subscription.ts b/packages/houdini-core/runtime/plugins/subscription.ts index d334bfdd0b..907b518a4c 100644 --- a/packages/houdini-core/runtime/plugins/subscription.ts +++ b/packages/houdini-core/runtime/plugins/subscription.ts @@ -1,6 +1,7 @@ import { deepEquals } from 'houdini/runtime' import type { ClientPluginContext } from 'houdini/runtime/documentStore' import { ArtifactKind, DataSource } from 'houdini/runtime/types' +import type { GraphQLError } from 'houdini/runtime/types' import { documentPlugin } from './utils/index.js' @@ -110,7 +111,7 @@ export type SubscriptionClient = { extensions?: Record<'persistedQuery', string> | Record }, handlers: { - next: (payload: { data?: {} | null; errors?: readonly { message: string }[] }) => void + next: (payload: { data?: {} | null; errors?: readonly GraphQLError[] }) => void error: (data: {}) => void complete: () => void } diff --git a/packages/houdini-core/runtime/public/list.test.ts b/packages/houdini-core/runtime/public/list.test.ts new file mode 100644 index 0000000000..5cac344676 --- /dev/null +++ b/packages/houdini-core/runtime/public/list.test.ts @@ -0,0 +1,110 @@ +import { test, expect, vi } from 'vitest' + +import type { SubscriptionSelection } from 'houdini/runtime/types' +import { testCache, testFragment } from './tests/test.js' + +const friendsSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + }, + }, + }, +} + +test('upsert inserts record when not already in list', () => { + const cache = testCache() + + cache._internal_unstable.write({ + selection: friendsSelection, + data: { viewer: { id: '1', friends: [{ id: '2', firstName: 'jane' }] } }, + }) + cache._internal_unstable.subscribe({ + rootType: 'Query', + onMessage: vi.fn(), + selection: friendsSelection, + }) + + const user = cache.get('User', { id: '3' }) + user.write({ + fragment: testFragment({ + fields: { firstName: { type: 'String', visible: true, keyRaw: 'firstName' } }, + }), + data: { firstName: 'mary' }, + }) + + const list = cache.list('All_Users') + list.upsert('last', user) + + expect([...list]).toEqual(['User:2', 'User:3']) +}) + +test('upsert updates existing record when already in list', () => { + const cache = testCache() + + cache._internal_unstable.write({ + selection: friendsSelection, + data: { + viewer: { + id: '1', + friends: [ + { id: '2', firstName: 'jane' }, + { id: '3', firstName: 'mary' }, + ], + }, + }, + }) + + const onMessage = vi.fn() + cache._internal_unstable.subscribe({ + rootType: 'Query', + onMessage, + selection: friendsSelection, + }) + + const user = cache.get('User', { id: '3' }) + user.write({ + fragment: testFragment({ + fields: { firstName: { type: 'String', visible: true, keyRaw: 'firstName' } }, + }), + data: { firstName: 'mary-updated' }, + }) + + const list = cache.list('All_Users') + list.upsert('last', user) + + // list must not grow + expect([...list]).toEqual(['User:2', 'User:3']) + + // subscriber receives the updated field + expect(onMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + kind: 'update', + data: expect.objectContaining({ + viewer: expect.objectContaining({ + friends: expect.arrayContaining([ + expect.objectContaining({ firstName: 'mary-updated' }), + ]), + }), + }), + }) + ) +}) diff --git a/packages/houdini-core/runtime/public/list.ts b/packages/houdini-core/runtime/public/list.ts index 70a0180c4f..b75e8d4ca9 100644 --- a/packages/houdini-core/runtime/public/list.ts +++ b/packages/houdini-core/runtime/public/list.ts @@ -81,6 +81,19 @@ export class ListCollection[]) { + if (!this.#collection) { + return + } + + const { selection, data } = this.#listOperationPayload(records) + for (const entry of data) { + if (entry) { + this.#collection.upsertInList(selection, entry, {}, where) + } + } + } + when(filter: ListFilters): ListCollection { if (!this.#collection) { return this diff --git a/packages/houdini-core/runtime/public/record.ts b/packages/houdini-core/runtime/public/record.ts index e8d1ce32c0..fa3d7a5c62 100644 --- a/packages/houdini-core/runtime/public/record.ts +++ b/packages/houdini-core/runtime/public/record.ts @@ -94,6 +94,14 @@ export class Record> { this.#cache._internal_unstable.delete(this.#id) } + /** + * Ask every document whose data contains this record to refetch itself + * so the record's values are reloaded from the API. + */ + refresh() { + this.#cache._internal_unstable.refresh(this.#id) + } + /** * Mark some elements of the record stale in the cache. * @param field diff --git a/packages/houdini-core/runtime/public/tests/list.test.ts b/packages/houdini-core/runtime/public/tests/list.test.ts index 58161efbc1..b8c31428fd 100644 --- a/packages/houdini-core/runtime/public/tests/list.test.ts +++ b/packages/houdini-core/runtime/public/tests/list.test.ts @@ -1,6 +1,6 @@ import { test, expect, vi } from 'vitest' -import type { SubscriptionSelection } from '../../lib' +import type { SubscriptionSelection } from 'houdini/runtime/types' import { testCache, testFragment } from './test.js' test('list.append accepts record proxies', () => { @@ -109,7 +109,7 @@ test('list.append accepts record proxies', () => { // subscribe to the fields cache._internal_unstable.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -134,32 +134,35 @@ test('list.append accepts record proxies', () => { // make sure the duplicate has been removed expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'Cat', - id: '2', - firstName: 'mary', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'Cat', + id: '2', + firstName: 'mary', + }, }, - }, - { - node: { - __typename: 'User', - id: '3', - firstName: 'jane', + { + node: { + __typename: 'User', + id: '3', + firstName: 'jane', + }, }, - }, - { - node: { - __typename: 'User', - id: '4', - firstName: 'jacob', + { + node: { + __typename: 'User', + id: '4', + firstName: 'jacob', + }, }, - }, - ], + ], + }, }, }, }) @@ -270,7 +273,7 @@ test('list.prepend accepts record proxies', () => { // subscribe to the fields cache._internal_unstable.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -295,32 +298,35 @@ test('list.prepend accepts record proxies', () => { // make sure the duplicate has been removed expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'User', - id: '4', - firstName: 'jacob', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: '4', + firstName: 'jacob', + }, }, - }, - { - node: { - __typename: 'Cat', - id: '2', - firstName: 'mary', + { + node: { + __typename: 'Cat', + id: '2', + firstName: 'mary', + }, }, - }, - { - node: { - __typename: 'User', - id: '3', - firstName: 'jane', + { + node: { + __typename: 'User', + id: '3', + firstName: 'jane', + }, }, - }, - ], + ], + }, }, }, }) @@ -401,7 +407,7 @@ test('list when must', () => { // subscribe to the fields cache._internal_unstable.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -429,18 +435,21 @@ test('list when must', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'mary', - id: '3', - }, - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'mary', + id: '3', + }, + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) }) @@ -514,7 +523,7 @@ test('can remove record', () => { // subscribe to the fields cache._internal_unstable.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -528,9 +537,12 @@ test('can remove record', () => { // the first time set was called, a new entry was added. // the second time it's called, we get a new value for mary-prime expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [], + }, }, }) }) @@ -624,7 +636,7 @@ test('can toggle records', () => { }, }, parentID: cache._internal_unstable._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -731,7 +743,7 @@ test('can remove record from all lists', () => { }, }, parentID: cache._internal_unstable._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) diff --git a/packages/houdini-core/runtime/public/tests/record.test.ts b/packages/houdini-core/runtime/public/tests/record.test.ts index bfcdf7a09b..89be9d7f1f 100644 --- a/packages/houdini-core/runtime/public/tests/record.test.ts +++ b/packages/houdini-core/runtime/public/tests/record.test.ts @@ -1,6 +1,10 @@ import { test, expect } from 'vitest' -import { ArtifactKind, type FragmentArtifact, type SubscriptionSelection } from '../../lib' +import { + ArtifactKind, + type FragmentArtifact, + type SubscriptionSelection, +} from 'houdini/runtime/types' import { testCache, testFragment } from './test.js' test('can read fragment', () => { diff --git a/packages/houdini-core/runtime/public/tests/stale.test.ts b/packages/houdini-core/runtime/public/tests/stale.test.ts index fdb92418ff..a573e7d7cf 100644 --- a/packages/houdini-core/runtime/public/tests/stale.test.ts +++ b/packages/houdini-core/runtime/public/tests/stale.test.ts @@ -1,6 +1,6 @@ import { test, expect } from 'vitest' -import { ArtifactKind, type FragmentArtifact } from '../../lib' +import { ArtifactKind, type FragmentArtifact } from 'houdini/runtime/types' import type { Cache } from '../cache.js' import { type CacheTypeDefTest, testCache } from './test.js' diff --git a/packages/houdini-core/runtime/public/tests/test.ts b/packages/houdini-core/runtime/public/tests/test.ts index 6e2d9b98ce..e355bba10f 100644 --- a/packages/houdini-core/runtime/public/tests/test.ts +++ b/packages/houdini-core/runtime/public/tests/test.ts @@ -1,11 +1,11 @@ import { testConfigFile } from '../../../../houdini/src/test/index.js' -import { Cache as _Cache } from '../../cache/cache' +import { Cache as _Cache } from '../../../../houdini/src/runtime/cache/index.js' import { ArtifactKind, type SubscriptionSelection, type FragmentArtifact, type QueryArtifact, -} from '../../lib' +} from '../../../../houdini/src/runtime/types.js' import { Cache } from '../cache.js' import type { Record } from '../record.js' diff --git a/packages/houdini-react/CHANGELOG.md b/packages/houdini-react/CHANGELOG.md index fcc7f26ad2..983dd9bad8 100644 --- a/packages/houdini-react/CHANGELOG.md +++ b/packages/houdini-react/CHANGELOG.md @@ -1,5 +1,44 @@ # houdini-react +## 2.0.0-next.38 + +### Patch Changes + +- [`892411c`](https://github.com/HoudiniGraphql/houdini/commit/892411c2938c93265583fbea9dca25cb4af1d9c1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix preload conflicting with navigations + +## 2.0.0-next.37 + +### Minor Changes + +- [#1655](https://github.com/HoudiniGraphql/houdini/pull/1655) [`2c796b8`](https://github.com/HoudiniGraphql/houdini/commit/2c796b82878d96da1d38e90b6eb46e1639c2c9f3) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add a `` component with a typed `to` prop checked at compile time against your app's route manifest, with `params` interpolation and custom scalar support. + +## 2.0.0-next.36 + +### Patch Changes + +- [#1654](https://github.com/HoudiniGraphql/houdini/pull/1654) [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - GraphQL errors now expose `locations`, `path`, and `extensions` per the spec; augment `App.GraphQLErrorExtensions` to type your server's extensions. + +- [#1650](https://github.com/HoudiniGraphql/houdini/pull/1650) [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix `useMutation` to return `[mutate, pending]` instead of `[pending, mutate]`, and fix list toggle operations accumulating across resolved optimistic mutation layers causing subsequent toggles to appear stuck. + +- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: + - houdini@2.0.0-next.34 + +## 2.0.0-next.35 + +### Patch Changes + +- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - prevent unnecessary re-renders on fragments by stabilizing returned values and skipping subscription updates when data hasn't changed + +- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix gaps in pagination request deduplication: stale inflight entries no longer block new requests, and ssr_signals now covers client-side concurrent renders to prevent duplicate observer/send pairs + +## 2.0.0-next.34 + +### Patch Changes + +- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - write generated files atomically to prevent partial-read parse errors when Vite loads a module mid-pipeline + +- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix HMR not regenerating the router manifest when a new `+page` or `+layout` file is added; invalidate component fields cache after each HMR cycle + ## 2.0.0-next.33 ### Patch Changes diff --git a/packages/houdini-react/package.json b/packages/houdini-react/package.json index b9e0efaaa9..179da90e34 100644 --- a/packages/houdini-react/package.json +++ b/packages/houdini-react/package.json @@ -1,6 +1,6 @@ { "name": "houdini-react", - "version": "2.0.0-next.33", + "version": "2.0.0-next.38", "description": "The React plugin for houdini", "keywords": [ "typescript", diff --git a/packages/houdini-react/package/vite/index.ts b/packages/houdini-react/package/vite/index.ts index d5ee3f4062..5190ea94da 100644 --- a/packages/houdini-react/package/vite/index.ts +++ b/packages/houdini-react/package/vite/index.ts @@ -8,7 +8,7 @@ import { import { load_manifest, type ProjectManifest } from 'houdini/router/manifest' import { type RouterManifest } from 'houdini/router/types' import { VitePluginContext } from 'houdini/vite' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import type * as React from 'react' import { build, type BuildOptions, type ConfigEnv, type Connect } from 'vite' @@ -16,6 +16,48 @@ import { PluginOption } from 'vite' import { transform_file, type ComponentFieldRow } from './transform.js' +// Matches GenerateTsConfig in packages/houdini-react/plugin/runtime.go — keep in sync. +const REACT_TSCONFIG_STUB = `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "$houdini": ["."], + "$houdini/*": ["./*"], + "~": ["../src"], + "~/*": ["../src/*"] + }, + "rootDirs": ["..", "./types"], + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "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", + "../src/+app.d.ts" + ], + "exclude": ["../node_modules/**", "./[!ambient.d.ts]**"] +} +` + // Resolve the node-compatible react-streaming server entry at load time. The // resolve.alias we add in config() redirects react-streaming/server to this // path, bypassing the package.json "browser" condition poison pill that @@ -45,6 +87,18 @@ export default function (ctx: VitePluginContext): PluginOption { async config(userConfig, env) { viteEnv = env + const runtimeDir = path.join( + ctx.config.root_dir, + ctx.config.config_file.runtimeDir ?? '.houdini' + ) + try { + mkdirSync(runtimeDir, { recursive: true }) + const tsconfigPath = path.join(runtimeDir, 'tsconfig.json') + if (!existsSync(tsconfigPath)) { + writeFileSync(tsconfigPath, REACT_TSCONFIG_STUB) + } + } catch {} + // SSR build: don't override outDir or input — let the inline config from // closeBundle() control where output lands. Transforms still run via the // transform hook registered on this plugin instance. @@ -122,10 +176,16 @@ export default function (ctx: VitePluginContext): PluginOption { }, resolveId(id) { - if (!id.includes('virtual:houdini')) { - return + if (id.includes('virtual:houdini')) { + return id.substring(id.indexOf('virtual:houdini')) } - return id.substring(id.indexOf('virtual:houdini')) + return null + }, + + hotUpdate() { + // Clear after every HMR cycle so the next transform re-queries the DB. + // Safe because transform is only called after the pipeline has finished. + cfCache = null }, async transform(code: string, filepath: string) { @@ -209,7 +269,14 @@ export default function (ctx: VitePluginContext): PluginOption { const pageName = parsedPath ? parsedPath.name : '' if (which === 'pages') { - const page = manifest.pages[pageName] + let page = manifest.pages[pageName] + if (!page) { + // Manifest may be stale after HMR regenerated it on disk — reload and retry. + try { + manifest = await load_manifest({ config: ctx.config }) + page = manifest.pages[pageName] + } catch {} + } if (!page) { throw new Error('unknown page' + pageName) } diff --git a/packages/houdini-react/plugin/generate.go b/packages/houdini-react/plugin/generate.go index 16adff5526..bd78fbf25d 100644 --- a/packages/houdini-react/plugin/generate.go +++ b/packages/houdini-react/plugin/generate.go @@ -17,6 +17,7 @@ import ( func unitsDir(pluginDir string) string { return filepath.Join(pluginDir, "units") } func pagesDir(pluginDir string) string { return filepath.Join(pluginDir, "units", "pages") } func layoutsDir(pluginDir string) string { return filepath.Join(pluginDir, "units", "layouts") } +func errorsDir(pluginDir string) string { return filepath.Join(pluginDir, "units", "errors") } func fallbacksDir(pluginDir, which string) string { return filepath.Join(pluginDir, "units", "fallbacks", which) } @@ -42,7 +43,7 @@ func writeIfChanged(fs afero.Fs, path, content string) (bool, error) { if err := fs.MkdirAll(filepath.Dir(path), 0755); err != nil { return false, err } - return true, afero.WriteFile(fs, path, []byte(content), 0644) + return true, plugins.WriteFile(fs, path, []byte(content), 0644) } // ---- unit file generation ---- @@ -139,6 +140,89 @@ func (p *HoudiniReact) GenerateDocumentWrappers(ctx context.Context) ([]string, return changed, nil } +// generateErrorUnitFile builds the JSX source for an error boundary wrapper component. +// layoutQueries is the list of layout-level query names the error component can access. +// paramKeys is the sorted list of URL route parameter names. +func generateErrorUnitFile(componentName, importPath string, layoutQueries, paramKeys []string) string { + var b strings.Builder + + b.WriteString("import { useQueryResult, PageContextProvider, HoudiniErrorBoundary, RedirectError, ClientRedirect } from '$houdini/plugins/houdini-react/runtime/routing'\n") + b.WriteString(fmt.Sprintf("import %s from '%s'\n\n", componentName, importPath)) + + b.WriteString("const ErrorView = ({ errors, children }) => {\n") + b.WriteString("\tconst redirectErr = errors.find(e => e instanceof RedirectError)\n") + b.WriteString("\tif (redirectErr) return \n") + if len(layoutQueries) > 0 { + for _, q := range layoutQueries { + b.WriteString(fmt.Sprintf("\tconst [%s$data, %s$handle] = useQueryResult(%q)\n", q, q, q)) + } + b.WriteString("\n") + } + + b.WriteString("\treturn (\n") + + var quotedKeys []string + for _, k := range paramKeys { + quotedKeys = append(quotedKeys, fmt.Sprintf("%q", k)) + } + b.WriteString(fmt.Sprintf("\t\t\n", strings.Join(quotedKeys, ", "))) + + var props []string + for _, q := range layoutQueries { + props = append(props, fmt.Sprintf("%s={%s$data}", q, q)) + props = append(props, fmt.Sprintf("%s$handle={%s$handle}", q, q)) + } + props = append(props, "errors={errors}") + b.WriteString(fmt.Sprintf("\t\t\t<%s %s>\n", componentName, strings.Join(props, " "))) + b.WriteString("\t\t\t\t{children}\n") + b.WriteString(fmt.Sprintf("\t\t\t\n", componentName)) + b.WriteString("\t\t\n") + b.WriteString("\t)\n") + b.WriteString("}\n\n") + + b.WriteString("export default ({ children }) => (\n") + b.WriteString("\t\n") + b.WriteString("\t\t{children}\n") + b.WriteString("\t\n") + b.WriteString(")\n") + + return b.String() +} + +// GenerateErrorWrappers generates per-page error boundary JSX wrapper components for +// pages that have a +error.tsx companion file. +func (p *HoudiniReact) GenerateErrorWrappers(ctx context.Context) ([]string, error) { + projectConfig, err := p.DB.ProjectConfig(ctx) + if err != nil { + return nil, err + } + manifest, err := p.LoadManifest(ctx) + if err != nil { + return nil, err + } + + pluginDir := projectConfig.PluginDirectory(p.Name()) + var changed []string + + for id, page := range manifest.Pages { + if page.ErrorPath == "" { + continue + } + compAbs := stripViewExt(filepath.Join(projectConfig.ProjectRoot, page.ErrorPath)) + compRel := toSlash(mustRel(errorsDir(pluginDir), compAbs)) + paramKeys := sortedKeys(page.Params) + content := generateErrorUnitFile("Component_"+id, compRel, page.LayoutQueries, paramKeys) + path := filepath.Join(errorsDir(pluginDir), id+".jsx") + if ok, err := writeIfChanged(p.Filesystem(), path, content); err != nil { + return nil, err + } else if ok { + changed = append(changed, path) + } + } + + return changed, nil +} + // ---- fallback generation ---- func generateFallbackFile(componentRel string, loadingQueries, requiredQueries []string) string { @@ -289,9 +373,19 @@ func generatePageEntry(id string, page PageManifest, manifest ProjectManifest, p imports = append(imports, fmt.Sprintf("import Layout_%s from '../layouts/%s.jsx'", layoutID, layoutID)) } + // Import error wrapper if the page has a +error companion + if page.ErrorPath != "" { + imports = append(imports, fmt.Sprintf("import Error_%s from '../errors/%s.jsx'", id, id)) + } + // Import page unit and client imports = append(imports, fmt.Sprintf("import Page_%s from '../pages/%s.jsx'", id, id)) imports = append(imports, "import client from '$houdini/plugins/houdini-react/runtime/client'") + if len(page.Layouts) > 0 { + imports = append(imports, "import { NotFoundGate, setCurrentSegment } from '$houdini/plugins/houdini-react/runtime/routing'") + } else { + imports = append(imports, "import { NotFoundGate } from '$houdini/plugins/houdini-react/runtime/routing'") + } // Import page fallback if page has a loading query if pq, ok := manifest.PageQueries[id]; ok && pq.Loading { @@ -311,14 +405,28 @@ func generatePageEntry(id string, page PageManifest, manifest ProjectManifest, p } // Build the ordered list of wrapper component names, outermost first. - // Process layouts outermost→innermost; inside each layout add fallback before layout. + // Process layouts outermost→innermost; inside each layout add fallback then SegmentSetter then layout. var wrappers []string + var segmentSetters []string for _, layoutID := range page.Layouts { if lq, ok := manifest.LayoutQueries[layoutID]; ok && lq.Loading { wrappers = append(wrappers, "LayoutFallback_"+layoutID) } + wrappers = append(wrappers, "SegmentSetter_"+layoutID) wrappers = append(wrappers, "Layout_"+layoutID) + segmentSetters = append(segmentSetters, + fmt.Sprintf("const SegmentSetter_%s = ({ children }) => { setCurrentSegment('%s'); return children }", layoutID, layoutID)) } + // Error boundary wraps page (inside layouts, outside page fallback) + if page.ErrorPath != "" { + wrappers = append(wrappers, "Error_"+id) + } + + // NotFoundGate sits inside the error boundary (if present) so that when the + // Router renders this entry for a 404 URL, the throw is caught at the right + // level and the layouts above it render normally. + wrappers = append(wrappers, "NotFoundGate") + if pq, ok := manifest.PageQueries[id]; ok && pq.Loading { wrappers = append(wrappers, "PageFallback_"+id) } @@ -328,6 +436,10 @@ func generatePageEntry(id string, page PageManifest, manifest ProjectManifest, p var b strings.Builder b.WriteString(strings.Join(imports, "\n")) + if len(segmentSetters) > 0 { + b.WriteString("\n\n") + b.WriteString(strings.Join(segmentSetters, "\n")) + } b.WriteString("\n\nexport default ({ url }) => {\n") b.WriteString("\treturn (\n") b.WriteString(nestedContent) @@ -416,7 +528,7 @@ import { HoudiniClient } from 'houdini/runtime/client' import { renderToStream } from 'houdini-react/server' import React from 'react' -import { router_cache } from '../../runtime/routing' +import { router_cache, StatusContext } from '../../runtime/routing' // @ts-expect-error import client from '%s/src/+client' // @ts-expect-error @@ -430,6 +542,7 @@ export const on_render = async ({ url, match, + is404, session, manifest, componentCache, @@ -441,28 +554,30 @@ export const on_render = createComponent: React.createElement }) - if (!match) { - return new Response('not found', { status: 404 }) - } - // Wire the per-request cache into the client so that all observe() calls // during this render write to (and read from) the same cache we serialize. client.setCache(cache) + // Mutable ref so that a synchronous RoutingError or redirect() inside + // HoudiniErrorBoundary can set the correct HTTP status/location before streaming. + const statusRef = { status: is404 ? 404 : 200, location: undefined } + const { readable, injectToStream, pipe: pipeTo, } = await renderToStream( - React.createElement(App, { - initialURL: url, - cache: cache, - session: session, - assetPrefix: assetPrefix, - manifest: manifest, - cssLinks: cssLinks || [], - ...router_cache() - }), + React.createElement(StatusContext.Provider, { value: statusRef }, + React.createElement(App, { + initialURL: url, + cache: cache, + session: session, + assetPrefix: assetPrefix, + manifest: manifest, + cssLinks: cssLinks || [], + ...router_cache() + }) + ), { webStream: production, userAgent: 'Vite' } ) @@ -474,14 +589,16 @@ export const on_render = ${documentPremable ?? ''} - + ${match ? '' : ''} ` + "`" + `) if (pipeTo && pipe) { pipeTo(pipe) return true + } else if (statusRef.location) { + return new Response(null, { status: statusRef.status, headers: { Location: statusRef.location } }) } else { - return new Response(readable) + return new Response(readable, { status: statusRef.status }) } } @@ -623,10 +740,11 @@ func (p *HoudiniReact) GenerateTypeRoots(ctx context.Context) ([]string, error) runtimeRel := toSlash(mustRel(targetDir, runtimeDir)) artifactRelDir := toSlash(mustRel(targetDir, artifactDir)) - var pageQueries, layoutQueries []string + var pageQueries, layoutQueries, errorQueries []string var params map[string]*ParamTypeInfo if entry.page != nil { pageQueries = entry.page.QueryOptions + errorQueries = entry.page.LayoutQueries params = entry.page.Params } if entry.layout != nil { @@ -635,10 +753,13 @@ func (p *HoudiniReact) GenerateTypeRoots(ctx context.Context) ([]string, error) params = entry.layout.Params } } + if errorQueries == nil { + errorQueries = []string{} + } allQueries := uniqueStrings(append(pageQueries, layoutQueries...)) - content := generateTypeRoot(runtimeRel, artifactRelDir, allQueries, pageQueries, layoutQueries, params) + content := generateTypeRoot(runtimeRel, artifactRelDir, allQueries, pageQueries, layoutQueries, errorQueries, params) if ok, err := writeIfChanged(p.Filesystem(), targetFile, content); err != nil { return nil, err } else if ok { @@ -649,7 +770,7 @@ func (p *HoudiniReact) GenerateTypeRoots(ctx context.Context) ([]string, error) return changed, nil } -func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries, layoutQueries []string, params map[string]*ParamTypeInfo) string { +func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries, layoutQueries, errorQueries []string, params map[string]*ParamTypeInfo) string { var b strings.Builder b.WriteString(fmt.Sprintf("import { DocumentHandle, RouteProp } from '%s'\n", runtimeRel)) @@ -658,6 +779,8 @@ func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries b.WriteString(fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '%s/%s'\n", q, q, q, artifactRelDir, q)) } + b.WriteString("import type { GraphQLError } from 'houdini/runtime'\n") + b.WriteString(fmt.Sprintf("import type { RoutingError } from '%s'\n", runtimeRel)) paramsType := formatParamsType(params) @@ -677,6 +800,14 @@ func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries } b.WriteString("}\n") + // ErrorProps + b.WriteString(fmt.Sprintf("\nexport type ErrorProps = {\n\tParams: %s,\n\terrors: Array,\n\tchildren: React.ReactNode,\n", paramsType)) + for _, q := range errorQueries { + b.WriteString(fmt.Sprintf("\t%s: %s$result,\n", q, q)) + b.WriteString(fmt.Sprintf("\t%s$handle: DocumentHandle<%s$artifact, %s$result, %s$input>,\n", q, q, q, q)) + } + b.WriteString("}\n") + return b.String() } diff --git a/packages/houdini-react/plugin/generate_test.go b/packages/houdini-react/plugin/generate_test.go index 0fcdb7283e..bc2658b8be 100644 --- a/packages/houdini-react/plugin/generate_test.go +++ b/packages/houdini-react/plugin/generate_test.go @@ -443,19 +443,29 @@ func TestGeneratePageEntries(t *testing.T) { import Layout__subRoute from '../layouts/_subRoute.jsx' import Page__subRoute_nested from '../pages/_subRoute_nested.jsx' import client from '$houdini/plugins/houdini-react/runtime/client' +import { NotFoundGate, setCurrentSegment } from '$houdini/plugins/houdini-react/runtime/routing' import PageFallback__subRoute_nested from '../fallbacks/page/_subRoute_nested.jsx' import LayoutFallback__ from '../fallbacks/layout/_.jsx' +const SegmentSetter__ = ({ children }) => { setCurrentSegment('_'); return children } +const SegmentSetter__subRoute = ({ children }) => { setCurrentSegment('_subRoute'); return children } + export default ({ url }) => { return ( - - - - - - - + + + + + + + + + + + + + ) } @@ -478,11 +488,58 @@ export default ({ url }) => { "expected": map[string]string{ "entries/_.jsx": `import Page__ from '../pages/_.jsx' import client from '$houdini/plugins/houdini-react/runtime/client' +import { NotFoundGate } from '$houdini/plugins/houdini-react/runtime/routing' import '../componentFields/wrapper_UserAvatar' export default ({ url }) => { return ( - + + + + ) +} +`, + }, + }, + }, + { + Name: "wraps page with error boundary when +error.tsx is present", + Pass: true, + Input: []string{ + mockQuery("RootQuery", false), + mockQuery("PageQuery", false), + }, + Filepaths: []string{ + "src/routes/+layout.gql", + "src/routes/subRoute/+page.gql", + }, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+layout.tsx": "export default ({children}) =>
{children}
", + "src/routes/subRoute/+page.tsx": mockView([]string{"RootQuery", "PageQuery"}), + "src/routes/subRoute/+error.tsx": "export default ({ errors }) =>
{errors[0].message}
", + }, + // entry: Layout(root) > Error > Page (no fallbacks — no @loading) + "expected": map[string]string{ + "entries/_subRoute.jsx": `import Layout__ from '../layouts/_.jsx' +import Error__subRoute from '../errors/_subRoute.jsx' +import Page__subRoute from '../pages/_subRoute.jsx' +import client from '$houdini/plugins/houdini-react/runtime/client' +import { NotFoundGate, setCurrentSegment } from '$houdini/plugins/houdini-react/runtime/routing' + +const SegmentSetter__ = ({ children }) => { setCurrentSegment('_'); return children } + +export default ({ url }) => { + return ( + + + + + + + + + ) } `, @@ -493,6 +550,127 @@ export default ({ url }) => { }) } +func TestGenerateErrorWrappers(t *testing.T) { + tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{ + Schema: ` + type Query { + id: ID + node(id: ID!): Node + } + interface Node { id: ID! } + `, + SetupAlwaysPasses: true, + + SetupTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + views, ok := test.Extra["views"].(map[string]string) + if !ok { + return + } + fs := p.Filesystem() + for fp, content := range views { + abs := filepath.Join("/project", fp) + require.NoError(t, fs.MkdirAll(filepath.Dir(abs), 0755)) + require.NoError(t, afero.WriteFile(fs, abs, []byte(content), 0644)) + } + }, + + PerformTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + ctx := context.Background() + _, err := p.GenerateErrorWrappers(ctx) + require.NoError(t, err) + + units := pluginUnitsDir(p) + for file, expected := range test.Extra["expected"].(map[string]string) { + got, err := afero.ReadFile(p.Filesystem(), filepath.Join(units, file)) + require.NoError(t, err) + require.Equal(t, expected, string(got), "file: %s", file) + } + }, + + Tests: []tests.Test[coreConfig.PluginConfig]{ + { + Name: "generates error wrapper with no layout queries", + Pass: true, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+page.tsx": mockView([]string{}), + "src/routes/+error.tsx": "export default ({ errors }) =>
{errors[0].message}
", + }, + "expected": map[string]string{ + "errors/_.jsx": `import { useQueryResult, PageContextProvider, HoudiniErrorBoundary, RedirectError, ClientRedirect } from '$houdini/plugins/houdini-react/runtime/routing' +import Component__ from '../../../../../src/routes/+error' + +const ErrorView = ({ errors, children }) => { + const redirectErr = errors.find(e => e instanceof RedirectError) + if (redirectErr) return + return ( + + + {children} + + + ) +} + +export default ({ children }) => ( + + {children} + +) +`, + }, + }, + }, + { + Name: "generates error wrapper with layout queries", + Pass: true, + Input: []string{ + mockQuery("RootQuery", false), + mockQuery("SubQuery", false), + }, + Filepaths: []string{ + "src/routes/+layout.gql", + "src/routes/subRoute/+layout.gql", + }, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+layout.tsx": "export default ({children}) =>
{children}
", + "src/routes/subRoute/+page.tsx": mockView([]string{"RootQuery", "SubQuery"}), + "src/routes/subRoute/+error.tsx": "export default ({ errors }) =>
{errors[0].message}
", + }, + // error wrapper at subRoute: layout queries are RootQuery + SubQuery + "expected": map[string]string{ + "errors/_subRoute.jsx": `import { useQueryResult, PageContextProvider, HoudiniErrorBoundary, RedirectError, ClientRedirect } from '$houdini/plugins/houdini-react/runtime/routing' +import Component__subRoute from '../../../../../src/routes/subRoute/+error' + +const ErrorView = ({ errors, children }) => { + const redirectErr = errors.find(e => e instanceof RedirectError) + if (redirectErr) return + const [RootQuery$data, RootQuery$handle] = useQueryResult("RootQuery") + const [SubQuery$data, SubQuery$handle] = useQueryResult("SubQuery") + + return ( + + + {children} + + + ) +} + +export default ({ children }) => ( + + {children} + +) +`, + }, + }, + }, + }, + }) +} + func TestGenerateRenderInfrastructure(t *testing.T) { tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{ Schema: `type Query { id: ID }`, @@ -745,6 +923,8 @@ func TestGenerateTypeRoots(t *testing.T) { "src/routes/$types.d.ts": `import { DocumentHandle, RouteProp } from '../../../plugins/houdini-react/runtime' import React from 'react' import type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../artifacts/LayoutQuery' +import type { GraphQLError } from 'houdini/runtime' +import type { RoutingError } from '../../../plugins/houdini-react/runtime' export type PageProps = { Params: {}, @@ -756,12 +936,22 @@ export type LayoutProps = { Params: {}, children: React.ReactNode, } + +export type ErrorProps = { + Params: {}, + errors: Array, + children: React.ReactNode, + LayoutQuery: LayoutQuery$result, + LayoutQuery$handle: DocumentHandle, +} `, "src/routes/(subRoute)/$types.d.ts": `import { DocumentHandle, RouteProp } from '../../../../plugins/houdini-react/runtime' import React from 'react' import type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../../artifacts/LayoutQuery' import type { RootQuery$result, RootQuery$artifact, RootQuery$input } from '../../../../artifacts/RootQuery' import type { FinalQuery$result, FinalQuery$artifact, FinalQuery$input } from '../../../../artifacts/FinalQuery' +import type { GraphQLError } from 'houdini/runtime' +import type { RoutingError } from '../../../../plugins/houdini-react/runtime' export type PageProps = { Params: {}, @@ -777,6 +967,16 @@ export type LayoutProps = { Params: {}, children: React.ReactNode, } + +export type ErrorProps = { + Params: {}, + errors: Array, + children: React.ReactNode, + LayoutQuery: LayoutQuery$result, + LayoutQuery$handle: DocumentHandle, + RootQuery: RootQuery$result, + RootQuery$handle: DocumentHandle, +} `, }, }, @@ -799,6 +999,8 @@ export type LayoutProps = { "src/routes/[id]/$types.d.ts": `import { DocumentHandle, RouteProp } from '../../../../plugins/houdini-react/runtime' import React from 'react' import type { MyQuery$result, MyQuery$artifact, MyQuery$input } from '../../../../artifacts/MyQuery' +import type { GraphQLError } from 'houdini/runtime' +import type { RoutingError } from '../../../../plugins/houdini-react/runtime' export type PageProps = { Params: { id: string }, @@ -810,6 +1012,14 @@ export type LayoutProps = { Params: { id: string }, children: React.ReactNode, } + +export type ErrorProps = { + Params: { id: string }, + errors: Array, + children: React.ReactNode, + MyQuery: MyQuery$result, + MyQuery$handle: DocumentHandle, +} `, }, }, diff --git a/packages/houdini-react/plugin/manifest.go b/packages/houdini-react/plugin/manifest.go index ce20371b12..bd4d774aa0 100644 --- a/packages/houdini-react/plugin/manifest.go +++ b/packages/houdini-react/plugin/manifest.go @@ -27,13 +27,15 @@ type ProjectManifest struct { } type PageManifest struct { - ID string `json:"id"` - Queries []string `json:"queries"` - QueryOptions []string `json:"query_options"` - URL string `json:"url"` - Layouts []string `json:"layouts"` - Path string `json:"path"` - Params map[string]*ParamTypeInfo `json:"params"` + ID string `json:"id"` + Queries []string `json:"queries"` + QueryOptions []string `json:"query_options"` + LayoutQueries []string `json:"layout_queries"` + URL string `json:"url"` + Layouts []string `json:"layouts"` + Path string `json:"path"` + ErrorPath string `json:"error_path"` + Params map[string]*ParamTypeInfo `json:"params"` } // ParamTypeInfo describes the GraphQL type of a URL route parameter. @@ -70,9 +72,10 @@ type routeDoc struct { // walkState carries accumulated context as we descend the route tree. type walkState struct { - availableQueries []string // layout query names in scope, outermost first - availableLayouts []string // layout IDs currently wrapping this level - variables map[string]VariableTypeInfo // route param types contributed by layout queries + availableQueries []string // layout query names in scope, outermost first + availableLayouts []string // layout IDs currently wrapping this level + availableErrorPath string // nearest ancestor +error.tsx path, empty if none + variables map[string]VariableTypeInfo // route param types contributed by layout queries } // LoadManifest builds a ProjectManifest by querying the database for route GQL @@ -191,13 +194,14 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error if info, ok := viewsByDir[dirKey]; ok && info.layoutViewPath != "" { relPath := toSlash(mustRel(projectConfig.ProjectRoot, info.layoutViewPath)) manifest.Layouts[id] = PageManifest{ - ID: id, - Queries: clone(state.availableQueries), - QueryOptions: clone(newLayoutQueries), - URL: url, - Layouts: clone(state.availableLayouts), - Path: relPath, - Params: buildParams(url, newVariables), + ID: id, + Queries: clone(state.availableQueries), + QueryOptions: clone(newLayoutQueries), + LayoutQueries: []string{}, + URL: url, + Layouts: clone(state.availableLayouts), + Path: relPath, + Params: buildParams(url, newVariables), } newLayoutIDs = append(newLayoutIDs, id) } @@ -218,29 +222,49 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error if info, ok := viewsByDir[dirKey]; ok && info.pageViewPath != "" { claimedPageDocs[dirKey] = true allQueries := clone(newLayoutQueries) + // Merge page query variables so param types from the page's own + // query (e.g. $id: ID!) are available when building the param map. + allVars := cloneVariables(newVariables) if pageDoc, ok := pageDocByDir[dirKey]; ok { allQueries = append(allQueries, pageDoc.name) + for k, v := range pageDoc.variables { + allVars[k] = v + } } pageURL := url if len(url) > 1 && strings.HasSuffix(url, "/") { pageURL = url[:len(url)-1] } relPath := toSlash(mustRel(projectConfig.ProjectRoot, info.pageViewPath)) + errorPath := state.availableErrorPath + if info.errorViewPath != "" { + errorPath = toSlash(mustRel(projectConfig.ProjectRoot, info.errorViewPath)) + } manifest.Pages[id] = PageManifest{ - ID: id, - Queries: clone(allQueries), - QueryOptions: clone(allQueries), - URL: pageURL, - Layouts: clone(newLayoutIDs), - Path: relPath, - Params: buildParams(url, newVariables), + ID: id, + Queries: clone(allQueries), + QueryOptions: clone(allQueries), + LayoutQueries: clone(newLayoutQueries), + URL: pageURL, + Layouts: clone(newLayoutIDs), + Path: relPath, + ErrorPath: errorPath, + Params: buildParams(url, allVars), } } + // Propagate the nearest error path to children: a directory's own +error.tsx + // takes precedence over an inherited one. + newErrorPath := state.availableErrorPath + if info, ok := viewsByDir[dirKey]; ok && info.errorViewPath != "" { + newErrorPath = toSlash(mustRel(projectConfig.ProjectRoot, info.errorViewPath)) + } + stateByDir[dirKey] = walkState{ - availableQueries: newLayoutQueries, - availableLayouts: newLayoutIDs, - variables: newVariables, + availableQueries: newLayoutQueries, + availableLayouts: newLayoutIDs, + availableErrorPath: newErrorPath, + variables: newVariables, } } @@ -340,6 +364,7 @@ func (p *HoudiniReact) loadRouteDocuments( type viewInfo struct { pageViewPath string // absolute path to +page.tsx or +page.jsx, empty if absent layoutViewPath string // absolute path to +layout.tsx or +layout.jsx, empty if absent + errorViewPath string // absolute path to +error.tsx or +error.jsx, empty if absent } // discoverViewFiles uses the parallel glob walker to find all +page and +layout view @@ -347,7 +372,7 @@ type viewInfo struct { // by directory path relative to routesDir. func (p *HoudiniReact) discoverViewFiles(ctx context.Context, routesDir string) (map[string]viewInfo, error) { walker := pluginglob.NewWalker() - for _, pattern := range []string{"+page.tsx", "+page.jsx", "+layout.tsx", "+layout.jsx"} { + for _, pattern := range []string{"+page.tsx", "+page.jsx", "+layout.tsx", "+layout.jsx", "+error.tsx", "+error.jsx"} { if err := walker.AddInclude("**/" + pattern); err != nil { return nil, err } @@ -377,8 +402,10 @@ func (p *HoudiniReact) discoverViewFiles(ctx context.Context, routesDir string) info := views[dir] if strings.HasPrefix(base, "+page") { info.pageViewPath = absPath - } else { + } else if strings.HasPrefix(base, "+layout") { info.layoutViewPath = absPath + } else { + info.errorViewPath = absPath } views[dir] = info mu.Unlock() diff --git a/packages/houdini-react/plugin/manifest_test.go b/packages/houdini-react/plugin/manifest_test.go index 68c7303d4f..f2bc3c40ad 100644 --- a/packages/houdini-react/plugin/manifest_test.go +++ b/packages/houdini-react/plugin/manifest_test.go @@ -113,33 +113,36 @@ func TestLoadManifest(t *testing.T) { "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{ "__subRoute__nested": { - ID: "__subRoute__nested", - Queries: []string{"RootQuery", "FinalQuery"}, - QueryOptions: []string{"RootQuery", "FinalQuery"}, - URL: "/(subRoute)/nested", - Layouts: []string{"_", "__subRoute_"}, - Path: "src/routes/(subRoute)/nested/+page.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "__subRoute__nested", + Queries: []string{"RootQuery", "FinalQuery"}, + QueryOptions: []string{"RootQuery", "FinalQuery"}, + LayoutQueries: []string{"RootQuery"}, + URL: "/(subRoute)/nested", + Layouts: []string{"_", "__subRoute_"}, + Path: "src/routes/(subRoute)/nested/+page.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, }, Layouts: map[string]plugin.PageManifest{ "_": { - ID: "_", - Queries: []string{}, - QueryOptions: []string{}, - URL: "/", - Layouts: []string{}, - Path: "src/routes/+layout.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_", + Queries: []string{}, + QueryOptions: []string{}, + LayoutQueries: []string{}, + URL: "/", + Layouts: []string{}, + Path: "src/routes/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, "__subRoute_": { - ID: "__subRoute_", - Queries: []string{}, - QueryOptions: []string{"RootQuery"}, - URL: "/(subRoute)/", - Layouts: []string{"_"}, - Path: "src/routes/(subRoute)/+layout.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "__subRoute_", + Queries: []string{}, + QueryOptions: []string{"RootQuery"}, + LayoutQueries: []string{}, + URL: "/(subRoute)/", + Layouts: []string{"_"}, + Path: "src/routes/(subRoute)/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, }, PageQueries: map[string]plugin.QueryManifest{ @@ -197,69 +200,76 @@ func TestLoadManifest(t *testing.T) { "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{ "_": { - ID: "_", - Queries: []string{"RootQuery"}, - QueryOptions: []string{"RootQuery"}, - URL: "/", - Layouts: []string{"_"}, - Path: "src/routes/+page.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_", + Queries: []string{"RootQuery"}, + QueryOptions: []string{"RootQuery"}, + LayoutQueries: []string{"RootQuery"}, + URL: "/", + Layouts: []string{"_"}, + Path: "src/routes/+page.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, "_subRoute": { - ID: "_subRoute", - Queries: []string{"RootQuery", "SubQuery"}, - QueryOptions: []string{"RootQuery", "SubQuery"}, - URL: "/subRoute", - Layouts: []string{"_", "_subRoute"}, - Path: "src/routes/subRoute/+page.jsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_subRoute", + Queries: []string{"RootQuery", "SubQuery"}, + QueryOptions: []string{"RootQuery", "SubQuery"}, + LayoutQueries: []string{"RootQuery", "SubQuery"}, + URL: "/subRoute", + Layouts: []string{"_", "_subRoute"}, + Path: "src/routes/subRoute/+page.jsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, "_another": { - ID: "_another", - Queries: []string{"RootQuery", "MyLayoutQuery", "MyQuery"}, - QueryOptions: []string{"RootQuery", "MyLayoutQuery", "MyQuery"}, - URL: "/another", - Layouts: []string{"_", "_another"}, - Path: "src/routes/another/+page.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_another", + Queries: []string{"RootQuery", "MyLayoutQuery", "MyQuery"}, + QueryOptions: []string{"RootQuery", "MyLayoutQuery", "MyQuery"}, + LayoutQueries: []string{"RootQuery", "MyLayoutQuery"}, + URL: "/another", + Layouts: []string{"_", "_another"}, + Path: "src/routes/another/+page.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, "_subRoute_nested": { - ID: "_subRoute_nested", - Queries: []string{"RootQuery", "SubQuery", "FinalQuery"}, - QueryOptions: []string{"RootQuery", "SubQuery", "FinalQuery"}, - URL: "/subRoute/nested", - Layouts: []string{"_", "_subRoute"}, - Path: "src/routes/subRoute/nested/+page.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_subRoute_nested", + Queries: []string{"RootQuery", "SubQuery", "FinalQuery"}, + QueryOptions: []string{"RootQuery", "SubQuery", "FinalQuery"}, + LayoutQueries: []string{"RootQuery", "SubQuery"}, + URL: "/subRoute/nested", + Layouts: []string{"_", "_subRoute"}, + Path: "src/routes/subRoute/nested/+page.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, }, Layouts: map[string]plugin.PageManifest{ "_": { - ID: "_", - Queries: []string{}, - QueryOptions: []string{"RootQuery"}, - URL: "/", - Layouts: []string{}, - Path: "src/routes/+layout.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_", + Queries: []string{}, + QueryOptions: []string{"RootQuery"}, + LayoutQueries: []string{}, + URL: "/", + Layouts: []string{}, + Path: "src/routes/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, "_subRoute": { - ID: "_subRoute", - Queries: []string{"RootQuery"}, - QueryOptions: []string{"RootQuery", "SubQuery"}, - URL: "/subRoute/", - Layouts: []string{"_"}, - Path: "src/routes/subRoute/+layout.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_subRoute", + Queries: []string{"RootQuery"}, + QueryOptions: []string{"RootQuery", "SubQuery"}, + LayoutQueries: []string{}, + URL: "/subRoute/", + Layouts: []string{"_"}, + Path: "src/routes/subRoute/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, "_another": { - ID: "_another", - Queries: []string{"RootQuery"}, - QueryOptions: []string{"RootQuery", "MyLayoutQuery"}, - URL: "/another/", - Layouts: []string{"_"}, - Path: "src/routes/another/+layout.tsx", - Params: map[string]*plugin.ParamTypeInfo{}, + ID: "_another", + Queries: []string{"RootQuery"}, + QueryOptions: []string{"RootQuery", "MyLayoutQuery"}, + LayoutQueries: []string{}, + URL: "/another/", + Layouts: []string{"_"}, + Path: "src/routes/another/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, }, }, PageQueries: map[string]plugin.QueryManifest{ @@ -362,12 +372,13 @@ func TestLoadManifest(t *testing.T) { "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{ "__id_": { - ID: "__id_", - Queries: []string{"MyQuery"}, - QueryOptions: []string{"MyQuery"}, - URL: "/[id]", - Layouts: []string{}, - Path: "src/routes/[id]/+page.tsx", + ID: "__id_", + Queries: []string{"MyQuery"}, + QueryOptions: []string{"MyQuery"}, + LayoutQueries: []string{"MyQuery"}, + URL: "/[id]", + Layouts: []string{}, + Path: "src/routes/[id]/+page.tsx", Params: map[string]*plugin.ParamTypeInfo{ "id": {Type: "ID", Wrappers: []string{"NonNull"}}, }, @@ -393,6 +404,128 @@ func TestLoadManifest(t *testing.T) { }, }, }, + { + Name: "+error.tsx sets ErrorPath on the page manifest", + Pass: true, + Input: []string{mockQuery("RootQuery", false)}, + Filepaths: []string{"src/routes/+layout.gql"}, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+layout.tsx": "export default ({children}) =>
{children}
", + "src/routes/+page.tsx": mockView([]string{"RootQuery"}), + "src/routes/+error.tsx": "export default ({ errors }) =>
{errors[0].message}
", + }, + "expected": plugin.ProjectManifest{ + Pages: map[string]plugin.PageManifest{ + "_": { + ID: "_", + Queries: []string{"RootQuery"}, + QueryOptions: []string{"RootQuery"}, + LayoutQueries: []string{"RootQuery"}, + URL: "/", + Layouts: []string{"_"}, + Path: "src/routes/+page.tsx", + ErrorPath: "src/routes/+error.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + }, + }, + Layouts: map[string]plugin.PageManifest{ + "_": { + ID: "_", + Queries: []string{}, + QueryOptions: []string{"RootQuery"}, + LayoutQueries: []string{}, + URL: "/", + Layouts: []string{}, + Path: "src/routes/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + }, + }, + PageQueries: map[string]plugin.QueryManifest{}, + LayoutQueries: map[string]plugin.QueryManifest{ + "_": { + Name: "RootQuery", + URL: "/", + Loading: false, + Path: "+layout.gql", + Variables: map[string]plugin.VariableTypeInfo{}, + }, + }, + Artifacts: []string{}, + LocalSchema: false, + LocalYoga: false, + ComponentFields: map[string]plugin.ComponentFieldInfo{}, + }, + }, + }, + { + Name: "+error.tsx propagates to child pages when no sibling +page.tsx", + Pass: true, + Input: []string{ + mockQuery("RootQuery", false), + mockQuery("ChildQuery", false), + }, + Filepaths: []string{ + "src/routes/+layout.gql", + "src/routes/child/+page.gql", + }, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+layout.tsx": "export default ({children}) =>
{children}
", + "src/routes/+error.tsx": "export default ({ errors }) =>
{errors[0].message}
", + "src/routes/child/+page.tsx": mockView([]string{"ChildQuery"}), + }, + "expected": plugin.ProjectManifest{ + Pages: map[string]plugin.PageManifest{ + "_child": { + ID: "_child", + Queries: []string{"RootQuery", "ChildQuery"}, + QueryOptions: []string{"RootQuery", "ChildQuery"}, + LayoutQueries: []string{"RootQuery"}, + URL: "/child", + Layouts: []string{"_"}, + Path: "src/routes/child/+page.tsx", + ErrorPath: "src/routes/+error.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + }, + }, + Layouts: map[string]plugin.PageManifest{ + "_": { + ID: "_", + Queries: []string{}, + QueryOptions: []string{"RootQuery"}, + LayoutQueries: []string{}, + URL: "/", + Layouts: []string{}, + Path: "src/routes/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + }, + }, + PageQueries: map[string]plugin.QueryManifest{ + "_child": { + Name: "ChildQuery", + URL: "/child/", + Loading: false, + Path: "child/+page.gql", + Variables: map[string]plugin.VariableTypeInfo{}, + }, + }, + LayoutQueries: map[string]plugin.QueryManifest{ + "_": { + Name: "RootQuery", + URL: "/", + Loading: false, + Path: "+layout.gql", + Variables: map[string]plugin.VariableTypeInfo{}, + }, + }, + Artifacts: []string{}, + LocalSchema: false, + LocalYoga: false, + ComponentFields: map[string]plugin.ComponentFieldInfo{}, + }, + }, + }, { Name: "page queries must be defined in the same directory as the page view", Pass: true, diff --git a/packages/houdini-react/plugin/runtime.go b/packages/houdini-react/plugin/runtime.go index ee52e787fd..6e3fd95d6e 100644 --- a/packages/houdini-react/plugin/runtime.go +++ b/packages/houdini-react/plugin/runtime.go @@ -126,7 +126,7 @@ func (p *HoudiniReact) UpdateIndexFiles(ctx context.Context) ([]string, error) { return []string{}, nil } - if err := afero.WriteFile(p.Filesystem(), targetPath, []byte(result), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), targetPath, []byte(result), 0644); err != nil { return nil, err } return []string{targetPath}, nil @@ -180,6 +180,12 @@ func (p *HoudiniReact) GenerateRuntime(ctx context.Context) ([]string, error) { } changed = append(changed, wrappers...) + errorWrappers, err := p.GenerateErrorWrappers(ctx) + if err != nil { + return nil, err + } + changed = append(changed, errorWrappers...) + fallbacks, err := p.GenerateFallbacks(ctx) if err != nil { return nil, err @@ -213,7 +219,7 @@ func (p *HoudiniReact) GenerateRuntime(ctx context.Context) ([]string, error) { runtimeDir := projectConfig.PluginRuntimeDirectory(p.Name()) artifactDir := filepath.Join(projectConfig.ProjectRoot, projectConfig.RuntimeDir, "artifacts") - content, err := formatManifest(manifest, runtimeDir, artifactDir, projectConfig.ProjectRoot) + content, err := formatManifest(manifest, runtimeDir, artifactDir, projectConfig.ProjectRoot, projectConfig.Scalars) if err != nil { return nil, err } @@ -228,7 +234,7 @@ func (p *HoudiniReact) GenerateRuntime(ctx context.Context) ([]string, error) { if err := p.Filesystem().MkdirAll(runtimeDir, 0755); err != nil { return nil, err } - if err := afero.WriteFile(p.Filesystem(), manifestPath, []byte(content), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), manifestPath, []byte(content), 0644); err != nil { return nil, err } @@ -241,8 +247,9 @@ type hookSpec struct { kind string // "query", "mutation", "subscription", or "fragment" marker string // text immediately before which overloads are inserted preamble string // extra import line to prepend (empty if not needed) - imports func(name string) string - overloads func(name string) string + // paginationQuery is the name of the pagination query document for paginated fragments, or "" + imports func(name string, paginationQuery string) string + overloads func(name string, paginationQuery string) string passthrough string // generic overload inserted last, bridges concrete overloads to the implementation } @@ -254,10 +261,10 @@ var hookSpecs = []hookSpec{ file: "useQuery.ts", kind: "query", marker: "export function useQuery<", - imports: func(name string) string { + imports: func(name string, _ string) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string) string { + overloads: func(name string, _ string) string { return fmt.Sprintf( "export function useQuery(document: { artifact: %s$artifact }, variables?: %s$input, config?: UseQueryConfig): %s$result\n", name, name, name, @@ -269,10 +276,10 @@ var hookSpecs = []hookSpec{ file: "useQueryHandle.ts", kind: "query", marker: "export function useQueryHandle<", - imports: func(name string) string { + imports: func(name string, _ string) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string) string { + overloads: func(name string, _ string) string { return fmt.Sprintf( "export function useQueryHandle(document: { artifact: %s$artifact }, variables?: %s$input, config?: UseQueryConfig): DocumentHandle<%s$artifact, %s$result, GraphQLVariables>\n", name, name, name, name, @@ -284,10 +291,10 @@ var hookSpecs = []hookSpec{ file: "useFragment.ts", kind: "fragment", marker: "export function useFragment<", - imports: func(name string) string { + imports: func(name string, _ string) string { return fmt.Sprintf("import type { %s$data, %s$artifact } from '$houdini/artifacts/%s'\n", name, name, name) }, - overloads: func(name string) string { + overloads: func(name string, _ string) string { return fmt.Sprintf( "export function useFragment(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact }): %s$data\n"+ "export function useFragment(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact }): %s$data | null\n", @@ -301,14 +308,26 @@ var hookSpecs = []hookSpec{ file: "useFragmentHandle.ts", kind: "fragment", marker: "export function useFragmentHandle<", - imports: func(name string) string { - return fmt.Sprintf("import type { %s$data, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) + // For paginated fragments, import the pagination query artifact too. + imports: func(name string, paginationQuery string) string { + base := fmt.Sprintf("import type { %s$data, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) + if paginationQuery != "" { + base += fmt.Sprintf("import type { %s$artifact } from '$houdini/artifacts/%s'\n", paginationQuery, paginationQuery) + } + return base }, - // DocumentHandle's first type param must extend QueryArtifact; for fragments that - // have no refetchArtifact we use the base QueryArtifact (already imported by the source). - // Both non-null and nullable reference overloads use the same non-null data type since - // DocumentHandle._Data extends GraphQLObject (not null). - overloads: func(name string) string { + // For paginated fragments, return DocumentHandle typed with the pagination query artifact + // so TypeScript exposes loadNext/loadPrevious/pageInfo on the returned handle. + // For non-paginated fragments, fall back to DocumentHandle. + overloads: func(name string, paginationQuery string) string { + if paginationQuery != "" { + return fmt.Sprintf( + "export function useFragmentHandle(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact; refetchArtifact?: %s$artifact }): DocumentHandle<%s$artifact, %s$data, %s$input>\n"+ + "export function useFragmentHandle(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact; refetchArtifact?: %s$artifact }): DocumentHandle<%s$artifact, %s$data, %s$input>\n", + fragmentKeyLiteral, name, name, paginationQuery, paginationQuery, name, name, + fragmentKeyLiteral, name, name, paginationQuery, paginationQuery, name, name, + ) + } return fmt.Sprintf( "export function useFragmentHandle(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact }): DocumentHandle\n"+ "export function useFragmentHandle(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact }): DocumentHandle\n", @@ -322,25 +341,25 @@ var hookSpecs = []hookSpec{ file: "useMutation.ts", kind: "mutation", marker: "export function useMutation<", - imports: func(name string) string { + imports: func(name string, _ string) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input, %s$optimistic } from '$houdini/artifacts/%s'\n", name, name, name, name, name) }, - overloads: func(name string) string { + overloads: func(name string, _ string) string { return fmt.Sprintf( - "export function useMutation(document: { artifact: %s$artifact }): [boolean, MutationHandler<%s$result, %s$input, %s$optimistic>]\n", + "export function useMutation(document: { artifact: %s$artifact }): [MutationHandler<%s$result, %s$input, %s$optimistic>, boolean]\n", name, name, name, name, ) }, - passthrough: "export function useMutation<_Result extends GraphQLObject, _Input extends GraphQLVariables, _Optimistic extends GraphQLObject>(document: { artifact: MutationArtifact }): [boolean, MutationHandler<_Result, _Input, _Optimistic>]", + passthrough: "export function useMutation<_Result extends GraphQLObject, _Input extends GraphQLVariables, _Optimistic extends GraphQLObject>(document: { artifact: MutationArtifact }): [MutationHandler<_Result, _Input, _Optimistic>, boolean]", }, { file: "useSubscription.ts", kind: "subscription", marker: "export function useSubscription<", - imports: func(name string) string { + imports: func(name string, _ string) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string) string { + overloads: func(name string, _ string) string { return fmt.Sprintf( "export function useSubscription(document: { artifact: %s$artifact }, variables?: %s$input): %s$result\n", name, name, name, @@ -352,10 +371,10 @@ var hookSpecs = []hookSpec{ file: "useSubscriptionHandle.ts", kind: "subscription", marker: "export function useSubscriptionHandle<", - imports: func(name string) string { + imports: func(name string, _ string) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string) string { + overloads: func(name string, _ string) string { return fmt.Sprintf( "export function useSubscriptionHandle(document: { artifact: %s$artifact }, variables?: %s$input): SubscriptionHandle<%s$result, %s$input>\n", name, name, name, name, @@ -404,7 +423,7 @@ func (p *HoudiniReact) AddGraphQLType(ctx context.Context) ([]string, error) { var typeChain strings.Builder for _, cf := range cfs { - preamble.WriteString(fmt.Sprintf("import type { %s } from '$houdini'\n", cf.fragment)) + preamble.WriteString(fmt.Sprintf("import type { %s } from '../artifacts/%s'\n", cf.fragment, cf.fragment)) typeChain.WriteString(fmt.Sprintf("_Document extends `%s` ? Required<%s>['shape'] : ", cf.content, cf.fragment)) } @@ -414,7 +433,7 @@ func (p *HoudiniReact) AddGraphQLType(ctx context.Context) ([]string, error) { "\nexport type GraphQL<_Document extends string> = " + typeChain.String() + "never\n" - if err := afero.WriteFile(p.Filesystem(), targetPath, []byte(appended), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), targetPath, []byte(appended), 0644); err != nil { return nil, err } return []string{targetPath}, nil @@ -444,6 +463,25 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) { return nil, err } + // Build the set of visible fragments that are paginated. We detect pagination + // via discovered_lists (populated during Validate) rather than looking for + // a pre-existing _Pagination_Query document, because GenerateRuntime runs + // concurrently with GenerateDocuments and the document may not exist yet. + paginatedFragments := map[string]string{} + err = p.DB.StepQuery(ctx, ` + SELECT DISTINCT d.name + FROM documents d + JOIN discovered_lists dl ON dl.document = d.id + WHERE d.visible = 1 AND d.kind = 'fragment' + AND dl.paginate IS NOT NULL + `, nil, func(q plugins.Row) { + name := q.ColumnText(0) + paginatedFragments[name] = name + "_Pagination_Query" + }) + if err != nil { + return nil, err + } + var changed []string for _, spec := range hookSpecs { names := docsByKind[spec.kind] @@ -476,13 +514,13 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) { top.WriteString("\n") } for _, name := range names { - top.WriteString(spec.imports(name)) + top.WriteString(spec.imports(name, paginatedFragments[name])) } top.WriteString("\n") var before strings.Builder for _, name := range names { - before.WriteString(spec.overloads(name)) + before.WriteString(spec.overloads(name, paginatedFragments[name])) } if spec.passthrough != "" { before.WriteString(spec.passthrough + "\n") @@ -490,7 +528,7 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) { result := top.String() + existingStr[:insertPos] + before.String() + existingStr[insertPos:] - if err := afero.WriteFile(p.Filesystem(), fp, []byte(result), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), fp, []byte(result), 0644); err != nil { return nil, err } changed = append(changed, fp) @@ -505,6 +543,7 @@ func formatManifest( runtimeDir string, artifactDir string, projectRoot string, + scalars map[string]plugins.ScalarConfig, ) (string, error) { // Build a lookup from query name → QueryManifest for artifact/loading/variable info. queryByName := map[string]QueryManifest{} @@ -523,6 +562,7 @@ func formatManifest( for _, id := range sortedKeys(manifest.Pages) { page := manifest.Pages[id] + cleanURL := stripRouteGroups(page.URL) pattern, params, err := parsePagePattern(page.URL) if err != nil { return "", fmt.Errorf("could not parse pattern for page %s: %w", id, err) @@ -539,8 +579,9 @@ func formatManifest( sb.WriteString(fmt.Sprintf("\t\t%q: {\n", id)) sb.WriteString(fmt.Sprintf("\t\t\tid: %q,\n", id)) + sb.WriteString(fmt.Sprintf("\t\t\turl: %q,\n", cleanURL)) sb.WriteString(fmt.Sprintf("\t\t\tpattern: %s,\n", pattern)) - sb.WriteString(fmt.Sprintf("\t\t\tparams: %s,\n", formatParams(params))) + sb.WriteString(fmt.Sprintf("\t\t\tparams: %s,\n", formatParams(params, page.Params))) // Documents block. sb.WriteString("\t\t\tdocuments: {\n") @@ -569,11 +610,20 @@ func formatManifest( sb.WriteString("\t\t\t},\n") sb.WriteString(fmt.Sprintf("\t\t\tcomponent: () => import(%q),\n", filepath.ToSlash(componentRel))) + sb.WriteString("\t\t},\n") } sb.WriteString("\t},\n") - sb.WriteString("} satisfies RouterManifest\n") + sb.WriteString("} as const satisfies RouterManifest\n") + + // Export a name→TS-type map for custom scalars so Link.tsx can resolve + // _TSType<"DateTime"> → Date without any per-project codegen in the jsx file. + sb.WriteString("\nexport type RouteScalars = {\n") + for _, name := range sortedKeys(scalars) { + sb.WriteString(fmt.Sprintf("\t%s: %s\n", name, scalars[name].Type)) + } + sb.WriteString("}\n") return sb.String(), nil } @@ -655,8 +705,9 @@ func regexEscape(s string) string { return b.String() } -// formatParams renders a []routeParam as a TypeScript array literal. -func formatParams(params []routeParam) string { +// formatParams renders a []routeParam as a TypeScript array literal, including a +// resolved TypeScript type for each param so the manifest can drive type extraction. +func formatParams(params []routeParam, pageParams map[string]*ParamTypeInfo) string { if len(params) == 0 { return "[]" } @@ -666,9 +717,15 @@ func formatParams(params []routeParam) string { if p.Matcher != "" { matcher = p.Matcher } + // Emit the GQL type name so the manifest-driven _TSType utility can resolve + // it against RouteScalars (custom scalars) and built-in GQL scalar names. + gqlType := "String" + if info, ok := pageParams[p.Name]; ok && info != nil { + gqlType = info.Type + } parts = append(parts, fmt.Sprintf( - `{ name: %q, matcher: %q, optional: %v, rest: %v, chained: %v }`, - p.Name, matcher, p.Optional, p.Rest, p.Chained, + `{ name: %q, matcher: %q, optional: %v, rest: %v, chained: %v, type: %q }`, + p.Name, matcher, p.Optional, p.Rest, p.Chained, gqlType, )) } return "[\n\t\t\t\t" + strings.Join(parts, ",\n\t\t\t\t") + "\n\t\t\t]" @@ -778,7 +835,7 @@ func (p *HoudiniReact) InjectComponentFieldArtifactTypes(ctx context.Context) ([ modified = reactImport + modified } - if err := afero.WriteFile(p.Filesystem(), artPath, []byte(modified), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), artPath, []byte(modified), 0644); err != nil { return nil, err } changed = append(changed, artPath) @@ -826,7 +883,7 @@ declare module 'houdini/runtime' { if err := p.Filesystem().MkdirAll(runtimeDir, 0755); err != nil { return nil, err } - if err := afero.WriteFile(p.Filesystem(), augPath, []byte(augContent), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), augPath, []byte(augContent), 0644); err != nil { return nil, err } changed = append(changed, augPath) @@ -843,7 +900,7 @@ declare module 'houdini/runtime' { sideEffect := `import './componentFieldTypes'` if !strings.Contains(indexStr, sideEffect) { patched := sideEffect + "\n" + indexStr - if err := afero.WriteFile(p.Filesystem(), indexPath, []byte(patched), 0644); err != nil { + if err := plugins.WriteFile(p.Filesystem(), indexPath, []byte(patched), 0644); err != nil { return nil, err } changed = append(changed, indexPath) @@ -852,67 +909,50 @@ declare module 'houdini/runtime' { return changed, nil } -// GenerateTsConfig writes .houdini/tsconfig.json so the project tsconfig can -// extend it and get JSX, path aliases, and all other compiler settings for free. +// stripRouteGroups removes (group) segments from a URL, leaving only real path +// segments. E.g. "/(auth)/users/[id]" → "/users/[id]". +func stripRouteGroups(url string) string { + parts := strings.Split(url, "/") + var out []string + for _, p := range parts { + if p == "" { + continue + } + if strings.HasPrefix(p, "(") && strings.HasSuffix(p, ")") { + continue + } + out = append(out, p) + } + return "/" + strings.Join(out, "/") +} + + +// GenerateTsConfig writes .houdini/tsconfig.json by copying the template from the +// plugin runtime directory (written there by IncludeRuntime). func (p *HoudiniReact) GenerateTsConfig(ctx context.Context) ([]string, error) { projectConfig, err := p.DB.ProjectConfig(ctx) if err != nil { return nil, err } + runtimeDir := projectConfig.PluginRuntimeDirectory(p.Name()) houdiniDir := filepath.Join(projectConfig.ProjectRoot, projectConfig.RuntimeDir) tsConfigPath := filepath.Join(houdiniDir, "tsconfig.json") - content := `{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "$houdini": ["."], - "$houdini/*": ["./*"], - "~": ["../src"], - "~/*": ["../src/*"] - }, - "rootDirs": ["..", "./types"], - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "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", - "../src/+app.d.ts" - ], - "exclude": ["../node_modules/**", "./[!ambient.d.ts]**"] -} -` + content, err := afero.ReadFile(p.Filesystem(), filepath.Join(runtimeDir, "tsconfig.json")) + if err != nil { + return nil, err + } existing, _ := afero.ReadFile(p.Filesystem(), tsConfigPath) - if string(existing) == content { + if string(existing) == string(content) { return []string{}, nil } if err := p.Filesystem().MkdirAll(houdiniDir, 0755); err != nil { return nil, err } - if err := afero.WriteFile(p.Filesystem(), tsConfigPath, []byte(content), 0644); err != nil { + if err := afero.WriteFile(p.Filesystem(), tsConfigPath, content, 0644); err != nil { return nil, err } diff --git a/packages/houdini-react/plugin/runtime_test.go b/packages/houdini-react/plugin/runtime_test.go index 2d746a8bad..a0fbc74348 100644 --- a/packages/houdini-react/plugin/runtime_test.go +++ b/packages/houdini-react/plugin/runtime_test.go @@ -2,6 +2,7 @@ package plugin_test import ( "context" + "os" "path/filepath" "testing" @@ -10,6 +11,7 @@ import ( coreConfig "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/packages/houdini-react/plugin" + plugins "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/tests" ) @@ -158,9 +160,33 @@ func TestUpdateIndexFiles(t *testing.T) { func TestUpdateHookFiles(t *testing.T) { tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{ Schema: ` - type Query { id: ID } + type Query { + id: ID + node(id: ID!): Node + } type Mutation { id: ID } type Subscription { id: ID } + + interface Node { id: ID! } + type User implements Node { + id: ID! + firstName: String! + friends(first: Int, after: String, last: Int, before: String): UserConnection! + } + type UserConnection { + pageInfo: PageInfo! + edges: [UserEdge!]! + } + type UserEdge { + cursor: String! + node: User! + } + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } `, SetupTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { @@ -237,8 +263,8 @@ func TestUpdateHookFiles(t *testing.T) { "useMutation.ts": "import type { MyMutation$result, MyMutation$artifact, MyMutation$input, MyMutation$optimistic } from '$houdini/artifacts/MyMutation'\n" + "\n" + "import type { MutationArtifact } from 'houdini/runtime'\n\n" + - "export function useMutation(document: { artifact: MyMutation$artifact }): [boolean, MutationHandler]\n" + - "export function useMutation<_Result extends GraphQLObject, _Input extends GraphQLVariables, _Optimistic extends GraphQLObject>(document: { artifact: MutationArtifact }): [boolean, MutationHandler<_Result, _Input, _Optimistic>]\n" + + "export function useMutation(document: { artifact: MyMutation$artifact }): [MutationHandler, boolean]\n" + + "export function useMutation<_Result extends GraphQLObject, _Input extends GraphQLVariables, _Optimistic extends GraphQLObject>(document: { artifact: MutationArtifact }): [MutationHandler<_Result, _Input, _Optimistic>, boolean]\n" + "export function useMutation<_A>(doc: any): any {}\n", }, }, @@ -264,6 +290,49 @@ func TestUpdateHookFiles(t *testing.T) { }, }, }, + { + Name: "injects useFragmentHandle overloads for non-paginated fragment", + Pass: true, + Input: []string{ + `fragment MyFragment on Query { id }`, + }, + Extra: map[string]any{ + "stubs": map[string]string{ + "useFragmentHandle.ts": "import type { QueryArtifact } from 'houdini/runtime'\n\nexport function useFragmentHandle<_A>(ref: any, doc: any): any {}\n", + }, + "expected": map[string]string{ + "useFragmentHandle.ts": "import type { MyFragment$data, MyFragment$artifact, MyFragment$input } from '$houdini/artifacts/MyFragment'\n" + + "\n" + + "import type { QueryArtifact } from 'houdini/runtime'\n\n" + + "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyFragment: any } }, document: { artifact: MyFragment$artifact }): DocumentHandle\n" + + "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyFragment: any } } | null, document: { artifact: MyFragment$artifact }): DocumentHandle\n" + + "export function useFragmentHandle<_Artifact extends FragmentArtifact, _Data extends GraphQLObject, _ReferenceType extends {}, _PaginationArtifact extends QueryArtifact, _Input extends GraphQLVariables>(reference: _Data | { \" $fragments\": _ReferenceType } | null, document: { artifact: _Artifact; refetchArtifact?: _PaginationArtifact }): DocumentHandle<_PaginationArtifact, _Data, _Input>\n" + + "export function useFragmentHandle<_A>(ref: any, doc: any): any {}\n", + }, + }, + }, + { + Name: "injects useFragmentHandle overloads with pagination query artifact for paginated fragment", + Pass: true, + Input: []string{ + `fragment MyPaginatedFragment on User { friends(first: 2) @paginate { edges { node { firstName } } } }`, + }, + Extra: map[string]any{ + "stubs": map[string]string{ + "useFragmentHandle.ts": "import type { QueryArtifact } from 'houdini/runtime'\n\nexport function useFragmentHandle<_A>(ref: any, doc: any): any {}\n", + }, + "expected": map[string]string{ + "useFragmentHandle.ts": "import type { MyPaginatedFragment$data, MyPaginatedFragment$artifact, MyPaginatedFragment$input } from '$houdini/artifacts/MyPaginatedFragment'\n" + + "import type { MyPaginatedFragment_Pagination_Query$artifact } from '$houdini/artifacts/MyPaginatedFragment_Pagination_Query'\n" + + "\n" + + "import type { QueryArtifact } from 'houdini/runtime'\n\n" + + "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyPaginatedFragment: any } }, document: { artifact: MyPaginatedFragment$artifact; refetchArtifact?: MyPaginatedFragment_Pagination_Query$artifact }): DocumentHandle\n" + + "export function useFragmentHandle(reference: { readonly \" $fragments\": { MyPaginatedFragment: any } } | null, document: { artifact: MyPaginatedFragment$artifact; refetchArtifact?: MyPaginatedFragment_Pagination_Query$artifact }): DocumentHandle\n" + + "export function useFragmentHandle<_Artifact extends FragmentArtifact, _Data extends GraphQLObject, _ReferenceType extends {}, _PaginationArtifact extends QueryArtifact, _Input extends GraphQLVariables>(reference: _Data | { \" $fragments\": _ReferenceType } | null, document: { artifact: _Artifact; refetchArtifact?: _PaginationArtifact }): DocumentHandle<_PaginationArtifact, _Data, _Input>\n" + + "export function useFragmentHandle<_A>(ref: any, doc: any): any {}\n", + }, + }, + }, { Name: "skips files not present in plugin runtime dir", Pass: true, @@ -352,7 +421,7 @@ func TestAddGraphQLType(t *testing.T) { }, }, // preamble (fragment imports) go BEFORE existing content, type appended at end - "expected": "import type { UserAvatar } from '$houdini'\n" + + "expected": "import type { UserAvatar } from '../artifacts/UserAvatar'\n" + indexStub + "\nexport type GraphQL<_Document extends string> = " + "_Document extends `fragment UserAvatar on User { avatar }` ? Required['shape'] : " + @@ -374,7 +443,7 @@ func TestAddGraphQLType(t *testing.T) { {"filepath": "src/components/Avatar.tsx", "type": "User", "field": "Avatar", "prop": "user", "fragment": "UserAvatar", "content": "fragment UserAvatar on User { avatar }"}, }, "call_twice": true, - "expected": "import type { UserAvatar } from '$houdini'\n" + + "expected": "import type { UserAvatar } from '../artifacts/UserAvatar'\n" + indexStub + "\nexport type GraphQL<_Document extends string> = " + "_Document extends `fragment UserAvatar on User { avatar }` ? Required['shape'] : " + @@ -531,6 +600,19 @@ func TestGenerateRuntime(t *testing.T) { SetupAlwaysPasses: true, SetupTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + cfg, err := p.DB.ProjectConfig(context.Background()) + require.NoError(t, err) + runtimeDir := cfg.PluginRuntimeDirectory(p.Name()) + require.NoError(t, p.Filesystem().MkdirAll(runtimeDir, 0755)) + + // GenerateTsConfig reads tsconfig.json from the plugin runtime dir (written + // there by IncludeRuntime in the real pipeline). Seed a minimal stub so the + // test doesn't fail on a missing file. + tsconfigStub, err := os.ReadFile("../runtime/tsconfig.json") + require.NoError(t, err) + require.NoError(t, afero.WriteFile(p.Filesystem(), + filepath.Join(runtimeDir, "tsconfig.json"), tsconfigStub, 0644)) + views, ok := test.Extra["views"].(map[string]string) if !ok { return @@ -573,7 +655,10 @@ func TestGenerateRuntime(t *testing.T) { export default { pages: { }, - } satisfies RouterManifest + } as const satisfies RouterManifest + + export type RouteScalars = { + } `) + "\n", }, }, @@ -605,6 +690,7 @@ func TestGenerateRuntime(t *testing.T) { pages: { "__subRoute__nested": { id: "__subRoute__nested", + url: "/nested", pattern: /^\/nested\/?$/, params: [], documents: { @@ -622,7 +708,10 @@ func TestGenerateRuntime(t *testing.T) { component: () => import("../units/entries/__subRoute__nested"), }, }, - } satisfies RouterManifest + } as const satisfies RouterManifest + + export type RouteScalars = { + } `) + "\n", }, }, @@ -646,9 +735,10 @@ func TestGenerateRuntime(t *testing.T) { pages: { "__id_": { id: "__id_", + url: "/[id]", pattern: /^\/([^/]+?)\/?$/, params: [ - { name: "id", matcher: "", optional: false, rest: false, chained: false } + { name: "id", matcher: "", optional: false, rest: false, chained: false, type: "ID" } ], documents: { MyQuery: { @@ -660,7 +750,75 @@ func TestGenerateRuntime(t *testing.T) { component: () => import("../units/entries/__id_"), }, }, - } satisfies RouterManifest + } as const satisfies RouterManifest + + export type RouteScalars = { + } + `) + "\n", + }, + }, + { + Name: "+error.tsx emits error field in manifest", + Pass: true, + Input: []string{ + mockQuery("PageQuery", false), + }, + Filepaths: []string{ + "src/routes/+page.gql", + }, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+page.tsx": mockView([]string{"PageQuery"}), + "src/routes/+error.tsx": "export default ({ errors }) =>
{errors[0].message}
", + }, + "expected": tests.Dedent(` + import type { RouterManifest } from 'houdini/runtime' + + export default { + pages: { + "_": { + id: "_", + url: "/", + pattern: /^\/$/, + params: [], + documents: { + PageQuery: { + artifact: () => import("../../../artifacts/PageQuery"), + loading: false, + variables: {}, + }, + }, + component: () => import("../units/entries/_"), + }, + }, + } as const satisfies RouterManifest + + export type RouteScalars = { + } + `) + "\n", + }, + }, + { + Name: "custom scalar emitted in RouteScalars", + Pass: true, + ProjectConfig: func(cfg *plugins.ProjectConfig) { + if cfg.Scalars == nil { + cfg.Scalars = make(map[string]plugins.ScalarConfig) + } + cfg.Scalars["DateTime"] = plugins.ScalarConfig{Type: "Date"} + }, + Extra: map[string]any{ + "expected": tests.Dedent(` + import type { RouterManifest } from 'houdini/runtime' + + export default { + pages: { + }, + } as const satisfies RouterManifest + + export type RouteScalars = { + DateTime: Date + } `) + "\n", }, }, diff --git a/packages/houdini-react/runtime/Link.tsx b/packages/houdini-react/runtime/Link.tsx new file mode 100644 index 0000000000..b02e027051 --- /dev/null +++ b/packages/houdini-react/runtime/Link.tsx @@ -0,0 +1,81 @@ +// this file is generated by houdini — do not edit +// @refresh reset +import type { AnchorHTMLAttributes, DetailedHTMLProps } from 'react' +import React from 'react' + +// @ts-ignore +import type rawManifest from './manifest.js' +// @ts-ignore +import type { RouteScalars } from './manifest.js' + +import { resolveHref } from './resolve-href.js' + +type _Pages = (typeof rawManifest)['pages'] +type _TSType = T extends keyof RouteScalars + ? RouteScalars[T] + : T extends 'Int' | 'Float' + ? number + : T extends 'ID' + ? string | number + : T extends 'Boolean' + ? boolean + : string +type _Param = { readonly name: string; readonly type: string; readonly optional: boolean } +type _ParamObj = { + [P in Ps[number] as P['optional'] extends true ? P['name'] : never]?: _TSType +} & { + [P in Ps[number] as P['optional'] extends true ? never : P['name']]: _TSType +} + +type _ExternalHref = + | `http://${string}` + | `https://${string}` + | `mailto:${string}` + | `tel:${string}` + | `blob:${string}` + | `data:${string}` + | `//${string}` + | `#${string}` + | `./${string}` + | `../${string}` + +// All known app route URL strings — useful as a constraint for custom link wrappers. +export type RouteHrefs = _Pages[keyof _Pages] extends { readonly url: infer U extends string } + ? U + : never + +// Separate 'to' from 'params' so TypeScript evaluates them independently: +// - 'to' completions show all routes (including parameterized) because 'to' itself is always valid +// - 'params' is a separate intersection that errors when required but absent +type _PageForRoute = Extract<_Pages[keyof _Pages], { readonly url: H }> +type _ParamsForRoute = [_PageForRoute] extends [never] + ? { params?: never } + : _PageForRoute extends { readonly params: readonly [] } + ? { params?: never } + : _PageForRoute extends { readonly params: infer Ps extends readonly _Param[] } + ? { params: _ParamObj } + : { params?: never } + +export type LinkProps = Omit< + DetailedHTMLProps, HTMLAnchorElement>, + 'href' +> & { + to: H + disabled?: boolean + preload?: boolean | 'data' | 'component' | 'page' +} & _ParamsForRoute + +export function Link({ + to, + params, + disabled, + preload, + ...rest +}: LinkProps): React.ReactElement { + const href = disabled + ? undefined + : params != null + ? resolveHref(to as string, params as Record) + : (to as string) + return React.createElement('a', { ...rest, href, 'data-houdini-preload': preload }) +} diff --git a/packages/houdini-react/runtime/hooks/recycleNodesInto.test.ts b/packages/houdini-react/runtime/hooks/recycleNodesInto.test.ts new file mode 100644 index 0000000000..3c8c5a8a6e --- /dev/null +++ b/packages/houdini-react/runtime/hooks/recycleNodesInto.test.ts @@ -0,0 +1,61 @@ +import { describe, test, expect } from 'vitest' +import { recycleNodesInto } from './recycleNodesInto.js' + +describe('recycleNodesInto', () => { + test('preserves object identity for unchanged subtrees', () => { + const a = { id: '1', name: 'Alice' } + const prev = [a] + const next = [{ id: '1', name: 'Alice' }] + const result = recycleNodesInto(prev, next) + expect(result).toBe(prev) + expect((result as any[])[0]).toBe(a) + }) + + test('returns new array when element changes', () => { + const prev = [{ id: '1', name: 'Alice' }] + const next = [{ id: '1', name: 'Bob' }] + const result = recycleNodesInto(prev, next) + expect(result).not.toBe(prev) + }) + + test('copies non-index properties from next when array length changes', () => { + const prev = [{ id: '1' }] as any[] + ;(prev as any).__id = 'root::MyList' + const next = [{ id: '1' }, { id: '2' }] as any[] + ;(next as any).__id = 'root::MyList' + + const result = recycleNodesInto(prev, next) as any[] + expect(result.length).toBe(2) + expect((result as any).__id).toBe('root::MyList') + }) + + test('copies non-index properties from next when element changes', () => { + const prev = [{ id: '1', name: 'Alice' }] as any[] + ;(prev as any).__id = 'root::MyList' + const next = [{ id: '1', name: 'Bob' }] as any[] + ;(next as any).__id = 'root::MyList' + + const result = recycleNodesInto(prev, next) as any[] + expect((result as any).__id).toBe('root::MyList') + }) + + test('returns prev (with its own properties) when array is unchanged', () => { + const prev = [{ id: '1' }] as any[] + ;(prev as any).__id = 'root::MyList' + const next = [{ id: '1' }] as any[] + ;(next as any).__id = 'root::MyList' + + const result = recycleNodesInto(prev, next) as any[] + expect(result).toBe(prev) + expect((result as any).__id).toBe('root::MyList') + }) + + test('first render (prev null) returns next with its properties', () => { + const next = [{ id: '1' }] as any[] + ;(next as any).__id = 'root::MyList' + + const result = recycleNodesInto(null, next) as any[] + expect(result).toBe(next) + expect((result as any).__id).toBe('root::MyList') + }) +}) diff --git a/packages/houdini-react/runtime/hooks/recycleNodesInto.ts b/packages/houdini-react/runtime/hooks/recycleNodesInto.ts new file mode 100644 index 0000000000..d2bdb062b0 --- /dev/null +++ b/packages/houdini-react/runtime/hooks/recycleNodesInto.ts @@ -0,0 +1,60 @@ +/** + * Walk `next` and, wherever a sub-tree is deeply equal to the corresponding + * sub-tree in `prev`, substitute `prev`'s reference. This preserves object + * identity for unchanged branches so React.memo can bail out on re-renders + * when a cache write touches unrelated parts of the graph. + * + * Arrays are reconciled by index. When lengths differ the array itself gets a + * new reference, but individual matching elements are still recycled so that + * items from the previous render keep their identities. + */ +export function recycleNodesInto(prev: T | null | undefined, next: T): T { + if (Object.is(prev, next)) return prev as T + + if (next === null || typeof next !== 'object') return next + if (prev === null || prev === undefined || typeof prev !== 'object') return next + + if (Array.isArray(next)) { + if (!Array.isArray(prev)) return next + + const nextLen = next.length + const prevLen = (prev as unknown[]).length + const minLen = Math.min(prevLen, nextLen) + + let changed = false + const result: unknown[] = new Array(nextLen) + + for (let i = 0; i < minLen; i++) { + result[i] = recycleNodesInto((prev as unknown[])[i], next[i]) + if (result[i] !== (prev as unknown[])[i]) changed = true + } + for (let i = minLen; i < nextLen; i++) { + result[i] = next[i] + } + + if (!changed && prevLen === nextLen) return prev as T + + // Copy non-index own properties from next (e.g. __id from @includeListID) + for (const key of Object.keys(next as any)) { + if (isNaN(Number(key))) { + ;(result as any)[key] = (next as any)[key] + } + } + return result as unknown as T + } + + const prevObj = prev as Record + const nextObj = next as Record + const nextKeys = Object.keys(nextObj) + const prevKeyCount = Object.keys(prevObj).length + + let changed = prevKeyCount !== nextKeys.length + const result: Record = {} + + for (const key of nextKeys) { + result[key] = recycleNodesInto(prevObj[key], nextObj[key]) + if (result[key] !== prevObj[key]) changed = true + } + + return changed ? (result as T) : (prev as T) +} diff --git a/packages/houdini-react/runtime/hooks/useDeepCompareEffect.ts b/packages/houdini-react/runtime/hooks/useDeepCompareEffect.ts index b18f47f7b1..114fd4528f 100644 --- a/packages/houdini-react/runtime/hooks/useDeepCompareEffect.ts +++ b/packages/houdini-react/runtime/hooks/useDeepCompareEffect.ts @@ -36,14 +36,16 @@ function isPrimitive(val: unknown) { */ export function useDeepCompareMemoize(value: T) { const ref = React.useRef(value) - const signalRef = React.useRef(0) if (!deepEquals(value, ref.current)) { ref.current = value - signalRef.current += 1 } - return React.useMemo(() => ref.current, []) + // Return ref.current directly so React.useEffect sees a new reference only + // when the deep value actually changes — matching the original use-deep-compare-effect. + // The useMemo(() => ref.current, []) wrapper that was here previously was incorrect: + // it froze the returned value at mount time, preventing effects from ever re-firing. + return ref.current } function useDeepCompareEffect( diff --git a/packages/houdini-react/runtime/hooks/useDocumentHandle.ts b/packages/houdini-react/runtime/hooks/useDocumentHandle.ts index 5d90e622a1..4c546ca6ee 100644 --- a/packages/houdini-react/runtime/hooks/useDocumentHandle.ts +++ b/packages/houdini-react/runtime/hooks/useDocumentHandle.ts @@ -4,6 +4,7 @@ import { ArtifactKind } from 'houdini/runtime' import type { GraphQLObject, GraphQLVariables, + GraphQLError, CursorHandlers, OffsetHandlers, PageInfo, @@ -32,6 +33,9 @@ export function useDocumentHandle< }): DocumentHandle<_Artifact, _Data, _Input> & { fetch: FetchFn<_Data, _Input> } { const [forwardPending, setForwardPending] = React.useState(false) const [backwardPending, setBackwardPending] = React.useState(false) + // Stable cursor stacks for SinglePage pagination — must survive re-renders caused by store updates + const previousCursorsRef = React.useRef<(string | null)[]>([]) + const nextCursorsRef = React.useRef<(string | null)[]>([]) const location = useLocation() // grab the current session value @@ -126,6 +130,8 @@ export function useDocumentHandle< getState: () => storeValue.data, getVariables: () => storeValue.variables!, fetch: fetchQuery, + previousCursors: previousCursorsRef.current, + nextCursors: nextCursorsRef.current, fetchUpdate: (args, updates) => { return paginationObserver!.send({ ...args, @@ -205,7 +211,7 @@ export type DocumentHandle< data: _Data partial: boolean fetching: boolean - errors: { message: string }[] | null + errors: GraphQLError[] | null fetch: FetchFn<_Data, Partial<_Input>> variables: _Input } & RefetchHandlers<_Artifact, _Data, _Input> diff --git a/packages/houdini-react/runtime/hooks/useDocumentStore.ts b/packages/houdini-react/runtime/hooks/useDocumentStore.ts index bc88fd2a96..cccee7cc07 100644 --- a/packages/houdini-react/runtime/hooks/useDocumentStore.ts +++ b/packages/houdini-react/runtime/hooks/useDocumentStore.ts @@ -9,6 +9,7 @@ import * as React from 'react' import { useClient } from '../routing/index.js' import { useIsMountedRef } from './useIsMounted.js' +import { recycleNodesInto } from './recycleNodesInto.js' export type UseDocumentStoreParams< _Artifact extends DocumentArtifact, @@ -17,6 +18,10 @@ export type UseDocumentStoreParams< > = { artifact: _Artifact observer?: DocumentStore<_Data, _Input> + // Optional synchronous seed for box.current. When provided, box.current is updated + // during render so useSyncExternalStore's snapshot is immediately correct (e.g. on + // fragment parent change). Must be memoized by the caller — tracked by reference. + initialState?: QueryResult<_Data, _Input> } & Partial> export function useDocumentStore< @@ -26,6 +31,7 @@ export function useDocumentStore< >({ artifact, observer: obs, + initialState, ...observeParams }: UseDocumentStoreParams<_Artifact, _Data, _Input>): [ QueryResult<_Data, _Input>, @@ -52,11 +58,41 @@ export function useDocumentStore< setObserver(obs) } + // Relay-style synchronous seeding: when initialState changes (i.e., the fragment + // parent changed), update box.current immediately during this render so + // useSyncExternalStore's getSnapshot returns the correct data without waiting for + // the subscription effect to fire. Tracked by reference — if provided, callers + // must memoize initialState to avoid spurious reseeds on every render. + const prevInitialStateRef = React.useRef | undefined>(undefined) + if (initialState !== undefined && initialState !== prevInitialStateRef.current) { + prevInitialStateRef.current = initialState + box.current = initialState + } + // the function that registers a new subscription for the observer const subscribe: any = React.useCallback( (fn: () => void) => { return observer.subscribe((val) => { - box.current = val + const prev = box.current + // Preserve object identity for unchanged subtrees so React.memo on + // fragment components can bail out when their data wasn't touched. + const stableData = recycleNodesInto(prev?.data, val.data) + const next = stableData === val.data ? val : { ...val, data: stableData } + + // Skip the re-render entirely if the new state is semantically identical + // to what React already has (e.g. an idempotent cache write). + if ( + next === prev || + (stableData === prev?.data && + val.fetching === prev?.fetching && + val.errors === prev?.errors && + val.source === prev?.source && + val.stale === prev?.stale) + ) { + return + } + + box.current = next if (isMountedRef.current) { fn() } diff --git a/packages/houdini-react/runtime/hooks/useDocumentSubscription.ts b/packages/houdini-react/runtime/hooks/useDocumentSubscription.ts index 003efafe4a..eeba08ff11 100644 --- a/packages/houdini-react/runtime/hooks/useDocumentSubscription.ts +++ b/packages/houdini-react/runtime/hooks/useDocumentSubscription.ts @@ -18,15 +18,17 @@ export function useDocumentSubscription< artifact, variables, send, + initialState, disabled, ...observeParams }: UseDocumentStoreParams<_Artifact, _Data, _Input> & { variables: _Input disabled?: boolean - send?: Partial + send?: Partial> }): [QueryResult<_Data, _Input> & { parent?: string | null }, DocumentStore<_Data, _Input>] { const [storeValue, observer] = useDocumentStore<_Data, _Input>({ artifact, + initialState, ...observeParams, }) @@ -42,6 +44,7 @@ export function useDocumentSubscription< // TODO: metadata metadata: {}, ...send, + initialState, }) } diff --git a/packages/houdini-react/runtime/hooks/useFragment.ts b/packages/houdini-react/runtime/hooks/useFragment.ts index 1e7d6cd572..890858669e 100644 --- a/packages/houdini-react/runtime/hooks/useFragment.ts +++ b/packages/houdini-react/runtime/hooks/useFragment.ts @@ -1,6 +1,10 @@ -import { deepEquals } from 'houdini/runtime' import { fragmentKey } from 'houdini/runtime' -import type { GraphQLObject, GraphQLVariables, FragmentArtifact } from 'houdini/runtime' +import type { + GraphQLObject, + GraphQLVariables, + FragmentArtifact, + QueryResult, +} from 'houdini/runtime' import * as React from 'react' import { useRouterContext } from '../routing/index.js' @@ -15,33 +19,54 @@ export function useFragment< document: { artifact: FragmentArtifact } ): _Data | null { const { cache } = useRouterContext() - - // get the fragment reference info const { parent, variables, loading } = fragmentReference<_Data, _Input, _ReferenceType>( reference, document ) - // if we got this far then we are safe to use the fields on the object - let cachedValue = reference as _Data | null + // Read from cache whenever the parent or loading state changes. The parent + // path uniquely identifies which cache record this fragment is bound to, so + // variables are excluded from the dep array — they are forwarded to + // observer.send() separately and don't affect which record we read. + // biome-ignore lint/correctness/useExhaustiveDependencies: variables intentionally excluded + const cachedValue = React.useMemo(() => { + if (reference && parent) { + return cache.read({ + selection: document.artifact.selection, + parent, + variables, + loading, + }).data as _Data + } + return reference as _Data | null + }, [parent, loading]) - // on the client, we want to ensure that we apply masking to the initial value by - // loading the value from cache - if (reference && parent) { - cachedValue = cache.read({ - selection: document.artifact.selection, - parent, - variables, - loading, - }).data as _Data - } + // Stable initialState derived from cachedValue. useDocumentStore uses this to + // seed box.current synchronously during render when the parent changes, so + // storeValue.data is immediately correct without waiting for the subscription + // effect to fire. Must be memoized (reference-stable) so the store doesn't + // re-seed on every render. + // biome-ignore lint/correctness/useExhaustiveDependencies: variables changes don't require re-seeding + const initialState = React.useMemo( + (): QueryResult<_Data, _Input> | undefined => + cachedValue !== null + ? { + data: cachedValue, + errors: null, + fetching: false, + partial: false, + stale: false, + source: null, + variables: variables ?? null, + } + : undefined, + [cachedValue] + ) - // we're ready to setup the live document const [storeValue] = useDocumentSubscription({ artifact: document.artifact, variables, initialValue: cachedValue, - // dont subscribe to anything if we are loading disabled: loading, send: { stuff: { @@ -49,27 +74,10 @@ export function useFragment< }, setup: true, }, + initialState, }) - // the parent has changed, we need to use initialValue for this render - // if we don't, then there is a very brief flash where we will show the old data - // before the store has had a chance to update - const lastReference = React.useRef<{ parent: string; variables: _Input } | null>(null) - return React.useMemo(() => { - // if the parent reference has changed we need to always prefer the cached value - const parentChange = - storeValue.parent !== parent || - !deepEquals({ parent, variables }, lastReference.current) - if (parentChange) { - // make sure we keep track of the last reference we used - lastReference.current = { parent, variables: { ...variables } } - - // and use the cached value - return cachedValue - } - - return storeValue.data - }, [variables, parent, storeValue.parent, storeValue.data, cachedValue]) + return storeValue.data } export function fragmentReference<_Data extends GraphQLObject, _Input, _ReferenceType extends {}>( diff --git a/packages/houdini-react/runtime/hooks/useFragmentHandle.ts b/packages/houdini-react/runtime/hooks/useFragmentHandle.ts index 24bb39a528..4d7b27b5c9 100644 --- a/packages/houdini-react/runtime/hooks/useFragmentHandle.ts +++ b/packages/houdini-react/runtime/hooks/useFragmentHandle.ts @@ -1,12 +1,15 @@ +import { extractPageInfo, cursorHandlers, offsetHandlers } from 'houdini/runtime' import type { GraphQLObject, FragmentArtifact, QueryArtifact, GraphQLVariables, + FetchFn, } from 'houdini/runtime' +import * as React from 'react' -import { useDocumentHandle, type DocumentHandle } from './useDocumentHandle.js' -import { useDocumentStore } from './useDocumentStore.js' +import { useClient, useSession } from '../routing/Router.js' +import type { DocumentHandle } from './useDocumentHandle.js' import { fragmentReference, useFragment } from './useFragment.js' // useFragmentHandle is just like useFragment except it also returns an imperative handle @@ -22,25 +25,167 @@ export function useFragmentHandle< document: { artifact: FragmentArtifact; refetchArtifact?: QueryArtifact } ): any { // get the fragment values - const data = useFragment<_Data, _ReferenceType, _Input>(reference, document) + const fragmentData = useFragment<_Data, _ReferenceType, _Input>(reference, document) // look at the fragment reference to get the variables const { variables } = fragmentReference<_Data, _Input, _ReferenceType>(reference, document) - // use the pagination fragment for meta data if it exists. - // if we pass this a fragment artifact, it won't add any data - const [handleValue, handleObserver] = useDocumentStore<_Data, _Input>({ - artifact: document.refetchArtifact ?? document.artifact, - }) - const handle = useDocumentHandle<_PaginationArtifact, _Data, _Input>({ - observer: handleObserver, - storeValue: handleValue, - artifact: document.refetchArtifact ?? document.artifact, - }) + const client = useClient() + const [session] = useSession() + + const [forwardPending, setForwardPending] = React.useState(false) + const [backwardPending, setBackwardPending] = React.useState(false) + + // Stable cursor stacks for SinglePage pagination — must survive re-renders + const previousCursorsRef = React.useRef<(string | null)[]>([]) + const nextCursorsRef = React.useRef<(string | null)[]>([]) + + const refetchArtifact = document.refetchArtifact as QueryArtifact | undefined + const refetchPath = refetchArtifact?.refetch?.path + + // Dedicated observer for pagination queries — separate from the fragment observer. + // cursorHandlers derives entity variables (e.g. { id }) and artifact defaults + // automatically via the type config, so no manual variable extraction is needed here. + const paginationObserver = React.useMemo(() => { + if (!refetchArtifact?.refetch?.paginated) return null + return client.observe<_Data, _Input>({ artifact: refetchArtifact }) + }, [refetchArtifact?.name]) + + // Subscribe to the pagination observer so React re-renders whenever a new page is fetched + // or served from cache (CacheOrNetwork). The fragment store subscription only watches the + // initial page's cache key; the observer is the live source of truth for SinglePage + // pagination where each page lives at its own per-cursor cache key. + const subscribeToObserver = React.useCallback( + (onChange: () => void) => { + if (!paginationObserver) return () => {} + return paginationObserver.subscribe(onChange) + }, + [paginationObserver] + ) + const getObserverSnapshot = React.useCallback( + () => paginationObserver?.state.data ?? null, + [paginationObserver] + ) + const paginationData = React.useSyncExternalStore( + subscribeToObserver, + getObserverSnapshot, + getObserverSnapshot + ) + + // Extract entity-level data from the pagination query response. For Node targetType + // the response is { node: EntityData }; we take the first root field to handle any type. + // Guard against partial cache hits (artifact has partial:true): only use the entity once the + // paginated connection field at refetch.path[0] is actually present in the response. + const paginationEntityData = React.useMemo<_Data | null>(() => { + if (!paginationData || !refetchArtifact?.selection?.fields) return null + const rootField = Object.keys(refetchArtifact.selection.fields)[0] + if (!rootField) return null + const entity = (paginationData as any)[rootField] + if (!entity) return null + const path = refetchArtifact.refetch?.path + if (path && path.length > 0 && (entity as any)[path[0]] == null) { + return null + } + return entity as _Data + }, [paginationData, refetchArtifact]) + + const isSinglePage = refetchArtifact?.refetch?.mode === 'SinglePage' + + // For SinglePage: use the pagination observer's entity data (each page has its own + // cache key) once a page fetch has landed. For Infinite: always use fragmentData, + // which reads accumulated pages from cache via the fragment's cache subscription. + const displayData = + isSinglePage && paginationEntityData !== null ? paginationEntityData : fragmentData + + const wrapLoad = <_Result>( + setLoading: (val: boolean) => void, + fn: (value: any) => Promise<_Result> + ) => { + return async (value: any) => { + setLoading(true) + let err: Error | null = null + let result: _Result | null = null + try { + result = await fn(value) + } catch (e) { + err = e as Error + } + setLoading(false) + if (err && err.name !== 'AbortError') throw err + return result + } + } + + const handle = React.useMemo(() => { + if (!refetchArtifact?.refetch?.paginated || !paginationObserver) return null + + const fetchFn: FetchFn<_Data, _Input> = (args) => { + return paginationObserver.send({ ...args, session }) + } + + const fetchUpdate = (args: any, updates: string[]) => { + return paginationObserver.send({ + ...args, + cacheParams: { + ...args?.cacheParams, + disableSubscriptions: true, + applyUpdates: updates, + }, + session, + }) + } + + if (refetchArtifact.refetch!.method === 'cursor') { + const handlers = cursorHandlers<_Data, _Input>({ + artifact: refetchArtifact, + getState: () => displayData as _Data | null, + // Use the observer's own variable state so cursor history is preserved + // across page navigations without manual tracking in the hook. + getVariables: () => + (paginationObserver.state.variables ?? variables) as NonNullable<_Input>, + fetch: fetchFn, + fetchUpdate, + getSession: async () => session, + previousCursors: previousCursorsRef.current, + nextCursors: nextCursorsRef.current, + }) + + return { + loadNext: wrapLoad(setForwardPending, handlers.loadNextPage), + loadNextPending: forwardPending, + loadPrevious: wrapLoad(setBackwardPending, handlers.loadPreviousPage), + loadPreviousPending: backwardPending, + pageInfo: refetchPath + ? extractPageInfo(displayData as GraphQLObject, refetchPath) + : null, + } + } + + if (refetchArtifact.refetch!.method === 'offset') { + const handlers = offsetHandlers({ + artifact: refetchArtifact, + getState: () => displayData as _Data | null, + getVariables: () => + (paginationObserver.state.variables ?? variables) as NonNullable<_Input>, + storeName: refetchArtifact.name, + fetch: fetchFn, + fetchUpdate: async (args: any, updates = ['append']) => + fetchUpdate(args, updates) as any, + getSession: async () => session, + }) + + return { + loadNext: wrapLoad(setForwardPending, handlers.loadNextPage), + loadNextPending: forwardPending, + } + } + + return null + }, [refetchArtifact, paginationObserver, displayData, session, forwardPending, backwardPending]) return { ...handle, variables, - data, + data: displayData, } } diff --git a/packages/houdini-react/runtime/hooks/useMutation.ts b/packages/houdini-react/runtime/hooks/useMutation.ts index 57c790b0c0..5e91bb9622 100644 --- a/packages/houdini-react/runtime/hooks/useMutation.ts +++ b/packages/houdini-react/runtime/hooks/useMutation.ts @@ -25,7 +25,7 @@ export function useMutation< artifact, }: { artifact: MutationArtifact -}): [boolean, MutationHandler<_Result, _Input, _Optimistic>] { +}): [MutationHandler<_Result, _Input, _Optimistic>, boolean] { // build the live document we'll use to send values const [storeValue, observer] = useDocumentStore<_Result, _Input>({ artifact }) @@ -62,7 +62,7 @@ export function useMutation< } } - return [pending, mutate] + return [mutate, pending] } export class RuntimeGraphQLError extends Error { diff --git a/packages/houdini-react/runtime/hooks/useQuery.ts b/packages/houdini-react/runtime/hooks/useQuery.ts index c203f0e53e..d5e62bb5f9 100644 --- a/packages/houdini-react/runtime/hooks/useQuery.ts +++ b/packages/houdini-react/runtime/hooks/useQuery.ts @@ -1,4 +1,4 @@ -import type { GraphQLObject, QueryArtifact } from 'houdini/runtime' +import type { GraphQLObject, GraphQLVariables, QueryArtifact } from 'houdini/runtime' import type { UseQueryConfig } from './useQueryHandle.js' import { useQueryHandle } from './useQueryHandle.js' diff --git a/packages/houdini-react/runtime/hooks/useQueryHandle.ts b/packages/houdini-react/runtime/hooks/useQueryHandle.ts index dc9548114c..4aff852928 100644 --- a/packages/houdini-react/runtime/hooks/useQueryHandle.ts +++ b/packages/houdini-react/runtime/hooks/useQueryHandle.ts @@ -107,7 +107,11 @@ export function useQueryHandle< // when it resolves the cached value will be updated // and it will be picked up in the next render let resolve: () => void = () => {} - const loadPromise = new Promise((r) => (resolve = r)) + let reject: (reason?: any) => void = () => {} + const loadPromise = new Promise((res, rej) => { + resolve = res + reject = rej + }) const suspenseUnit: QuerySuspenseUnit<_Data, _Input> = { // biome-ignore lint/suspicious/noThenProperty: suspense protocol requires a thenable @@ -135,12 +139,16 @@ export function useQueryHandle< suspenseUnit.resolved = { ...handle, data: value.data, - partia: value.partial, + partial: value.partial, artifact, } as unknown as DocumentHandle suspenseUnit.resolve() }) + .catch((err) => { + promiseCache.delete(identifier) + reject(err) + }) suspenseTracker.current = true throw suspenseUnit } diff --git a/packages/houdini-react/runtime/hooks/useSubscriptionHandle.ts b/packages/houdini-react/runtime/hooks/useSubscriptionHandle.ts index 095c556242..cfe38d8a5c 100644 --- a/packages/houdini-react/runtime/hooks/useSubscriptionHandle.ts +++ b/packages/houdini-react/runtime/hooks/useSubscriptionHandle.ts @@ -1,10 +1,15 @@ -import type { SubscriptionArtifact, GraphQLObject, GraphQLVariables } from 'houdini/runtime' +import type { + SubscriptionArtifact, + GraphQLObject, + GraphQLVariables, + GraphQLError, +} from 'houdini/runtime' import { useDocumentSubscription } from './useDocumentSubscription.js' export type SubscriptionHandle<_Result extends GraphQLObject, _Input extends GraphQLVariables> = { data: _Result | null - errors: { message: string }[] | null + errors: GraphQLError[] | null variables: _Input listen: (args: { variables?: _Input }) => void unlisten: () => void diff --git a/packages/houdini-react/runtime/index.tsx b/packages/houdini-react/runtime/index.tsx index 657ad6a6bb..cde29fc6f2 100644 --- a/packages/houdini-react/runtime/index.tsx +++ b/packages/houdini-react/runtime/index.tsx @@ -6,7 +6,24 @@ import manifest from './manifest.js' import { Router as RouterImpl, type RouterCache, RouterContextProvider } from './routing/index.js' export * from './hooks/index.js' -export { router_cache, useSession, useLocation, useRoute } from './routing/index.js' +export { + router_cache, + useCache, + useSession, + useLocation, + useRoute, + useCurrentVariables, + notFound, + unauthorized, + forbidden, + httpError, + redirect, + isRoutingError, + isApiError, + RoutingError, + RedirectError, +} from './routing/index.js' +export * from './Link.js' export function Router({ cache, diff --git a/packages/houdini-react/runtime/resolve-href.test.ts b/packages/houdini-react/runtime/resolve-href.test.ts new file mode 100644 index 0000000000..15b4367617 --- /dev/null +++ b/packages/houdini-react/runtime/resolve-href.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from 'vitest' +import { resolveHref } from './resolve-href.js' + +describe('resolveHref', () => { + test('substitutes a regular param', () => { + expect(resolveHref('/users/[id]', { id: '42' })).toBe('/users/42') + }) + + test('substitutes multiple params', () => { + expect(resolveHref('/users/[id]/posts/[postId]', { id: '1', postId: '99' })).toBe( + '/users/1/posts/99' + ) + }) + + test('converts number and boolean params to strings', () => { + expect(resolveHref('/page/[n]', { n: 3 })).toBe('/page/3') + expect(resolveHref('/flag/[v]', { v: true })).toBe('/flag/true') + }) + + test('includes optional [[param]] segment when value is provided', () => { + expect(resolveHref('/blog/[[slug]]', { slug: 'hello' })).toBe('/blog/hello') + }) + + test('omits optional [[param]] segment when value is absent', () => { + expect(resolveHref('/blog/[[slug]]', {})).toBe('/blog') + }) + + test('substitutes rest [...slug] param', () => { + expect(resolveHref('/docs/[...path]', { path: 'a/b/c' })).toBe('/docs/a/b/c') + }) + + test('omits rest [...slug] when value is absent', () => { + expect(resolveHref('/docs/[...path]', {})).toBe('/docs/') + }) + + test('handles mixed required, optional, and rest params', () => { + expect( + resolveHref('/[lang]/docs/[[version]]/[...path]', { lang: 'en', path: 'guide' }) + ).toBe('/en/docs/guide') + expect( + resolveHref('/[lang]/docs/[[version]]/[...path]', { + lang: 'en', + version: 'v2', + path: 'guide', + }) + ).toBe('/en/docs/v2/guide') + }) + + test('static href passes through unchanged', () => { + expect(resolveHref('/about', {})).toBe('/about') + }) +}) diff --git a/packages/houdini-react/runtime/resolve-href.ts b/packages/houdini-react/runtime/resolve-href.ts new file mode 100644 index 0000000000..e566cb5864 --- /dev/null +++ b/packages/houdini-react/runtime/resolve-href.ts @@ -0,0 +1,14 @@ +export function resolveHref( + href: string, + params: Record +): string { + // optional [[param]] — strip the whole /[[param]] segment when the value is absent + href = href.replace(/\/\[\[([^\]]+)\]\]/g, (_, key: string) => { + const val = params[key] + return val !== undefined ? '/' + String(val) : '' + }) + // rest [...slug] — substitute [..slug] with the value (or empty string when absent) + href = href.replace(/\[\.\.\.([^\]]+)\]/g, (_, key: string) => String(params[key] ?? '')) + // regular [param] + return href.replace(/\[([^\]]+)\]/g, (_, key: string) => String(params[key] ?? key)) +} diff --git a/packages/houdini-react/runtime/routing/Router.tsx b/packages/houdini-react/runtime/routing/Router.tsx index 38a29adf28..0ba5f67140 100644 --- a/packages/houdini-react/runtime/routing/Router.tsx +++ b/packages/houdini-react/runtime/routing/Router.tsx @@ -7,16 +7,17 @@ import configFile from '$houdini/runtime/imports/config' import { deepEquals } from 'houdini/runtime' import type { LRUCache } from 'houdini/runtime' import { marshalSelection, marshalInputs } from 'houdini/runtime' -import { find_match } from 'houdini/router/match' +import { find_match, find_prefix_match } from 'houdini/router/match' import type { RouterManifest, RouterPageManifest } from 'houdini/router/types' import React from 'react' -import { useContext } from 'react' +import { useContext, useEffect } from 'react' import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js' import { useDocumentStore } from '../hooks/useDocumentStore.js' import { type SuspenseCache, suspense_cache } from './cache.js' +import { GraphQLErrors, RoutingError, StatusContext } from './errors.js' -type PageComponent = React.ComponentType<{ url: string }> +type PageComponent = React.ComponentType<{ url: string; children?: React.ReactNode }> const PreloadWhich = { component: 'component', @@ -55,9 +56,12 @@ export function Router({ // find the matching page for the current route const [page, variables] = find_match(configFile, manifest, currentURL) - // if we dont have a page, its a 404 - if (!page) { - throw new Error('404') + const is404 = !page + // When no exact match, find the deepest prefix-matching page to render + // its layout chain with NotFoundGate throwing inside the appropriate boundary. + const targetPage = page ?? find_prefix_match(manifest, currentURL) + if (!targetPage) { + throw new RoutingError(404) } // the only time this component will directly suspend (instead of one of its children) @@ -67,14 +71,14 @@ export function Router({ // load the page assets (source, artifacts, data). this will suspend if the component is not available yet // this hook embeds pending requests in context so that the component can suspend if necessary14 const { loadData, loadComponent } = usePageData({ - page, + page: targetPage, variables, assetPrefix, injectToStream, }) // if we get this far, it's safe to load the component - const { component_cache, data_cache } = useRouterContext() - const PageComponent = component_cache.get(page.id)! + const { component_cache, data_cache, ssr_signals } = useRouterContext() + const PageComponent = component_cache.get(targetPage.id)! // if we got this far then we're past suspense @@ -107,6 +111,8 @@ export function Router({ const goto = (url: string) => { // clear the data cache so that we refetch queries with the new session (will force a cache-lookup) data_cache.clear() + // clear pending signals so the next render starts fresh load_query calls + ssr_signals.clear() // perform the navigation setCurrentURL(url) @@ -148,7 +154,15 @@ export function Router({ params: variables ?? {}, }} > - + + {is404 ? ( + + + + ) : ( + + )} + ) @@ -157,6 +171,14 @@ export function Router({ // export the location information in context export const useLocation = () => useContext(LocationContext) +export const ClientRedirect = ({ to }: { to: string }) => { + const { goto } = useLocation() + useEffect(() => { + goto(to) + }, [to]) + return null +} + /** * usePageData is responsible for kicking off the network requests necessary to render the page. * This includes loading the artifact, the component source, and any query results. This hook @@ -238,9 +260,11 @@ function usePageData({ .then(async () => { data_cache.set(id, observer) - // if there is an error, we need to reject the promise + // if there is an error, signal completion (the error is visible via + // useQueryResult reading observer.state.errors) and clean up if (observer.state.errors && observer.state.errors.length > 0) { - reject(observer.state.errors.map((e) => e.message).join('\n')) + ssr_signals.delete(id) + resolve() return } @@ -333,17 +357,23 @@ function usePageData({ `) + ssr_signals.delete(id) resolve() }) - .catch(reject) + .catch((err) => { + ssr_signals.delete(id) + if (err?.name === 'AbortError') { + return + } + reject(err) + }) }) - // if we are on the server, we need to save a signal that we can use to - // communicate with the client when we're done + // register the pending signal on both client and server so that concurrent React renders + // (concurrent mode / strict mode) that call load_query before data_cache is populated + // find the existing signal and don't create a duplicate observer+send const resolvable = { ...promise, resolve, reject } - if (!globalThis.window) { - ssr_signals.set(id, resolvable) - } + ssr_signals.set(id, resolvable) // we're done return resolvable @@ -378,6 +408,7 @@ function usePageData({ // before we can compare we need to only look at the variables that the artifact cares about if (Object.keys(usedVariables ?? {}).length > 0 && !deepEquals(last, usedVariables)) { data_cache.delete(artifact) + ssr_signals.delete(artifact) } } @@ -426,18 +457,11 @@ function usePageData({ } } - // if we don't have the component then we need to load it, save it in the cache, and - // then suspend with a promise that will resolve once its in cache async function loadComponent(targetPage: RouterPageManifest) { - // if we already have the component, don't do anything if (component_cache.has(targetPage.id)) { return } - - // load the component and then save it in the cache const mod = await targetPage.component() - - // save the component in the cache component_cache.set(targetPage.id, mod.default) } @@ -583,6 +607,7 @@ export function useSession(): [App.Session, (newSession: Partial) = const updateSession = (newSession: Partial) => { // clear the data cache so that we refetch queries with the new session (will force a cache-lookup) ctx.data_cache.clear() + ctx.ssr_signals.clear() // update the local state ctx.setSession(newSession) @@ -644,7 +669,7 @@ export function useQueryResult<_Data extends GraphQLObject, _Input extends Graph // if there is an error in the response we need to throw to the nearest boundary if (errors && errors.length > 0) { - throw new Error(JSON.stringify(errors)) + throw new GraphQLErrors(errors) } // create the handle that we will use to interact with the store const handle = useDocumentHandle({ @@ -753,8 +778,12 @@ function usePreload({ preload }: { preload: (url: string, which: PreloadWhichVal return } - // if the anchor doesn't allow for preloading, don't do anything - const preloadWhichRaw = anchor.attributes.getNamedItem('data-houdini-preload')?.value + // if the anchor doesn't explicitly opt in to preloading, don't do anything + const preloadAttr = anchor.attributes.getNamedItem('data-houdini-preload') + if (!preloadAttr) { + return + } + const preloadWhichRaw = preloadAttr.value const preloadWhich: PreloadWhichValue = !preloadWhichRaw || preloadWhichRaw === 'true' ? 'page' @@ -842,6 +871,51 @@ export function router_cache({ return result } +// Catches RoutingErrors that escape all HoudiniErrorBoundary instances during prefix-match +// (is404) rendering, preventing an infinite loop when a layout itself throws notFound(). +class NotFoundLayoutBoundary extends React.Component< + { children: React.ReactNode }, + { caught: boolean } +> { + static contextType = StatusContext + declare context: React.ContextType + + constructor(props: { children: React.ReactNode }) { + super(props) + this.state = { caught: false } + } + + static getDerivedStateFromError(error: unknown) { + if (error instanceof RoutingError) { + return { caught: true } + } + return null + } + + componentDidCatch(error: Error): void { + if (error instanceof RoutingError && this.context) { + this.context.status = error.status + } + } + + render() { + if (this.state.caught) { + return null + } + return this.props.children + } +} + +export const Is404Context = React.createContext(false) + +export function NotFoundGate({ children }: { children: React.ReactNode }) { + const is404 = React.useContext(Is404Context) + if (is404) { + throw new RoutingError(404) + } + return <>{children} +} + const PageContext = React.createContext<{ params: Record }>({ params: {} }) export function PageContextProvider({ diff --git a/packages/houdini-react/runtime/routing/cache.ts b/packages/houdini-react/runtime/routing/cache.ts index 272df29875..55b0b757fe 100644 --- a/packages/houdini-react/runtime/routing/cache.ts +++ b/packages/houdini-react/runtime/routing/cache.ts @@ -31,7 +31,10 @@ export class SuspenseCache<_Data> extends LRUCache<_Data> { }) } - // TODO: reject? + override clear() { + super.clear() + this.#callbacks.clear() + } set(key: string, value: _Data) { // perform the set like normal diff --git a/packages/houdini-react/runtime/routing/errors.tsx b/packages/houdini-react/runtime/routing/errors.tsx new file mode 100644 index 0000000000..4c82222cdc --- /dev/null +++ b/packages/houdini-react/runtime/routing/errors.tsx @@ -0,0 +1,145 @@ +import type { GraphQLError } from 'houdini/runtime' +import React from 'react' + +export class GraphQLErrors extends Error { + graphqlErrors: GraphQLError[] + + constructor(errors: GraphQLError[]) { + super(errors.map((e) => e.message).join('\n')) + this.name = 'GraphQLErrors' + this.graphqlErrors = errors + } +} + +let _currentSegment: string | undefined + +export function setCurrentSegment(id: string | undefined): void { + _currentSegment = id +} + +function getCurrentSegment(): string | undefined { + return _currentSegment +} + +export class RoutingError extends Error { + status: number + segment: string | undefined + + constructor(status: number) { + super(`Routing error: ${status}`) + this.name = 'RoutingError' + this.status = status + this.segment = getCurrentSegment() + } +} + +export class RedirectError extends Error { + status: number + location: string + + constructor(status: number, location: string) { + super(`Redirect: ${status} ${location}`) + this.name = 'RedirectError' + this.status = status + this.location = location + } +} + +export function isRoutingError(error: unknown): error is RoutingError { + return error instanceof RoutingError +} + +export function isApiError(error: unknown): error is GraphQLErrors { + return error instanceof GraphQLErrors +} + +export function notFound(): never { + throw new RoutingError(404) +} + +export function unauthorized(): never { + throw new RoutingError(401) +} + +export function forbidden(): never { + throw new RoutingError(403) +} + +export function httpError(status: number): never { + throw new RoutingError(status) +} + +export function redirect(status: 300 | 301 | 302 | 303 | 307 | 308, location: string): never { + throw new RedirectError(status, location) +} + +// Mutable ref passed from the server renderer so that a synchronous RoutingError +// or redirect() can propagate the correct HTTP status/location before streaming. +export const StatusContext = React.createContext<{ status: number; location?: string } | null>(null) + +type HoudiniErrorBoundaryProps = { + errorView: React.ComponentType<{ + errors: Array + children: React.ReactNode + }> + children: React.ReactNode +} + +type HoudiniErrorBoundaryState = { + hasError: boolean + errors: Array +} + +export class HoudiniErrorBoundary extends React.Component< + HoudiniErrorBoundaryProps, + HoudiniErrorBoundaryState +> { + static contextType = StatusContext + declare context: React.ContextType + + constructor( + props: HoudiniErrorBoundaryProps, + context: React.ContextType + ) { + super(props, context) + // Second-pass SSR: statusRef is pre-set to an error status by on_render after the first + // render threw. Start in error state immediately so children never render (and never throw). + if (typeof window === 'undefined' && context && context.status >= 400) { + this.state = { + hasError: true, + errors: [new RoutingError(context.status)], + } + } else { + this.state = { hasError: false, errors: [] } + } + } + + static getDerivedStateFromError(error: unknown): HoudiniErrorBoundaryState { + if (error instanceof GraphQLErrors) { + return { hasError: true, errors: error.graphqlErrors } + } + return { + hasError: true, + errors: [error instanceof Error ? error : new Error(String(error))], + } + } + + componentDidCatch(error: Error): void { + if (this.context) { + if (error instanceof RoutingError) { + this.context.status = error.status + } else if (error instanceof RedirectError) { + this.context.status = error.status + this.context.location = error.location + } + } + } + + render() { + if (this.state.hasError) { + const ErrorView = this.props.errorView + return {this.props.children} + } + return this.props.children + } +} diff --git a/packages/houdini-react/runtime/routing/index.ts b/packages/houdini-react/runtime/routing/index.ts index 081c64a326..a53d669812 100644 --- a/packages/houdini-react/runtime/routing/index.ts +++ b/packages/houdini-react/runtime/routing/index.ts @@ -1,2 +1,17 @@ export * from './Router.js' export { type SuspenseCache, suspense_cache } from './cache.js' +export { + HoudiniErrorBoundary, + GraphQLErrors, + RoutingError, + RedirectError, + notFound, + unauthorized, + forbidden, + httpError, + redirect, + isRoutingError, + isApiError, + StatusContext, + setCurrentSegment, +} from './errors.js' diff --git a/packages/houdini-react/runtime/tsconfig.json b/packages/houdini-react/runtime/tsconfig.json new file mode 100644 index 0000000000..cfa4fbcb34 --- /dev/null +++ b/packages/houdini-react/runtime/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "$houdini": ["."], + "$houdini/*": ["./*"], + "~": ["../src"], + "~/*": ["../src/*"] + }, + "rootDirs": ["..", "./types"], + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "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", + "../src/+app.d.ts" + ], + "exclude": ["../node_modules/**", "./[!ambient.d.ts]**"] +} diff --git a/packages/houdini-svelte/CHANGELOG.md b/packages/houdini-svelte/CHANGELOG.md index 6e562ed7db..1f52bfc135 100644 --- a/packages/houdini-svelte/CHANGELOG.md +++ b/packages/houdini-svelte/CHANGELOG.md @@ -1,5 +1,14 @@ # houdini-svelte +## 3.0.0-next.33 + +### Patch Changes + +- [#1654](https://github.com/HoudiniGraphql/houdini/pull/1654) [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - GraphQL errors now expose `locations`, `path`, and `extensions` per the spec; augment `App.GraphQLErrorExtensions` to type your server's extensions. + +- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: + - houdini@2.0.0-next.34 + ## 3.0.0-next.32 ### Patch Changes diff --git a/packages/houdini-svelte/package.json b/packages/houdini-svelte/package.json index 5325658cc1..b348d44928 100644 --- a/packages/houdini-svelte/package.json +++ b/packages/houdini-svelte/package.json @@ -1,6 +1,6 @@ { "name": "houdini-svelte", - "version": "3.0.0-next.32", + "version": "3.0.0-next.33", "scripts": { "compile": "scripts build-go", "typedefs": "scripts typedefs --plugin --go-package" diff --git a/packages/houdini-svelte/runtime/stores/pagination/fragment.ts b/packages/houdini-svelte/runtime/stores/pagination/fragment.ts index d67a4a5b26..7b8d4aa7c4 100644 --- a/packages/houdini-svelte/runtime/stores/pagination/fragment.ts +++ b/packages/houdini-svelte/runtime/stores/pagination/fragment.ts @@ -4,16 +4,17 @@ import { keyFieldsForType } from 'houdini/runtime' import { siteURL } from 'houdini/runtime' import { extractPageInfo } from 'houdini/runtime' import { cursorHandlers, offsetHandlers } from 'houdini/runtime' +import { fragmentKey } from 'houdini/runtime' import type { CachePolicies, FragmentArtifact, GraphQLObject, + GraphQLError, HoudiniFetchContext, QueryArtifact, PageInfo, CursorHandlers, GraphQLVariables, - fragmentKey, } from 'houdini/runtime' import { CompiledFragmentKind } from 'houdini/runtime' import type { Readable, Subscriber } from 'svelte/store' @@ -79,6 +80,14 @@ export class BasePaginatedFragmentStore< } } +// Keyed by ":" so reactive re-invocations of get() don't reset cursor history. +type _SinglePageState = { + paginationStore: DocumentStore + previousCursors: (string | null)[] + nextCursors: (string | null)[] +} +const _singlePageStateCache = new Map() + // both cursor paginated stores add a page info to their subscribe export class FragmentStoreCursor< _Data extends GraphQLObject, @@ -93,18 +102,74 @@ export class FragmentStoreCursor< }) const store = base.get(initialValue) - // generate the pagination handlers - const paginationStore = getClient().observe<_Data, _Input>({ - artifact: this.paginationArtifact, - initialValue: store.initialValue, - }) + const isSinglePage = this.paginationArtifact.refetch?.mode === 'SinglePage' + + let paginationStore: DocumentStore<_Data, _Input> + let previousCursors: (string | null)[] + let nextCursors: (string | null)[] + + if (isSinglePage) { + const parent = (initialValue as any)?.[fragmentKey]?.values?.[this.artifact.name] + ?.parent + const stateKey = parent ? `${this.paginationArtifact.name}:${parent}` : null + const cached = stateKey ? _singlePageStateCache.get(stateKey) : null + + if (cached) { + paginationStore = cached.paginationStore + previousCursors = cached.previousCursors + nextCursors = cached.nextCursors + } else { + paginationStore = getClient().observe<_Data, _Input>({ + artifact: this.paginationArtifact, + initialValue: store.initialValue, + }) + previousCursors = [] + nextCursors = [] + if (stateKey) { + _singlePageStateCache.set(stateKey, { + paginationStore, + previousCursors, + nextCursors, + }) + } + } + } else { + paginationStore = getClient().observe<_Data, _Input>({ + artifact: this.paginationArtifact, + initialValue: store.initialValue, + }) + previousCursors = [] + nextCursors = [] + } + + // First key of paginationArtifact.selection.fields is the query-level root (e.g. "user"). + // initialValue is fragment-level data with no such wrapper, so wrapped is null until + // the first paginated fetch completes. + const rootField = isSinglePage + ? Object.keys(this.paginationArtifact.selection.fields ?? {})[0] + : null + + const getPaginationEntity = (): _Data | null => { + if (!isSinglePage || !rootField) return null + const $pagination = get(paginationStore) + if (!$pagination.data) return null + const wrapped = ($pagination.data as any)?.[rootField] + if (!wrapped) return null + return wrapped as _Data + } const handlers = this.storeHandlers( paginationStore, store.initialValue, - () => get(store), - // the variables that are needed for this query are the store's values and the ids - () => store.variables as NonNullable<_Input> + () => getPaginationEntity() ?? get(store), + () => { + if (!isSinglePage) return store.variables as NonNullable<_Input> + const paginationVars = get(paginationStore).variables + if (paginationVars) return paginationVars as NonNullable<_Input> + return store.variables as NonNullable<_Input> + }, + previousCursors, + nextCursors ) const subscribe = ( @@ -116,10 +181,17 @@ export class FragmentStoreCursor< | undefined ): (() => void) => { const combined = derived([store, paginationStore], ([$parent, $pagination]) => { + let currentData: _Data | null + if (isSinglePage && rootField) { + const wrapped = ($pagination.data as any)?.[rootField] + currentData = wrapped ? (wrapped as _Data) : $parent + } else { + currentData = $parent + } return { ...$pagination, - data: $parent, - pageInfo: extractPageInfo($parent, this.paginationArtifact.refetch!.path), + data: currentData, + pageInfo: extractPageInfo(currentData, this.paginationArtifact.refetch!.path), } as FragmentPaginatedResult<_Data, { pageInfo: PageInfo }> }) @@ -130,8 +202,6 @@ export class FragmentStoreCursor< kind: CompiledFragmentKind, subscribe: subscribe, fetch: handlers.fetch, - - // add the pagination handlers loadNextPage: handlers.loadNextPage, loadPreviousPage: handlers.loadPreviousPage, } @@ -141,7 +211,9 @@ export class FragmentStoreCursor< observer: DocumentStore<_Data, _Input>, _initialValue: _Data | null, getState: () => _Data | null, - getVariables: () => NonNullable<_Input> + getVariables: () => NonNullable<_Input>, + previousCursors?: (string | null)[], + nextCursors?: (string | null)[] ): CursorHandlers<_Data, _Input> { return cursorHandlers<_Data, _Input>({ getState, @@ -150,12 +222,19 @@ export class FragmentStoreCursor< fetchUpdate: async (args, updates) => { await initClient() + // undefined entity vars would shadow the id cursorHandlers resolved via getVariables() + const entityVars = Object.fromEntries( + Object.entries(this.queryVariables(getState) as any).filter( + ([, v]) => v !== undefined + ) + ) as _Input + return observer.send({ session: await getSession(), ...args, variables: { ...args?.variables, - ...this.queryVariables(getState), + ...entityVars, }, cacheParams: { applyUpdates: updates, @@ -166,19 +245,27 @@ export class FragmentStoreCursor< fetch: async (args) => { await initClient() + const entityVars = Object.fromEntries( + Object.entries(this.queryVariables(getState) as any).filter( + ([, v]) => v !== undefined + ) + ) as _Input + + const resolvedVars = { ...args?.variables, ...entityVars } + return await observer.send({ session: await getSession(), ...args, - variables: { - ...args?.variables, - ...this.queryVariables(getState), - }, + variables: resolvedVars, + policy: args?.policy, cacheParams: { disableSubscriptions: true, }, }) }, getSession, + previousCursors, + nextCursors, }) } } @@ -270,7 +357,7 @@ export class FragmentStoreOffset< export type FragmentStorePaginated<_Data extends GraphQLObject, _Input> = Readable<{ data: _Data fetching: boolean - errors: { message: string }[] | null + errors: GraphQLError[] | null pageInfo: PageInfo }> & { fetch(params?: { policy?: CachePolicies }): Promise @@ -289,5 +376,5 @@ export type FragmentStorePaginated<_Data extends GraphQLObject, _Input> = Readab export type FragmentPaginatedResult<_Data, _ExtraFields = {}> = { data: _Data | null fetching: boolean - errors: { message: string }[] | null + errors: GraphQLError[] | null } & _ExtraFields diff --git a/packages/houdini/CHANGELOG.md b/packages/houdini/CHANGELOG.md index be6f8db0cd..bb5ce16693 100644 --- a/packages/houdini/CHANGELOG.md +++ b/packages/houdini/CHANGELOG.md @@ -1,5 +1,60 @@ # houdini +## 2.0.0-next.35 + +### Patch Changes + +- [`1cd7883`](https://github.com/HoudiniGraphql/houdini/commit/1cd78837960b7e9937ca3265415ea77d8a744acf) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix document change count in hmr + +## 2.0.0-next.34 + +### Minor Changes + +- [#1646](https://github.com/HoudiniGraphql/houdini/pull/1646) [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add `record.refresh()` to refetch every document that contains a given cache record, including those that reference it only through a fragment spread. + +- [#1653](https://github.com/HoudiniGraphql/houdini/pull/1653) [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - add `_upsert` list operation (insert if absent, update in place if present) and `_update` fragment (write field values to an existing cached record without affecting list membership) + +### Patch Changes + +- [#1654](https://github.com/HoudiniGraphql/houdini/pull/1654) [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - GraphQL errors now expose `locations`, `path`, and `extensions` per the spec; augment `App.GraphQLErrorExtensions` to type your server's extensions. + +- [#1647](https://github.com/HoudiniGraphql/houdini/pull/1647) [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix null cascade when combining @mask_disable with @include/@skip ([#1550](https://github.com/HoudiniGraphQL/houdini/issues/1550)), and restore correct runtime masking behavior in artifacts + +- [#1649](https://github.com/HoudiniGraphql/houdini/pull/1649) [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix list filters and @when conditions that contain object values or variable references nested inside objects + +- [#1650](https://github.com/HoudiniGraphql/houdini/pull/1650) [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix `useMutation` to return `[mutate, pending]` instead of `[pending, mutate]`, and fix list toggle operations accumulating across resolved optimistic mutation layers causing subsequent toggles to appear stuck. + +- [#1657](https://github.com/HoudiniGraphql/houdini/pull/1657) [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix cache link leak when refetching connections — embedded edge records now reuse their existing keys on write instead of generating new ones, and records that fall out of the list are cleaned up immediately. + +- [#1655](https://github.com/HoudiniGraphql/houdini/pull/1655) [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Prevent panic in the presence of concurrent writes to dev server websocket + +- Updated dependencies [[`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`f40e510`](https://github.com/HoudiniGraphql/houdini/commit/f40e510e0e67cd4ecc444f01662e3163fe45e736), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`5f3fd63`](https://github.com/HoudiniGraphql/houdini/commit/5f3fd635199681ef36ecb90a16df2e109a354c22)]: + - houdini-core@2.0.0-next.22 + +## 2.0.0-next.33 + +### Patch Changes + +- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix addMany ignoring field visibility when subscribing, preventing hidden fields from leaking into list updates + +- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - prevent unnecessary re-renders on fragments by stabilizing returned values and skipping subscription updates when data hasn't changed + +- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix gaps in pagination request deduplication: stale inflight entries no longer block new requests, and ssr_signals now covers client-side concurrent renders to prevent duplicate observer/send pairs + +## 2.0.0-next.32 + +### Patch Changes + +- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - write generated files atomically to prevent partial-read parse errors when Vite loads a module mid-pipeline + +- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix HMR not regenerating the router manifest when a new `+page` or `+layout` file is added; invalidate component fields cache after each HMR cycle + +- [#1639](https://github.com/HoudiniGraphql/houdini/pull/1639) [`b3798cd`](https://github.com/HoudiniGraphql/houdini/commit/b3798cde406da0f4160ee64e6026817162e61959) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix HMR pipeline: targeted js-update instead of full-reload, handle file deletions and cleanup files, serialize concurrent pipeline runs + +- [#1639](https://github.com/HoudiniGraphql/houdini/pull/1639) [`b3798cd`](https://github.com/HoudiniGraphql/houdini/commit/b3798cde406da0f4160ee64e6026817162e61959) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - encode per-field pagination direction in pageInfo updates arrays; runtime now drives cache behavior from the artifact instead of hardcoded field names + +- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - show a clear error when a plugin is found but has no bin field, calling out local monorepo packages as the likely cause + ## 2.0.0-next.31 ### Patch Changes diff --git a/packages/houdini/package.json b/packages/houdini/package.json index 7f3c8a4d9f..69d39bd148 100644 --- a/packages/houdini/package.json +++ b/packages/houdini/package.json @@ -1,6 +1,6 @@ { "name": "houdini", - "version": "2.0.0-next.31", + "version": "2.0.0-next.35", "description": "The disappearing GraphQL clients", "keywords": [ "typescript", diff --git a/packages/houdini/src/lib/codegen.ts b/packages/houdini/src/lib/codegen.ts index 7d41cd3fb9..83f1a4054c 100644 --- a/packages/houdini/src/lib/codegen.ts +++ b/packages/houdini/src/lib/codegen.ts @@ -161,7 +161,7 @@ export async function codegen_setup( // _db is the same object as the caller's db (ctx.db). reload() mutates it // in-place so the caller always sees the latest state without reassignment. const _db = db - const logger = new Logger(config.config_file.logLevel ?? LogLevel.Summary) + const logger = new Logger(config.config_file.logLevel ?? LogLevel.ShortSummary) // We need the root dir before we get to the exciting stuff await fs.mkdirpSync(conventions.houdini_root(config)) @@ -295,14 +295,15 @@ export async function codegen_setup( // WebSocket plugins insert themselves into the DB; for stdio plugins (port=0) // we do it here. INSERT OR IGNORE avoids a duplicate-key error either way. _db.run( - `INSERT OR IGNORE INTO plugins (name, hooks, port, plugin_order, include_runtime, config_module, client_plugins) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO plugins (name, hooks, port, plugin_order, include_runtime, include_static_runtime, config_module, client_plugins) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ spec.name, JSON.stringify([...spec.hooks]), spec.port, spec.order, msg.includeRuntime ?? null, + msg.includeStaticRuntime ?? null, msg.configModule ?? null, msg.clientPlugins ?? null, ] diff --git a/packages/houdini/src/lib/plugins.test.ts b/packages/houdini/src/lib/plugins.test.ts index c587e6a046..64488b14ad 100644 --- a/packages/houdini/src/lib/plugins.test.ts +++ b/packages/houdini/src/lib/plugins.test.ts @@ -80,7 +80,7 @@ describe('plugin_path npm package resolution', () => { test('throws when bin is missing', async () => { mockReadFile.mockResolvedValue(JSON.stringify({ name: 'my-plugin' })) await expect(plugin_path('my-plugin', '/project/houdini.config.js')).rejects.toThrow( - 'Could not find plugin: my-plugin' + "Found package 'my-plugin' but it has no bin field" ) }) }) diff --git a/packages/houdini/src/lib/plugins.ts b/packages/houdini/src/lib/plugins.ts index 6971f71714..8bc5e69414 100644 --- a/packages/houdini/src/lib/plugins.ts +++ b/packages/houdini/src/lib/plugins.ts @@ -23,66 +23,70 @@ export async function plugin_path( return { executable, directory: path.dirname(executable) } } - try { - // check if we are in a PnP environment - if (process.versions.pnp) { - // retrieve the PnP API (Yarn injects the `findPnpApi` into `node:module` builtin module in runtime) - const { findPnpApi } = require('node:module') - - // this will traverse the file system to find the closest `.pnp.cjs` file and return the PnP API based on it - // normally it will reside at the same level with `houdini.config.js` file, so it is unlikely that traversing the whole file system will happen - const pnp = findPnpApi(config_path) - - // this directly returns the ESM export of the corresponding module, thanks to the PnP API - // it will throw if the module isn't found in the project's dependencies - return pnp.resolveRequest(plugin_name, config_path, { - conditions: new Set(['import']), - }) - } - - // otherwise we have to hunt the module down relative to the current path - // use Node.js's built-in module resolution which handles all package managers correctly - const plugin_dir = find_module(plugin_name, config_path) - - // load up the package json - const package_json_src = await fs.readFile(path.join(plugin_dir, 'package.json')) - if (!package_json_src) { - throw new Error('There is no package.json.') - } - const package_json = JSON.parse(package_json_src) - - // a plugin is an executable so it must have a bin field - if (!package_json.bin) { - throw new Error('There is no bin defined.') - } - - // npm normalizes `"bin": "path"` to `{"pkg-name": "path"}` at publish time - const bin_value = - typeof package_json.bin === 'string' - ? package_json.bin - : (Object.values(package_json.bin)[0] as string) - const native_bin = path.join(plugin_dir, bin_value) - - if (preferWasm) { - try { - const wasm_dir = find_module(`${plugin_name}-wasm`, config_path) - return { - executable: path.join(wasm_dir, 'bin', `${plugin_name}.wasm`), - directory: plugin_dir, - } - } catch {} - } + // check if we are in a PnP environment + if (process.versions.pnp) { + // retrieve the PnP API (Yarn injects the `findPnpApi` into `node:module` builtin module in runtime) + const { findPnpApi } = require('node:module') + + // this will traverse the file system to find the closest `.pnp.cjs` file and return the PnP API based on it + // normally it will reside at the same level with `houdini.config.js` file, so it is unlikely that traversing the whole file system will happen + const pnp = findPnpApi(config_path) + + // this directly returns the ESM export of the corresponding module, thanks to the PnP API + // it will throw if the module isn't found in the project's dependencies + return pnp.resolveRequest(plugin_name, config_path, { + conditions: new Set(['import']), + }) + } - return { - executable: native_bin, - directory: plugin_dir, - } + // resolve the package directory — if this throws, the package isn't installed + let plugin_dir: string + try { + plugin_dir = find_module(plugin_name, config_path) } catch (e) { - const err = new Error( - `Could not find plugin: ${plugin_name}. Are you sure its installed? If so, please open a ticket on GitHub. ${e}` + throw new Error( + `Could not find plugin: ${plugin_name}. Are you sure it's installed? If so, please open a ticket on GitHub. ${e}` + ) + } + + // load up the package json + const package_json_src = await fs.readFile(path.join(plugin_dir, 'package.json')) + if (!package_json_src) { + throw new Error( + `Found package '${plugin_name}' but could not read its package.json at ${plugin_dir}.` ) + } + const package_json = JSON.parse(package_json_src) + + // a plugin is an executable so it must have a bin field + if (!package_json.bin) { + throw new Error( + `Found package '${plugin_name}' but it has no bin field in its package.json. ` + + `Houdini plugins are executables — add a "bin" entry pointing to the plugin's entry point. ` + + `If this is a local monorepo package, this is the most likely cause.` + ) + } + + // npm normalizes `"bin": "path"` to `{"pkg-name": "path"}` at publish time + const bin_value = + typeof package_json.bin === 'string' + ? package_json.bin + : (Object.values(package_json.bin)[0] as string) + const native_bin = path.join(plugin_dir, bin_value) + + if (preferWasm) { + try { + const wasm_dir = find_module(`${plugin_name}-wasm`, config_path) + return { + executable: path.join(wasm_dir, 'bin', `${plugin_name}.wasm`), + directory: plugin_dir, + } + } catch {} + } - throw err + return { + executable: native_bin, + directory: plugin_dir, } } diff --git a/packages/houdini/src/node/index.test.ts b/packages/houdini/src/node/index.test.ts index 1f191d9f15..b3caa5f66c 100644 --- a/packages/houdini/src/node/index.test.ts +++ b/packages/houdini/src/node/index.test.ts @@ -121,7 +121,7 @@ describe('runPlugin stdio — register message', () => { expect(reg.hooks).not.toContain('afterLoad') }) - test('only lists hooks that are functions', () => { + test('always includes Config and only lists hooks that are functions', () => { const { lines, restore } = captureStdout() runPlugin({ @@ -136,7 +136,10 @@ describe('runPlugin stdio — register message', () => { restore() const reg = lines[0] - expect(reg.hooks).toEqual(['Schema']) + // Config is always registered; schema is the only user-provided hook here + expect(reg.hooks).toContain('Config') + expect(reg.hooks).toContain('Schema') + expect(reg.hooks).not.toContain('Validate') }) }) diff --git a/packages/houdini/src/node/index.ts b/packages/houdini/src/node/index.ts index b3b7aecf0b..f843de2289 100644 --- a/packages/houdini/src/node/index.ts +++ b/packages/houdini/src/node/index.ts @@ -1,8 +1,11 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import http from 'node:http' +import { dirname, join } from 'node:path' import { createInterface } from 'node:readline' +import { WebSocketServer } from 'ws' + import { openDb, type Db } from '../lib/db.js' export type { Db } from '../lib/db.js' -import { WebSocketServer } from 'ws' export type PipelineHook = | 'config' @@ -17,6 +20,8 @@ export type PipelineHook = | 'generateDocuments' | 'generateRuntime' | 'afterGenerate' + | 'environment' + | 'indexFile' export type PluginContext = { taskId: string @@ -29,16 +34,26 @@ export type PluginContext = { ): Promise> } +export type TransformFn = (source: string, content: string) => Promise | string + export type HookHandler = ( ctx: PluginContext, payload: Record -) => Promise | undefined> | Record | undefined +) => + | Promise | string[] | string | undefined> + | Record + | string[] + | string + | undefined export type NodePluginConfig = { name: string order: 'before' | 'after' | 'core' hooks: Partial> includeRuntime?: string + staticRuntime?: string + transformRuntime?: TransformFn + transformStaticRuntime?: TransformFn configModule?: string clientPlugins?: Record } @@ -79,15 +94,18 @@ async function runStdio( databasePath: string, pluginKey: string ): Promise { + const resolvedName = pluginKey || config.name const { pending, invokeCounter, rl } = makeStdioChannel() + const wireHooks = registeredHookNames(config).map(toWireName) const reg: Record = { type: 'register', - name: pluginKey || config.name, - hooks: hookKeys(config).map(toWireName), + name: resolvedName, + hooks: wireHooks, order: config.order, } if (config.includeRuntime !== undefined) reg.includeRuntime = config.includeRuntime + if (config.staticRuntime !== undefined) reg.includeStaticRuntime = config.staticRuntime if (config.configModule !== undefined) reg.configModule = config.configModule if (config.clientPlugins !== undefined) reg.clientPlugins = JSON.stringify(config.clientPlugins) @@ -113,7 +131,7 @@ async function runStdio( db, invokeHook: makeInvokeHook(pending, invokeCounter, msg.taskId ?? ''), } - await dispatch(config, msg, ctx, (response) => stdioWrite(response)) + await dispatch(config, resolvedName, msg, ctx, (response) => stdioWrite(response)) } else if (msg.type === 'invoke_result') { resolveInvoke(pending, msg) } @@ -143,8 +161,9 @@ async function runWebSocket( process.exit(1) } + const resolvedName = pluginKey || config.name const db = await openDb(databasePath) - const wireHooks = hookKeys(config).map(toWireName) + const wireHooks = registeredHookNames(config).map(toWireName) const wsInvokeHook: PluginContext['invokeHook'] = () => { throw new Error('invokeHook is not supported in websocket transport') @@ -177,7 +196,7 @@ async function runWebSocket( invokeHook: wsInvokeHook, } - await dispatch(config, msg, ctx, (response) => { + await dispatch(config, resolvedName, msg, ctx, (response) => { if (response.error) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(response.error)) @@ -217,7 +236,9 @@ async function runWebSocket( db, invokeHook: wsInvokeHook, } - await dispatch(config, msg, ctx, (response) => ws.send(JSON.stringify(response))) + await dispatch(config, resolvedName, msg, ctx, (response) => + ws.send(JSON.stringify(response)) + ) }) ws.on('close', () => { @@ -236,14 +257,15 @@ async function runWebSocket( // Write to the DB so the orchestrator (and Go plugins) can find us. db.run( - `INSERT INTO plugins (name, hooks, port, plugin_order, include_runtime, config_module, client_plugins) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO plugins (name, hooks, port, plugin_order, include_runtime, include_static_runtime, config_module, client_plugins) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ - pluginKey || config.name, + resolvedName, JSON.stringify(wireHooks), port, config.order, config.includeRuntime ?? null, + config.staticRuntime ?? null, config.configModule ?? null, config.clientPlugins ? JSON.stringify(config.clientPlugins) : null, ] @@ -255,6 +277,239 @@ async function runWebSocket( process.on('SIGTERM', () => shutdown(db, server)) } +// ─── hook dispatch ──────────────────────────────────────────────────────────── + +async function dispatch( + config: NodePluginConfig, + pluginName: string, + msg: { + id: string + hook: string + payload: Record + taskId: string + pluginDirectory: string + }, + ctx: PluginContext, + send: (response: Record) => void +): Promise { + const normalized = msg.hook.toLowerCase() + const payload = msg.payload ?? {} + + try { + let result: any + + switch (normalized) { + case 'config': { + // Always handled. Equivalent to Go's DefaultConfig: return plugin config defaults. + const handler = config.hooks.config + result = handler ? await handler(ctx, payload) : undefined + break + } + case 'afterload': { + result = await dispatchAfterLoad(config, pluginName, ctx, payload) + break + } + case 'generateruntime': { + result = await dispatchGenerateRuntime(config, pluginName, ctx, payload) + break + } + case 'indexfile': { + await dispatchIndexFile(config, pluginName, ctx, payload) + result = undefined + break + } + default: { + const hookKey = (Object.keys(config.hooks) as PipelineHook[]).find( + (k) => k.toLowerCase() === normalized + ) + const handler = hookKey ? config.hooks[hookKey] : undefined + if (!handler) { + send({ + id: msg.id, + type: 'response', + error: { message: `no handler for hook ${msg.hook}` }, + }) + return + } + result = await handler(ctx, payload) + } + } + + send({ id: msg.id, type: 'response', result: result ?? {} }) + } catch (err) { + send({ id: msg.id, type: 'response', error: serializeError(err) }) + } +} + +// Equivalent to Go's handleAfterLoad: +// 1. DefaultConfig (config hook) → UPDATE plugins SET config in DB +// 2. StaticRuntime (staticRuntime field) → copy static files into plugins//static +// 3. AfterLoad (afterLoad hook) → call user hook +async function dispatchAfterLoad( + config: NodePluginConfig, + pluginName: string, + ctx: PluginContext, + payload: Record +): Promise | undefined> { + if (config.hooks.config) { + const defaults = await config.hooks.config(ctx, payload) + if (defaults !== undefined) { + ctx.db.run('UPDATE plugins SET config = $config WHERE name = $name', { + $config: JSON.stringify(defaults), + $name: pluginName, + }) + } + } + + if (config.staticRuntime) { + const { projectRoot, runtimeDir } = readProjectConfig(ctx.db) + const src = join(ctx.pluginDirectory, config.staticRuntime) + const dst = pluginStaticRuntimeDir(projectRoot, runtimeDir, pluginName) + await recursiveCopy(src, dst, config.transformStaticRuntime) + } + + if (config.hooks.afterLoad) { + return (await config.hooks.afterLoad(ctx, payload)) as Record | undefined + } + + return undefined +} + +// Equivalent to Go's handleGenerateRuntime: +// 1. IncludeRuntime (includeRuntime field) → copy runtime files into plugins//runtime +// 2. GenerateRuntime (generateRuntime hook) → call user hook, collect file paths +// Returns the list of written file paths ([]string equivalent). +async function dispatchGenerateRuntime( + config: NodePluginConfig, + pluginName: string, + ctx: PluginContext, + payload: Record +): Promise { + const paths: string[] = [] + + if (config.includeRuntime !== undefined) { + const { projectRoot, runtimeDir } = readProjectConfig(ctx.db) + const src = join(ctx.pluginDirectory, config.includeRuntime) + const dst = pluginRuntimeDir(projectRoot, runtimeDir, pluginName) + const copied = await recursiveCopy(src, dst, config.transformRuntime) + paths.push(...copied) + } + + if (config.hooks.generateRuntime) { + const result = await config.hooks.generateRuntime(ctx, payload) + if (Array.isArray(result)) { + paths.push(...result) + } + } + + return paths +} + +// Equivalent to Go's handleIndexFile: +// 1. Derive the index.ts path from project config +// 2. Call the user's indexFile hook to get content to append +// 3. Read existing content, append, write back +async function dispatchIndexFile( + config: NodePluginConfig, + pluginName: string, + ctx: PluginContext, + payload: Record +): Promise { + if (!config.hooks.indexFile) return + + const { projectRoot, runtimeDir } = readProjectConfig(ctx.db) + const targetPath = join(projectRoot, runtimeDir, 'index.ts') + + const content = await config.hooks.indexFile(ctx, { ...payload, filepath: targetPath }) + if (typeof content === 'string' && content) { + const existing = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : '' + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, existing + '\n' + content, 'utf8') + } +} + +// ─── project config helpers ─────────────────────────────────────────────────── + +function readProjectConfig(db: Db): { projectRoot: string; runtimeDir: string } { + const row = db.get<{ project_root: string; runtime_dir: string }>( + 'SELECT project_root, runtime_dir FROM config LIMIT 1' + ) + return { projectRoot: row?.project_root ?? '', runtimeDir: row?.runtime_dir ?? '' } +} + +// Mirrors Go's ProjectConfig.PluginRuntimeDirectory. +function pluginRuntimeDir(projectRoot: string, runtimeDir: string, name: string): string { + if (name === 'houdini-core') { + return join(projectRoot, runtimeDir, 'runtime') + } + return join(projectRoot, runtimeDir, 'plugins', name, 'runtime') +} + +// Mirrors Go's ProjectConfig.PluginStaticRuntimeDirectory. +function pluginStaticRuntimeDir(projectRoot: string, runtimeDir: string, name: string): string { + return join(projectRoot, runtimeDir, 'plugins', name, 'static') +} + +// Mirrors Go's RecursiveCopy: walks src, copies each file to dst, applying transform. +async function recursiveCopy(src: string, dst: string, transform?: TransformFn): Promise { + const written: string[] = [] + if (!existsSync(src)) return written + + const walk = async (srcDir: string, dstDir: string) => { + mkdirSync(dstDir, { recursive: true }) + for (const entry of readdirSync(srcDir)) { + const srcPath = join(srcDir, entry) + const dstPath = join(dstDir, entry) + if (statSync(srcPath).isDirectory()) { + await walk(srcPath, dstPath) + } else { + let content = readFileSync(srcPath, 'utf8') + if (transform) content = await transform(srcPath, content) + mkdirSync(dirname(dstPath), { recursive: true }) + writeFileSync(dstPath, content, 'utf8') + written.push(dstPath) + } + } + } + + await walk(src, dst) + return written +} + +// ─── registered hook names ──────────────────────────────────────────────────── + +// Mirrors Go's registerPluginHooks registration conditions. +function registeredHookNames(config: NodePluginConfig): string[] { + const names = new Set() + + // Config is always registered (Go registers it unconditionally). + names.add('config') + + // AfterLoad: staticRuntime (StaticRuntime) OR afterLoad hook (AfterLoad) OR config hook (DefaultConfig). + if (config.staticRuntime || config.hooks.afterLoad || config.hooks.config) { + names.add('afterLoad') + } + + // GenerateRuntime: includeRuntime (IncludeRuntime) OR generateRuntime hook (GenerateRuntime) OR configModule (Config). + if ( + config.includeRuntime !== undefined || + config.hooks.generateRuntime || + config.configModule !== undefined + ) { + names.add('generateRuntime') + } + + // All other user-provided hooks (including environment, indexFile, and any that + // were already added above — Set deduplicates). + for (const key of Object.keys(config.hooks) as PipelineHook[]) { + if (typeof config.hooks[key] === 'function') { + names.add(key) + } + } + + return Array.from(names) +} + // ─── shared helpers ─────────────────────────────────────────────────────────── type PendingMap = Map void; reject: (e: any) => void }> @@ -308,47 +563,6 @@ function shutdown(db: Db, server: http.Server): void { process.exit(0) } -async function dispatch( - config: NodePluginConfig, - msg: { - id: string - hook: string - payload: Record - taskId: string - pluginDirectory: string - }, - ctx: PluginContext, - send: (response: Record) => void -): Promise { - const normalizedHook = msg.hook.toLowerCase() - const hookKey = (Object.keys(config.hooks) as PipelineHook[]).find( - (k) => k.toLowerCase() === normalizedHook - ) - const handler = hookKey ? config.hooks[hookKey] : undefined - - if (!handler) { - send({ - id: msg.id, - type: 'response', - error: { message: `no handler for hook ${msg.hook}` }, - }) - return - } - - try { - const result = await handler(ctx, msg.payload ?? {}) - send({ id: msg.id, type: 'response', result: result ?? {} }) - } catch (err) { - send({ id: msg.id, type: 'response', error: serializeError(err) }) - } -} - -function hookKeys(config: NodePluginConfig): string[] { - return Object.keys(config.hooks).filter( - (k) => typeof config.hooks[k as PipelineHook] === 'function' - ) -} - function parseArgs(): { transport: string; database: string; pluginKey: string } { const argv = process.argv let transport = 'websocket' diff --git a/packages/houdini/src/router/match.ts b/packages/houdini/src/router/match.ts index f1c6f06dad..95ac0d5168 100644 --- a/packages/houdini/src/router/match.ts +++ b/packages/houdini/src/router/match.ts @@ -15,10 +15,53 @@ export type RouteParam = { optional: boolean rest: boolean chained: boolean + // GraphQL scalar name for this param (e.g. "ID", "String", "DateTime") + type?: string } export type ParamMatcher = (param: string) => boolean +// find_prefix_match returns the page whose leading static URL segments (the +// segments before the first dynamic [param] segment) match the most segments of +// the given URL. Used as a fallback when find_match returns null so that the +// 404 UI renders inside the correct layout chain. +export function find_prefix_match<_ComponentType>( + manifest: RouterManifest<_ComponentType>, + url: string +): RouterPageManifest<_ComponentType> | null { + const urlSegments = url.split('/').filter(Boolean) + let best: RouterPageManifest<_ComponentType> | null = null + let bestCount = -1 + + for (const page of Object.values(manifest.pages)) { + const pageSegments = page.url.split('/').filter(Boolean) + + // count leading static (non-dynamic) segments + let staticCount = 0 + for (const seg of pageSegments) { + if (seg.startsWith('[') || seg.startsWith('(') || seg === '*') break + staticCount++ + } + + // all static segments must match the corresponding URL segments exactly + if (staticCount > urlSegments.length) continue + let matches = true + for (let i = 0; i < staticCount; i++) { + if (pageSegments[i] !== urlSegments[i]) { + matches = false + break + } + } + + if (matches && staticCount > bestCount) { + bestCount = staticCount + best = page + } + } + + return best +} + // find the matching page given the current path export function find_match<_ComponentType>( config: ConfigFile, @@ -197,7 +240,7 @@ export function get_route_segments(route: string) { return route.slice(1).split('/').filter(affects_path) } -export function exec(match: RegExpMatchArray, params: RouteParam[]) { +export function exec(match: RegExpMatchArray, params: readonly RouteParam[]) { const result: Record = {} const values = match.slice(1) diff --git a/packages/houdini/src/router/server.ts b/packages/houdini/src/router/server.ts index 3ae2fb0657..e065b6a5ac 100644 --- a/packages/houdini/src/router/server.ts +++ b/packages/houdini/src/router/server.ts @@ -10,7 +10,7 @@ import { import type { ConfigFile } from '../lib/config.js' import type { HoudiniClient } from '../runtime/client.js' import { serialize as encodeCookie } from './cookies.js' -import { find_match } from './match.js' +import { find_match, find_prefix_match } from './match.js' import { get_session, handle_request, session_cookie_name } from './session.js' import type { RouterManifest, RouterPageManifest, YogaServerOptions } from './types.js' @@ -36,6 +36,7 @@ export function _serverHandler({ on_render: (args: { url: string match: RouterPageManifest | null + is404: boolean manifest: RouterManifest session: App.Session componentCache: Record @@ -113,13 +114,17 @@ export function _serverHandler({ // the request is for a server-side rendered page - // find the matching url - const [match] = find_match(config_file, manifest, url) + // find the matching url; fall back to the deepest prefix match so that + // 404 pages render inside the correct layout chain + const [exactMatch] = find_match(config_file, manifest, url) + const is404 = !exactMatch + const match = exactMatch ?? find_prefix_match(manifest, url) // call the framework-specific render hook with the latest session const rendered = await on_render({ url, match, + is404, session: await get_session(request.headers, session_keys), manifest, componentCache, diff --git a/packages/houdini/src/router/types.ts b/packages/houdini/src/router/types.ts index b441506d36..b662cbde29 100644 --- a/packages/houdini/src/router/types.ts +++ b/packages/houdini/src/router/types.ts @@ -15,11 +15,13 @@ export type { ServerAdapterFactory } from './server.js' export type RouterPageManifest<_ComponentType> = { id: string + // the navigable url for this page (route groups stripped), e.g. "/users/[id]" + url: string // the url pattern to match against. created from './match/parse_page_pattern' pattern: RegExp // the params used to execute the pattern and extract the variables - params: RouteParam[] + params: readonly RouteParam[] // loaders for the information that we need to render a page // and its loading state diff --git a/packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts b/packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts new file mode 100644 index 0000000000..7fda251446 --- /dev/null +++ b/packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts @@ -0,0 +1,1161 @@ +import { bench, describe } from 'vitest' + +import { testConfigFile } from '../../../test/index.js' +import { RefetchUpdateMode } from '../../types.js' +import type { SubscriptionSelection, SubscriptionSpec } from '../../types.js' +import { Cache } from '../index.js' + +// --------------------------------------------------------------------------- +// Category filter +// +// Pass BENCH=[,...] to run only certain suites. +// Omit BENCH (or set BENCH=all) to run everything. +// +// Categories: +// core — write and read at various sizes (fastest, ~30s) +// subscriptions — write→notify, subscribe/unsubscribe, churn +// lists — list mutations, applyUpdates / pagination +// multi-doc — fan-out, shared records, overlapping selections +// optimistic — optimistic write + layer resolve +// gc — garbage-collector tick, stale marking +// ssr — serialize / hydrate +// +// Quick mode (BENCH_QUICK=1): every benchmark runs with minimal iterations so +// the whole suite finishes in under a minute. Use this during local dev to +// catch obvious regressions without waiting for a full statistical run. +// +// Examples: +// BENCH=core vitest bench ... +// BENCH=core,subscriptions vitest bench ... +// BENCH_QUICK=1 vitest bench ... +// BENCH=core BENCH_QUICK=1 vitest bench ... +// --------------------------------------------------------------------------- +const activeCategories = new Set( + (process.env.BENCH ?? 'all') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +) +const skip = (...cats: string[]) => + !activeCategories.has('all') && !cats.some((c) => activeCategories.has(c)) + +// BENCH_MAX_N caps the largest-n benchmarks (default: unlimited). +// Set to 1000 in CI to avoid timing out on the base branch before our O(n²) fix. +const MAX_N = parseInt(process.env.BENCH_MAX_N ?? '0', 10) +const skipN = (n: number) => MAX_N > 0 && n > MAX_N + +// In quick mode every bench is capped at 3 iterations (1 warmup) regardless +// of the per-bench options — enough to catch crashes and gross regressions. +const QUICK = process.env.BENCH_QUICK === '1' +type BenchArgs = Parameters +function b(name: BenchArgs[0], fn: BenchArgs[1], opts?: BenchArgs[2]): void { + if (QUICK) { + bench(name, fn as () => void, { + time: 0, + iterations: 3, + warmupTime: 0, + warmupIterations: 1, + }) + } else { + bench(name, fn as () => void, opts) + } +} +// Like b() but skipped when BENCH_MAX_N is set below n. +function bN(n: number, name: BenchArgs[0], fn: BenchArgs[1], opts?: BenchArgs[2]): void { + if (!skipN(n)) b(name, fn, opts) +} + +const config = testConfigFile() + +// A flat selection of a single record with a handful of scalar fields. +const flatSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + lastName: { type: 'String', visible: true, keyRaw: 'lastName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + age: { type: 'Int', visible: true, keyRaw: 'age' }, + }, + }, + }, + }, +} + +const flatData = { + viewer: { + id: '1', + firstName: 'Bob', + lastName: 'Smith', + email: 'bob@example.com', + age: 42, + }, +} + +// A nested selection: viewer → friends list → each friend's profile. +const nestedSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + }, + }, + }, + }, + }, + }, +} + +function makeFriends(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: String(i + 2), + firstName: `Friend${i}`, + email: `friend${i}@example.com`, + })) +} + +// A wide selection: one record with many scalar fields. +function makeWideSelection(n: number): SubscriptionSelection { + const fields: SubscriptionSelection['fields'] = { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + } + for (let i = 0; i < n; i++) { + fields[`field${i}`] = { type: 'String', visible: true, keyRaw: `field${i}` } + } + return { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { fields }, + }, + }, + } +} + +function makeWideData(n: number) { + const record: Record = { id: '1' } + for (let i = 0; i < n; i++) { + record[`field${i}`] = `value${i}` + } + return { viewer: record } +} + +const wideSelection10 = makeWideSelection(10) +const wideSelection100 = makeWideSelection(100) +const wideSelection1000 = makeWideSelection(1000) +const wideSelection10000 = makeWideSelection(10000) + +const wideData10 = makeWideData(10) +const wideData100 = makeWideData(100) +const wideData1000 = makeWideData(1000) +const wideData10000 = makeWideData(10000) + +// Reusable single-record selection — used by GC, stale, and disjoint benchmarks. +// Module-level so the WeakMap in getFieldsForType hits on every call. +const idFirstNameSelection: SubscriptionSelection = { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, +} + +// 100 per-field selections for the overlapping-specs benchmark. +// Pre-generated so the same object references are reused across bench iterations. +const overlappingSelections100: SubscriptionSelection[] = Array.from( + { length: 100 }, + (_, i): SubscriptionSelection => ({ + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + [`field${i}`]: { type: 'String', visible: true, keyRaw: `field${i}` }, + }, + }) +) + +// A list of wide records: rows items each with cols scalar fields. +// Total data volume = rows * cols, letting us compare shapes at the same total size. +function makeWideListSelection(cols: number): SubscriptionSelection { + const itemFields: SubscriptionSelection['fields'] = { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + } + for (let i = 0; i < cols; i++) { + itemFields[`field${i}`] = { type: 'String', visible: true, keyRaw: `field${i}` } + } + return { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + items: { + type: 'Item', + visible: true, + keyRaw: 'items', + selection: { fields: itemFields }, + }, + }, + }, + }, + }, + } +} + +function makeWideListData(rows: number, cols: number) { + const items = Array.from({ length: rows }, (_, r) => { + const record: Record = { id: String(r + 2) } + for (let c = 0; c < cols; c++) { + record[`field${c}`] = `r${r}c${c}` + } + return record + }) + return { viewer: { id: '1', items } } +} + +// ~1000 total cells at different aspect ratios +const wideList10x100Selection = makeWideListSelection(100) +const wideList100x10Selection = makeWideListSelection(10) +const wideList10x100Data = makeWideListData(10, 100) +const wideList100x10Data = makeWideListData(100, 10) + +// ~10000 total cells at different aspect ratios +const wideList10x1000Selection = makeWideListSelection(1000) +const wideList100x100Selection = makeWideListSelection(100) +const wideList1000x10Selection = makeWideListSelection(10) +const wideList10x1000Data = makeWideListData(10, 1000) +const wideList100x100Data = makeWideListData(100, 100) +const wideList1000x10Data = makeWideListData(1000, 10) + +const nestedData10 = { viewer: { id: '1', firstName: 'Bob', friends: makeFriends(10) } } +const nestedData100 = { viewer: { id: '1', firstName: 'Bob', friends: makeFriends(100) } } +const nestedData1000 = { viewer: { id: '1', firstName: 'Bob', friends: makeFriends(1000) } } +const nestedData10000 = { viewer: { id: '1', firstName: 'Bob', friends: makeFriends(10000) } } + +describe.skipIf(skip('core'))('write', () => { + b('flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + }) + + b('nested list (10 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData10 }) + }) + + b('nested list (100 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + }) + + b('nested list (1000 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData1000 }) + }) + + bN(10000, 'nested list (10000 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData10000 }) + }) + + b('wide record (10 fields)', () => { + const cache = new Cache(config) + cache.write({ selection: wideSelection10, data: wideData10 }) + }) + + b('wide record (100 fields)', () => { + const cache = new Cache(config) + cache.write({ selection: wideSelection100, data: wideData100 }) + }) + + b('wide record (1000 fields)', () => { + const cache = new Cache(config) + cache.write({ selection: wideSelection1000, data: wideData1000 }) + }) + + bN( + 10000, + 'wide record (10000 fields)', + () => { + const cache = new Cache(config) + cache.write({ selection: wideSelection10000, data: wideData10000 }) + }, + { warmupIterations: 1, warmupTime: 0, iterations: 5 } + ) + + b('repeated writes to same record', () => { + const cache = new Cache(config) + for (let i = 0; i < 10; i++) { + cache.write({ selection: flatSelection, data: flatData }) + } + }) +}) + +// ~1000 and ~10000 total cells split across rows×cols at different aspect ratios. +// Isolates whether cost is driven by record count, field count, or total cells. +describe.skipIf(skip('core'))('write — wide list (same total cells, different shape)', () => { + b('~1000 cells: 10 rows × 100 cols', () => { + const cache = new Cache(config) + cache.write({ selection: wideList10x100Selection, data: wideList10x100Data }) + }) + + b('~1000 cells: 100 rows × 10 cols', () => { + const cache = new Cache(config) + cache.write({ selection: wideList100x10Selection, data: wideList100x10Data }) + }) + + bN(10000, '~10000 cells: 10 rows × 1000 cols', () => { + const cache = new Cache(config) + cache.write({ selection: wideList10x1000Selection, data: wideList10x1000Data }) + }) + + bN(10000, '~10000 cells: 100 rows × 100 cols', () => { + const cache = new Cache(config) + cache.write({ selection: wideList100x100Selection, data: wideList100x100Data }) + }) + + bN(10000, '~10000 cells: 1000 rows × 10 cols', () => { + const cache = new Cache(config) + cache.write({ selection: wideList1000x10Selection, data: wideList1000x10Data }) + }) +}) + +describe.skipIf(skip('core'))('read', () => { + b('flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + cache.read({ selection: flatSelection }) + }) + + b('nested list (10 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData10 }) + cache.read({ selection: nestedSelection }) + }) + + b('nested list (100 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + cache.read({ selection: nestedSelection }) + }) + + b('nested list (1000 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData1000 }) + cache.read({ selection: nestedSelection }) + }) + + bN( + 10000, + 'nested list (10000 items)', + () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData10000 }) + cache.read({ selection: nestedSelection }) + }, + { warmupIterations: 1, warmupTime: 0, iterations: 5 } + ) +}) + +describe.skipIf(skip('subscriptions'))('write + notify subscribers', () => { + b('1 subscriber, flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + + const spec: SubscriptionSpec = { + rootType: 'Query', + selection: flatSelection, + onMessage: () => {}, + } + cache.subscribe(spec) + cache.write({ selection: flatSelection, data: flatData }) + cache.unsubscribe(spec) + }) + + b('10 subscribers, flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + + const specs: SubscriptionSpec[] = Array.from({ length: 10 }, () => ({ + rootType: 'Query', + selection: flatSelection, + onMessage: () => {}, + })) + for (const s of specs) cache.subscribe(s) + cache.write({ selection: flatSelection, data: flatData }) + for (const s of specs) cache.unsubscribe(s) + }) + + b('1 subscriber, nested list (100 items)', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + + const spec: SubscriptionSpec = { + rootType: 'Query', + selection: nestedSelection, + onMessage: () => {}, + } + cache.subscribe(spec) + cache.write({ selection: nestedSelection, data: nestedData100 }) + cache.unsubscribe(spec) + }) +}) + +// Measures only write→onMessage latency. Each variant pre-builds the cache and +// subscriber once so the bench loop body is purely the write + notification dispatch. +function subscribedCache(selection: SubscriptionSelection, data: unknown) { + const cache = new Cache(config) + cache.write({ selection, data: data as any }) + cache.subscribe({ rootType: 'Query', selection, onMessage: () => {} }) + return cache +} + +describe.skipIf(skip('subscriptions'))('write → subscriber notification', () => { + const cacheFlat = subscribedCache(flatSelection, flatData) + b('flat record', () => { + cacheFlat.write({ selection: flatSelection, data: flatData }) + }) + + const cacheNested10 = subscribedCache(nestedSelection, nestedData10) + b('nested list (10 items)', () => { + cacheNested10.write({ selection: nestedSelection, data: nestedData10 }) + }) + + const cacheNested100 = subscribedCache(nestedSelection, nestedData100) + b('nested list (100 items)', () => { + cacheNested100.write({ selection: nestedSelection, data: nestedData100 }) + }) + + const cacheNested1000 = subscribedCache(nestedSelection, nestedData1000) + b('nested list (1000 items)', () => { + cacheNested1000.write({ selection: nestedSelection, data: nestedData1000 }) + }) + + const cacheNested10000 = skipN(10000) ? null : subscribedCache(nestedSelection, nestedData10000) + bN( + 10000, + 'nested list (10000 items)', + () => { + cacheNested10000!.write({ selection: nestedSelection, data: nestedData10000 }) + }, + { time: 0, iterations: 10, warmupTime: 0, warmupIterations: 1 } + ) + + const cacheWide100 = subscribedCache(wideSelection100, wideData100) + b('wide record (100 fields)', () => { + cacheWide100.write({ selection: wideSelection100, data: wideData100 }) + }) + + const cacheWide1000 = subscribedCache(wideSelection1000, wideData1000) + b('wide record (1000 fields)', () => { + cacheWide1000.write({ selection: wideSelection1000, data: wideData1000 }) + }) + + const cacheWide10000 = skipN(10000) ? null : subscribedCache(wideSelection10000, wideData10000) + bN( + 10000, + 'wide record (10000 fields)', + () => { + cacheWide10000!.write({ selection: wideSelection10000, data: wideData10000 }) + }, + { time: 0, iterations: 5, warmupTime: 0, warmupIterations: 1 } + ) + + const cacheList10x1000 = skipN(10000) + ? null + : subscribedCache(wideList10x1000Selection, wideList10x1000Data) + bN( + 10000, + '~10000 cells: 10 rows × 1000 cols', + () => { + cacheList10x1000!.write({ + selection: wideList10x1000Selection, + data: wideList10x1000Data, + }) + }, + { time: 0, iterations: 5, warmupTime: 0, warmupIterations: 1 } + ) + + const cacheList100x100 = skipN(10000) + ? null + : subscribedCache(wideList100x100Selection, wideList100x100Data) + bN(10000, '~10000 cells: 100 rows × 100 cols', () => { + cacheList100x100!.write({ selection: wideList100x100Selection, data: wideList100x100Data }) + }) + + const cacheList1000x10 = skipN(10000) + ? null + : subscribedCache(wideList1000x10Selection, wideList1000x10Data) + bN(10000, '~10000 cells: 1000 rows × 10 cols', () => { + cacheList1000x10!.write({ selection: wideList1000x10Selection, data: wideList1000x10Data }) + }) +}) + +// --------------------------------------------------------------------------- +// Multi-document scenarios +// --------------------------------------------------------------------------- + +// N specs watching the same selection. Measures pure dispatch fan-out cost: +// does notifying N subscribers scale linearly with N? +function fanOutCache(n: number): { cache: Cache; specs: SubscriptionSpec[] } { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + const specs = Array.from({ length: n }, () => ({ + rootType: 'Query', + selection: nestedSelection, + onMessage: () => {}, + })) + for (const s of specs) cache.subscribe(s) + return { cache, specs } +} + +describe.skipIf(skip('multi-doc'))( + 'fan-out: N documents watching same selection (100-item list)', + () => { + const f1 = fanOutCache(1) + b('1 document', () => { + f1.cache.write({ selection: nestedSelection, data: nestedData100 }) + }) + + const f10 = fanOutCache(10) + b('10 documents', () => { + f10.cache.write({ selection: nestedSelection, data: nestedData100 }) + }) + + const f100 = fanOutCache(100) + b('100 documents', () => { + f100.cache.write({ selection: nestedSelection, data: nestedData100 }) + }) + + const f1000 = fanOutCache(1000) + b('1000 documents', () => { + f1000.cache.write({ selection: nestedSelection, data: nestedData100 }) + }) + } +) + +// One record (User:1) appears as the target of N different query roots. +// Each root has its own subscription spec. Writing User:1 notifies all N. +function sharedRecordCache(n: number): { cache: Cache; updateSelection: SubscriptionSelection } { + const cache = new Cache(config) + + // Each query root points to the same underlying User:1. + // We model this by writing viewer→User:1 for each "query" using a unique keyRaw per root. + for (let i = 0; i < n; i++) { + const sel: SubscriptionSelection = { + fields: { + [`query${i}`]: { + type: 'User', + visible: true, + keyRaw: `query${i}`, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + }, + }, + }, + } + cache.write({ + selection: sel, + data: { [`query${i}`]: { id: '1', firstName: 'Bob', email: 'bob@example.com' } }, + }) + cache.subscribe({ rootType: 'Query', selection: sel, onMessage: () => {} }) + } + + // Writing directly to the shared record triggers all N subscribers. + const updateSelection: SubscriptionSelection = { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + } + return { cache, updateSelection } +} + +describe.skipIf(skip('multi-doc'))('shared record: User:1 referenced from N query roots', () => { + const s1 = sharedRecordCache(1) + b('1 query root', () => { + s1.cache.write({ + selection: s1.updateSelection, + data: { id: '1', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:1', + }) + }) + + const s10 = sharedRecordCache(10) + b('10 query roots', () => { + s10.cache.write({ + selection: s10.updateSelection, + data: { id: '1', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:1', + }) + }) + + const s100 = sharedRecordCache(100) + b('100 query roots', () => { + s100.cache.write({ + selection: s100.updateSelection, + data: { id: '1', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:1', + }) + }) + + const s1000 = sharedRecordCache(1000) + b('1000 query roots', () => { + s1000.cache.write({ + selection: s1000.updateSelection, + data: { id: '1', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:1', + }) + }) +}) + +// N subscriptions watching different fields of the same record vs N subscriptions +// watching entirely different records. Tells us whether field-level overlap adds overhead. +function overlapCache(n: number): Cache { + const cache = new Cache(config) + // Write N users + for (let i = 0; i < n; i++) { + cache.write({ + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + [`field${i}`]: { type: 'String', visible: true, keyRaw: `field${i}` }, + }, + }, + data: { id: String(i + 1), [`field${i}`]: `value${i}` }, + parent: `User:${i + 1}`, + }) + } + return cache +} + +describe.skipIf(skip('multi-doc'))('overlapping vs disjoint selections (N=100)', () => { + // All N specs watch the same record User:1 but request different fields + b('overlapping: N specs, same record, different fields', () => { + const cache = new Cache(config) + cache.write({ selection: wideSelection100, data: wideData100 }) + const specs = overlappingSelections100.map((sel) => { + const spec = { rootType: 'Query', selection: sel, onMessage: () => {} } + cache.subscribe(spec, {}) + return { spec } + }) + cache.write({ selection: wideSelection100, data: wideData100 }) + for (const { spec } of specs) cache.unsubscribe(spec) + }) + + // Each of N specs watches a completely different record + b('disjoint: N specs, N different records, same field', () => { + const cache = new Cache(config) + const specs = Array.from({ length: 100 }, (_, i) => { + cache.write({ + selection: idFirstNameSelection, + data: { id: String(i + 1), firstName: `User${i}` }, + parent: `User:${i + 1}`, + }) + const spec = { rootType: 'Query', selection: idFirstNameSelection, onMessage: () => {} } + cache.subscribe(spec, {}) + return { spec } + }) + // Write to one record — should only notify 1 of the 100 subscribers + cache.write({ + selection: idFirstNameSelection, + data: { id: '1', firstName: 'Alice' }, + parent: 'User:1', + }) + for (const { spec } of specs) cache.unsubscribe(spec) + }) +}) + +// A list query + individual detail queries per item. Writing one item should +// notify both the list subscriber and that item's detail subscriber. +function listPlusDetailCache(n: number): { cache: Cache; updateSel: SubscriptionSelection } { + const cache = new Cache(config) + const friends = makeFriends(n) + + // List query subscribing to all N items + cache.write({ + selection: nestedSelection, + data: { viewer: { id: '1', firstName: 'Bob', friends } }, + }) + cache.subscribe({ rootType: 'Query', selection: nestedSelection, onMessage: () => {} }) + + // Detail query for each item + const detailFields: SubscriptionSelection = { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + } + for (const friend of friends) { + cache.subscribe( + { rootType: 'Query', selection: detailFields, onMessage: () => {} }, + { parentID: `User:${friend.id}` } + ) + } + + const updateSel: SubscriptionSelection = { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + } + return { cache, updateSel } +} + +describe.skipIf(skip('multi-doc'))( + 'list query + detail queries: write one item notifies both', + () => { + const ld10 = listPlusDetailCache(10) + b('10-item list + 10 detail docs', () => { + ld10.cache.write({ + selection: ld10.updateSel, + data: { id: '2', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:2', + }) + }) + + const ld100 = listPlusDetailCache(100) + b('100-item list + 100 detail docs', () => { + ld100.cache.write({ + selection: ld100.updateSel, + data: { id: '2', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:2', + }) + }) + + const ld1000 = listPlusDetailCache(1000) + b('1000-item list + 1000 detail docs', () => { + ld1000.cache.write({ + selection: ld1000.updateSel, + data: { id: '2', firstName: 'Alice', email: 'alice@example.com' }, + parent: 'User:2', + }) + }) + } +) + +describe.skipIf(skip('subscriptions'))('subscribe / unsubscribe', () => { + b('subscribe then unsubscribe (flat)', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + + const spec: SubscriptionSpec = { + rootType: 'Query', + selection: flatSelection, + onMessage: () => {}, + } + cache.subscribe(spec) + cache.unsubscribe(spec) + }) +}) + +// --------------------------------------------------------------------------- +// applyUpdates — pagination append +// --------------------------------------------------------------------------- + +const appendSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + updates: [RefetchUpdateMode.append], + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + }, + }, + }, + }, + }, + }, +} + +describe.skipIf(skip('lists'))('write — applyUpdates append (pagination)', () => { + b('append page of 10 to 100-item list', () => { + const cache = new Cache(config) + cache.write({ + selection: appendSelection, + data: { viewer: { id: '1', friends: makeFriends(100) } }, + }) + cache.write({ + selection: appendSelection, + data: { viewer: { id: '1', friends: makeFriends(10) } }, + applyUpdates: ['append'], + }) + }) + + b('append page of 100 to 1000-item list', () => { + const cache = new Cache(config) + cache.write({ + selection: appendSelection, + data: { viewer: { id: '1', friends: makeFriends(1000) } }, + }) + cache.write({ + selection: appendSelection, + data: { viewer: { id: '1', friends: makeFriends(100) } }, + applyUpdates: ['append'], + }) + }) +}) + +// --------------------------------------------------------------------------- +// List mutations — append / prepend / remove via cache.list() +// --------------------------------------------------------------------------- + +const listSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + }, + }, + }, +} + +const listItemSelection: SubscriptionSelection = { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, +} + +function makeListCache(n: number): Cache { + const cache = new Cache(config) + cache.write({ + selection: listSelection, + data: { viewer: { id: '1', friends: makeFriends(n) } }, + }) + cache.subscribe({ rootType: 'Query', selection: listSelection, onMessage: () => {} }) + return cache +} + +describe.skipIf(skip('lists'))('list mutations', () => { + b('append to 10-item list', () => { + const cache = makeListCache(10) + cache + .list('All_Users') + .append({ selection: listItemSelection, data: { id: '9999', firstName: 'New' } }) + }) + + b('append to 100-item list', () => { + const cache = makeListCache(100) + cache + .list('All_Users') + .append({ selection: listItemSelection, data: { id: '9999', firstName: 'New' } }) + }) + + b('append to 1000-item list', () => { + const cache = makeListCache(1000) + cache + .list('All_Users') + .append({ selection: listItemSelection, data: { id: '9999', firstName: 'New' } }) + }) + + b('prepend to 100-item list', () => { + const cache = makeListCache(100) + cache + .list('All_Users') + .prepend({ selection: listItemSelection, data: { id: '9999', firstName: 'New' } }) + }) + + b('remove from 10-item list', () => { + const cache = makeListCache(10) + cache.list('All_Users').remove({ id: '2' }) + }) + + b('remove from 100-item list', () => { + const cache = makeListCache(100) + cache.list('All_Users').remove({ id: '2' }) + }) + + b('remove from 1000-item list', () => { + const cache = makeListCache(1000) + cache.list('All_Users').remove({ id: '2' }) + }) +}) + +// --------------------------------------------------------------------------- +// Optimistic write + resolve +// --------------------------------------------------------------------------- + +describe.skipIf(skip('optimistic'))('optimistic write + resolve', () => { + b('flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + cache.subscribe({ rootType: 'Query', selection: flatSelection, onMessage: () => {} }) + + const layer = cache._internal_unstable.storage.createLayer(true) + cache.write({ + selection: flatSelection, + data: { + viewer: { + id: '1', + firstName: 'Optimistic', + lastName: 'Smith', + email: 'o@example.com', + age: 0, + }, + }, + layer: layer.id, + }) + cache.clearLayer(layer.id) + }) + + b('100-item list', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + cache.subscribe({ rootType: 'Query', selection: nestedSelection, onMessage: () => {} }) + + const layer = cache._internal_unstable.storage.createLayer(true) + cache.write({ selection: nestedSelection, data: nestedData100, layer: layer.id }) + cache.clearLayer(layer.id) + }) + + b('1000-item list', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData1000 }) + cache.subscribe({ rootType: 'Query', selection: nestedSelection, onMessage: () => {} }) + + const layer = cache._internal_unstable.storage.createLayer(true) + cache.write({ selection: nestedSelection, data: nestedData1000, layer: layer.id }) + cache.clearLayer(layer.id) + }) +}) + +// --------------------------------------------------------------------------- +// GC tick +// --------------------------------------------------------------------------- + +// Write N records without subscribing so they all land in the lifetime map, +// then measure one GC pass over all of them. +describe.skipIf(skip('gc'))('GC tick (unsubscribed records)', () => { + b('100 records', () => { + const cache = new Cache(config) + for (let i = 0; i < 100; i++) { + cache.write({ + selection: idFirstNameSelection, + data: { id: String(i), firstName: `User${i}` }, + parent: `User:${i}`, + }) + } + cache._internal_unstable.collectGarbage() + }) + + b('1000 records', () => { + const cache = new Cache(config) + for (let i = 0; i < 1000; i++) { + cache.write({ + selection: idFirstNameSelection, + data: { id: String(i), firstName: `User${i}` }, + parent: `User:${i}`, + }) + } + cache._internal_unstable.collectGarbage() + }) + + bN(10000, '10000 records', () => { + const cache = new Cache(config) + for (let i = 0; i < 10000; i++) { + cache.write({ + selection: idFirstNameSelection, + data: { id: String(i), firstName: `User${i}` }, + parent: `User:${i}`, + }) + } + cache._internal_unstable.collectGarbage() + }) +}) + +// --------------------------------------------------------------------------- +// Stale marking +// --------------------------------------------------------------------------- + +function populatedCache(n: number): Cache { + const cache = new Cache(config) + for (let i = 0; i < n; i++) { + cache.write({ + selection: idFirstNameSelection, + data: { id: String(i), firstName: `User${i}` }, + parent: `User:${i}`, + }) + } + return cache +} + +describe.skipIf(skip('gc'))('stale marking', () => { + const cache100 = populatedCache(100) + b('markAllStale (100 records)', () => { + cache100.markTypeStale() + }) + + const cache1000 = populatedCache(1000) + b('markAllStale (1000 records)', () => { + cache1000.markTypeStale() + }) + + const cache10000 = skipN(10000) ? null : populatedCache(10000) + bN(10000, 'markAllStale (10000 records)', () => { + cache10000!.markTypeStale() + }) + + b('markTypeStale User (100 records)', () => { + cache100.markTypeStale({ type: 'User' }) + }) + + b('markTypeStale User (1000 records)', () => { + cache1000.markTypeStale({ type: 'User' }) + }) +}) + +// --------------------------------------------------------------------------- +// Subscribe / unsubscribe churn — simulates component mount/unmount cycles +// --------------------------------------------------------------------------- + +describe.skipIf(skip('subscriptions'))('subscribe/unsubscribe churn', () => { + b('10 cycles, flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + for (let i = 0; i < 10; i++) { + const spec: SubscriptionSpec = { + rootType: 'Query', + selection: flatSelection, + onMessage: () => {}, + } + cache.subscribe(spec) + cache.write({ selection: flatSelection, data: flatData }) + cache.unsubscribe(spec) + } + }) + + b('100 cycles, flat record', () => { + const cache = new Cache(config) + cache.write({ selection: flatSelection, data: flatData }) + for (let i = 0; i < 100; i++) { + const spec: SubscriptionSpec = { + rootType: 'Query', + selection: flatSelection, + onMessage: () => {}, + } + cache.subscribe(spec) + cache.write({ selection: flatSelection, data: flatData }) + cache.unsubscribe(spec) + } + }) + + b('10 cycles, 100-item list', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + for (let i = 0; i < 10; i++) { + const spec: SubscriptionSpec = { + rootType: 'Query', + selection: nestedSelection, + onMessage: () => {}, + } + cache.subscribe(spec) + cache.write({ selection: nestedSelection, data: nestedData100 }) + cache.unsubscribe(spec) + } + }) +}) + +// --------------------------------------------------------------------------- +// Serialize / hydrate (SSR path) +// --------------------------------------------------------------------------- + +const serialized100 = (() => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + return cache.serialize() +})() + +const serialized1000 = (() => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData1000 }) + return cache.serialize() +})() + +const parsed100 = JSON.parse(serialized100) +const parsed1000 = JSON.parse(serialized1000) + +describe.skipIf(skip('ssr'))('serialize', () => { + b('100-item list', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData100 }) + cache.serialize() + }) + + b('1000-item list', () => { + const cache = new Cache(config) + cache.write({ selection: nestedSelection, data: nestedData1000 }) + cache.serialize() + }) +}) + +describe.skipIf(skip('ssr'))('hydrate', () => { + b('100-item list', () => { + const cache = new Cache(config) + cache.hydrate(parsed100) + }) + + b('1000-item list', () => { + const cache = new Cache(config) + cache.hydrate(parsed1000) + }) +}) diff --git a/packages/houdini/src/runtime/cache/gc.ts b/packages/houdini/src/runtime/cache/gc.ts index 9ecee64ae7..89ab049413 100644 --- a/packages/houdini/src/runtime/cache/gc.ts +++ b/packages/houdini/src/runtime/cache/gc.ts @@ -18,6 +18,10 @@ export class GarbageCollector { this.lifetimes.clear() } + delete(id: string) { + this.lifetimes.delete(id) + } + resetLifetime(id: string, field: string) { // if this is the first time we've seen the id if (!this.lifetimes.get(id)) { @@ -36,8 +40,14 @@ export class GarbageCollector { // look at every field of every record we know about for (const [id, fieldMap] of this.lifetimes.entries()) { for (const [field, lifetime] of fieldMap.entries()) { - // if there is an active subscriber for the field move on - if (this.cache._internal_unstable.subscriptions.get(id, field).length > 0) { + // if there is an active subscriber for the field move on. masked parent + // subscriptions count too — a masked field is still part of some + // document's active data even if no one is notified when it changes + if ( + this.cache._internal_unstable.subscriptions.get(id, field).length > 0 || + this.cache._internal_unstable.subscriptions.getMaskedParents(id, field).length > + 0 + ) { continue } diff --git a/packages/houdini/src/runtime/cache/index.ts b/packages/houdini/src/runtime/cache/index.ts index 1dbd87e476..9dfea25c50 100644 --- a/packages/houdini/src/runtime/cache/index.ts +++ b/packages/houdini/src/runtime/cache/index.ts @@ -9,6 +9,9 @@ import { PendingValue } from '../types.js' import type { GraphQLObject, GraphQLValue, + ListFilter, + ListWhen, + MutationOperation, NestedList, SubscriptionSelection, SubscriptionSpec, @@ -18,12 +21,12 @@ import type { import { fragmentKey } from '../types.js' import { GarbageCollector } from './gc.js' import type { ListCollection } from './lists.js' -import { ListManager } from './lists.js' +import { ListManager, opaqueListID } from './lists.js' import { StaleManager } from './staleManager.js' import type { Layer, LayerID } from './storage.js' import { InMemoryStorage } from './storage.js' import { evaluateKey, rootID } from './stuff.js' -import { InMemorySubscriptions, type FieldSelection } from './subscription.js' +import { filterValue, InMemorySubscriptions } from './subscription.js' export class Cache { // the internal implementation for a lot of the cache's methods are moved into @@ -80,10 +83,9 @@ export class Cache { ? this._internal_unstable.storage.getLayer(layerID) : this._internal_unstable.storage.topLayer - // write any values that we run into and get a list of subscribers - const subscribers = this._internal_unstable - .writeSelection({ ...args, layer }) - .map((sub) => sub[0]) + // write any values that we run into and get a set of subscribers to notify + const toNotify = this._internal_unstable.writeSelection({ ...args, layer }) + const subscribers = [...toNotify] this.#notifySubscribers(subscribers.concat(notifySubscribers)) @@ -196,6 +198,30 @@ export class Cache { } } + // ask every document whose data contains the record to refetch itself. + // this includes documents that only contain the record behind a masked + // boundary (a fragment spread) thanks to their masked parent subscriptions + refresh(id: string) { + // when an optimistic key resolves we might know the record by two ids + const recordIDs = [this._internal_unstable.storage.idMaps[id], id].filter( + Boolean + ) as string[] + + // a document can be subscribed to multiple fields of the record so make + // sure we only send the message once per set + const notified = new Set() + for (const recordID of recordIDs) { + for (const [spec] of this._internal_unstable.subscriptions.getAll(recordID, { + includeMaskedParents: true, + })) { + if (!notified.has(spec.onMessage)) { + notified.add(spec.onMessage) + spec.onMessage({ kind: 'refetch' }) + } + } + } + } + markRecordStale(id: string, options: { field?: string; when?: {} }) { if (options.field) { const key = computeKey({ field: options.field, args: options.when ?? {} }) @@ -334,27 +360,37 @@ export class Cache { this.#notifySubscribers(subSpecs) } + // returns the current notification generation; fragment references carry this value + // so hooks can detect which epoch of data they were rendered with + getEpoch(): number { + return this._internal_unstable.epoch + } + #notifySubscribers(subs: SubscriptionSpec[]) { // if there's no one to notify, its a no-op if (subs.length === 0) { return } + // advance the epoch before stamping fragment references so every reference + // produced during this flush carries the new generation number + this._internal_unstable.epoch++ // the same spec will likely need to be updated multiple times, create the unique list by using the set // function's identity - const notified: SubscriptionSpec['set'][] = [] + const notified = new Set() for (const spec of subs) { // if we haven't added the set yet - if (!notified.includes(spec.set)) { - notified.push(spec.set) + if (!notified.has(spec.onMessage)) { + notified.add(spec.onMessage) // trigger the update - spec.set( - this._internal_unstable.getSelection({ + spec.onMessage({ + kind: 'update', + data: this._internal_unstable.getSelection({ parent: spec.parentID || rootID, selection: spec.selection, variables: spec.variables?.() || {}, ignoreMasking: false, - }).data - ) + }).data, + }) } } } @@ -364,6 +400,10 @@ class CacheInternal { // for server-side requests we need to be able to flag the cache as disabled so we dont write to it disabled = false + // monotonically increasing counter incremented on every cache notification flush; + // threaded into fragment references so hooks know which generation of data they hold + epoch = 0 + _config?: ConfigFile storage: InMemoryStorage subscriptions: InMemorySubscriptions @@ -433,7 +473,7 @@ class CacheInternal { parent = rootID, applyUpdates, layer, - toNotify = [], + toNotify = new Set(), forceNotify, forceStale, }: { @@ -443,14 +483,14 @@ class CacheInternal { parent?: string root?: string layer: Layer - toNotify?: FieldSelection[] + toNotify?: Set applyUpdates?: string[] forceNotify?: boolean forceStale?: boolean - }): FieldSelection[] { + }): Set { // if the cache is disabled, dont do anything if (this.disabled) { - return [] + return toNotify } // which selection we need to walk down depends on the type of the data @@ -487,9 +527,15 @@ class CacheInternal { linkedType = value.__typename as string } - // the current set of subscribers + // the current set of subscribers. the masked parent ones belong to documents + // whose data contains this record behind a masked boundary — they never + // get notified but they do need their subscriptions propagated when + // links change so containment lookups (cache.refresh) stay accurate const currentSubscribers = this.subscriptions.get(parent, key) - const specs = currentSubscribers.map((sub) => sub[0]) + const maskedParentSubscribers = this.subscriptions.getMaskedParents(parent, key) + const specs = currentSubscribers + .map((sub) => sub[0]) + .concat(maskedParentSubscribers.map((sub) => sub[0])) // look up the previous value const { value: previousValue, displayLayers } = this.storage.get(parent, key) @@ -548,11 +594,14 @@ class CacheInternal { if (displayLayer && (valueChanged || forceNotify)) { // we need to add the fields' subscribers to the set of callbacks // we need to invoke - toNotify.push(...currentSubscribers) + for (const [sub] of currentSubscribers) toNotify.add(sub) } // write value to the layer layer.writeField(parent, key, newValue) + if (key === '__typename' && typeof newValue === 'string') { + this.storage.typenames.set(parent, newValue) + } } // if we are writing `null` over a link else if (value === null) { @@ -570,7 +619,7 @@ class CacheInternal { layer.writeLink(parent, key, null) // add the list of subscribers for this field - toNotify.push(...currentSubscribers) + for (const [sub] of currentSubscribers) toNotify.add(sub) } // the field could point to a linked object else if (value instanceof Object && !Array.isArray(value)) { @@ -618,8 +667,17 @@ class CacheInternal { variables, parentType: linkedType, }) + if (maskedParentSubscribers.length > 0) { + this.subscriptions.addMany({ + parent: linkedID, + subscribers: maskedParentSubscribers, + variables, + parentType: linkedType, + masked: true, + }) + } - toNotify.push(...currentSubscribers) + for (const [sub] of currentSubscribers) toNotify.add(sub) } // if the link target points to another record in the cache we need to walk down its @@ -681,6 +739,16 @@ class CacheInternal { // build up the list of linked ids let linkedIDs: NestedList = [] + // if we are applying an append/prepend update the new entries get concatenated + // with the previous ones so they can't take over the previous embedded keys. + // a normal write replaces the field's links so embedded entries can reuse the + // key of the record they replace instead of generating a new one every write + const insertingUpdates = Boolean( + applyUpdates?.some( + (update) => update !== 'replace' && updates?.includes(update) + ) + ) + // it could be a list of lists, in order to recreate the list of lists we need // we need to track two sets of IDs, the ids of the embedded records and // then the full structure of embedded lists. we'll use the flat list to add @@ -698,6 +766,10 @@ class CacheInternal { fields: fieldSelection, layer, forceNotify, + previousIDs: + !insertingUpdates && Array.isArray(previousValue) + ? (previousValue as NestedList) + : null, }) // we have to do something different if we are writing to an optimistic layer or not @@ -807,16 +879,33 @@ class CacheInternal { // we need to look at the last time we saw each subscriber to check if they need to be added to the spec if (contentChanged || forceNotify) { - toNotify.push(...currentSubscribers) + for (const [sub] of currentSubscribers) toNotify.add(sub) } // any ids that don't show up in the new list need to have their subscribers wiped + const linkedIDSet = new Set( + (linkedIDs as (string | null)[]).flat(Infinity) + ) for (const lostID of oldIDs) { - if (linkedIDs.includes(lostID) || !lostID) { + if (!lostID || linkedIDSet.has(lostID)) { continue } this.subscriptions.remove(lostID, fieldSelection, specs, variables) + + // embedded records can only be referenced by the field that wrote them + // so once they fall out of the list their data can be cleaned up. if + // we're writing to an optimistic layer the old values have to survive + // a potential rollback so we leave them for the garbage collector + if ( + !layer.optimistic && + typeof lostID === 'string' && + lostID.startsWith(`${parent}.${key}[`) + ) { + this.storage.removeEmbeddedRecord(lostID) + this.lifetimes.delete(lostID) + this.staleManager.deleteRecord(lostID) + } } // if there was a change in the list @@ -825,8 +914,9 @@ class CacheInternal { } // every new id that isn't a prevous relationship needs a new subscriber - for (const id of newIDs.filter((id) => !oldIDs.includes(id))) { - if (id == null) { + const oldIDSet = new Set(oldIDs) + for (const id of newIDs) { + if (id == null || oldIDSet.has(id)) { continue } @@ -836,6 +926,15 @@ class CacheInternal { variables, parentType: linkedType, }) + if (maskedParentSubscribers.length > 0) { + this.subscriptions.addMany({ + parent: id, + subscribers: maskedParentSubscribers, + variables, + parentType: linkedType, + masked: true, + }) + } } } @@ -863,12 +962,26 @@ class CacheInternal { } } + // resolve the opaque list ID from @listID (the value of __id set by @includeListID) + let opaqueListID: string | undefined + if (operation.listID) { + if (operation.listID.kind !== 'Variable') { + opaqueListID = operation.listID.value + } else { + const id = variables[operation.listID.value] + if (typeof id !== 'string') { + throw new Error('listID value must be a string') + } + opaqueListID = id + } + } + // if the necessary list doesn't exist, don't do anything - if ( - operation.list && - !this.lists.get(operation.list, parentID, operation.target === 'all') - ) { - continue + if (operation.list) { + const exists = opaqueListID + ? this.lists.getByOpaqueID(opaqueListID) + : this.lists.get(operation.list, parentID, operation.target === 'all') + if (!exists) continue } // there could be a list of elements to perform the operation on @@ -881,14 +994,16 @@ class CacheInternal { fieldSelection && operation.list ) { - this.cache - .list( - operation.list, - parentID, - operation.target === 'all', - processedOperations - ) - .when(operation.when) + const insertList = opaqueListID + ? this.lists.getByOpaqueID(opaqueListID, processedOperations)! + : this.cache.list( + operation.list, + parentID, + operation.target === 'all', + processedOperations + ) + insertList + .when(resolveWhen(operation.when, variables)) .addToList( fieldSelection, target, @@ -905,21 +1020,47 @@ class CacheInternal { fieldSelection && operation.list ) { - this.cache - .list( - operation.list, - parentID, - operation.target === 'all', - processedOperations - ) + const toggleList = opaqueListID + ? this.lists.getByOpaqueID(opaqueListID, processedOperations)! + : this.cache.list( + operation.list, + parentID, + operation.target === 'all', + processedOperations + ) + toggleList.when(resolveWhen(operation.when, variables)).toggleElement({ + selection: fieldSelection, + data: target, + variables, + where: operation.position || 'last', + layer, + }) + } + + // upsert: insert if not present, update if already in list + else if ( + operation.action === 'upsert' && + target instanceof Object && + fieldSelection && + operation.list + ) { + const upsertList = opaqueListID + ? this.lists.getByOpaqueID(opaqueListID, processedOperations)! + : this.cache.list( + operation.list, + parentID, + operation.target === 'all', + processedOperations + ) + upsertList .when(operation.when) - .toggleElement({ - selection: fieldSelection, - data: target, + .upsertInList( + fieldSelection, + target, variables, - where: operation.position || 'last', - layer, - }) + operation.position || 'last', + layer + ) } // remove object from list @@ -929,14 +1070,16 @@ class CacheInternal { fieldSelection && operation.list ) { - this.cache - .list( - operation.list, - parentID, - operation.target === 'all', - processedOperations - ) - .when(operation.when) + const removeList = opaqueListID + ? this.lists.getByOpaqueID(opaqueListID, processedOperations)! + : this.cache.list( + operation.list, + parentID, + operation.target === 'all', + processedOperations + ) + removeList + .when(resolveWhen(operation.when, variables)) .remove(target, variables, layer) } @@ -947,11 +1090,11 @@ class CacheInternal { continue } - toNotify.push( - ...this.subscriptions - .getAll(targetID) - .filter((sub) => sub[0].parentID !== targetID) - ) + for (const [sub] of this.subscriptions + .getAll(targetID) + .filter((sub) => sub[0].parentID !== targetID)) { + toNotify.add(sub) + } this.cache.delete(targetID, layer) } @@ -959,11 +1102,9 @@ class CacheInternal { if (operation.list) { // figure out the field referenced by the list - const matchingLists = this.cache.list( - operation.list, - parentID, - operation.target === 'all' - ) + const matchingLists = opaqueListID + ? this.lists.getByOpaqueID(opaqueListID)! + : this.cache.list(operation.list, parentID, operation.target === 'all') for (const list of matchingLists.lists) { processedOperations.add(list.fieldRef) } @@ -1038,7 +1179,7 @@ class CacheInternal { let stale = false // if we have abstract fields, grab the __typename and include them in the list - const typename = this.storage.get(parent, '__typename').value as string + const typename = this.storage.getTypename(parent) // collect all of the fields that we need to write const targetSelection = getFieldsForType(selection, typename, !!generateLoading) @@ -1260,6 +1401,13 @@ class CacheInternal { } } + // if the list requested an opaque key, attach it to the hydrated value so the + // user can pass it to @listID on a mutation to target this specific list instance. + // the format matches what ListManager.listsByOpaqueID uses as its key. + if (list?.includeListID && fieldTarget[attributeName] != null) { + ;(fieldTarget[attributeName] as any).__id = opaqueListID(parent, list.name) + } + // if we are generating a loading value then we might need to wrap up the result if (generateLoading && fieldLoading?.list) { fieldTarget[attributeName] = wrapInLists( @@ -1454,6 +1602,7 @@ class CacheInternal { specs, layer, forceNotify, + previousIDs, }: { value: GraphQLValue[] recordID: string @@ -1461,11 +1610,12 @@ class CacheInternal { linkedType: string abstract: boolean variables: {} - specs: FieldSelection[] + specs: Set applyUpdates?: string[] fields: SubscriptionSelection layer: Layer forceNotify?: boolean + previousIDs?: NestedList | null }): { nestedIDs: NestedList; newIDs: (string | null)[] } { // build up the two lists const nestedIDs: NestedList = [] @@ -1475,6 +1625,7 @@ class CacheInternal { // if we found another list if (Array.isArray(entry)) { // compute the nested list of ids + const previousEntry = previousIDs?.[i] const inner = this.extractNestedListIDs({ value: entry as GraphQLValue[], abstract, @@ -1487,6 +1638,7 @@ class CacheInternal { specs, layer, forceNotify, + previousIDs: Array.isArray(previousEntry) ? previousEntry : null, }) // add the list of new ids to our list @@ -1506,8 +1658,14 @@ class CacheInternal { // we know now that entry is an object const entryObj = entry as GraphQLObject - // start off building up the embedded id - let linkedID = `${recordID}.${key}[${this.storage.nextRank}]` + // start off building up the embedded id. if the previous write left an + // embedded record at this position, take over its key so that rewriting + // a list doesn't leave orphaned records behind + const previousID = previousIDs?.[i] + let linkedID = + typeof previousID === 'string' && previousID.startsWith(`${recordID}.${key}[`) + ? previousID + : `${recordID}.${key}[${this.storage.nextRank}]` let innerType = linkedType const typename = entryObj.__typename as string | undefined @@ -1609,6 +1767,27 @@ export function variableValue(value: ValueNode, args: GraphQLObject): GraphQLVal } } +// resolve the variable references inside of an operation's when conditions +// so they can be compared against the list's filters +function resolveWhen( + when: MutationOperation['when'], + variables: Record +): ListWhen | undefined { + if (!when) { + return undefined + } + + const resolve = (conditions: Record) => + Object.fromEntries( + Object.entries(conditions).map(([key, value]) => [key, filterValue(value, variables)]) + ) + + return { + ...(when.must ? { must: resolve(when.must) } : {}), + ...(when.must_not ? { must_not: resolve(when.must_not) } : {}), + } +} + type DisplaySummary = { id: string; field: string; value?: any } export function fragmentReference({ diff --git a/packages/houdini/src/runtime/cache/lists.ts b/packages/houdini/src/runtime/cache/lists.ts index 71c084f77b..471849834f 100644 --- a/packages/houdini/src/runtime/cache/lists.ts +++ b/packages/houdini/src/runtime/cache/lists.ts @@ -1,9 +1,20 @@ +import { deepEquals } from '../deepEquals.js' import { flatten } from '../flatten.js' -import type { SubscriptionSelection, ListWhen, SubscriptionSpec, NestedList } from '../types.js' +import type { + Filter, + SubscriptionSelection, + ListWhen, + SubscriptionSpec, + NestedList, +} from '../types.js' import type { Cache } from './index.js' import type { Layer } from './storage.js' import { rootID } from './stuff.js' +export function opaqueListID(parentID: string, listName: string): string { + return `${parentID}::${listName}` +} + export class ListManager { rootID: string cache: Cache @@ -18,6 +29,9 @@ export class ListManager { private listsByField: Map> = new Map() + // indexed by the opaque list ID exposed as __id + private listsByOpaqueID: Map = new Map() + get(listName: string, id?: string, allLists?: boolean, skipMatches?: Set) { // get the list collection const lists = this.getLists(listName, id, allLists) @@ -33,6 +47,18 @@ export class ListManager { } } + // look up a list by the opaque ID that was attached as __id + getByOpaqueID(opaqueID: string, skipMatches?: Set): ListCollection | null { + const collection = this.listsByOpaqueID.get(opaqueID) + if (!collection) return null + if (skipMatches) { + return new ListCollection( + collection.lists.filter((list) => !skipMatches.has(list.fieldRef)) + ) + } + return collection + } + getLists(listName: string, id?: string, allLists?: boolean) { const matches = this.lists.get(listName) @@ -50,8 +76,7 @@ export class ListManager { const head = [...matches.values()][0] - // the provided id won't match the cache's ID so we have to compute the internal ID, using - // one of the matches to figure out the type of the list element + // compute the internal ID from the record type and provided id const { recordType } = head.lists[0] const parentID = id ? this.cache._internal_unstable.id(recordType || '', id)! : this.rootID @@ -72,7 +97,7 @@ export class ListManager { // root's ID is fixed if (!id) { console.error( - `Found multiple instances of "${listName}". Please provide one of @parentID or @allLists directives to ` + + `Found multiple instances of "${listName}". Please provide one of @parentID, @listID, or @allLists directives to ` + `help identify which list you want modify. For more information, visit this guide: https://www.houdinigraphql.com/api/graphql#parentidvalue-string ` ) return null @@ -83,7 +108,9 @@ export class ListManager { } remove(listName: string, id: string) { - this.lists.get(listName)?.delete(id || this.rootID) + const parentID = id || this.rootID + this.lists.get(listName)?.delete(parentID) + this.listsByOpaqueID.delete(opaqueListID(parentID, listName)) } add(list: { @@ -132,6 +159,9 @@ export class ListManager { // add the list to the collection this.lists.get(list.name)!.get(parentID)!.lists.push(handler) this.listsByField.get(parentID)!.get(list.key)!.push(handler) + + // register the opaque ID lookup (format matches what getSelection injects as __id) + this.listsByOpaqueID.set(opaqueListID(parentID, name), this.lists.get(name)!.get(parentID)!) } removeIDFromAllLists(id: string, layer?: Layer) { @@ -158,6 +188,7 @@ export class ListManager { this.lists.get(list.name)?.get(list.recordID)?.deleteListWithKey(field) if (this.lists.get(list.name)?.get(list.recordID)?.lists.length === 0) { this.lists.get(list.name)?.delete(list.recordID) + this.listsByOpaqueID.delete(opaqueListID(list.recordID, list.name)) } } @@ -168,6 +199,7 @@ export class ListManager { reset() { this.lists.clear() this.listsByField.clear() + this.listsByOpaqueID.clear() } } @@ -179,7 +211,7 @@ export class List { private cache: Cache readonly selection: SubscriptionSelection private _when?: ListWhen - private filters?: { [key: string]: number | boolean | string } + private filters?: Filter readonly name: string private connection: boolean private manager: ListManager @@ -425,16 +457,34 @@ export class List { // get the list of specs that are subscribing to the list const subscribers = this.cache._internal_unstable.subscriptions.get(this.recordID, this.key) + const maskedParentSubscribers = + this.cache._internal_unstable.subscriptions.getMaskedParents(this.recordID, this.key) + + // if we are unsubscribing from a connection, the fields we care about + // are tucked away under edges + const targetSelection = this.connection + ? this.selection.fields!.edges.selection! + : this.selection // disconnect record from any subscriptions associated with the list this.cache._internal_unstable.subscriptions.remove( targetID, - // if we are unsubscribing from a connection, the fields we care about - // are tucked away under edges - this.connection ? this.selection.fields!.edges.selection! : this.selection, + targetSelection, subscribers.map((sub) => sub[0]), variables ) + // documents that contain the list behind a masked boundary registered + // everything below it silently + if (maskedParentSubscribers.length > 0) { + this.cache._internal_unstable.subscriptions.remove( + targetID, + targetSelection, + maskedParentSubscribers.map((sub) => sub[0]), + variables, + [], + true + ) + } // remove the target from the parent this.cache._internal_unstable.storage.remove(parentID, targetKey, targetID, layer) @@ -442,14 +492,15 @@ export class List { // notify the subscribers about the change for (const [spec] of subscribers) { // trigger the update - spec.set( - this.cache._internal_unstable.getSelection({ + spec.onMessage({ + kind: 'update', + data: this.cache._internal_unstable.getSelection({ parent: spec.parentID || this.manager.rootID, selection: spec.selection, variables: spec.variables?.() || {}, ignoreMasking: false, - }).data - ) + }).data, + }) } // return true if we deleted something @@ -483,7 +534,7 @@ export class List { // check must's first if (filters.must && targets) { ok = Object.entries(filters.must).reduce( - (prev, [key, value]) => Boolean(prev && targets[key] === value), + (prev, [key, value]) => Boolean(prev && deepEquals(targets[key], value)), ok ) } @@ -492,7 +543,7 @@ export class List { ok = !targets || Object.entries(filters.must_not).reduce( - (prev, [key, value]) => Boolean(prev && targets[key] !== value), + (prev, [key, value]) => Boolean(prev && !deepEquals(targets[key], value)), ok ) } @@ -520,6 +571,57 @@ export class List { } } + upsertInList( + selection: SubscriptionSelection, + data: {}, + variables: {} = {}, + where: 'first' | 'last', + layer?: Layer + ) { + const listType = this.listType(data) + const dataID = this.cache._internal_unstable.id(listType, data) + + if (!this.validateWhen() || !dataID) { + return + } + + // check if the item is already in the list + let isInList = false + if (this.connection) { + const { value: embeddedConnection } = this.cache._internal_unstable.storage.get( + this.recordID, + this.key + ) + if (embeddedConnection) { + const { value: edges } = this.cache._internal_unstable.storage.get( + embeddedConnection as string, + 'edges' + ) + for (const edge of flatten(edges as NestedList) || []) { + if (!edge) continue + const { value: nodeID } = this.cache._internal_unstable.storage.get( + edge as string, + 'node' + ) + if (nodeID === dataID) { + isInList = true + break + } + } + } + } else { + const { value } = this.cache._internal_unstable.storage.get(this.recordID, this.key) + isInList = !!(value as NestedList)?.includes(dataID) + } + + if (isInList) { + // item already in list — just write to update the record data + this.cache.write({ selection, data, variables, layer: layer?.id }) + } else { + this.addToList(selection, data, variables, where, layer) + } + } + // iterating over the list handler should be the same as iterating over // the underlying linked list *[Symbol.iterator]() { @@ -596,6 +698,12 @@ export class ListCollection { }) } + upsertInList(...args: Parameters) { + this.lists.forEach((list) => { + list.upsertInList(...args) + }) + } + when(when?: ListWhen): ListCollection { return new ListCollection( this.lists.filter((list) => { diff --git a/packages/houdini/src/runtime/cache/staleManager.ts b/packages/houdini/src/runtime/cache/staleManager.ts index f77c27d456..1616b1bf68 100644 --- a/packages/houdini/src/runtime/cache/staleManager.ts +++ b/packages/houdini/src/runtime/cache/staleManager.ts @@ -99,6 +99,11 @@ export class StaleManager { } } + // remove every entry associated with a record + deleteRecord(id: string) { + this.fieldsTime.delete(id) + } + // clean up the stale manager delete(id: string, field: string) { if (this.fieldsTime.has(id)) { diff --git a/packages/houdini/src/runtime/cache/storage.ts b/packages/houdini/src/runtime/cache/storage.ts index 657cd78a51..a8ea53bf93 100644 --- a/packages/houdini/src/runtime/cache/storage.ts +++ b/packages/houdini/src/runtime/cache/storage.ts @@ -11,6 +11,8 @@ export class InMemoryStorage { private idCount = 1 private rank = 0 idMaps: Record = {} + // fast lookup: record id → __typename, maintained alongside normal field writes + typenames: Map = new Map() constructor() { this.data = [] @@ -58,6 +60,23 @@ export class InMemoryStorage { return this.topLayer.deleteField(id, field) } + // embedded records are only ever referenced by the field that wrote them so once + // that reference is gone the data can be physically removed. any embedded + // descendants are left for the garbage collector + removeEmbeddedRecord(id: string) { + for (const layer of this.data) { + // optimistic layers hold their own copy of the data and get rolled back + // or merged wholesale so we leave them alone + if (layer.optimistic) { + continue + } + + delete layer.fields[id] + delete layer.links[id] + } + this.typenames.delete(id) + } + getLayer(id: number): Layer { for (const layer of this.data) { if (layer.id === id) { @@ -73,6 +92,8 @@ export class InMemoryStorage { for (const layer of this.data) { layer.replaceID(replacement) } + const typename = this.typenames.get(replacement.from) + if (typename) this.typenames.set(replacement.to, typename) } get( targetID: string, @@ -83,21 +104,17 @@ export class InMemoryStorage { kind: 'link' | 'scalar' | 'unknown' displayLayers: number[] } { - // the list of operations for the field - const operations = { - [OperationKind.insert]: { - [OperationLocation.start]: [] as string[], - [OperationLocation.end]: [] as string[], - }, - [OperationKind.remove]: new Set(), - } - - // the list of layers we used to build up the value - const layerIDs: number[] = [] - - // the record might be known by multiple ids and we need to look at every layer + // Lazy accumulators — only allocated when we actually encounter list + // operations. Scalar reads (the common case) never touch these. + let insertStart: string[] | undefined + let insertEnd: string[] | undefined + let removeSet: Set | undefined + let layerIDs: number[] | undefined + + // the record might be known by multiple ids and we need to look at every layer // in the correct order - const recordIDs = [this.idMaps[targetID], targetID].filter(Boolean) as string[] + const mappedID = this.idMaps[targetID] + const recordIDs = mappedID ? [mappedID, targetID] : [targetID] // go through the list of layers in reverse for (let i = this.data.length - 1; i >= 0; i--) { @@ -106,17 +123,20 @@ export class InMemoryStorage { const layer = this.data[i] let [layerValue, kind] = layer.get(id, field) - const layerOperations = layer.getOperations(id, field) || [] - layer.deletedIDs.forEach((v) => { - // if the layer wants to undo a delete for the id - if (layer.operations[v]?.undoDeletesInList?.includes(field)) { - return - } - operations.remove.add(v) - if (this.idMaps[v]) { - operations.remove.add(this.idMaps[v]) - } - }) + const layerOperations = layer.getOperations(id, field) + if (layer.deletedIDs.size > 0) { + layer.deletedIDs.forEach((v) => { + // if the layer wants to undo a delete for the id + if (layer.operations[v]?.undoDeletesInList?.includes(field)) { + return + } + if (!removeSet) removeSet = new Set() + removeSet.add(v) + if (this.idMaps[v]) { + removeSet.add(this.idMaps[v]) + } + }) + } // if we don't have a value to return, we're done if (typeof layerValue === 'undefined' && defaultValue) { @@ -126,8 +146,9 @@ export class InMemoryStorage { } // if the layer does not contain a value for the field, move on - if (typeof layerValue === 'undefined' && layerOperations.length === 0) { + if (typeof layerValue === 'undefined' && !layerOperations?.length) { if (layer.deletedIDs.size > 0) { + if (!layerIDs) layerIDs = [] layerIDs.push(layer.id) } continue @@ -144,23 +165,26 @@ export class InMemoryStorage { } // if the layer contains operations or values add it to the list of relevant layers - // add the layer to the list + if (!layerIDs) layerIDs = [] layerIDs.push(layer.id) // if we have an operation - if (layerOperations.length > 0) { + if (layerOperations?.length) { // process every operation for (const op of layerOperations) { // remove operation if (isRemoveOperation(op)) { - operations.remove.add(op.id) + if (!removeSet) removeSet = new Set() + removeSet.add(op.id) } // inserts are sorted by location if (isInsertOperation(op)) { if (op.location === OperationLocation.end) { - operations.insert[op.location].unshift(op.id) + if (!insertEnd) insertEnd = [] + insertEnd.unshift(op.id) } else { - operations.insert[op.location].push(op.id) + if (!insertStart) insertStart = [] + insertStart.push(op.id) } } // if we found a delete operation, we're done @@ -180,21 +204,15 @@ export class InMemoryStorage { } // if there are no operations, move along - if ( - !operations.remove.size && - !operations.insert.start.length && - !operations.insert.end.length - ) { + if (!removeSet?.size && !insertStart?.length && !insertEnd?.length) { return { value: layerValue, displayLayers: layerIDs, kind: 'link' } } // we have operations to apply to the list return { - value: [ - ...operations.insert.start, - ...layerValue, - ...operations.insert.end, - ].filter((value) => !operations.remove.has(value as string)), + value: [...(insertStart ?? []), ...layerValue, ...(insertEnd ?? [])].filter( + (value) => !removeSet?.has(value as string) + ), displayLayers: layerIDs, kind, } @@ -333,8 +351,19 @@ export class InMemoryStorage { layer.links = links } + // fast typename lookup with lazy-populate fallback for records written via hydrate() + getTypename(id: string): string | undefined { + let typename = this.typenames.get(id) + if (typename === undefined) { + typename = this.get(id, '__typename').value as string | undefined + if (typename !== undefined) this.typenames.set(id, typename) + } + return typename + } + reset() { this.data = [] + this.typenames.clear() } } @@ -363,26 +392,18 @@ export class Layer { } getOperations(id: string, field: string): Operation[] | undefined { - // if the id has been deleted - if (this.operations[id]?.deleted) { - return [ - { - kind: OperationKind.delete, - target: id, - }, - ] - } - - // there could be a mutation for the specific field - if (this.operations[id]?.fields?.[field]) { - return this.operations[id].fields[field] - } + const ops = this.operations[id] + if (!ops) return undefined + if (ops.deleted) return [{ kind: OperationKind.delete, target: id }] + return ops.fields?.[field] } writeField(id: string, field: string, value: GraphQLField): LayerID { - this.fields[id] = { - ...this.fields[id], - [field]: value, + const record = this.fields[id] + if (record) { + record[field] = value + } else { + this.fields[id] = { [field]: value } } return this.id @@ -415,20 +436,26 @@ export class Layer { } } - this.links[id] = { - ...this.links[id], - [field]: value, + const linkRecord = this.links[id] + if (linkRecord) { + linkRecord[field] = value + } else { + this.links[id] = { [field]: value } } return this.id } isDisplayLayer(displayLayers: number[]) { - return ( - displayLayers.length === 0 || - displayLayers.includes(this.id) || - Math.max(...displayLayers) < this.id - ) + const n = displayLayers.length + if (n === 0) return true + let max = 0 + for (let i = 0; i < n; i++) { + const d = displayLayers[i] + if (d === this.id) return true + if (d > max) max = d + } + return max < this.id } clear() { @@ -533,6 +560,60 @@ export class Layer { } } + // Each mutation writes to an optimistic layer that gets resolved (merged) into + // the base layer here. If a toggle fires twice — insert in M1, remove in M2 — + // both operations end up in the base after their layers resolve. A third mutation + // (insert again) would then be cancelled by the stale remove from M2, so the + // list appears stuck. We fix this by compacting the merged operation list: + // for each ID, compute the net count (inserts minus removes) and keep only that + // many operations, favouring the newest ones (layer's ops come first in the array). + for (const [fieldName, ops] of Object.entries(fields)) { + // count how many times each ID is inserted and removed across both layers + const insertCount = new Map() + const removeCount = new Map() + for (const op of ops) { + if (isInsertOperation(op)) + insertCount.set(op.id, (insertCount.get(op.id) ?? 0) + 1) + else if (isRemoveOperation(op)) + removeCount.set(op.id, (removeCount.get(op.id) ?? 0) + 1) + } + + // net > 0 means the ID should end up inserted that many more times than removed, + // net < 0 means it should end up removed that many more times than inserted, + // net = 0 means the operations cancel completely and nothing should be stored + const net = new Map() + for (const [id, n] of insertCount) net.set(id, n) + for (const [id, n] of removeCount) net.set(id, (net.get(id) ?? 0) - n) + + // walk the ops array (newest first) and keep only as many inserts/removes + // as the net dictates, discarding the excess older ones + const keptInserts = new Map() + const keptRemoves = new Map() + fields[fieldName] = ops.filter((op) => { + if (isInsertOperation(op)) { + const n = net.get(op.id) ?? 0 + // net is zero or negative — no inserts survive + if (n <= 0) return false + const kept = keptInserts.get(op.id) ?? 0 + // already kept enough newer inserts + if (kept >= n) return false + keptInserts.set(op.id, kept + 1) + return true + } + if (isRemoveOperation(op)) { + const n = net.get(op.id) ?? 0 + // net is zero or positive — no removes survive + if (n >= 0) return false + const kept = keptRemoves.get(op.id) ?? 0 + // already kept enough newer removes + if (kept >= -n) return false + keptRemoves.set(op.id, kept + 1) + return true + } + return true + }) + } + // only copy a field key if there is something if (Object.keys(fields).length > 0) { this.operations[id] = { diff --git a/packages/houdini/src/runtime/cache/stuff.ts b/packages/houdini/src/runtime/cache/stuff.ts index 7085ffebbd..e6794ac1a5 100644 --- a/packages/houdini/src/runtime/cache/stuff.ts +++ b/packages/houdini/src/runtime/cache/stuff.ts @@ -1,5 +1,8 @@ // given a raw key and a set of variables, generate the fully qualified key export function evaluateKey(key: string, variables: Record | null = null): string { + // fast path: no variable interpolation needed + if (!key.includes('$')) return key + // accumulate the evaluated key let evaluated = '' // accumulate a variable name that we're evaluating @@ -11,7 +14,7 @@ export function evaluateKey(key: string, variables: Record | null = // if we are building up a variable if (varName) { // if we are looking at a valid variable character - if (varChars.includes(char)) { + if (varCharSet.has(char)) { // add it to the variable name varName += char continue @@ -22,7 +25,7 @@ export function evaluateKey(key: string, variables: Record | null = // look up the variable and add the result (varName starts with a $) const value = variables?.[varName.slice(1)] - evaluated += typeof value !== 'undefined' ? JSON.stringify(value) : 'undefined' + evaluated += JSON.stringify(value ?? null) // clear the variable name accumulator varName = '' @@ -50,7 +53,7 @@ export function evaluateKey(key: string, variables: Record | null = } // the list of characters that make up a valid graphql variable name -const varChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789' +const varCharSet = new Set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789') // fields on the root of the data store are keyed with a fixed id export const rootID = '_ROOT_' diff --git a/packages/houdini/src/runtime/cache/subscription.ts b/packages/houdini/src/runtime/cache/subscription.ts index af88cf3362..fecfa944e5 100644 --- a/packages/houdini/src/runtime/cache/subscription.ts +++ b/packages/houdini/src/runtime/cache/subscription.ts @@ -1,6 +1,12 @@ import { flatten } from '../flatten.js' import { getFieldsForType } from '../selection.js' -import type { GraphQLValue, SubscriptionSelection, SubscriptionSpec, NestedList } from '../types.js' +import type { + GraphQLValue, + ListFilter, + SubscriptionSelection, + SubscriptionSpec, + NestedList, +} from '../types.js' import type { Cache } from './index.js' import { evaluateKey, rootID } from './stuff.js' @@ -23,7 +29,14 @@ export class InMemorySubscriptions { string, { selections: FieldSelection[] - referenceCounts: Map + referenceCounts: Map + // masked parent subscriptions track documents whose data contains this record + // behind a masked boundary (eg a fragment spread). they are never + // notified when a value changes but they do participate in containment + // lookups (cache.refresh) and write-path propagation so that we always + // know every document whose data contains a given record + maskedParentSelections: FieldSelection[] + maskedParentReferenceCounts: Map } > >() @@ -44,16 +57,19 @@ export class InMemorySubscriptions { selection, variables, parentType, + masked = false, }: { parent: string parentType?: string spec: SubscriptionSpec selection: SubscriptionSelection variables: { [key: string]: GraphQLValue } + // when true, every subscription we register is a masked parent regardless of the + // field's visibility. this happens when the walk crosses a masked boundary + masked?: boolean }) { // figure out the correct selection - const __typename = this.cache._internal_unstable.storage.get(parent, '__typename') - .value as string + const __typename = this.cache._internal_unstable.storage.getTypename(parent) const targetSelection = getFieldsForType(selection, __typename, false) // walk down the selection @@ -66,18 +82,15 @@ export class InMemorySubscriptions { filters, visible, } = fieldSelection - if (!visible) { - continue - } + + // once we cross a masked boundary everything below it is a masked parent + const fieldMasked = masked || !visible const key = evaluateKey(keyRaw, variables) // add the subscriber to the field let targetSelection: FieldSelection[1] if (innerSelection) { - // figure out the correct selection - const __typename = this.cache._internal_unstable.storage.get(parent, '__typename') - .value as string targetSelection = getFieldsForType(innerSelection, __typename, false) } this.addFieldSubscription({ @@ -85,9 +98,10 @@ export class InMemorySubscriptions { key, selection: [spec, targetSelection], type, + masked: fieldMasked, }) - if (list) { + if (list && !fieldMasked) { this.registerList({ list, filters, @@ -123,6 +137,7 @@ export class InMemorySubscriptions { selection: innerSelection, variables, parentType: type, + masked: fieldMasked, }) } } @@ -134,11 +149,13 @@ export class InMemorySubscriptions { key, selection, type: _type, + masked = false, }: { id: string key: string selection: FieldSelection type: string + masked?: boolean }) { const spec = selection[0] @@ -153,11 +170,22 @@ export class InMemorySubscriptions { subscriber.set(key, { selections: [], referenceCounts: new Map(), + maskedParentSelections: [], + maskedParentReferenceCounts: new Map(), }) } const subscriberField = subscriber.get(key)! + // masked parent subscriptions get tracked separately so they never participate + // in update notifications + const selections = masked + ? subscriberField.maskedParentSelections + : subscriberField.selections + const referenceCounts = masked + ? subscriberField.maskedParentReferenceCounts + : subscriberField.referenceCounts + // if this is the first time we've seen the raw key if (!this.keyVersions[key]) { this.keyVersions[key] = new Set() @@ -166,15 +194,12 @@ export class InMemorySubscriptions { // add this version of the key if we need to this.keyVersions[key].add(key) - if (!subscriberField.selections.some(([{ set }]) => set === spec.set)) { - subscriberField.selections.push([spec, selection[1]]) + if (!referenceCounts.has(spec.onMessage)) { + selections.push([spec, selection[1]]) } // we're going to increment the current value by one - subscriberField.referenceCounts.set( - spec.set, - (subscriberField.referenceCounts.get(spec.set) || 0) + 1 - ) + referenceCounts.set(spec.onMessage, (referenceCounts.get(spec.onMessage) || 0) + 1) // reset the lifetime for the key this.cache._internal_unstable.lifetimes.resetLifetime(id, key) @@ -201,16 +226,14 @@ export class InMemorySubscriptions { name: list.name, connection: list.connection, recordID: id, - recordType: - (this.cache._internal_unstable.storage.get(id, '__typename')?.value as string) || - parentType, + recordType: this.cache._internal_unstable.storage.getTypename(id) || parentType, listType: list.type, key, selection: selection, - filters: Object.entries(filters || {}).reduce((acc, [key, { kind, value }]) => { + filters: Object.entries(filters || {}).reduce((acc, [key, filter]) => { return { ...acc, - [key]: kind !== 'Variable' ? value : variables[value as string], + [key]: filterValue(filter, variables), } }, {}), }) @@ -222,11 +245,16 @@ export class InMemorySubscriptions { variables, subscribers, parentType, + masked = false, }: { parent: string variables: {} subscribers: FieldSelection[] parentType: string + // when true, every subscription we register is a masked parent regardless of the + // field's visibility. this happens when the batch we are propagating was + // already behind a masked boundary + masked?: boolean }) { // every subscriber specifies a different selection set to add to the parent for (const [spec, targetSelection] of subscribers) { @@ -238,7 +266,12 @@ export class InMemorySubscriptions { selection: innerSelection, list, filters, + visible, } = selection + + // once we cross a masked boundary everything below it is a masked parent + const fieldMasked = masked || !visible + const key = evaluateKey(keyRaw, variables) // figure out the selection for the field we are writing @@ -251,9 +284,10 @@ export class InMemorySubscriptions { key, selection: [spec, fieldSelection], type: linkedType, + masked: fieldMasked, }) - if (list) { + if (list && !fieldMasked) { this.registerList({ list, filters, @@ -281,10 +315,8 @@ export class InMemorySubscriptions { } // figure out the correct selection - const __typename = this.cache._internal_unstable.storage.get( - linkedRecord, - '__typename' - ).value as string + const __typename = + this.cache._internal_unstable.storage.getTypename(linkedRecord) const targetSelection = getFieldsForType(childSelection, __typename, false) // insert the subscriber this.addMany({ @@ -292,6 +324,7 @@ export class InMemorySubscriptions { variables, subscribers: subscribers.map(([sub]) => [sub, targetSelection]), parentType: linkedType, + masked: fieldMasked, }) } } @@ -303,9 +336,15 @@ export class InMemorySubscriptions { return this.subscribers.get(id)?.get(field)?.selections || [] } - getAll(id: string): FieldSelection[] { - return [...(this.subscribers.get(id)?.values() || [])].flatMap( - (fieldSub) => fieldSub.selections + getMaskedParents(id: string, field: string): FieldSelection[] { + return this.subscribers.get(id)?.get(field)?.maskedParentSelections || [] + } + + getAll(id: string, { includeMaskedParents = false }: { includeMaskedParents?: boolean } = {}) { + return [...(this.subscribers.get(id)?.values() || [])].flatMap((fieldSub) => + includeMaskedParents + ? fieldSub.selections.concat(fieldSub.maskedParentSelections) + : fieldSub.selections ) } @@ -314,24 +353,28 @@ export class InMemorySubscriptions { selection: SubscriptionSelection, targets: SubscriptionSpec[], variables: {}, - visited: string[] = [] + visited: string[] = [], + masked: boolean = false ) { visited.push(id) // walk down to every record we know about - const linkedIDs: [string, SubscriptionSelection][] = [] + const linkedIDs: [string, SubscriptionSelection, boolean][] = [] // figure out the correct selection - const __typename = this.cache._internal_unstable.storage.get(id, '__typename') - .value as string + const __typename = this.cache._internal_unstable.storage.getTypename(id) const targetSelection = getFieldsForType(selection, __typename, false) // look at the fields for ones corresponding to links for (const fieldSelection of Object.values(targetSelection || {})) { const key = evaluateKey(fieldSelection.keyRaw, variables) + // mirror the walk that added the subscriptions: once we cross a masked + // boundary everything below it was registered silently + const fieldMasked = masked || !fieldSelection.visible + // remove the subscribers for the field - this.removeSubscribers(id, key, targets) + this.removeSubscribers(id, key, targets, fieldMasked) // if there is no subselection it doesn't point to a link, move on if (!fieldSelection.selection) { @@ -347,13 +390,13 @@ export class InMemorySubscriptions { for (const link of links) { if (link !== null) { - linkedIDs.push([link, fieldSelection.selection || {}]) + linkedIDs.push([link, fieldSelection.selection || {}, fieldMasked]) } } } - for (const [linkedRecordID, linkFields] of linkedIDs) { - this.remove(linkedRecordID, linkFields, targets, visited) + for (const [linkedRecordID, linkFields, linkMasked] of linkedIDs) { + this.remove(linkedRecordID, linkFields, targets, variables, visited, linkMasked) } } @@ -366,53 +409,87 @@ export class InMemorySubscriptions { this.subscribers.delete(id) } - // Get list of all SubscriptionSpecs of subscribers + // Get list of all SubscriptionSpecs of subscribers (including masked parents so + // that documents holding records only behind fragment boundaries are also notified) const subscriptionSpecs = subscribers.flatMap(([_id, fields]) => - [...fields.values()].flatMap((field) => field.selections.map(([spec]) => spec)) + [...fields.values()].flatMap((field) => + field.selections.concat(field.maskedParentSelections).map(([spec]) => spec) + ) ) return subscriptionSpecs } - private removeSubscribers(id: string, fieldName: string, specs: SubscriptionSpec[]) { + private removeSubscribers( + id: string, + fieldName: string, + specs: SubscriptionSpec[], + preferMasked: boolean = false + ) { // build up a list of the sets we actually need to remove after // checking reference counts - const targets: SubscriptionSpec['set'][] = [] + const targets: SubscriptionSpec['onMessage'][] = [] + const maskedParentTargets: SubscriptionSpec['onMessage'][] = [] const subscriber = this.subscribers.get(id) if (!subscriber) { return } const subscriberField = subscriber.get(fieldName) + if (!subscriberField) { + return + } + for (const spec of specs) { - const counts = subscriberField?.referenceCounts + // each removal visit accounts for a single reference that flowed through + // this link path. we prefer the count map that matches the visibility of + // the walk that got us here but fall back to the other one so that mixed + // paths (the same field reached both masked and unmasked) still clean up + const ordered: Array< + [Map, SubscriptionSpec['onMessage'][]] + > = preferMasked + ? [ + [subscriberField.maskedParentReferenceCounts, maskedParentTargets], + [subscriberField.referenceCounts, targets], + ] + : [ + [subscriberField.referenceCounts, targets], + [subscriberField.maskedParentReferenceCounts, maskedParentTargets], + ] // if we dont know this field/set combo, there's nothing to do (probably a bug somewhere) - if (!counts?.has(spec.set)) { + const match = ordered.find(([counts]) => counts.has(spec.onMessage)) + if (!match) { continue } - const newVal = (counts.get(spec.set) || 0) - 1 + const [counts, removed] = match + + const newVal = (counts.get(spec.onMessage) || 0) - 1 // decrement the reference of every field - counts.set(spec.set, newVal) + counts.set(spec.onMessage, newVal) // if that was the last reference we knew of if (newVal <= 0) { - targets.push(spec.set) + removed.push(spec.onMessage) // remove the reference to the set function - counts.delete(spec.set) - } - - // if we have no more references to the field, we need to remove it from the map - if (counts.size === 0) { - subscriber.delete(fieldName) + counts.delete(spec.onMessage) } } - // we do need to remove the set from the list - if (subscriberField) { - subscriberField.selections = this.get(id, fieldName).filter( - ([{ set }]) => !targets.includes(set) - ) + // we do need to remove the set from the lists + subscriberField.selections = subscriberField.selections.filter( + ([{ onMessage }]) => !targets.includes(onMessage) + ) + subscriberField.maskedParentSelections = subscriberField.maskedParentSelections.filter( + ([{ onMessage }]) => !maskedParentTargets.includes(onMessage) + ) + + // if we have no more references to the field, we need to remove it from the map + if ( + subscriberField.referenceCounts.size === 0 && + subscriberField.maskedParentReferenceCounts.size === 0 + ) { + subscriber.delete(fieldName) } // if we got this far and there are no subscribers on the field, we need to clean things up @@ -424,8 +501,10 @@ export class InMemorySubscriptions { removeAllSubscribers(id: string, targets?: SubscriptionSpec[]) { // get the list of subscriptions specs for the id if we didn't provide a specific list if (!targets) { - targets = [...(this.subscribers.get(id)?.values() || [])].flatMap((spec) => - spec.selections.flatMap((sel) => sel[0]!) + targets = [...(this.subscribers.get(id)?.values() || [])].flatMap((fieldSub) => + fieldSub.selections + .concat(fieldSub.maskedParentSelections) + .flatMap((sel) => sel[0]!) ) } @@ -467,8 +546,7 @@ export class InMemorySubscriptions { // the target id is embedded inside of the selection // figure out the correct selection - const __typename = this.cache._internal_unstable.storage.get(parentID, '__typename') - .value as string + const __typename = this.cache._internal_unstable.storage.getTypename(parentID) const targetSelection = getFieldsForType(selection, __typename, false) // look at the fields for ones corresponding to links @@ -510,3 +588,20 @@ export class InMemorySubscriptions { return selections } } + +// resolve a list filter to its concrete value, looking up variables +// (including ones nested inside object and list values) +export function filterValue(filter: ListFilter, variables: Record): any { + if (filter.kind === 'Variable') { + return variables[filter.value as string] + } + if (filter.kind === 'Object') { + return Object.fromEntries( + Object.entries(filter.value).map(([key, value]) => [key, filterValue(value, variables)]) + ) + } + if (filter.kind === 'List') { + return filter.value.map((value) => filterValue(value, variables)) + } + return filter.value +} diff --git a/packages/houdini/src/runtime/cache/tests/availability.test.ts b/packages/houdini/src/runtime/cache/tests/availability.test.ts index 80e8ff9cbf..c1d5ed999f 100644 --- a/packages/houdini/src/runtime/cache/tests/availability.test.ts +++ b/packages/houdini/src/runtime/cache/tests/availability.test.ts @@ -446,7 +446,7 @@ test('missing cursor of item in connection from operation should not trigger nul }) cache.subscribe({ - set: vi.fn(), + onMessage: vi.fn(), selection, rootType: 'Query', }) diff --git a/packages/houdini/src/runtime/cache/tests/gc.test.ts b/packages/houdini/src/runtime/cache/tests/gc.test.ts index ff003de3e9..528d11ab94 100644 --- a/packages/houdini/src/runtime/cache/tests/gc.test.ts +++ b/packages/houdini/src/runtime/cache/tests/gc.test.ts @@ -3,6 +3,7 @@ import { test, vi, expect } from 'vitest' import { testConfigFile } from '../../../test/index.js' import type { SubscriptionSelection } from '../../types.js' import { Cache } from '../index.js' +import { opaqueListID } from '../lists.js' const config = testConfigFile() config.cacheBufferSize! = 10 @@ -115,7 +116,7 @@ test("subscribed data shouldn't be garbage collected", () => { }, }, }, - set: vi.fn(), + onMessage: vi.fn(), }) // tick the garbage collector enough times to fill up the buffer size @@ -202,7 +203,7 @@ test('resubscribing to fields marked for garbage collection resets counter', () }, }, }, - set, + onMessage: set, }) // tick the garbage collector enough times to fill up the buffer size @@ -231,7 +232,7 @@ test('resubscribing to fields marked for garbage collection resets counter', () }, }, }, - set, + onMessage: set, }) // tick the garbage collector enough times to fill up the buffer size @@ -350,7 +351,7 @@ test('ticks of gc delete list handlers', () => { cache.subscribe( { rootType: 'Query', - set, + onMessage: set, selection, }, { @@ -361,7 +362,7 @@ test('ticks of gc delete list handlers', () => { cache.unsubscribe( { rootType: 'Query', - set, + onMessage: set, selection, }, { @@ -377,3 +378,69 @@ test('ticks of gc delete list handlers', () => { // make sure we dont have a handler for the list expect(cache._internal_unstable.lists.get('All_Users')).toBeNull() }) + +test('ticks of gc clean up listsByOpaqueID', () => { + const cache = new Cache(config) + + const selection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { + name: 'All_Users', + connection: false, + type: 'User', + includeListID: true, + }, + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + }, + }, + }, + }, + }, + }, + }, + } + + cache.write({ + selection, + data: { + viewer: { + id: '1', + friends: [{ id: '2' }], + }, + }, + }) + + const set = vi.fn() + + cache.subscribe({ rootType: 'Query', set, selection }, {}) + cache.unsubscribe({ rootType: 'Query', set, selection }, {}) + + for (const _ of Array.from({ length: config.cacheBufferSize! + 1 })) { + cache._internal_unstable.collectGarbage() + } + + expect( + cache._internal_unstable.lists.getByOpaqueID(opaqueListID('User:1', 'All_Users')) + ).toBeNull() +}) diff --git a/packages/houdini/src/runtime/cache/tests/keys.test.ts b/packages/houdini/src/runtime/cache/tests/keys.test.ts index b94567d02a..b78067e403 100644 --- a/packages/houdini/src/runtime/cache/tests/keys.test.ts +++ b/packages/houdini/src/runtime/cache/tests/keys.test.ts @@ -24,7 +24,7 @@ describe('key evaluation', () => { { title: 'undefined variable', key: 'fieldName(foo: $bar)', - expected: 'fieldName(foo: undefined)', + expected: 'fieldName(foo: null)', }, ] diff --git a/packages/houdini/src/runtime/cache/tests/list.test.ts b/packages/houdini/src/runtime/cache/tests/list.test.ts index 0856ca8330..e809697b47 100644 --- a/packages/houdini/src/runtime/cache/tests/list.test.ts +++ b/packages/houdini/src/runtime/cache/tests/list.test.ts @@ -4,6 +4,7 @@ import { testConfigFile } from '../../../test/index.js' import type { SubscriptionSelection } from '../../types.js' import { RefetchUpdateMode } from '../../types.js' import { Cache } from '../index.js' +import { opaqueListID } from '../lists.js' const config = testConfigFile() @@ -259,7 +260,7 @@ test('append in list', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -279,18 +280,21 @@ test('append in list', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'jane', - id: '2', - }, - { - firstName: 'mary', - id: '3', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'jane', + id: '2', + }, + { + firstName: 'mary', + id: '3', + }, + ], + }, }, }) }) @@ -364,7 +368,7 @@ test('prepend in list', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -384,18 +388,21 @@ test('prepend in list', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'mary', - id: '3', - }, - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'mary', + id: '3', + }, + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) }) @@ -505,7 +512,7 @@ test('remove from connection', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -517,18 +524,21 @@ test('remove from connection', () => { // the first time set was called, a new entry was added. // the second time it's called, we get a new value for mary-prime expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'User', - id: '3', - firstName: 'jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: '3', + firstName: 'jane', + }, }, - }, - ], + ], + }, }, }, }) @@ -644,7 +654,7 @@ test('element removed from list can be added back', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -676,25 +686,28 @@ test('element removed from list can be added back', () => { }) expect(set).toHaveBeenNthCalledWith(2, { - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'User', - id: '3', - firstName: 'jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: '3', + firstName: 'jane', + }, }, - }, - { - node: { - __typename: 'User', - id: '2', - firstName: 'jane2', + { + node: { + __typename: 'User', + id: '2', + firstName: 'jane2', + }, }, - }, - ], + ], + }, }, }, }) @@ -798,7 +811,7 @@ test('append in connection', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -818,25 +831,28 @@ test('append in connection', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'User', - id: '2', - firstName: 'jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: '2', + firstName: 'jane', + }, }, - }, - { - node: { - __typename: 'User', - id: '3', - firstName: 'mary', + { + node: { + __typename: 'User', + id: '3', + firstName: 'mary', + }, }, - }, - ], + ], + }, }, }, }) @@ -1692,7 +1708,7 @@ test('append in connection', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -1712,27 +1728,30 @@ test('append in connection', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - __typename: 'UserEdge', - node: { - __typename: 'User', - id: '2', - firstName: 'jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + __typename: 'UserEdge', + node: { + __typename: 'User', + id: '2', + firstName: 'jane', + }, }, - }, - { - __typename: 'UserEdge', - node: { - __typename: 'User', - id: '3', - firstName: 'mary', + { + __typename: 'UserEdge', + node: { + __typename: 'User', + id: '3', + firstName: 'mary', + }, }, - }, - ], + ], + }, }, }, }) @@ -1871,7 +1890,7 @@ test('inserting data with an update overwrites a record inserted with list.appen // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -1985,25 +2004,28 @@ test('inserting data with an update overwrites a record inserted with list.appen // make sure the duplicate has been removed expect(set).toHaveBeenNthCalledWith(2, { - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'User', - id: '2', - firstName: 'jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: '2', + firstName: 'jane', + }, }, - }, - { - node: { - __typename: 'User', - id: '3', - firstName: 'mary', + { + node: { + __typename: 'User', + id: '3', + firstName: 'mary', + }, }, - }, - ], + ], + }, }, }, }) @@ -2116,7 +2138,7 @@ test('list filter - must_not positive', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -2139,18 +2161,21 @@ test('list filter - must_not positive', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'mary', - id: '3', - }, - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'mary', + id: '3', + }, + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) }) @@ -2230,7 +2255,7 @@ test('list filter - must_not negative', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -2330,7 +2355,7 @@ test('list filter - must positive', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -2353,18 +2378,21 @@ test('list filter - must positive', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'mary', - id: '3', - }, - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'mary', + id: '3', + }, + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) }) @@ -2444,7 +2472,7 @@ test('list filter - must negative', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -2469,7 +2497,7 @@ test('list filter - must negative', () => { expect(set).not.toHaveBeenCalled() }) -test('remove from list', () => { +test('list filter - object value with nested variable', () => { // instantiate a cache const cache = new Cache(config) @@ -2495,6 +2523,17 @@ test('remove from list', () => { connection: false, type: 'User', }, + filters: { + filter: { + kind: 'Object', + value: { + name: { + kind: 'Variable', + value: 'value', + }, + }, + }, + }, selection: { fields: { id: { @@ -2530,29 +2569,152 @@ test('remove from list', () => { ], }, }, + variables: { value: 'bar' }, }) // a function to spy on that will play the role of set const set = vi.fn() // subscribe to the fields - cache.subscribe({ - rootType: 'Query', - set, - selection, - }) - - // remove user 2 from the list - cache.list('All_Users').remove({ - id: '2', - }) + cache.subscribe( + { + rootType: 'Query', + onMessage: (msg) => { + if (msg.kind === 'update') set(msg.data) + }, + selection, + }, + { value: 'bar' } + ) - // the first time set was called, a new entry was added. - // the second time it's called, we get a new value for mary-prime + const insert = (id: string, name: string) => + cache + .list('All_Users') + .when({ must: { filter: { name } } }) + .prepend({ + selection: { + fields: { + id: { visible: true, type: 'ID', keyRaw: 'id' }, + firstName: { visible: true, type: 'String', keyRaw: 'firstName' }, + }, + }, + data: { + id, + firstName: 'mary', + }, + }) + + // a when condition that matches the resolved object filter applies + insert('3', 'bar') expect(set).toHaveBeenCalledWith({ viewer: { id: '1', - friends: [], + friends: [ + { + firstName: 'mary', + id: '3', + }, + { + firstName: 'jane', + id: '2', + }, + ], + }, + }) + + // one that doesn't match is skipped + set.mockClear() + insert('4', 'not-bar') + expect(set).not.toHaveBeenCalled() +}) + +test('remove from list', () => { + // instantiate a cache + const cache = new Cache(config) + + const selection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { + name: 'All_Users', + connection: false, + type: 'User', + }, + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + }, + }, + }, + }, + }, + }, + }, + } + + // start off associated with one object + cache.write({ + selection, + data: { + viewer: { + id: '1', + friends: [ + { + id: '2', + firstName: 'jane', + }, + ], + }, + }, + }) + + // a function to spy on that will play the role of set + const set = vi.fn() + + // subscribe to the fields + cache.subscribe({ + rootType: 'Query', + onMessage: set, + selection, + }) + + // remove user 2 from the list + cache.list('All_Users').remove({ + id: '2', + }) + + // the first time set was called, a new entry was added. + // the second time it's called, we get a new value for mary-prime + expect(set).toHaveBeenCalledWith({ + kind: 'update', + data: { + viewer: { + id: '1', + friends: [], + }, }, }) @@ -2629,7 +2791,7 @@ test('delete node', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -2642,9 +2804,12 @@ test('delete node', () => { // we should have been updated with an empty list expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [], + }, }, }) @@ -2750,7 +2915,7 @@ test('delete node from connection', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -2763,10 +2928,13 @@ test('delete node from connection', () => { // we should have been updated with an empty list expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [], + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [], + }, }, }, }) @@ -2839,7 +3007,7 @@ test('append operation', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -2946,7 +3114,7 @@ test('append from list', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3076,7 +3244,7 @@ test('toggle list', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3120,6 +3288,100 @@ test('toggle list', () => { expect([...cache.list('All_Users', '1')]).toEqual(['User:5', 'User:3']) }) +test('toggle list survives multiple on-off cycles through mutation layers', () => { + const cache = new Cache(config) + + // shared selections + const friendsSelection: SubscriptionSelection = { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { + name: 'All_Users', + connection: false, + type: 'User', + }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + }, + }, + }, + }, + } + + const toggleSelection: SubscriptionSelection = { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [{ action: 'toggle', list: 'All_Users' }], + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + }, + }, + }, + }, + } + + // write the initial query result and subscribe so the list is registered + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: friendsSelection, + }, + }, + }, + data: { viewer: { id: '1', friends: [] } }, + }) + + cache.subscribe( + { + rootType: 'User', + selection: friendsSelection, + parentID: cache._internal_unstable.id('User', '1')!, + onMessage: vi.fn(), + }, + {} + ) + + // simulate a mutation by writing through an optimistic layer then resolving it, + // matching the lifecycle the mutation plugin uses in production + const mutate = (data: Record) => { + const layer = cache._internal_unstable.storage.createLayer(true) + cache.write({ selection: toggleSelection, data, layer: layer.id }) + cache._internal_unstable.storage.resolveLayer(layer.id) + } + + // cycle 1 — on + mutate({ newUser: { id: '3' } }) + expect([...cache.list('All_Users', '1')]).toEqual(['User:3']) + + // cycle 1 — off + mutate({ newUser: { id: '3' } }) + expect([...cache.list('All_Users', '1')]).toEqual([]) + + // cycle 2 — on (this is where the stale remove op used to re-cancel the insert) + mutate({ newUser: { id: '3' } }) + expect([...cache.list('All_Users', '1')]).toEqual(['User:3']) + + // cycle 2 — off + mutate({ newUser: { id: '3' } }) + expect([...cache.list('All_Users', '1')]).toEqual([]) + + // cycle 3 — on + mutate({ newUser: { id: '3' } }) + expect([...cache.list('All_Users', '1')]).toEqual(['User:3']) +}) + test('append when operation', () => { // instantiate a cache const cache = new Cache(config) @@ -3190,7 +3452,7 @@ test('append when operation', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3210,7 +3472,10 @@ test('append when operation', () => { list: 'All_Users', when: { must: { - value: 'not-foo', + value: { + kind: 'String', + value: 'not-foo', + }, }, }, }, @@ -3238,7 +3503,7 @@ test('append when operation', () => { expect([...cache.list('All_Users', '1')]).toHaveLength(0) }) -test('prepend when operation', () => { +test('when operation with variable condition', () => { // instantiate a cache const cache = new Cache(config) @@ -3297,67 +3562,69 @@ test('prepend when operation', () => { visible: true, keyRaw: 'id', }, - firstName: { - type: 'String', - visible: true, - keyRaw: 'firstName', - }, }, }, }, }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) - // write some data to a different location with a new user - // that should be added to the list - cache.write({ - selection: { - fields: { - newUser: { - type: 'User', - visible: true, - keyRaw: 'newUser', - operations: [ - { - action: 'insert', - list: 'All_Users', - position: 'first', - when: { - must: { - value: 'not-foo', + // the selection for a mutation whose when condition points to a variable + const mutationSelection: SubscriptionSelection = { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [ + { + action: 'insert', + list: 'All_Users', + when: { + must: { + value: { + kind: 'Variable', + value: 'target', }, }, }, - ], - selection: { - fields: { - id: { - type: 'ID', - visible: true, - keyRaw: 'id', - }, + }, + ], + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', }, }, }, }, }, - data: { - newUser: { - id: '3', - }, - }, - }) + } - // make sure we just added to the list + // a write whose variable doesn't match the list's filters gets skipped + cache.write({ + selection: mutationSelection, + data: { newUser: { id: '3' } }, + variables: { target: 'not-foo' }, + }) expect([...cache.list('All_Users', '1')]).toHaveLength(0) + + // one whose variable matches the filter applies + cache.write({ + selection: mutationSelection, + data: { newUser: { id: '3' } }, + variables: { target: 'foo' }, + }) + expect([...cache.list('All_Users', '1')]).toEqual(['User:3']) }) -test('prepend operation', () => { +test('prepend when operation', () => { // instantiate a cache const cache = new Cache(config) @@ -3376,9 +3643,131 @@ test('prepend operation', () => { visible: true, keyRaw: 'id', }, - friends: { - type: 'User', - visible: true, + }, + }, + }, + }, + }, + data: { + viewer: { + id: '1', + }, + }, + }) + + // subscribe to the data to register the list + cache.subscribe( + { + rootType: 'User', + selection: { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { + name: 'All_Users', + connection: false, + type: 'User', + }, + filters: { + value: { + kind: 'String', + value: 'foo', + }, + }, + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + }, + }, + }, + }, + }, + parentID: cache._internal_unstable.id('User', '1')!, + onMessage: vi.fn(), + }, + {} + ) + + // write some data to a different location with a new user + // that should be added to the list + cache.write({ + selection: { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [ + { + action: 'insert', + list: 'All_Users', + position: 'first', + when: { + must: { + value: { + kind: 'String', + value: 'not-foo', + }, + }, + }, + }, + ], + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + }, + }, + }, + }, + }, + data: { + newUser: { + id: '3', + }, + }, + }) + + // make sure we just added to the list + expect([...cache.list('All_Users', '1')]).toHaveLength(0) +}) + +test('prepend operation', () => { + // instantiate a cache + const cache = new Cache(config) + + // create a list we will add to + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + friends: { + type: 'User', + visible: true, keyRaw: 'friends', selection: { fields: { @@ -3446,7 +3835,7 @@ test('prepend operation', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3574,7 +3963,7 @@ test('remove operation', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3704,7 +4093,7 @@ test('remove operation from list', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3829,7 +4218,7 @@ test('delete operation', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -3958,7 +4347,7 @@ test('delete operation with non-string id', () => { }, }, parentID: cache._internal_unstable.id('User', 1)!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -4090,7 +4479,7 @@ test('delete operation from list', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -4275,7 +4664,7 @@ test('delete operation from connection', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -4965,7 +5354,7 @@ test('when conditions look for all matching lists', () => { cache.subscribe( { rootType: 'Query', - set, + onMessage: set, selection, }, { @@ -4975,7 +5364,7 @@ test('when conditions look for all matching lists', () => { cache.subscribe( { rootType: 'Query', - set, + onMessage: set, selection, }, { @@ -5093,7 +5482,7 @@ test('parentID must be passed if there are multiple instances of a list handler' rootType: 'User', selection: friendsSelection, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -5104,7 +5493,7 @@ test('parentID must be passed if there are multiple instances of a list handler' rootType: 'User', selection: friendsSelection, parentID: cache._internal_unstable.id('User', '2')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -5273,7 +5662,7 @@ test('append in abstract list', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -5294,21 +5683,24 @@ test('append in abstract list', () => { // make sure we got the new value expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - __typename: 'User', - friends: [ - { - firstName: 'jane', - id: '2', - __typename: 'User', - }, - { - firstName: 'mary', - id: '3', - __typename: 'User', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + __typename: 'User', + friends: [ + { + firstName: 'jane', + id: '2', + __typename: 'User', + }, + { + firstName: 'mary', + id: '3', + __typename: 'User', + }, + ], + }, }, }) }) @@ -5432,7 +5824,7 @@ test('list operations on interface fields without a well defined parent update t // subscribe to the fields (create the list handler) cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -5465,38 +5857,41 @@ test('list operations on interface fields without a well defined parent update t }) expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - __typename: 'User', - friends: [ - { - id: '2', - __typename: 'User', - notFriends: [ - { - id: '3', - firstName: 'jane', - __typename: 'User', - }, - ], - }, - { - id: '3', - __typename: 'User', - notFriends: [ - { - id: '4', - firstName: 'jane', - __typename: 'User', - }, - { - id: '5', - firstName: 'Billy', - __typename: 'User', - }, - ], - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + __typename: 'User', + friends: [ + { + id: '2', + __typename: 'User', + notFriends: [ + { + id: '3', + firstName: 'jane', + __typename: 'User', + }, + ], + }, + { + id: '3', + __typename: 'User', + notFriends: [ + { + id: '4', + firstName: 'jane', + __typename: 'User', + }, + { + id: '5', + firstName: 'Billy', + __typename: 'User', + }, + ], + }, + ], + }, }, }) }) @@ -5565,7 +5960,7 @@ test("parentID ignores single lists that don't match", () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -5676,7 +6071,7 @@ test('inserting in list at a specific layer affects just that layer', () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -5825,7 +6220,7 @@ test("two operations referencing the same list don't commit twice", () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -5863,7 +6258,7 @@ test("two operations referencing the same list don't commit twice", () => { }, }, parentID: cache._internal_unstable.id('User', '1')!, - set: vi.fn(), + onMessage: vi.fn(), }, {} ) @@ -5940,3 +6335,784 @@ test("two operations referencing the same list don't commit twice", () => { ], }) }) + +test('@includeListID attaches opaque key to plain list array', () => { + const cache = new Cache(config) + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + }, + }, + }, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friends: [{ id: '2' }] } }, + }) + + const result = cache.read({ + selection: { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { + name: 'All_Users', + connection: false, + type: 'User', + includeListID: true, + }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + }, + }, + }, + }, + }, + parent: cache._internal_unstable.id('User', '1')!, + }) + + const parentKey = cache._internal_unstable.id('User', '1') + expect((result.data?.friends as any).__id).toBe(opaqueListID(parentKey!, 'All_Users')) +}) + +test('@includeListID attaches opaque key to connection object', () => { + const cache = new Cache(config) + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friendsConnection: { + type: 'UserConnection', + visible: true, + keyRaw: 'friendsConnection', + selection: { + fields: { + edges: { + type: 'UserEdge', + visible: true, + keyRaw: 'edges', + selection: { + fields: { + node: { + type: 'User', + visible: true, + keyRaw: 'node', + nullable: true, + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friendsConnection: { edges: [{ node: { id: '2' } }] } } }, + }) + + const result = cache.read({ + selection: { + fields: { + friendsConnection: { + type: 'UserConnection', + visible: true, + keyRaw: 'friendsConnection', + list: { + name: 'Friends_Conn', + connection: true, + type: 'User', + includeListID: true, + }, + selection: { + fields: { + edges: { + type: 'UserEdge', + visible: true, + keyRaw: 'edges', + selection: { + fields: { + node: { + type: 'User', + visible: true, + keyRaw: 'node', + nullable: true, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + parent: cache._internal_unstable.id('User', '1')!, + }) + + const parentKey = cache._internal_unstable.id('User', '1') + // the connection object (not an array) gets __id + expect((result.data?.friendsConnection as any)?.__id).toBe( + opaqueListID(parentKey!, 'Friends_Conn') + ) +}) + +test('@includeListID generates distinct keys for two lists on the same parent', () => { + const cache = new Cache(config) + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + selection: { + fields: { id: { type: 'ID', visible: true, keyRaw: 'id' } }, + }, + }, + followers: { + type: 'User', + visible: true, + keyRaw: 'followers', + selection: { + fields: { id: { type: 'ID', visible: true, keyRaw: 'id' } }, + }, + }, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friends: [{ id: '2' }], followers: [{ id: '3' }] } }, + }) + + const result = cache.read({ + selection: { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { + name: 'My_Friends', + connection: false, + type: 'User', + includeListID: true, + }, + selection: { fields: { id: { type: 'ID', visible: true, keyRaw: 'id' } } }, + }, + followers: { + type: 'User', + visible: true, + keyRaw: 'followers', + list: { + name: 'My_Followers', + connection: false, + type: 'User', + includeListID: true, + }, + selection: { fields: { id: { type: 'ID', visible: true, keyRaw: 'id' } } }, + }, + }, + }, + parent: cache._internal_unstable.id('User', '1')!, + }) + + const parentKey = cache._internal_unstable.id('User', '1') + const friendsListID = (result.data?.friends as any).__id + const followersListID = (result.data?.followers as any).__id + + // same parent, but different list names → different opaque IDs + expect(friendsListID).toBe(opaqueListID(parentKey!, 'My_Friends')) + expect(followersListID).toBe(opaqueListID(parentKey!, 'My_Followers')) + expect(friendsListID).not.toBe(followersListID) +}) + +test('@listID operation inserts into the correct list via opaque key', () => { + const cache = new Cache(config) + + const friendsSelection: SubscriptionSelection = { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User', includeListID: true }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + } + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + ...friendsSelection.fields, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friends: [{ id: '2', firstName: 'Jean' }] } }, + }) + + // subscribing registers the list in listsByOpaqueID + cache.subscribe( + { + rootType: 'User', + selection: friendsSelection, + parentID: cache._internal_unstable.id('User', '1')!, + onMessage: vi.fn(), + }, + {} + ) + + // read to obtain the __id value + const readResult = cache.read({ + selection: friendsSelection, + parent: cache._internal_unstable.id('User', '1')!, + }) + + const opaqueID = (readResult.data?.friends as any).__id as string + expect(opaqueID).toBe(opaqueListID(cache._internal_unstable.id('User', '1')!, 'All_Users')) + + // use the opaque key in a mutation operation + cache.write({ + selection: { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [ + { + action: 'insert', + list: 'All_Users', + listID: { kind: 'String', value: opaqueID }, + }, + ], + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + }, + data: { newUser: { id: '3', firstName: 'New User' } }, + }) + + expect([...cache.list('All_Users', '1')]).toHaveLength(2) +}) + +test('writing the same data with connections should not cause additional links to be inserted', function () { + // instantiate a cache + const cache = new Cache(config) + + const selection: SubscriptionSelection = { + fields: { + user: { + keyRaw: 'user(id: $id, snapshot: "testing")', + type: 'User', + visible: true, + selection: { + fields: { + id: { + keyRaw: 'id', + type: 'ID', + visible: true, + }, + friendsConnection: { + keyRaw: 'friendsConnection', + type: 'UserConnection', + visible: true, + selection: { + fields: { + edges: { + keyRaw: 'edges', + type: 'UserEdge', + visible: true, + selection: { + fields: { + node: { + keyRaw: 'node', + nullable: true, + type: 'User', + visible: true, + selection: { + fields: { + id: { + keyRaw: 'id', + type: 'ID', + visible: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // start off associated with one object + cache.write({ + selection, + data: { + user: { + friendsConnection: { + edges: [ + { node: { id: '1' } }, + { node: { id: '2' } }, + { node: { id: '3' } }, + { node: { id: '4' } }, + ], + }, + }, + }, + }) + + const pre = Object.keys(cache._internal_unstable.storage.data[0].links).length + + // We'll write the same selection again. This shouldn't affect the amount of links stored in the cache. + cache.write({ + selection, + data: { + user: { + friendsConnection: { + edges: [ + { node: { id: '1' } }, + { node: { id: '2' } }, + { node: { id: '3' } }, + { node: { id: '4' } }, + ], + }, + }, + }, + }) + + const post = Object.keys(cache._internal_unstable.storage.data[0].links).length + + expect(post).toBe(pre) +}) + +test('shrinking a connection cleans up the orphaned edge records', function () { + // instantiate a cache + const cache = new Cache(config) + + const selection: SubscriptionSelection = { + fields: { + user: { + keyRaw: 'user(id: $id, snapshot: "testing")', + type: 'User', + visible: true, + selection: { + fields: { + id: { + keyRaw: 'id', + type: 'ID', + visible: true, + }, + friendsConnection: { + keyRaw: 'friendsConnection', + type: 'UserConnection', + visible: true, + selection: { + fields: { + edges: { + keyRaw: 'edges', + type: 'UserEdge', + visible: true, + selection: { + fields: { + node: { + keyRaw: 'node', + nullable: true, + type: 'User', + visible: true, + selection: { + fields: { + id: { + keyRaw: 'id', + type: 'ID', + visible: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // start off with a connection holding 4 edges + cache.write({ + selection, + data: { + user: { + friendsConnection: { + edges: [ + { node: { id: '1' } }, + { node: { id: '2' } }, + { node: { id: '3' } }, + { node: { id: '4' } }, + ], + }, + }, + }, + }) + + const pre = Object.keys(cache._internal_unstable.storage.data[0].links).length + + // write the same connection with only 2 edges. the records for the 2 lost + // edges should be cleaned up + cache.write({ + selection, + data: { + user: { + friendsConnection: { + edges: [{ node: { id: '1' } }, { node: { id: '2' } }], + }, + }, + }, + }) + + const post = Object.keys(cache._internal_unstable.storage.data[0].links).length + + expect(post).toBe(pre - 2) +}) + +test('upsert list inserts when not present', () => { + const cache = new Cache(config) + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + }, + }, + }, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friends: [{ id: '5', firstName: 'Alice' }] } }, + }) + + cache.subscribe( + { + rootType: 'User', + selection: { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + }, + parentID: cache._internal_unstable.id('User', '1')!, + onMessage: vi.fn(), + }, + {} + ) + + cache.write({ + selection: { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [{ action: 'upsert', list: 'All_Users' }], + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + }, + data: { newUser: { id: '3', firstName: 'Bob' } }, + }) + + expect([...cache.list('All_Users', '1')]).toEqual(['User:5', 'User:3']) +}) + +test('upsert list does not duplicate when already present', () => { + const cache = new Cache(config) + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + }, + }, + }, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friends: [{ id: '5', firstName: 'Alice' }] } }, + }) + + cache.subscribe( + { + rootType: 'User', + selection: { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + }, + parentID: cache._internal_unstable.id('User', '1')!, + onMessage: vi.fn(), + }, + {} + ) + + const upsertSelection: SubscriptionSelection = { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [{ action: 'upsert', list: 'All_Users' }], + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + }, + }, + } + + // upsert User:5 which is already in the list + cache.write({ + selection: upsertSelection, + data: { newUser: { id: '5', firstName: 'Alice Updated' } }, + }) + + // should still be length 1, no duplicate + expect([...cache.list('All_Users', '1')]).toEqual(['User:5']) +}) + +test('upsert list updates record data when already present', () => { + const cache = new Cache(config) + + const friendsField: SubscriptionSelection = { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + } + + cache.write({ + selection: { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: friendsField, + }, + }, + }, + }, + }, + }, + data: { viewer: { id: '1', friends: [{ id: '5', firstName: 'Alice' }] } }, + }) + + const onMessage = vi.fn() + cache.subscribe( + { + rootType: 'User', + selection: { + fields: { + friends: { + type: 'User', + visible: true, + keyRaw: 'friends', + list: { name: 'All_Users', connection: false, type: 'User' }, + selection: friendsField, + }, + }, + }, + parentID: cache._internal_unstable.id('User', '1')!, + onMessage, + }, + {} + ) + + // upsert an existing user with updated data + cache.write({ + selection: { + fields: { + newUser: { + type: 'User', + visible: true, + keyRaw: 'newUser', + operations: [{ action: 'upsert', list: 'All_Users' }], + selection: friendsField, + }, + }, + }, + data: { newUser: { id: '5', firstName: 'Alice Updated' } }, + }) + + // list unchanged, but subscriber was called with updated data + expect([...cache.list('All_Users', '1')]).toEqual(['User:5']) + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + data: expect.objectContaining({ + friends: expect.arrayContaining([ + expect.objectContaining({ firstName: 'Alice Updated' }), + ]), + }), + }) + ) +}) diff --git a/packages/houdini/src/runtime/cache/tests/refresh.test.ts b/packages/houdini/src/runtime/cache/tests/refresh.test.ts new file mode 100644 index 0000000000..f10895f781 --- /dev/null +++ b/packages/houdini/src/runtime/cache/tests/refresh.test.ts @@ -0,0 +1,465 @@ +import { test, expect, vi } from 'vitest' + +import { testConfigFile } from '../../../test/index.js' +import type { SubscriptionSelection } from '../../types.js' +import { Cache } from '../index.js' +import { rootID } from '../stuff.js' + +const config = testConfigFile() + +// a selection where every field of the user is visible to the document +const visibleSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + }, + }, + }, + }, +} + +// a selection that mirrors a query holding a fragment spread: the viewer field +// is visible but everything on the user (including its keys) is masked +const maskedSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + firstName: { + type: 'String', + keyRaw: 'firstName', + }, + friend: { + type: 'User', + keyRaw: 'friend', + selection: { + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + firstName: { + type: 'String', + keyRaw: 'firstName', + }, + }, + }, + }, + }, + }, + }, + }, +} + +test('refresh notifies documents subscribed to the record', () => { + const cache = new Cache(config) + + cache.write({ + selection: visibleSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + }, + }, + }) + + const set = vi.fn() + cache.subscribe({ + rootType: 'Query', + selection: visibleSelection, + onMessage: set, + }) + + cache.refresh('User:1') + + // the document subscribes to multiple fields of the user but only gets one message + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ kind: 'refetch' }) + + // refreshing the root sends the message too (the document subscribes to viewer) + set.mockClear() + cache.refresh(rootID) + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ kind: 'refetch' }) +}) + +test('refresh reaches documents that only contain the record behind a mask', () => { + const cache = new Cache(config) + + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '2', + firstName: 'jane', + }, + }, + }, + }) + + const set = vi.fn() + cache.subscribe({ + rootType: 'Query', + selection: maskedSelection, + onMessage: set, + }) + + // writing to a masked field must not notify the document + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '2', + firstName: 'jane-prime', + }, + }, + }, + }) + expect(set).not.toHaveBeenCalled() + + // but refreshing the masked records still finds the document + cache.refresh('User:1') + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ kind: 'refetch' }) + + set.mockClear() + cache.refresh('User:2') + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ kind: 'refetch' }) +}) + +test('refresh notifies every document that contains the record', () => { + const cache = new Cache(config) + + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '2', + firstName: 'jane', + }, + }, + }, + }) + + // a query that contains the user behind a mask + const querySet = vi.fn() + cache.subscribe({ + rootType: 'Query', + selection: maskedSelection, + onMessage: querySet, + }) + + // a fragment mounted directly on the user + const fragmentSet = vi.fn() + cache.subscribe({ + rootType: 'User', + parentID: 'User:1', + selection: { + fields: { + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + }, + }, + onMessage: fragmentSet, + }) + + cache.refresh('User:1') + + expect(querySet).toHaveBeenCalledTimes(1) + expect(querySet).toHaveBeenCalledWith({ kind: 'refetch' }) + expect(fragmentSet).toHaveBeenCalledTimes(1) + expect(fragmentSet).toHaveBeenCalledWith({ kind: 'refetch' }) +}) + +test('link changes keep masked containment up to date', () => { + const cache = new Cache(config) + + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '2', + firstName: 'jane', + }, + }, + }, + }) + + const set = vi.fn() + cache.subscribe({ + rootType: 'Query', + selection: maskedSelection, + onMessage: set, + }) + + // swap the masked friend link to a new record + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '3', + firstName: 'mark', + }, + }, + }, + }) + // the link lives behind the mask so the document was not notified + expect(set).not.toHaveBeenCalled() + + // the new friend is part of the document's data now + cache.refresh('User:3') + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ kind: 'refetch' }) + + // and the old one isn't anymore + set.mockClear() + cache.refresh('User:2') + expect(set).not.toHaveBeenCalled() +}) + +test('list membership keeps masked containment up to date', () => { + const cache = new Cache(config) + + const listSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + friends: { + type: 'User', + keyRaw: 'friends', + selection: { + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + firstName: { + type: 'String', + keyRaw: 'firstName', + }, + }, + }, + }, + }, + }, + }, + }, + } + + cache.write({ + selection: listSelection, + data: { + viewer: { + id: '1', + friends: [ + { id: '2', firstName: 'jane' }, + { id: '3', firstName: 'mark' }, + ], + }, + }, + }) + + const set = vi.fn() + cache.subscribe({ + rootType: 'Query', + selection: listSelection, + onMessage: set, + }) + + // replace the masked list contents + cache.write({ + selection: listSelection, + data: { + viewer: { + id: '1', + friends: [ + { id: '3', firstName: 'mark' }, + { id: '4', firstName: 'sally' }, + ], + }, + }, + }) + expect(set).not.toHaveBeenCalled() + + // the new member is contained, the removed one isn't + cache.refresh('User:4') + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ kind: 'refetch' }) + + set.mockClear() + cache.refresh('User:2') + expect(set).not.toHaveBeenCalled() +}) + +test('unsubscribing removes masked containment', () => { + const cache = new Cache(config) + + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '2', + firstName: 'jane', + }, + }, + }, + }) + + const set = vi.fn() + const spec = { + rootType: 'Query', + selection: maskedSelection, + onMessage: set, + } + cache.subscribe(spec) + cache.unsubscribe(spec) + + cache.refresh('User:1') + cache.refresh('User:2') + expect(set).not.toHaveBeenCalled() +}) + +test('deleting a record removes its containment', () => { + const cache = new Cache(config) + + cache.write({ + selection: maskedSelection, + data: { + viewer: { + id: '1', + firstName: 'bob', + friend: { + id: '2', + firstName: 'jane', + }, + }, + }, + }) + + const set = vi.fn() + cache.subscribe({ + rootType: 'Query', + selection: maskedSelection, + onMessage: set, + }) + + cache.delete('User:2') + set.mockClear() + + cache.refresh('User:2') + expect(set).not.toHaveBeenCalled() +}) + +test('refresh on an unknown record is a no-op', () => { + const cache = new Cache(config) + + // nothing to assert beyond it not throwing + cache.refresh('User:999') +}) + +test('refresh notifies a document exactly once even when subscribed to many fields', () => { + // regression for the O(n²) dedup: the handler must fire once regardless of how + // many fields of the record the document is subscribed to + const cache = new Cache(config) + + const wideSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { type: 'ID', visible: true, keyRaw: 'id' }, + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + lastName: { type: 'String', visible: true, keyRaw: 'lastName' }, + email: { type: 'String', visible: true, keyRaw: 'email' }, + }, + }, + }, + }, + } + + cache.write({ + selection: wideSelection, + data: { viewer: { id: '1', firstName: 'bob', lastName: 'smith', email: 'b@b.com' } }, + }) + + const onMessage = vi.fn() + cache.subscribe({ rootType: 'Query', selection: wideSelection, onMessage }) + + cache.refresh('User:1') + + // subscribed to 4 fields on the record — must still only get one message + expect(onMessage).toHaveBeenCalledTimes(1) + expect(onMessage).toHaveBeenCalledWith({ kind: 'refetch' }) +}) + +test('refresh after unsubscribe does not notify removed handler', () => { + const cache = new Cache(config) + + cache.write({ + selection: visibleSelection, + data: { viewer: { id: '1', firstName: 'bob' } }, + }) + + const onMessage = vi.fn() + const spec = { rootType: 'Query', selection: visibleSelection, onMessage } + cache.subscribe(spec) + cache.unsubscribe(spec) + + cache.refresh('User:1') + expect(onMessage).not.toHaveBeenCalled() +}) diff --git a/packages/houdini/src/runtime/cache/tests/reset.test.ts b/packages/houdini/src/runtime/cache/tests/reset.test.ts index a40000b355..f4da06752d 100644 --- a/packages/houdini/src/runtime/cache/tests/reset.test.ts +++ b/packages/houdini/src/runtime/cache/tests/reset.test.ts @@ -122,7 +122,7 @@ test('make sure the cache lists were reset', () => { const set = vi.fn() cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -135,6 +135,52 @@ test('make sure the cache lists were reset', () => { expect(() => cache.list('All_Users')).toThrowError('Cannot find list with name') }) +test('cache.reset notifies documents holding records only behind masked boundaries', () => { + const cache = new Cache(config) + + // viewer is visible but everything on the user is masked (simulates a fragment spread) + const maskedSelection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + keyRaw: 'id', + }, + firstName: { + type: 'String', + keyRaw: 'firstName', + }, + }, + }, + }, + }, + } + + cache.write({ + selection: maskedSelection, + data: { viewer: { id: '1', firstName: 'bob' } }, + }) + + const onMessage = vi.fn() + cache.subscribe({ rootType: 'Query', selection: maskedSelection, onMessage }) + + // a masked-field write must NOT notify the subscriber + cache.write({ + selection: maskedSelection, + data: { viewer: { id: '1', firstName: 'alice' } }, + }) + expect(onMessage).not.toHaveBeenCalled() + + // reset MUST notify the subscriber even though its fields are masked + cache.reset() + expect(onMessage).toHaveBeenCalledTimes(1) +}) + test('make sure the cache subscribers were reset', () => { const cache = new Cache(config) @@ -186,7 +232,7 @@ test('make sure the cache subscribers were reset', () => { const set = vi.fn() cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) diff --git a/packages/houdini/src/runtime/cache/tests/storage.test.ts b/packages/houdini/src/runtime/cache/tests/storage.test.ts index 3a3e9032dc..fd405c8045 100644 --- a/packages/houdini/src/runtime/cache/tests/storage.test.ts +++ b/packages/houdini/src/runtime/cache/tests/storage.test.ts @@ -544,5 +544,107 @@ describe('in memory layers', () => { ) test.todo('an optimistic layer after a stack non-optimistic survives resolution') + + test('resolving insert then remove cancels both operations', () => { + const storage = new InMemoryStorage() + + // base list starts empty + const baseID = storage.writeLink('User:1', 'friends', []) + + // first mutation: insert User:3 via optimistic layer + const layer1 = storage.createLayer(true) + layer1.insert('User:1', 'friends', OperationLocation.end, 'User:3') + storage.resolveLayer(layer1.id) + + // after resolving, User:3 should be in the list + expect(storage.get('User:1', 'friends')).toEqual({ + value: ['User:3'], + displayLayers: [baseID], + kind: 'link', + }) + + // second mutation: remove User:3 via optimistic layer + const layer2 = storage.createLayer(true) + layer2.remove('User:1', 'friends', 'User:3') + storage.resolveLayer(layer2.id) + + // after resolving, list should be empty — the insert and remove cancel out + expect(storage.get('User:1', 'friends')).toEqual({ + value: [], + displayLayers: [baseID], + kind: 'link', + }) + }) + + test('re-inserting after an insert+remove cycle works correctly', () => { + const storage = new InMemoryStorage() + + // base list starts empty + const baseID = storage.writeLink('User:1', 'friends', []) + + // cycle 1: insert then remove + const layer1 = storage.createLayer(true) + layer1.insert('User:1', 'friends', OperationLocation.end, 'User:3') + storage.resolveLayer(layer1.id) + + const layer2 = storage.createLayer(true) + layer2.remove('User:1', 'friends', 'User:3') + storage.resolveLayer(layer2.id) + + // list is empty after cycle 1 + expect(storage.get('User:1', 'friends').value).toEqual([]) + + // cycle 2: insert again — must not be cancelled by the old remove + const layer3 = storage.createLayer(true) + layer3.insert('User:1', 'friends', OperationLocation.end, 'User:3') + storage.resolveLayer(layer3.id) + + expect(storage.get('User:1', 'friends')).toEqual({ + value: ['User:3'], + displayLayers: [baseID], + kind: 'link', + }) + + // cycle 3: remove again + const layer4 = storage.createLayer(true) + layer4.remove('User:1', 'friends', 'User:3') + storage.resolveLayer(layer4.id) + + expect(storage.get('User:1', 'friends').value).toEqual([]) + + // cycle 4: insert once more for good measure + const layer5 = storage.createLayer(true) + layer5.insert('User:1', 'friends', OperationLocation.end, 'User:3') + storage.resolveLayer(layer5.id) + + expect(storage.get('User:1', 'friends')).toEqual({ + value: ['User:3'], + displayLayers: [baseID], + kind: 'link', + }) + }) + + test('net operations are preserved when counts differ', () => { + const storage = new InMemoryStorage() + + storage.writeLink('User:1', 'friends', []) + + // insert User:3 twice, remove once — net: one insert should survive + const layer1 = storage.createLayer(true) + layer1.insert('User:1', 'friends', OperationLocation.end, 'User:3') + storage.resolveLayer(layer1.id) + + const layer2 = storage.createLayer(true) + layer2.insert('User:1', 'friends', OperationLocation.end, 'User:3') + storage.resolveLayer(layer2.id) + + const layer3 = storage.createLayer(true) + layer3.remove('User:1', 'friends', 'User:3') + storage.resolveLayer(layer3.id) + + // one insert and one remove cancel; the second insert remains + const result = storage.get('User:1', 'friends') + expect(result.value).toEqual(['User:3']) + }) }) }) diff --git a/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts b/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts index 91cb749ee0..f943c41ff0 100644 --- a/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts +++ b/packages/houdini/src/runtime/cache/tests/subscriptions.test.ts @@ -60,7 +60,7 @@ test('root subscribe - field change', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // somehow write a user to the cache with the same id, but a different name @@ -76,10 +76,13 @@ test('root subscribe - field change', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - firstName: 'mary', - favoriteColors: ['red', 'green', 'blue'], - id: '1', + kind: 'update', + data: { + viewer: { + firstName: 'mary', + favoriteColors: ['red', 'green', 'blue'], + id: '1', + }, }, }) }) @@ -137,7 +140,7 @@ test('root subscribe - linked object changed', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // somehow write a user to the cache with a different id @@ -153,11 +156,14 @@ test('root subscribe - linked object changed', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - firstName: 'mary', - // this is a sanity-check. the cache wasn't written with that value - favoriteColors: null, - id: '2', + kind: 'update', + data: { + viewer: { + firstName: 'mary', + // this is a sanity-check. the cache wasn't written with that value + favoriteColors: null, + id: '2', + }, }, }) @@ -182,10 +188,13 @@ test('root subscribe - linked object changed', () => { // make sure that set got called with the full response expect(set).toHaveBeenLastCalledWith({ - viewer: { - firstName: 'Michelle', - id: '2', - favoriteColors: null, + kind: 'update', + data: { + viewer: { + firstName: 'Michelle', + id: '2', + favoriteColors: null, + }, }, }) @@ -242,7 +251,7 @@ test("subscribing to null object doesn't explode", () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // somehow write a user to the cache with a different id @@ -258,10 +267,13 @@ test("subscribing to null object doesn't explode", () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - firstName: 'mary', - favoriteColors: null, - id: '2', + kind: 'update', + data: { + viewer: { + firstName: 'mary', + favoriteColors: null, + id: '2', + }, }, }) }) @@ -318,7 +330,7 @@ test('overwriting a reference with null clears its subscribers', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // start off associated with one object @@ -331,7 +343,10 @@ test('overwriting a reference with null clears its subscribers', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: null, + kind: 'update', + data: { + viewer: null, + }, }) // we shouldn't be subscribing to user 3 any more @@ -388,7 +403,7 @@ test('overwriting a linked list with null clears its subscribers', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // add some users that we will subscribe to @@ -431,9 +446,12 @@ test('overwriting a linked list with null clears its subscribers', () => { // make sure that set got called with the full response expect(set).toHaveBeenNthCalledWith(2, { - viewer: { - id: '1', - friends: null, + kind: 'update', + data: { + viewer: { + id: '1', + friends: null, + }, }, }) @@ -511,7 +529,7 @@ test('root subscribe - linked list lost entry', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // somehow write a user to the cache with a new friends list @@ -531,14 +549,17 @@ test('root subscribe - linked list lost entry', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) @@ -612,7 +633,7 @@ test("subscribing to list with null values doesn't explode", () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // somehow write a user to the cache with a new friends list @@ -632,14 +653,17 @@ test("subscribing to list with null values doesn't explode", () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) }) @@ -712,7 +736,7 @@ test('root subscribe - linked list reorder', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -736,18 +760,21 @@ test('root subscribe - linked list reorder', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - id: '3', - firstName: 'mary', - }, - { - id: '2', - firstName: 'jane', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + id: '3', + firstName: 'mary', + }, + { + id: '2', + firstName: 'jane', + }, + ], + }, }, }) @@ -805,7 +832,7 @@ test('unsubscribe', () => { const spec = { rootType: 'Query', selection, - set: vi.fn(), + onMessage: vi.fn(), } // subscribe to the fields @@ -874,7 +901,7 @@ test('subscribe to new list nodes', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -920,14 +947,17 @@ test('subscribe to new list nodes', () => { // the first time set was called, a new entry was added. // the second time it's called, we get a new value for jane expect(set).toHaveBeenNthCalledWith(2, { - viewer: { - id: '1', - friends: [ - { - firstName: 'jane-prime', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'jane-prime', + id: '2', + }, + ], + }, }, }) @@ -977,18 +1007,21 @@ test('subscribe to new list nodes', () => { // the third time set was called, a new entry was added. // the fourth time it's called, we get a new value for mary expect(set).toHaveBeenNthCalledWith(4, { - viewer: { - id: '1', - friends: [ - { - firstName: 'jane-prime', - id: '2', - }, - { - firstName: 'mary-prime', - id: '3', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'jane-prime', + id: '2', + }, + { + firstName: 'mary-prime', + id: '3', + }, + ], + }, }, }) }) @@ -1071,7 +1104,7 @@ test('variables in query and subscription', () => { { rootType: 'Query', selection, - set, + onMessage: set, variables: () => ({ filter: 'foo' }), }, { @@ -1102,14 +1135,17 @@ test('variables in query and subscription', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: [ - { - firstName: 'jane', - id: '2', - }, - ], + kind: 'update', + data: { + viewer: { + id: '1', + friends: [ + { + firstName: 'jane', + id: '2', + }, + ], + }, }, }) @@ -1199,7 +1235,7 @@ test('deleting a node removes nested subscriptions', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // sanity check @@ -1655,7 +1691,7 @@ test('same record twice in a query survives one unsubscribe (reference counting) { rootType: 'Query', selection, - set, + onMessage: set, }, { filter: 'foo', @@ -1766,7 +1802,7 @@ test('embedded references', () => { { rootType: 'Query', selection, - set, + onMessage: set, }, { filter: 'foo', @@ -1808,23 +1844,26 @@ test('embedded references', () => { // make sure we got the updated data expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - id: '2', - firstName: 'not-jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + id: '2', + firstName: 'not-jane', + }, }, - }, - { - node: { - id: '3', - firstName: 'mary', + { + node: { + id: '3', + firstName: 'mary', + }, }, - }, - ], + ], + }, }, }, }) @@ -1921,7 +1960,7 @@ test('self-referencing linked lists can be unsubscribed (avoid infinite recursio // subscribe to the list const spec = { - set: vi.fn(), + onMessage: vi.fn(), selection, rootType: 'Query', } @@ -2042,7 +2081,7 @@ test('self-referencing links can be unsubscribed (avoid infinite recursion)', () // subscribe to the list const spec = { - set: vi.fn(), + onMessage: vi.fn(), selection, rootType: 'Query', } @@ -2105,7 +2144,7 @@ test('overwriting a value in an optimistic layer triggers subscribers', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // create an optimistic layer on top @@ -2125,10 +2164,13 @@ test('overwriting a value in an optimistic layer triggers subscribers', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - firstName: 'mary', - favoriteColors: ['red', 'green', 'blue'], - id: '1', + kind: 'update', + data: { + viewer: { + firstName: 'mary', + favoriteColors: ['red', 'green', 'blue'], + id: '1', + }, }, }) }) @@ -2185,7 +2227,7 @@ test('clearing a display layer updates subscribers', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // create an optimistic layer on top @@ -2205,10 +2247,13 @@ test('clearing a display layer updates subscribers', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - firstName: 'mary', - favoriteColors: ['red', 'green', 'blue'], - id: '1', + kind: 'update', + data: { + viewer: { + firstName: 'mary', + favoriteColors: ['red', 'green', 'blue'], + id: '1', + }, }, }) @@ -2229,10 +2274,13 @@ test('clearing a display layer updates subscribers', () => { }) expect(set).toHaveBeenNthCalledWith(2, { - viewer: { - firstName: 'mary', - favoriteColors: ['red', 'green', 'blue'], - id: '1', + kind: 'update', + data: { + viewer: { + firstName: 'mary', + favoriteColors: ['red', 'green', 'blue'], + id: '1', + }, }, }) }) @@ -2343,7 +2391,7 @@ test('optimistic layer & lists & add ok', () => { cache.subscribe({ rootType: 'Query', selection: selectionList, - set: setOnQuery, + onMessage: setOnQuery, }) // create an optimistic layer on top @@ -2363,20 +2411,23 @@ test('optimistic layer & lists & add ok', () => { // make sure that set got called with the full response expect(setOnQuery).toHaveBeenCalledWith({ - usersList: [ - { - id: 'mutation-opti-list:1', - name: 'Bruce Willis', - }, - { - id: 'mutation-opti-list:2', - name: 'Samuel Jackson', - }, - { - id: '??? id ???', - name: '...optimisticResponse... I could have guessed JYC!', - }, - ], + kind: 'update', + data: { + usersList: [ + { + id: 'mutation-opti-list:1', + name: 'Bruce Willis', + }, + { + id: 'mutation-opti-list:2', + name: 'Samuel Jackson', + }, + { + id: '??? id ???', + name: '...optimisticResponse... I could have guessed JYC!', + }, + ], + }, }) // clear the layer @@ -2395,20 +2446,23 @@ test('optimistic layer & lists & add ok', () => { }) expect(setOnQuery).toHaveBeenNthCalledWith(2, { - usersList: [ - { - id: 'mutation-opti-list:1', - name: 'Bruce Willis', - }, - { - id: 'mutation-opti-list:2', - name: 'Samuel Jackson', - }, - { - id: 'mutation-opti-list:9', - name: 'Alec', - }, - ], + kind: 'update', + data: { + usersList: [ + { + id: 'mutation-opti-list:1', + name: 'Bruce Willis', + }, + { + id: 'mutation-opti-list:2', + name: 'Samuel Jackson', + }, + { + id: 'mutation-opti-list:9', + name: 'Alec', + }, + ], + }, }) }) @@ -2518,7 +2572,7 @@ test('optimistic layer & lists & add null (optimistic will revert)', () => { cache.subscribe({ rootType: 'Query', selection: selectionList, - set: setOnQuery, + onMessage: setOnQuery, }) // create an optimistic layer on top @@ -2538,20 +2592,23 @@ test('optimistic layer & lists & add null (optimistic will revert)', () => { // make sure that set got called with the full response expect(setOnQuery).toHaveBeenCalledWith({ - usersList: [ - { - id: 'mutation-opti-list:1', - name: 'Bruce Willis', - }, - { - id: 'mutation-opti-list:2', - name: 'Samuel Jackson', - }, - { - id: '??? id ???', - name: '...optimisticResponse... I could have guessed JYC!', - }, - ], + kind: 'update', + data: { + usersList: [ + { + id: 'mutation-opti-list:1', + name: 'Bruce Willis', + }, + { + id: 'mutation-opti-list:2', + name: 'Samuel Jackson', + }, + { + id: '??? id ???', + name: '...optimisticResponse... I could have guessed JYC!', + }, + ], + }, }) // clear the layer @@ -2582,16 +2639,19 @@ test('optimistic layer & lists & add null (optimistic will revert)', () => { // Subscription should be call with the right data (element removed from the list) expect(setOnQuery).toHaveBeenNthCalledWith(2, { - usersList: [ - { - id: 'mutation-opti-list:1', - name: 'Bruce Willis', - }, - { - id: 'mutation-opti-list:2', - name: 'Samuel Jackson', - }, - ], + kind: 'update', + data: { + usersList: [ + { + id: 'mutation-opti-list:1', + name: 'Bruce Willis', + }, + { + id: 'mutation-opti-list:2', + name: 'Samuel Jackson', + }, + ], + }, }) }) @@ -2684,7 +2744,7 @@ test('ensure parent type is properly passed for nested lists', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // add a city to the list by hand since using the list util adds type information @@ -2806,7 +2866,7 @@ test('subscribe to abstract fields of matching type', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // somehow write a user to the cache with the same id, but a different name @@ -2823,11 +2883,14 @@ test('subscribe to abstract fields of matching type', () => { // make sure that set got called with the full response expect(set).toHaveBeenCalledWith({ - viewer: { - __typename: 'User', - firstName: 'bob', - favoriteColors: ['red', 'green', 'blue'], - id: '1', + kind: 'update', + data: { + viewer: { + __typename: 'User', + firstName: 'bob', + favoriteColors: ['red', 'green', 'blue'], + id: '1', + }, }, }) }) @@ -2903,7 +2966,7 @@ test('overlapping subscriptions', () => { cache.subscribe({ rootType: 'Query', selection: selection1, - set: set1, + onMessage: set1, }) // a function to spy on that will play the role of set @@ -2913,7 +2976,7 @@ test('overlapping subscriptions', () => { cache.subscribe({ rootType: 'Query', selection: selection2, - set: set2, + onMessage: set2, }) // write to the first selection @@ -3026,7 +3089,7 @@ test('ignore hidden fields', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // write to the favoriteColors field to make sure we didn't get an update @@ -3101,14 +3164,14 @@ test('clearing a layer should notify subscribers of displayed values', () => { cache.subscribe({ rootType: 'Query', selection, - set, + onMessage: set, }) // clear the layer cache.clearLayer(layer.id) // make sure our callback was invoked - expect(set).toHaveBeenCalledWith({ viewer: null }) + expect(set).toHaveBeenCalledWith({ kind: 'update', data: { viewer: null } }) }) test('reverting optimistic remove notifies subscribers', () => { @@ -3218,7 +3281,7 @@ test('reverting optimistic remove notifies subscribers', () => { // subscribe to the fields cache.subscribe({ rootType: 'Query', - set, + onMessage: set, selection, }) @@ -3233,21 +3296,24 @@ test('reverting optimistic remove notifies subscribers', () => { // make sure our callback was invoked expect(set).toHaveBeenCalledWith({ - viewer: { - id: '1', - friends: { - edges: [ - { - node: { - __typename: 'User', - id: '2', - firstName: 'jane', + kind: 'update', + data: { + viewer: { + id: '1', + friends: { + edges: [ + { + node: { + __typename: 'User', + id: '2', + firstName: 'jane', + }, }, - }, - { - node: user3, - }, - ], + { + node: user3, + }, + ], + }, }, }, }) @@ -3300,7 +3366,7 @@ test('overwrite null value with list', () => { cache.subscribe({ rootType: 'Query', selection: selection, - set: set, + onMessage: set, }) // add some data to the cache @@ -3312,7 +3378,10 @@ test('overwrite null value with list', () => { }) expect(set).toHaveBeenCalledWith({ - friends: [], + kind: 'update', + data: { + friends: [], + }, }) }) @@ -3357,7 +3426,7 @@ test('removing all subscribers of a field cleans up reference count object', () // subscribe to the list const spec = { - set: vi.fn(), + onMessage: vi.fn(), selection, rootType: 'Query', } @@ -3433,7 +3502,7 @@ test('reference count garbage collection requires totally empty garbage', () => // subscribe to selection 1 const spec1 = { - set: vi.fn(), + onMessage: vi.fn(), selection: selection1, rootType: 'Query', } @@ -3441,7 +3510,7 @@ test('reference count garbage collection requires totally empty garbage', () => // subscribe to selection 2 const spec2 = { - set: vi.fn(), + onMessage: vi.fn(), selection: selection2, rootType: 'Query', } @@ -3456,3 +3525,81 @@ test('reference count garbage collection requires totally empty garbage', () => // make sure the subscribers object is empty expect(cache._internal_unstable.subscriptions.size).toEqual(2) }) + +test('addMany skips external (visible: false) fields on new linked records', () => { + const cache = new Cache(config) + + // Query selection: viewer → User with one visible field and one external field + const selection: SubscriptionSelection = { + fields: { + viewer: { + type: 'User', + visible: true, + keyRaw: 'viewer', + selection: { + fields: { + id: { + type: 'ID', + visible: true, + keyRaw: 'id', + }, + firstName: { + type: 'String', + visible: true, + keyRaw: 'firstName', + }, + // external field owned by a fragment — should never trigger this query's subscriber + bio: { + type: 'String', + visible: false, + keyRaw: 'bio', + }, + }, + }, + }, + }, + } + + // write initial data pointing viewer at User:1 + cache.write({ + selection, + data: { viewer: { id: '1', firstName: 'alice', bio: 'hello' } }, + }) + + const set = vi.fn() + cache.subscribe({ rootType: 'Query', selection, onMessage: set }) + + // change viewer to User:2 — this triggers addMany to propagate subscribers onto User:2 + cache.write({ + selection, + data: { viewer: { id: '2', firstName: 'bob', bio: 'world' } }, + }) + + // set called once for the linked-object change + expect(set).toHaveBeenCalledTimes(1) + set.mockClear() + + // write only the external field on User:2 — query subscriber must NOT fire + cache.write({ + selection: { + fields: { + bio: { type: 'String', visible: false, keyRaw: 'bio' }, + }, + }, + data: { bio: 'updated bio' }, + parent: 'User:2', + }) + expect(set).not.toHaveBeenCalled() + + // write a visible field on User:2 — query subscriber MUST fire + cache.write({ + selection: { + fields: { + firstName: { type: 'String', visible: true, keyRaw: 'firstName' }, + }, + }, + data: { firstName: 'robert' }, + parent: 'User:2', + }) + expect(set).toHaveBeenCalledTimes(1) +}) diff --git a/packages/houdini/src/runtime/documentStore.test.ts b/packages/houdini/src/runtime/documentStore.test.ts index 678f7dfedf..d06004d333 100644 --- a/packages/houdini/src/runtime/documentStore.test.ts +++ b/packages/houdini/src/runtime/documentStore.test.ts @@ -1163,6 +1163,48 @@ test('track variable changes for fragments', async () => { expect(spy).toHaveBeenNthCalledWith(4, false) }) +test('plugins can kick off a brand new request through ctx.documentStore', async () => { + let count = 0 + let captured: DocumentStore> | null = null + + const fakeFetch: ClientPlugin = () => ({ + network(ctx, { resolve }) { + // hold onto the document store reference like the cache's refetch + // handling does + captured = ctx.documentStore + count++ + resolve(ctx, { + data: { count }, + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Network, + variables: null, + }) + }, + }) + + const store = createStore([fakeFetch]) + const fn = vi.fn() + store.subscribe(fn) + + await store.send() + expect(count).toBe(1) + + // a plugin holding the reference can restart the pipeline from the very beginning + const result = await captured!.send() + expect(count).toBe(2) + expect(result.data).toEqual({ count: 2 }) + + // and the store's subscribers see the new value + expect(fn).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: { count: 2 }, + }) + ) +}) + export function createStore( plugins: ClientPlugin[], fetching: boolean | undefined = undefined diff --git a/packages/houdini/src/runtime/documentStore.ts b/packages/houdini/src/runtime/documentStore.ts index 48b96a172d..f6ffd9218a 100644 --- a/packages/houdini/src/runtime/documentStore.ts +++ b/packages/houdini/src/runtime/documentStore.ts @@ -140,6 +140,7 @@ export class DocumentStore< stuff, cacheParams, setup = false, + initialState, silenceEcho = false, abortController = new AbortController(), }: SendParams = {}) { @@ -154,13 +155,14 @@ export class DocumentStore< // just use an empty object const dedupeKey = this.controllerKey(variables) - // if there is already a pending request - if (inflightRequests[dedupeKey]) { + // if there is already a live pending request + const existingRequest = inflightRequests[dedupeKey] + if (existingRequest && !existingRequest.controller.signal.aborted) { if (this.artifact.dedupe.cancel === 'first') { // cancel the existing one - inflightRequests[dedupeKey].controller.abort() + existingRequest.controller.abort() // and register the new one - inflightRequests[dedupeKey].controller = abortController + existingRequest.controller = abortController } // otherwise we have to abort this one else { @@ -180,6 +182,7 @@ export class DocumentStore< let context = new ClientPluginContextWrapper({ abortController, config: this.#configFile, + documentStore: this, name: this.artifact.name, text: this.artifact.raw, hash: this.artifact.hash, @@ -211,6 +214,7 @@ export class DocumentStore< // the initial state of the iterator const state: IteratorState = { setup, + initialState, currentStep: 0, index: 0, silenceEcho, @@ -233,14 +237,12 @@ export class DocumentStore< this.#step('forward', state) }) - // fire off the chain - const response = await promise - - // after the whole plugin chain, we need to clean up the in flight tracking - delete inflightRequests[this.controllerKey(variables)] - - // we're done - return response + // fire off the chain — always clean up the inflight entry regardless of outcome + try { + return await promise + } finally { + delete inflightRequests[this.controllerKey(variables)] + } } async cleanup() { @@ -456,7 +458,7 @@ export class DocumentStore< currentStep: 0, index: this.#plugins.length, }, - this.state + ctx.initialState ?? this.state ) return } @@ -667,6 +669,7 @@ type IteratorState = { context: ClientPluginContextWrapper index: number setup: boolean + initialState?: unknown currentStep: number silenceEcho: boolean promise: { @@ -696,6 +699,9 @@ export type ClientPluginContext = { name: string text: string hash: string + // a reference to the document store driving the pipeline so that plugins + // can kick off a brand new request (eg when the cache asks for a refetch) + documentStore: DocumentStore artifact: DocumentArtifact policy?: CachePolicies fetch?: Fetch @@ -764,6 +770,9 @@ export type SendParams = { stuff?: Partial cacheParams?: ClientPluginContext['cacheParams'] setup?: boolean + // when setup:true, the backward pass normally uses this.state (which may carry stale data + // from a previous parent). Pass initialState to override it with the correct initial value. + initialState?: QueryResult silenceEcho?: boolean abortController?: AbortController } diff --git a/packages/houdini/src/runtime/pagination.ts b/packages/houdini/src/runtime/pagination.ts index 9579cdf090..ab0652597f 100644 --- a/packages/houdini/src/runtime/pagination.ts +++ b/packages/houdini/src/runtime/pagination.ts @@ -1,6 +1,7 @@ import { deepEquals } from './deepEquals.js' import type { SendParams } from './documentStore.js' import { countPage, extractPageInfo, missingPageSizeError } from './pageInfo.js' +import { defaultConfigValues, getCurrentConfig, keyFieldsForType } from './config.js' import { CachePolicy, DataSource } from './types.js' import type { CursorHandlers, @@ -22,6 +23,8 @@ export function cursorHandlers< getState, getVariables, getSession, + previousCursors = [], + nextCursors = [], }: { artifact: QueryArtifact getState: () => _Data | null @@ -29,7 +32,27 @@ export function cursorHandlers< getSession: () => Promise fetch: FetchFn<_Data, _Input> fetchUpdate: (arg: SendParams, updates: string[]) => ReturnType> + previousCursors?: (string | null)[] + nextCursors?: (string | null)[] }): CursorHandlers<_Data, _Input> { + const targetType = artifact.refetch?.targetType + + // Derive entity variables from the type config (e.g. { id: "..." } for Node). + // This mirrors what Svelte's queryVariables() does, so fragment pagination callers + // don't have to extract entity IDs manually. + const getEntityVars = (): Record => { + if (!targetType || targetType === 'Query') return {} + const config = defaultConfigValues(getCurrentConfig()) + const typeConfig = config.types?.[targetType] + const state = getState() + if (!state) return {} + if (typeConfig?.resolve?.arguments) { + return (typeConfig.resolve.arguments(state) as Record) ?? {} + } + const keys = keyFieldsForType(config, targetType) + return Object.fromEntries(keys.map((key) => [key, (state as any)[key]])) + } + // dry up the page-loading logic const loadPage = async ({ pageSizeVar, @@ -46,8 +69,11 @@ export function cursorHandlers< fetch?: typeof globalThis.fetch where: 'start' | 'end' }) => { - // build up the variables to pass to the query + // build up the variables to pass to the query, layering in order of precedence: + // artifact defaults < entity vars < caller-supplied vars < page-specific cursor args const loadVariables: _Input = { + ...(artifact.input?.defaults ?? {}), + ...getEntityVars(), ...getVariables(), ...input, } @@ -60,13 +86,17 @@ export function cursorHandlers< // Get the Pagination Mode const isSinglePage = artifact.refetch?.mode === 'SinglePage' + // SinglePage pagination uses per-cursor cache keys, so CacheOrNetwork naturally serves + // back-navigation from cache. Infinite mode appends pages, so always fetch fresh. + const policy = isSinglePage ? artifact.policy : CachePolicy.NetworkOnly + // send the query return (isSinglePage ? parentFetch : parentFetchUpdate)( { variables: loadVariables, fetch, metadata, - policy: isSinglePage ? artifact.policy : CachePolicy.NetworkOnly, + policy, session: await getSession(), }, isSinglePage ? [] : [where === 'start' ? 'prepend' : 'append'] @@ -89,6 +119,57 @@ export function cursorHandlers< fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { + const isSinglePage = artifact.refetch?.mode === 'SinglePage' + const direction = artifact.refetch?.direction + + // Backward-only SinglePage: use nextCursors stack to re-issue a backward query + if (isSinglePage && direction === 'backward') { + if (nextCursors.length === 0) { + return Promise.resolve({ + data: getState(), + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Cache, + variables: getVariables(), + }) + } + const beforeCursor = nextCursors.pop()! + return loadPage({ + pageSizeVar: 'last', + functionName: 'loadNextPage', + input: { + before: beforeCursor, + last: first ?? artifact.refetch!.pageSize, + first: null, + after: null, + } as unknown as _Input, + fetch, + metadata, + where: 'start', + }) + } + + // Bidirectional SinglePage: if the user went backward and hasn't caught back up, + // re-issue the saved backward query (cache hit) instead of doing a fresh forward fetch. + if (isSinglePage && direction === 'both' && nextCursors.length > 0) { + const beforeCursor = nextCursors.pop()! + return loadPage({ + pageSizeVar: 'last', + functionName: 'loadNextPage', + input: { + before: beforeCursor, + last: first ?? artifact.refetch!.pageSize, + first: null, + after: null, + } as unknown as _Input, + fetch, + metadata, + where: 'start', + }) + } + // we need to find the connection object holding the current page info const currentPageInfo = getPageInfo() // if there is no next page, we're done @@ -104,6 +185,12 @@ export function cursorHandlers< }) } + // SinglePage (forward or both): push the current 'after' cursor so + // loadPreviousPage can re-issue the same forward query (cache hit on back-nav). + if (isSinglePage && (direction === 'forward' || direction === 'both')) { + previousCursors.push((getVariables() as any)?.after ?? null) + } + // only specify the page count if we're given one const input: any = { first: first ?? artifact.refetch!.pageSize, @@ -133,10 +220,46 @@ export function cursorHandlers< fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { + const isSinglePage = artifact.refetch?.mode === 'SinglePage' + const direction = artifact.refetch?.direction + + // SinglePage (forward or both): use previousCursors stack to re-issue a forward query. + if ( + isSinglePage && + (direction === 'forward' || direction === 'both') && + previousCursors.length > 0 + ) { + const afterCursor = previousCursors.pop()! + return loadPage({ + pageSizeVar: 'first', + functionName: 'loadPreviousPage', + input: { + after: afterCursor, + first: last ?? artifact.refetch!.pageSize, + before: null, + last: null, + } as unknown as _Input, + fetch, + metadata, + where: 'end', + }) + } + if (isSinglePage && direction === 'forward') { + return Promise.resolve({ + data: getState(), + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Cache, + variables: getVariables(), + }) + } + // we need to find the connection object holding the current page info const currentPageInfo = getPageInfo() - // if there is no next page, we're done + // if there is no previous page, we're done if (!currentPageInfo.hasPreviousPage) { return Promise.resolve({ data: getState(), @@ -149,6 +272,20 @@ export function cursorHandlers< }) } + // Backward-only or bidirectional SinglePage going backward: push the current + // 'before' cursor so loadNextPage can re-issue the same backward query (cache hit). + // Skip if the current page was forward-loaded (has first/after set) — in that case + // loadNextPage's natural forward nav will reach the same cache key via endCursor. + const pVars = getVariables() as any + const isForwardPage = pVars?.first != null || pVars?.after != null + if ( + isSinglePage && + (direction === 'backward' || direction === 'both') && + !isForwardPage + ) { + nextCursors.push(pVars?.before ?? null) + } + // only specify the page count if we're given one const input: any = { before: before ?? currentPageInfo.startCursor, diff --git a/packages/houdini/src/runtime/selection.ts b/packages/houdini/src/runtime/selection.ts index b251c3c464..85756c2d56 100644 --- a/packages/houdini/src/runtime/selection.ts +++ b/packages/houdini/src/runtime/selection.ts @@ -1,9 +1,33 @@ import type { SubscriptionSelection } from './types.js' +const _memoCache = new WeakMap< + SubscriptionSelection, + Map['fields']> +>() + export function getFieldsForType( selection: SubscriptionSelection, __typename: string | undefined | null, loading: boolean +): Required['fields'] { + const cacheKey = `${__typename ?? ''}:${loading ? 1 : 0}` + let inner = _memoCache.get(selection) + if (inner !== undefined) { + const cached = inner.get(cacheKey) + if (cached !== undefined) return cached + } else { + inner = new Map() + _memoCache.set(selection, inner) + } + const result = _getFieldsForType(selection, __typename, loading) + inner.set(cacheKey, result) + return result +} + +function _getFieldsForType( + selection: SubscriptionSelection, + __typename: string | undefined | null, + loading: boolean ): Required['fields'] { // if we are loading, then we either have loading types or we return the base fields if (loading) { diff --git a/packages/houdini/src/runtime/types.ts b/packages/houdini/src/runtime/types.ts index df31b952bd..3c2254884f 100644 --- a/packages/houdini/src/runtime/types.ts +++ b/packages/houdini/src/runtime/types.ts @@ -18,6 +18,7 @@ declare global { namespace App { interface Session {} interface Metadata {} + interface GraphQLErrorExtensions {} interface Stuff { inputs: { init: boolean @@ -142,7 +143,15 @@ export type FetchContext = { session: App.Session | null } -type Filter = { [key: string]: string | boolean | number } +export type FilterValue = + | string + | boolean + | number + | null + | readonly FilterValue[] + | { [key: string]: FilterValue } + +export type Filter = { [key: string]: FilterValue } export type ListWhen = { must?: Filter @@ -167,16 +176,25 @@ export const DataSource = { export type DataSources = ValuesOf export type MutationOperation = { - action: 'insert' | 'remove' | 'delete' | 'toggle' + action: 'insert' | 'remove' | 'delete' | 'toggle' | 'upsert' list?: string type?: string parentID?: { kind: string value: string } + listID?: { + kind: string + value: string + } position?: 'first' | 'last' target?: 'all' - when?: ListWhen + // when conditions are encoded as filter nodes so that variable references + // can be resolved when the operation is applied + when?: { + must?: Record + must_not?: Record + } } export type GraphQLObject = { [key: string]: GraphQLValue } @@ -204,6 +222,14 @@ export type LoadingSpec = | { kind: 'continue'; list?: { depth: number; count: number } } | { kind: 'value'; value?: any; list?: { depth: number; count: number } } +export type ListFilter = + | { + kind: 'Boolean' | 'String' | 'Float' | 'Int' | 'Enum' | 'Variable' + value: string | number | boolean + } + | { kind: 'Object'; value: Record } + | { kind: 'List'; value: readonly ListFilter[] } + export type SubscriptionSelection = Readonly<{ loadingTypes?: string[] fragments?: Record @@ -220,18 +246,13 @@ export type SubscriptionSelection = Readonly<{ name: string connection: boolean type: string + includeListID?: boolean } loading?: LoadingSpec directives?: readonly { name: string; arguments: ValueMap }[] updates?: readonly string[] visible?: boolean - filters?: Record< - string, - { - kind: 'Boolean' | 'String' | 'Float' | 'Int' | 'Variable' - value: string | number | boolean - } - > + filters?: Record selection?: SubscriptionSelection abstract?: boolean // If set, this is an abstract type with at least one abstract field made non-nullable by @@ -257,10 +278,22 @@ export type SubscriptionSelection = Readonly<{ } }> +// the cache communicates with subscribers using tagged messages so that +// it can push more than just new data (for example, asking the document +// to refetch itself) +export type CacheMessage<_Data = any> = + | { + kind: 'update' + data: _Data + } + | { + kind: 'refetch' + } + export type SubscriptionSpec = Readonly<{ rootType: string selection: SubscriptionSelection - set: (data: any) => void + onMessage: (message: CacheMessage) => void parentID?: string variables?: () => any }> @@ -270,9 +303,16 @@ export type FetchQueryResult<_Data> = { source: DataSources | null } +export type GraphQLError = { + message: string + locations?: readonly { line: number; column: number }[] + path?: readonly (string | number)[] + extensions?: App.GraphQLErrorExtensions +} + export type QueryResult<_Data = GraphQLObject, _Input = GraphQLVariables | undefined> = { data: _Data | null - errors: { message: string }[] | null + errors: GraphQLError[] | null fetching: boolean partial: boolean stale: boolean @@ -282,11 +322,7 @@ export type QueryResult<_Data = GraphQLObject, _Input = GraphQLVariables | undef export type RequestPayload = { data: GraphQLObject | null - errors: - | { - message: string - }[] - | null + errors: GraphQLError[] | null } export type NestedList<_Result = string> = (_Result | null | NestedList<_Result>)[] diff --git a/packages/houdini/src/vite/hmr.ts b/packages/houdini/src/vite/hmr.ts index 700b181e6e..64bef145d0 100644 --- a/packages/houdini/src/vite/hmr.ts +++ b/packages/houdini/src/vite/hmr.ts @@ -37,6 +37,12 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { // Tracked so the debounce callback can still pass them to the pipeline for artifact cleanup. const cleanupFiles = new Set() + // Any file whose basename starts with '+' is a special route file (pages, layouts, + // API handlers, etc.) that affects the manifest even without $houdini content. + function isManifestFile(filepath: string): boolean { + return (filepath.split('/').pop() ?? '').startsWith('+') + } + return { name: 'houdini', @@ -104,9 +110,9 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { // Handle file deletions that may contain graphql documents. if (opts.type === 'delete') { const relPath = opts.file.substring(rootPrefix.length) - // For non-.gql files, only proceed if the DB has rows for this path. - // This avoids a pipeline run when deleting unrelated source files. - if (!opts.file.endsWith('.gql')) { + // For non-.gql files, only proceed if the DB has rows for this path or + // it's a route view file (whose deletion must update the manifest). + if (!opts.file.endsWith('.gql') && !isManifestFile(opts.file)) { const rowCount = ctx.db.get<{ count: number }>( 'SELECT COUNT(*) as count FROM raw_documents WHERE filepath = ?', @@ -135,16 +141,19 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { return } if (!preReadContent.includes('$houdini')) { - // Check whether this file previously had graphql documents in the DB. - // If so, fall through so the pipeline can delete the stale artifacts. - const relPath = opts.file.substring(rootPrefix.length) - const rowCount = - ctx.db.get<{ count: number }>( - 'SELECT COUNT(*) as count FROM raw_documents WHERE filepath = ?', - [relPath] - )?.count ?? 0 - if (rowCount === 0) return // not a Houdini file — let Vite handle it normally - cleanupFiles.add(opts.file) + // Route view files must trigger manifest regeneration even without $houdini. + if (!isManifestFile(opts.file)) { + // Check whether this file previously had graphql documents in the DB. + // If so, fall through so the pipeline can delete the stale artifacts. + const relPath = opts.file.substring(rootPrefix.length) + const rowCount = + ctx.db.get<{ count: number }>( + 'SELECT COUNT(*) as count FROM raw_documents WHERE filepath = ?', + [relPath] + )?.count ?? 0 + if (rowCount === 0) return // not a Houdini file — let Vite handle it normally + cleanupFiles.add(opts.file) + } } } @@ -180,7 +189,8 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { !filepath.includes(generatedDir) && (filepath.endsWith('.gql') || content.includes('$houdini') || - cleanupFiles.delete(filepath)) + cleanupFiles.delete(filepath) || + isManifestFile(filepath)) ) { const relPath = filepath.substring(rootPrefix.length) relativePaths.push(relPath) @@ -361,11 +371,10 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { ]) const changes = ctx.db.rowsModified() - // Skip only if there were no documents before and none were found now. - // If savedDocs had rows but nothing was re-extracted (changes === 0), the user - // removed all graphql calls — still need the pipeline to clean up stale artifacts. + // Skip only if there were no documents before and none were found now, + // and no route view files are involved (those require manifest regeneration). if (changes === 0 && savedDocs.length === 0) { - return + if (!filepaths.some(isManifestFile)) return } // trigger_hook handles flush before AfterExtract and reload after @@ -414,21 +423,11 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { // the task now includes every document that we need to process. // BeforeValidate → Validate → AfterValidate → GenerateDocuments → GenerateRuntime let results: Awaited> - let taskDocCount = 0 try { results = await run_pipeline(compiler.trigger_hook, { task_id, after: 'AfterExtract', }) - // Count before finally clears current_task — querying after would always give 0. - taskDocCount = - ctx.db.get<{ count: number }>( - `SELECT COUNT(DISTINCT d.id) as count - FROM documents d - JOIN raw_documents rd ON rd.id = d.raw_document - WHERE rd.current_task = ?`, - [task_id] - )?.count ?? 0 } finally { // Always clear the task association, even on pipeline failure, so // stale current_task values don't bleed into the next HMR run. @@ -437,8 +436,10 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { [task_id] ) } + const changedDocCount = Object.values(results.GenerateDocuments || {}).flat() + .length console.log( - `🎩 Updated ${taskDocCount} ${taskDocCount === 1 ? 'document' : 'documents'}` + `🎩 Updated ${changedDocCount} ${changedDocCount === 1 ? 'document' : 'documents'}` ) const updated_modules = [ diff --git a/packages/houdini/src/vite/houdini.ts b/packages/houdini/src/vite/houdini.ts index ea03d2b807..149fbc4b2e 100644 --- a/packages/houdini/src/vite/houdini.ts +++ b/packages/houdini/src/vite/houdini.ts @@ -1,4 +1,3 @@ -import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ResolvedConfig, ConfigEnv as ViteEnv, Plugin as VitePlugin } from 'vite' @@ -7,48 +6,6 @@ import { codegen_setup } from '../lib/codegen.js' import * as fs from '../lib/fs.js' import type { CompilerProxy } from '../lib/index.js' -// Matches GenerateTsConfig in packages/houdini-react/plugin/runtime.go — keep in sync. -const REACT_TSCONFIG_STUB = `{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "$houdini": ["."], - "$houdini/*": ["./*"], - "~": ["../src"], - "~/*": ["../src/*"] - }, - "rootDirs": ["..", "./types"], - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "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", - "../src/+app.d.ts" - ], - "exclude": ["../node_modules/**", "./[!ambient.d.ts]**"] -} -` - export let compiler: CompilerProxy let alreadyBuilt = false @@ -78,19 +35,6 @@ export function houdini(ctx: VitePluginContext): VitePlugin { ctx.config.config_file.runtimeDir ?? '.houdini' ) - // Write a stub tsconfig before any other plugin reads tsconfig.json. - // The Go pipeline overwrites it with the real content on first compile. - const tsconfigPath = path.join(runtimeDir, 'tsconfig.json') - if ( - !fs.existsSync(tsconfigPath) && - ctx.config.plugins.some((p) => p.name === 'houdini-react') - ) { - try { - mkdirSync(runtimeDir, { recursive: true }) - writeFileSync(tsconfigPath, REACT_TSCONFIG_STUB) - } catch {} - } - // add the necessary values for the houdini imports to resolve // In vite 8 the aliases can be an object or an array of objects, // so we'll have to add our own aliases accordingly @@ -120,6 +64,12 @@ export function houdini(ctx: VitePluginContext): VitePlugin { ...userConfig.server?.fs, allow: ['.'].concat(userConfig.server?.fs?.allow || []), }, + watch: { + ...userConfig.server?.watch, + ignored: ['**/*.houdini_tmp'].concat( + (userConfig.server?.watch?.ignored as string[]) || [] + ), + }, }, } }, diff --git a/perf/benchmark.json b/perf/benchmark.json new file mode 100644 index 0000000000..b7b488a1ad --- /dev/null +++ b/perf/benchmark.json @@ -0,0 +1,1974 @@ +{ + "files": [ + { + "filepath": "/Users/alec/dv/houdini/worktrees/perf/packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts", + "groups": [ + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > write", + "benchmarks": [ + { + "id": "387541488_0_0", + "name": "flat record", + "rank": 1, + "rme": 0.3721808805329383, + "samples": [], + "totalTime": 500.0023560000393, + "min": 0.003040999999939231, + "max": 0.24737500000014734, + "hz": 299078.5907416569, + "period": 0.003343602755115951, + "mean": 0.003343602755115951, + "variance": 0.000006028131262468596, + "sd": 0.0024552252977005176, + "sem": 0.000006349107232405169, + "df": 149539, + "critical": 1.96, + "moe": 0.000012444250175514131, + "p75": 0.003332999999884123, + "p99": 0.00416599999994105, + "p995": 0.004541999999901236, + "p999": 0.005959000000075321, + "sampleCount": 149540, + "median": 0.003250000000207365 + }, + { + "id": "387541488_0_1", + "name": "nested list (10 items)", + "rank": 3, + "rme": 0.382071175528188, + "samples": [], + "totalTime": 500.0157309999695, + "min": 0.018124999999827196, + "max": 0.2610829999998714, + "hz": 51574.377366942426, + "period": 0.019389473049479196, + "mean": 0.019389473049479196, + "variance": 0.00003684054791881035, + "sd": 0.006069641498376189, + "sem": 0.00003779672837187059, + "df": 25787, + "critical": 1.96, + "moe": 0.00007408158760886636, + "p75": 0.01929199999995035, + "p99": 0.02412499999991269, + "p995": 0.02624999999989086, + "p999": 0.1016669999999067, + "sampleCount": 25788, + "median": 0.019000000000005457 + }, + { + "id": "387541488_0_2", + "name": "nested list (100 items)", + "rank": 6, + "rme": 0.4163606603156695, + "samples": [], + "totalTime": 500.1813279999956, + "min": 0.18791699999974298, + "max": 0.46395799999982046, + "hz": 5026.177226671729, + "period": 0.19895836435958456, + "mean": 0.19895836435958456, + "variance": 0.00044907296624828127, + "sd": 0.02119134177555261, + "sem": 0.00042264508142899075, + "df": 2513, + "critical": 1.96, + "moe": 0.0008283843596008218, + "p75": 0.19712500000014188, + "p99": 0.24120899999979883, + "p995": 0.39587500000016007, + "p999": 0.45891600000004473, + "sampleCount": 2514, + "median": 0.1957919999999831 + }, + { + "id": "387541488_0_3", + "name": "nested list (1000 items)", + "rank": 8, + "rme": 0.6182465904275198, + "samples": [], + "totalTime": 500.107124000001, + "min": 3.7742920000000595, + "max": 4.601749999999811, + "hz": 251.9460210688775, + "period": 3.969104158730167, + "mean": 3.969104158730167, + "variance": 0.019749988825973022, + "sd": 0.14053465347014246, + "sem": 0.012519822006054159, + "df": 125, + "critical": 1.96, + "moe": 0.02453885113186615, + "p75": 4.008624999999938, + "p99": 4.361666000000241, + "p995": 4.601749999999811, + "p999": 4.601749999999811, + "sampleCount": 126, + "median": 3.9376254999999674 + }, + { + "id": "387541488_0_4", + "name": "nested list (10000 items)", + "rank": 10, + "rme": 0.1487605620522966, + "samples": [], + "totalTime": 1741.006043999998, + "min": 173.70716699999957, + "max": 174.71429199999966, + "hz": 5.743805447696661, + "period": 174.10060439999978, + "mean": 174.10060439999978, + "variance": 0.13109646390675392, + "sd": 0.36207245670825877, + "sem": 0.11449736412108093, + "df": 9, + "critical": 2.262, + "moe": 0.2589930376418851, + "p75": 174.36595899999975, + "p99": 174.71429199999966, + "p995": 174.71429199999966, + "p999": 174.71429199999966, + "sampleCount": 10, + "median": 174.04758349999975 + }, + { + "id": "387541488_0_5", + "name": "wide record (10 fields)", + "rank": 2, + "rme": 7.794308576598297, + "samples": [], + "totalTime": 500.0018489999384, + "min": 0.004874999999628926, + "max": 19.854166000000077, + "hz": 175889.349561207, + "period": 0.005685392563533326, + "mean": 0.005685392563533326, + "variance": 0.004495470124007931, + "sd": 0.06704826712158884, + "sem": 0.00022609032662895907, + "df": 87944, + "critical": 1.96, + "moe": 0.0004431370401927598, + "p75": 0.005333000000064203, + "p99": 0.007665999999517226, + "p995": 0.013542000000597909, + "p999": 0.02962500000012369, + "sampleCount": 87945, + "median": 0.00520799999958399 + }, + { + "id": "387541488_0_6", + "name": "wide record (100 fields)", + "rank": 5, + "rme": 2.8854009927915416, + "samples": [], + "totalTime": 500.02112399997895, + "min": 0.03891699999985576, + "max": 6.320166000000427, + "hz": 22621.044306121108, + "period": 0.04420662399433993, + "mean": 0.04420662399433993, + "variance": 0.004790445600098104, + "sd": 0.06921304501391413, + "sem": 0.0006507848814348512, + "df": 11310, + "critical": 1.96, + "moe": 0.0012755383676123083, + "p75": 0.04179199999998673, + "p99": 0.0970420000003287, + "p995": 0.1270409999997355, + "p999": 0.2695420000000013, + "sampleCount": 11311, + "median": 0.04100000000016735 + }, + { + "id": "387541488_0_7", + "name": "wide record (1000 fields)", + "rank": 7, + "rme": 0.47702643946416706, + "samples": [], + "totalTime": 500.1674239999693, + "min": 0.381666000000223, + "max": 0.6774580000001151, + "hz": 2461.175880179025, + "period": 0.4063098489033057, + "mean": 0.4063098489033057, + "variance": 0.0012037755305862434, + "sd": 0.034695468444542486, + "sem": 0.000988880308885549, + "df": 1230, + "critical": 1.96, + "moe": 0.001938205405415676, + "p75": 0.4059580000002825, + "p99": 0.6240000000007058, + "p995": 0.6398750000007567, + "p999": 0.6522079999995185, + "sampleCount": 1231, + "median": 0.39741700000013225 + }, + { + "id": "387541488_0_8", + "name": "wide record (10000 fields)", + "rank": 9, + "rme": 1.1883284924855961, + "samples": [], + "totalTime": 503.45066300000326, + "min": 5.088207999999213, + "max": 6.189708999998402, + "hz": 186.7114434607456, + "period": 5.355858117021311, + "mean": 5.355858117021311, + "variance": 0.09652877136130647, + "sd": 0.3106907970334919, + "sem": 0.0320453089077422, + "df": 93, + "critical": 1.9861, + "moe": 0.06364518802166678, + "p75": 5.322833000000173, + "p99": 6.189708999998402, + "p995": 6.189708999998402, + "p999": 6.189708999998402, + "sampleCount": 94, + "median": 5.208395999999084 + }, + { + "id": "387541488_0_9", + "name": "repeated writes to same record", + "rank": 4, + "rme": 0.38940167364854394, + "samples": [], + "totalTime": 500.017014000161, + "min": 0.023041999998895335, + "max": 0.2703750000000582, + "hz": 40364.6264724774, + "period": 0.024774167071305603, + "mean": 0.024774167071305603, + "variance": 0.000048895324099489005, + "sd": 0.0069925191526008, + "sem": 0.00004921990877966861, + "df": 20182, + "critical": 1.96, + "moe": 0.00009647102120815048, + "p75": 0.024459000000206288, + "p99": 0.033792000000175904, + "p995": 0.0406249999996362, + "p999": 0.1894159999992553, + "sampleCount": 20183, + "median": 0.02420899999924586 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > write — wide list (same total cells, different shape)", + "benchmarks": [ + { + "id": "387541488_1_0", + "name": "~1000 cells: 10 rows × 100 cols", + "rank": 1, + "rme": 0.41608875079621754, + "samples": [], + "totalTime": 500.17869500003144, + "min": 0.38416700000016135, + "max": 0.7023749999989377, + "hz": 2453.123278271424, + "period": 0.4076435982070346, + "mean": 0.4076435982070346, + "variance": 0.00091889443607961, + "sd": 0.03031327161623453, + "sem": 0.0008653873242246952, + "df": 1226, + "critical": 1.96, + "moe": 0.0016961591554804025, + "p75": 0.40779099999963364, + "p99": 0.5904580000005808, + "p995": 0.6104170000016893, + "p999": 0.639208000000508, + "sampleCount": 1227, + "median": 0.3993750000008731 + }, + { + "id": "387541488_1_1", + "name": "~1000 cells: 100 rows × 10 cols", + "rank": 2, + "rme": 0.4583043652949171, + "samples": [], + "totalTime": 500.04453500000454, + "min": 0.41820800000095915, + "max": 0.8198339999999007, + "hz": 2255.7990759762824, + "period": 0.44330189273050047, + "mean": 0.44330189273050047, + "variance": 0.0012120041749201188, + "sd": 0.03481385033173031, + "sem": 0.0010365673090912625, + "df": 1127, + "critical": 1.96, + "moe": 0.0020316719258188743, + "p75": 0.44229099999938626, + "p99": 0.6366249999991851, + "p995": 0.664374999998472, + "p999": 0.7264169999998558, + "sampleCount": 1128, + "median": 0.43383299999914016 + }, + { + "id": "387541488_1_2", + "name": "~10000 cells: 10 rows × 1000 cols", + "rank": 3, + "rme": 0.7456007314485945, + "samples": [], + "totalTime": 500.03050300000905, + "min": 3.887458000001061, + "max": 4.688417000001209, + "hz": 245.98499343948575, + "period": 4.065288642276497, + "mean": 4.065288642276497, + "variance": 0.02941632349218703, + "sd": 0.1715118756593462, + "sem": 0.015464705026688876, + "df": 122, + "critical": 1.96, + "moe": 0.030310821852310196, + "p75": 4.07837500000096, + "p99": 4.643290999998499, + "p995": 4.688417000001209, + "p999": 4.688417000001209, + "sampleCount": 123, + "median": 3.9943750000002183 + }, + { + "id": "387541488_1_3", + "name": "~10000 cells: 100 rows × 100 cols", + "rank": 4, + "rme": 0.7321538368463342, + "samples": [], + "totalTime": 501.9010340000041, + "min": 3.9661250000008295, + "max": 4.8830830000006245, + "hz": 241.08338457816168, + "period": 4.1479424297521, + "mean": 4.1479424297521, + "variance": 0.028465912838889173, + "sd": 0.16871844249781698, + "sem": 0.015338040227074272, + "df": 120, + "critical": 1.98, + "moe": 0.030369319649607058, + "p75": 4.195583999999144, + "p99": 4.78150000000096, + "p995": 4.8830830000006245, + "p999": 4.8830830000006245, + "sampleCount": 121, + "median": 4.086666999999579 + }, + { + "id": "387541488_1_4", + "name": "~10000 cells: 1000 rows × 10 cols", + "rank": 5, + "rme": 0.7701847703268357, + "samples": [], + "totalTime": 502.36124500001097, + "min": 5.663000000000466, + "max": 6.698334000000614, + "hz": 167.21035079049173, + "period": 5.980491011904893, + "mean": 5.980491011904893, + "variance": 0.045043236513121906, + "sd": 0.21223391932752386, + "sem": 0.023156619056084033, + "df": 83, + "critical": 1.9891, + "moe": 0.046060830964456755, + "p75": 6.117000000000189, + "p99": 6.698334000000614, + "p995": 6.698334000000614, + "p999": 6.698334000000614, + "sampleCount": 84, + "median": 5.8855414999998175 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > read", + "benchmarks": [ + { + "id": "387541488_2_0", + "name": "flat record", + "rank": 1, + "rme": 0.3777102868847151, + "samples": [], + "totalTime": 500.00197499980277, + "min": 0.004166000000623171, + "max": 0.2604169999995065, + "hz": 219169.13428200604, + "period": 0.004562686270929441, + "mean": 0.004562686270929441, + "variance": 0.000008472231433968174, + "sd": 0.0029107097818175166, + "sem": 0.000008792722144682194, + "df": 109584, + "critical": 1.96, + "moe": 0.0000172337354035771, + "p75": 0.004500000000916771, + "p99": 0.0059999999994033715, + "p995": 0.0063330000011774246, + "p999": 0.018500000000130967, + "sampleCount": 109585, + "median": 0.004417000000103144 + }, + { + "id": "387541488_2_1", + "name": "nested list (10 items)", + "rank": 2, + "rme": 0.3988591074098076, + "samples": [], + "totalTime": 500.00728199987316, + "min": 0.025332999999591266, + "max": 0.3135000000002037, + "hz": 36679.46580026939, + "period": 0.02726321057796473, + "mean": 0.02726321057796473, + "variance": 0.00005645211410919249, + "sd": 0.007513462191905439, + "sem": 0.00005548050936863592, + "df": 18339, + "critical": 1.96, + "moe": 0.00010874179836252639, + "p75": 0.02691700000104902, + "p99": 0.037208000001555774, + "p995": 0.043208000000959146, + "p999": 0.2119590000002063, + "sampleCount": 18340, + "median": 0.026625000000422006 + }, + { + "id": "387541488_2_2", + "name": "nested list (100 items)", + "rank": 3, + "rme": 0.4365514239607262, + "samples": [], + "totalTime": 500.2139419999603, + "min": 0.27062499999919964, + "max": 0.588541000000987, + "hz": 3464.5175883564984, + "period": 0.2886404743219621, + "mean": 0.2886404743219621, + "variance": 0.0007162616476158447, + "sd": 0.02676306498919443, + "sem": 0.0006428898473364897, + "df": 1732, + "critical": 1.96, + "moe": 0.00126006410077952, + "p75": 0.28862499999922875, + "p99": 0.4781249999996362, + "p995": 0.4985000000015134, + "p999": 0.5474169999997684, + "sampleCount": 1733, + "median": 0.28145799999947485 + }, + { + "id": "387541488_2_3", + "name": "nested list (1000 items)", + "rank": 4, + "rme": 1.6559520594136643, + "samples": [], + "totalTime": 504.81841800000984, + "min": 4.961957999999868, + "max": 8.468375000000378, + "hz": 188.18647777624892, + "period": 5.31387808421063, + "mean": 5.31387808421063, + "variance": 0.18653970438990117, + "sd": 0.43190242461683537, + "sem": 0.044312253786996325, + "df": 94, + "critical": 1.9858, + "moe": 0.0879952735702173, + "p75": 5.375124999998661, + "p99": 8.468375000000378, + "p995": 8.468375000000378, + "p999": 8.468375000000378, + "sampleCount": 95, + "median": 5.193707999998878 + }, + { + "id": "387541488_2_4", + "name": "nested list (10000 items)", + "rank": 5, + "rme": 7.638567507823674, + "samples": [], + "totalTime": 941.6009999999987, + "min": 182.46562500000073, + "max": 209.0314589999998, + "hz": 5.310104810848764, + "period": 188.32019999999974, + "mean": 188.32019999999974, + "variance": 134.26064726497336, + "sd": 11.587089680544176, + "sem": 5.18190403741662, + "df": 4, + "critical": 2.776, + "moe": 14.384965607868537, + "p75": 183.6126660000009, + "p99": 209.0314589999998, + "p995": 209.0314589999998, + "p999": 209.0314589999998, + "sampleCount": 5, + "median": 183.5165419999994 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > write + notify subscribers", + "benchmarks": [ + { + "id": "387541488_3_0", + "name": "1 subscriber, flat record", + "rank": 1, + "rme": 7.497792476144853, + "samples": [], + "totalTime": 500.0033359997724, + "min": 0.008874999999534339, + "max": 15.631250000002183, + "hz": 88247.41121331256, + "period": 0.011331777173415202, + "mean": 0.011331777173415202, + "variance": 0.008291357038893261, + "sd": 0.09105688902490168, + "sem": 0.0004334862940417474, + "df": 44123, + "critical": 1.96, + "moe": 0.0008496331363218248, + "p75": 0.009708000001410255, + "p99": 0.015457999998034211, + "p995": 0.021124999999301508, + "p999": 0.04516700000021956, + "sampleCount": 44124, + "median": 0.009458000000449829 + }, + { + "id": "387541488_3_1", + "name": "10 subscribers, flat record", + "rank": 2, + "rme": 1.9278627560875456, + "samples": [], + "totalTime": 500.02404599992224, + "min": 0.03295800000341842, + "max": 1.0955840000024182, + "hz": 25894.75466946246, + "period": 0.03861785959220901, + "mean": 0.03861785959220901, + "variance": 0.001868181956367178, + "sd": 0.04322247050282038, + "sem": 0.0003798465981001935, + "df": 12947, + "critical": 1.96, + "moe": 0.0007444993322763792, + "p75": 0.03645899999901303, + "p99": 0.05912499999976717, + "p995": 0.06854199999725097, + "p999": 0.9433749999989232, + "sampleCount": 12948, + "median": 0.03566599999976461 + }, + { + "id": "387541488_3_2", + "name": "1 subscriber, nested list (100 items)", + "rank": 3, + "rme": 4.6878761654114705, + "samples": [], + "totalTime": 500.09483300000284, + "min": 0.6044590000019525, + "max": 3.5092079999994894, + "hz": 1381.7379312935154, + "period": 0.7237262416787306, + "mean": 0.7237262416787306, + "variance": 0.20704598182655512, + "sd": 0.4550230563680868, + "sem": 0.017309892850247664, + "df": 690, + "critical": 1.96, + "moe": 0.03392738998648542, + "p75": 0.6544160000012198, + "p99": 3.312292000002344, + "p995": 3.3880839999983436, + "p999": 3.5092079999994894, + "sampleCount": 691, + "median": 0.6349579999987327 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > write → subscriber notification", + "benchmarks": [ + { + "id": "387541488_4_0", + "name": "flat record", + "rank": 1, + "rme": 4.369906360670699, + "samples": [], + "totalTime": 500.001024000554, + "min": 0.002582999997684965, + "max": 11.114874999999302, + "hz": 345241.29294544953, + "period": 0.0028965248955837007, + "mean": 0.0028965248955837007, + "variance": 0.0007199134350932421, + "sd": 0.02683120263971114, + "sem": 0.00006457929880128697, + "df": 172620, + "critical": 1.96, + "moe": 0.00012657542565052245, + "p75": 0.0027919999993173406, + "p99": 0.0038749999985157046, + "p995": 0.004042000000481494, + "p999": 0.010541999999986729, + "sampleCount": 172621, + "median": 0.002749999999650754 + }, + { + "id": "387541488_4_1", + "name": "nested list (10 items)", + "rank": 2, + "rme": 1.3751140055866125, + "samples": [], + "totalTime": 500.00691900024685, + "min": 0.01900000000023283, + "max": 1.231999999999971, + "hz": 47757.33913391749, + "period": 0.02093919004146936, + "mean": 0.02093919004146936, + "variance": 0.0005153486713068455, + "sd": 0.02270129228275002, + "sem": 0.0001469070076105318, + "df": 23878, + "critical": 1.96, + "moe": 0.00028793773491664234, + "p75": 0.02029200000106357, + "p99": 0.027541999999812106, + "p995": 0.03299999999944703, + "p999": 0.05274999999892316, + "sampleCount": 23879, + "median": 0.01999999999679858 + }, + { + "id": "387541488_4_2", + "name": "nested list (100 items)", + "rank": 4, + "rme": 1.2767846937415945, + "samples": [], + "totalTime": 500.0097219999625, + "min": 0.21079100000133622, + "max": 1.1595840000009048, + "hz": 4493.912620363347, + "period": 0.22252324076544838, + "mean": 0.22252324076544838, + "variance": 0.004721467640816663, + "sd": 0.06871293648809271, + "sem": 0.0014495625908729597, + "df": 2246, + "critical": 1.96, + "moe": 0.002841142678111001, + "p75": 0.21900000000096043, + "p99": 0.2647499999984575, + "p995": 1.0173329999997804, + "p999": 1.1009589999994205, + "sampleCount": 2247, + "median": 0.21299999999973807 + }, + { + "id": "387541488_4_3", + "name": "nested list (1000 items)", + "rank": 6, + "rme": 1.1876643849179451, + "samples": [], + "totalTime": 500.3026309999914, + "min": 2.320292000000336, + "max": 3.435958000001847, + "hz": 401.7568318564438, + "period": 2.489067815920355, + "mean": 2.489067815920355, + "variance": 0.04572406568029007, + "sd": 0.2138318631081207, + "sem": 0.015082536717418887, + "df": 200, + "critical": 1.96, + "moe": 0.02956177196614102, + "p75": 2.464125000002241, + "p99": 3.2878329999985, + "p995": 3.367500000000291, + "p999": 3.435958000001847, + "sampleCount": 201, + "median": 2.4244589999980235 + }, + { + "id": "387541488_4_4", + "name": "nested list (10000 items)", + "rank": 11, + "rme": 1.7663830060921828, + "samples": [], + "totalTime": 266.77762500000244, + "min": 25.781083000001672, + "max": 27.78887499999837, + "hz": 37.48440297419961, + "period": 26.677762500000245, + "mean": 26.677762500000245, + "variance": 0.4339936331605757, + "sd": 0.6587819314162887, + "sem": 0.2083251384640307, + "df": 9, + "critical": 2.262, + "moe": 0.4712314632056374, + "p75": 27.347207999999227, + "p99": 27.78887499999837, + "p995": 27.78887499999837, + "p999": 27.78887499999837, + "sampleCount": 10, + "median": 26.546916500001316 + }, + { + "id": "387541488_4_5", + "name": "wide record (100 fields)", + "rank": 3, + "rme": 0.3659491700661581, + "samples": [], + "totalTime": 500.0390010002775, + "min": 0.04233399999793619, + "max": 0.3062500000014552, + "hz": 22678.231052608848, + "period": 0.04409515000002447, + "mean": 0.04409515000002447, + "variance": 0.00007686419683921608, + "sd": 0.00876722286925661, + "sem": 0.00008232950789005974, + "df": 11339, + "critical": 1.96, + "moe": 0.00016136583546451708, + "p75": 0.04349999999976717, + "p99": 0.057833999999274965, + "p995": 0.06333399999857647, + "p999": 0.25141599999915343, + "sampleCount": 11340, + "median": 0.043208000002778135 + }, + { + "id": "387541488_4_6", + "name": "wide record (1000 fields)", + "rank": 5, + "rme": 0.4020609913809803, + "samples": [], + "totalTime": 500.280790000088, + "min": 0.43549999999959255, + "max": 0.7304159999985131, + "hz": 2176.777565254521, + "period": 0.4593946648302002, + "mean": 0.4593946648302002, + "variance": 0.0009671002818145029, + "sd": 0.031098235992006087, + "sem": 0.0009423707876365481, + "df": 1088, + "critical": 1.96, + "moe": 0.0018470467437676343, + "p75": 0.4598330000007991, + "p99": 0.6869999999980791, + "p995": 0.701082999999926, + "p999": 0.7070830000011483, + "sampleCount": 1089, + "median": 0.4501249999993888 + }, + { + "id": "387541488_4_7", + "name": "wide record (10000 fields)", + "rank": 9, + "rme": 6.75220983514654, + "samples": [], + "totalTime": 27.919581999998627, + "min": 5.348249999999098, + "max": 6.050916000000143, + "hz": 179.08577571112082, + "period": 5.583916399999725, + "mean": 5.583916399999725, + "variance": 0.09223606279884139, + "sd": 0.3037039064596328, + "sem": 0.1358205159751953, + "df": 4, + "critical": 2.776, + "moe": 0.3770377523471421, + "p75": 5.7337919999990845, + "p99": 6.050916000000143, + "p995": 6.050916000000143, + "p999": 6.050916000000143, + "sampleCount": 5, + "median": 5.403166000000056 + }, + { + "id": "387541488_4_8", + "name": "~10000 cells: 10 rows × 1000 cols", + "rank": 8, + "rme": 10.491497904660218, + "samples": [], + "totalTime": 24.249542000001384, + "min": 4.6368750000001455, + "max": 5.582166000000143, + "hz": 206.18946122775083, + "period": 4.849908400000277, + "mean": 4.849908400000277, + "variance": 0.16798602343224023, + "sd": 0.4098609806168919, + "sem": 0.1832954027968188, + "df": 4, + "critical": 2.776, + "moe": 0.5088280381639689, + "p75": 4.688583999999537, + "p99": 5.582166000000143, + "p995": 5.582166000000143, + "p999": 5.582166000000143, + "sampleCount": 5, + "median": 4.682958000001236 + }, + { + "id": "387541488_4_9", + "name": "~10000 cells: 100 rows × 100 cols", + "rank": 7, + "rme": 0.6341404696333087, + "samples": [], + "totalTime": 501.6815849999948, + "min": 4.497875000000931, + "max": 5.107874999997875, + "hz": 215.27599024788026, + "period": 4.645199861111063, + "mean": 4.645199861111063, + "variance": 0.023841484882009628, + "sd": 0.15440688094126384, + "sem": 0.014857809046028195, + "df": 107, + "critical": 1.9826, + "moe": 0.0294570922146555, + "p75": 4.6507500000006985, + "p99": 5.063249999999243, + "p995": 5.107874999997875, + "p999": 5.107874999997875, + "sampleCount": 108, + "median": 4.5917294999999285 + }, + { + "id": "387541488_4_10", + "name": "~10000 cells: 1000 rows × 10 cols", + "rank": 10, + "rme": 7.951003612352693, + "samples": [], + "totalTime": 502.9154150000031, + "min": 5.025666999998066, + "max": 23.535916000000725, + "hz": 176.9681289248202, + "period": 5.650735000000035, + "mean": 5.650735000000035, + "variance": 4.547637235308059, + "sd": 2.132518988264362, + "sem": 0.22604656066335316, + "df": 88, + "critical": 1.9876, + "moe": 0.44929014397448075, + "p75": 5.429041999999754, + "p99": 23.535916000000725, + "p995": 23.535916000000725, + "p999": 23.535916000000725, + "sampleCount": 89, + "median": 5.225875000000087 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > fan-out: N documents watching same selection (100-item list)", + "benchmarks": [ + { + "id": "387541488_5_0", + "name": "1 document", + "rank": 1, + "rme": 1.2788459422256198, + "samples": [], + "totalTime": 500.027005000029, + "min": 0.2047500000007858, + "max": 1.099207999999635, + "hz": 4541.754699828399, + "period": 0.22017921840600133, + "mean": 0.22017921840600133, + "variance": 0.004686990816337815, + "sd": 0.06846160103545501, + "sem": 0.0014366086735761391, + "df": 2270, + "critical": 1.96, + "moe": 0.0028157530002092326, + "p75": 0.21500000000014552, + "p99": 0.24766699999963748, + "p995": 0.9960830000018177, + "p999": 1.0624580000003334, + "sampleCount": 2271, + "median": 0.21154199999728007 + }, + { + "id": "387541488_5_1", + "name": "10 documents", + "rank": 2, + "rme": 1.3490504695256722, + "samples": [], + "totalTime": 500.09342500009006, + "min": 0.21583299999838346, + "max": 1.1967080000031274, + "hz": 4297.197068726935, + "period": 0.2327098301536017, + "mean": 0.2327098301536017, + "variance": 0.0055132809736347975, + "sd": 0.07425147118835287, + "sem": 0.0016017209471018152, + "df": 2148, + "critical": 1.96, + "moe": 0.003139373056319558, + "p75": 0.22791700000016135, + "p99": 0.27387499999895226, + "p995": 1.0332909999997355, + "p999": 1.113666999997804, + "sampleCount": 2149, + "median": 0.2226669999981823 + }, + { + "id": "387541488_5_2", + "name": "100 documents", + "rank": 3, + "rme": 1.1557878216634736, + "samples": [], + "totalTime": 500.1746100000091, + "min": 0.3259170000019367, + "max": 1.070917000000918, + "hz": 2837.009259626301, + "period": 0.35248386892178235, + "mean": 0.35248386892178235, + "variance": 0.006130628140122691, + "sd": 0.07829832782456271, + "sem": 0.002078553893023572, + "df": 1418, + "critical": 1.96, + "moe": 0.0040739656303262015, + "p75": 0.3461250000000291, + "p99": 0.9731670000001031, + "p995": 0.996541000000434, + "p999": 1.0390000000006694, + "sampleCount": 1419, + "median": 0.33683299999756855 + }, + { + "id": "387541488_5_3", + "name": "1000 documents", + "rank": 4, + "rme": 1.0776858654312584, + "samples": [], + "totalTime": 500.99957900002846, + "min": 1.8779169999979786, + "max": 2.772166999999172, + "hz": 483.03433803880756, + "period": 2.0702461942149935, + "mean": 2.0702461942149935, + "variance": 0.03135678922941632, + "sd": 0.1770784832480116, + "sem": 0.011383036027899768, + "df": 241, + "critical": 1.96, + "moe": 0.022310750614683544, + "p75": 2.0906250000007276, + "p99": 2.6852079999989655, + "p995": 2.716499999998632, + "p999": 2.772166999999172, + "sampleCount": 242, + "median": 2.0071250000000873 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > shared record: User:1 referenced from N query roots", + "benchmarks": [ + { + "id": "387541488_6_0", + "name": "1 query root", + "rank": 1, + "rme": 0.4212316927950463, + "samples": [], + "totalTime": 500.000830000874, + "min": 0.001082999999198364, + "max": 0.25737500000104774, + "hz": 802822.667312969, + "period": 0.0012456050890378811, + "mean": 0.0012456050890378811, + "variance": 0.0000028766103267150738, + "sd": 0.0016960572887479578, + "sem": 0.0000026769813275997502, + "df": 401411, + "critical": 1.96, + "moe": 0.00000524688340209551, + "p75": 0.0012090000018361025, + "p99": 0.0017079999997804407, + "p995": 0.0017910000024130568, + "p999": 0.0037080000001878943, + "sampleCount": 401412, + "median": 0.0012080000014975667 + }, + { + "id": "387541488_6_1", + "name": "10 query roots", + "rank": 2, + "rme": 0.5268225001742762, + "samples": [], + "totalTime": 500.0002170010848, + "min": 0.0011249999988649506, + "max": 0.6826250000012806, + "hz": 733361.681719439, + "period": 0.0013635836517329363, + "mean": 0.0013635836517329363, + "variance": 0.000004925705766155904, + "sd": 0.0022193931076210684, + "sem": 0.0000036651354520546693, + "df": 366680, + "critical": 1.96, + "moe": 0.0000071836654860271515, + "p75": 0.0013330000001587905, + "p99": 0.0018339999987802003, + "p995": 0.001958000000740867, + "p999": 0.009915999999066116, + "sampleCount": 366681, + "median": 0.0012920000008307397 + }, + { + "id": "387541488_6_2", + "name": "100 query roots", + "rank": 3, + "rme": 0.46556832218482774, + "samples": [], + "totalTime": 500.00094100022034, + "min": 0.001958000000740867, + "max": 0.27537500000107684, + "hz": 444447.1635502423, + "period": 0.0022499862346111147, + "mean": 0.0022499862346111147, + "variance": 0.000006347539111790785, + "sd": 0.002519432299505344, + "sem": 0.00000534450161319824, + "df": 222223, + "critical": 1.96, + "moe": 0.000010475223161868549, + "p75": 0.0021670000023732428, + "p99": 0.003042000000277767, + "p995": 0.0032080000019050203, + "p999": 0.020791999999346444, + "sampleCount": 222224, + "median": 0.0021249999990686774 + }, + { + "id": "387541488_6_3", + "name": "1000 query roots", + "rank": 4, + "rme": 1.4679176816669168, + "samples": [], + "totalTime": 500.00769400001445, + "min": 0.01374999999825377, + "max": 2.1228749999972933, + "hz": 56277.13400746027, + "period": 0.017769206226234565, + "mean": 0.017769206226234565, + "variance": 0.00049835173634699, + "sd": 0.022323793054653367, + "sem": 0.00013308026535038565, + "df": 28138, + "critical": 1.96, + "moe": 0.00026083732008675586, + "p75": 0.01566599999932805, + "p99": 0.06000000000130967, + "p995": 0.09533299999748124, + "p999": 0.2882499999977881, + "sampleCount": 28139, + "median": 0.015167000001383713 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > overlapping vs disjoint selections (N=100)", + "benchmarks": [ + { + "id": "387541488_7_0", + "name": "overlapping: N specs, same record, different fields", + "rank": 1, + "rme": 6.581385747147314, + "samples": [], + "totalTime": 500.005552000006, + "min": 0.25541700000030687, + "max": 5.899249999998574, + "hz": 2669.9703526491726, + "period": 0.3745359940074951, + "mean": 0.3745359940074951, + "variance": 0.21114992801836635, + "sd": 0.4595105309112799, + "sem": 0.012576356391605, + "df": 1334, + "critical": 1.96, + "moe": 0.0246496585275458, + "p75": 0.31987500000104774, + "p99": 3.190083000001323, + "p995": 3.5803329999980633, + "p999": 5.362417000000278, + "sampleCount": 1335, + "median": 0.28995900000154506 + }, + { + "id": "387541488_7_1", + "name": "disjoint: N specs, N different records, same field", + "rank": 2, + "rme": 34.413166629571734, + "samples": [], + "totalTime": 545.7037849998705, + "min": 0.28816700000243145, + "max": 95.78312499999811, + "hz": 2338.26488852428, + "period": 0.4276675431033468, + "mean": 0.4276675431033468, + "variance": 7.194496236246655, + "sd": 2.6822558111124777, + "sem": 0.07508874705548484, + "df": 1275, + "critical": 1.96, + "moe": 0.14717394422875027, + "p75": 0.3145839999997406, + "p99": 1.7150000000001455, + "p995": 1.7752919999984442, + "p999": 2.365625000002183, + "sampleCount": 1276, + "median": 0.299354000000676 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > list query + detail queries: write one item notifies both", + "benchmarks": [ + { + "id": "387541488_8_0", + "name": "10-item list + 10 detail docs", + "rank": 2, + "rme": 3.407626015424315, + "samples": [], + "totalTime": 501.17761600222, + "min": 0.001082999999198364, + "max": 5.383582999998907, + "hz": 647111.9013395112, + "period": 0.0015453277832319515, + "mean": 0.0015453277832319515, + "variance": 0.00023410138678749216, + "sd": 0.015300372112713212, + "sem": 0.00002686683243111829, + "df": 324317, + "critical": 1.96, + "moe": 0.00005265899156499185, + "p75": 0.0012920000008307397, + "p99": 0.0031670000025769696, + "p995": 0.0033750000002328306, + "p999": 0.022625000001426088, + "sampleCount": 324318, + "median": 0.0012500000011641532 + }, + { + "id": "387541488_8_1", + "name": "100-item list + 100 detail docs", + "rank": 3, + "rme": 8.159162098252086, + "samples": [], + "totalTime": 500.00063399974533, + "min": 0.0012499999975261744, + "max": 20.432041000000027, + "hz": 642235.1856461117, + "period": 0.001557061995901025, + "mean": 0.001557061995901025, + "variance": 0.0013491350951648732, + "sd": 0.03673057439198131, + "sem": 0.00006481796541624688, + "df": 321117, + "critical": 1.96, + "moe": 0.00012704321221584388, + "p75": 0.0014579999988200143, + "p99": 0.0019170000014128163, + "p995": 0.002082999999402091, + "p999": 0.006874999999126885, + "sampleCount": 321118, + "median": 0.0014169999994919635 + }, + { + "id": "387541488_8_2", + "name": "1000-item list + 1000 detail docs", + "rank": 1, + "rme": 0.4037870850790554, + "samples": [], + "totalTime": 500.00060499948086, + "min": 0.0012909999968542252, + "max": 0.2921249999999418, + "hz": 697475.1560557854, + "period": 0.0014337428241243595, + "mean": 0.0014337428241243595, + "variance": 0.0000030425273625188378, + "sd": 0.0017442841977495633, + "sem": 0.0000029537083454397345, + "df": 348737, + "critical": 1.96, + "moe": 0.000005789268357061879, + "p75": 0.0014169999994919635, + "p99": 0.0018329999984416645, + "p995": 0.0019160000010742806, + "p999": 0.0023330000003625173, + "sampleCount": 348738, + "median": 0.0014159999991534278 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > subscribe / unsubscribe", + "benchmarks": [ + { + "id": "387541488_9_0", + "name": "subscribe then unsubscribe (flat)", + "rank": 1, + "rme": 6.6621110085642625, + "samples": [], + "totalTime": 500.0023090003451, + "min": 0.006375000000844011, + "max": 5.442458000001352, + "hz": 117391.45788616646, + "period": 0.0085185073769992, + "mean": 0.0085185073769992, + "variance": 0.004920929021142682, + "sd": 0.07014933371845154, + "sem": 0.0002895471519022564, + "df": 58695, + "critical": 1.96, + "moe": 0.0005675124177284225, + "p75": 0.006999999997788109, + "p99": 0.010249999999359716, + "p995": 0.01795799999672454, + "p999": 0.04991700000027777, + "sampleCount": 58696, + "median": 0.006790999999793712 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > write — applyUpdates append (pagination)", + "benchmarks": [ + { + "id": "387541488_10_0", + "name": "append page of 10 to 100-item list", + "rank": 1, + "rme": 5.133774956618757, + "samples": [], + "totalTime": 500.2277270000195, + "min": 0.2816670000029262, + "max": 7.568458000001556, + "hz": 2564.8318370803745, + "period": 0.38988910911926694, + "mean": 0.38988910911926694, + "variance": 0.13380439417157317, + "sd": 0.3657928295792212, + "sem": 0.01021225991966781, + "df": 1282, + "critical": 1.96, + "moe": 0.02001602944254891, + "p75": 0.33533400000305846, + "p99": 1.6872500000026776, + "p995": 2.619790999997349, + "p999": 5.874541999997746, + "sampleCount": 1283, + "median": 0.310791000003519 + }, + { + "id": "387541488_10_1", + "name": "append page of 100 to 1000-item list", + "rank": 2, + "rme": 2.5936868838615736, + "samples": [], + "totalTime": 500.343834999956, + "min": 6.708000000005995, + "max": 12.514208000000508, + "hz": 139.9037923591207, + "period": 7.147769071427943, + "mean": 7.147769071427943, + "variance": 0.6041860496044227, + "sd": 0.7772940560717178, + "sem": 0.09290440936824894, + "df": 69, + "critical": 1.9955, + "moe": 0.18539074889434076, + "p75": 7.327417000000423, + "p99": 12.514208000000508, + "p995": 12.514208000000508, + "p999": 12.514208000000508, + "sampleCount": 70, + "median": 6.895728999999847 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > list mutations", + "benchmarks": [ + { + "id": "387541488_11_0", + "name": "append to 10-item list", + "rank": 2, + "rme": 10.703110214707987, + "samples": [], + "totalTime": 500.0131679999613, + "min": 0.040791999999783, + "max": 23.82999999999447, + "hz": 18597.51021597239, + "period": 0.053770638563282215, + "mean": 0.053770638563282215, + "variance": 0.08017417288837565, + "sd": 0.2831504421475899, + "sem": 0.0029362911778471282, + "df": 9298, + "critical": 1.96, + "moe": 0.005755130708580371, + "p75": 0.043750000004365575, + "p99": 0.11549999999988358, + "p995": 0.20854200000030687, + "p999": 2.574416999996174, + "sampleCount": 9299, + "median": 0.0422919999982696 + }, + { + "id": "387541488_11_1", + "name": "append to 100-item list", + "rank": 4, + "rme": 3.4572878085253405, + "samples": [], + "totalTime": 500.080799000003, + "min": 0.38712500000110595, + "max": 3.185582999998587, + "hz": 2271.6329086652117, + "period": 0.4402119709507069, + "mean": 0.4402119709507069, + "variance": 0.06849532882462431, + "sd": 0.26171612259206406, + "sem": 0.007764997348646889, + "df": 1135, + "critical": 1.96, + "moe": 0.015219394803347903, + "p75": 0.411166999998386, + "p99": 2.264500000004773, + "p995": 2.377583000001323, + "p999": 2.5547500000029686, + "sampleCount": 1136, + "median": 0.39620799999829615 + }, + { + "id": "387541488_11_2", + "name": "append to 1000-item list", + "rank": 7, + "rme": 2.093116423151619, + "samples": [], + "totalTime": 510.0002089999907, + "min": 9.104707999998936, + "max": 11.674999999995634, + "hz": 99.99995901962646, + "period": 10.000004098039033, + "mean": 10.000004098039033, + "variance": 0.5536017179430932, + "sd": 0.7440441639735461, + "sem": 0.10418702244494274, + "df": 50, + "critical": 2.009, + "moe": 0.20931172809188994, + "p75": 10.510957999998936, + "p99": 11.674999999995634, + "p995": 11.674999999995634, + "p999": 11.674999999995634, + "sampleCount": 51, + "median": 9.76445799999783 + }, + { + "id": "387541488_11_3", + "name": "prepend to 100-item list", + "rank": 5, + "rme": 12.862228800094538, + "samples": [], + "totalTime": 500.4144260000685, + "min": 0.38833400000294205, + "max": 29.70799999999872, + "hz": 2000.3420125219632, + "period": 0.4999145114885799, + "mean": 0.4999145114885799, + "variance": 1.0773228802941461, + "sd": 1.0379416555347156, + "sem": 0.03280619809823268, + "df": 1000, + "critical": 1.96, + "moe": 0.06430014827253605, + "p75": 0.418708000004699, + "p99": 2.5376670000041486, + "p995": 2.7126250000001164, + "p999": 10.802792000002228, + "sampleCount": 1001, + "median": 0.3987909999996191 + }, + { + "id": "387541488_11_4", + "name": "remove from 10-item list", + "rank": 1, + "rme": 4.069919419285044, + "samples": [], + "totalTime": 500.03235899976426, + "min": 0.0337909999943804, + "max": 2.5991250000006403, + "hz": 24532.4123113516, + "period": 0.04076239985324564, + "mean": 0.04076239985324564, + "variance": 0.008788552138693955, + "sd": 0.09374727803351922, + "sem": 0.0008464269527519707, + "df": 12266, + "critical": 1.96, + "moe": 0.0016589968273938624, + "p75": 0.036041000006662216, + "p99": 0.0647499999977299, + "p995": 0.09558300000207964, + "p999": 2.146500000002561, + "sampleCount": 12267, + "median": 0.03512500000215368 + }, + { + "id": "387541488_11_5", + "name": "remove from 100-item list", + "rank": 3, + "rme": 3.7775482726626666, + "samples": [], + "totalTime": 500.0555869999225, + "min": 0.3340420000022277, + "max": 2.921041999994486, + "hz": 2589.7120913483577, + "period": 0.3861433104246506, + "mean": 0.3861433104246506, + "variance": 0.07172568463032569, + "sd": 0.26781651299037873, + "sem": 0.007442219363749403, + "df": 1294, + "critical": 1.96, + "moe": 0.014586749952948829, + "p75": 0.3569169999973383, + "p99": 2.337542000001122, + "p995": 2.4126669999968726, + "p999": 2.811375000004773, + "sampleCount": 1295, + "median": 0.3431249999994179 + }, + { + "id": "387541488_11_6", + "name": "remove from 1000-item list", + "rank": 6, + "rme": 3.5541868223977944, + "samples": [], + "totalTime": 502.045208999989, + "min": 7.343874999998661, + "max": 14.415291999997862, + "hz": 121.50300193383848, + "period": 8.230249327868671, + "mean": 8.230249327868671, + "variance": 1.3048972993195815, + "sd": 1.1423210141285074, + "sem": 0.14625921853079568, + "df": 60, + "critical": 2, + "moe": 0.29251843706159136, + "p75": 8.839958000004117, + "p99": 14.415291999997862, + "p995": 14.415291999997862, + "p999": 14.415291999997862, + "sampleCount": 61, + "median": 7.679333000000042 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > optimistic write + resolve", + "benchmarks": [ + { + "id": "387541488_12_0", + "name": "flat record", + "rank": 1, + "rme": 2.8611964416288744, + "samples": [], + "totalTime": 500.00944599955255, + "min": 0.013457999993988778, + "max": 3.4100000000034925, + "hz": 64278.78564523911, + "period": 0.015557232296190186, + "mean": 0.015557232296190186, + "variance": 0.0016576535012412707, + "sd": 0.040714291118000207, + "sem": 0.00022710355962986308, + "df": 32139, + "critical": 1.96, + "moe": 0.0004451229768745316, + "p75": 0.014333999999507796, + "p99": 0.02208299999620067, + "p995": 0.032707999998820014, + "p999": 0.09929200000624405, + "sampleCount": 32140, + "median": 0.014000000002852175 + }, + { + "id": "387541488_12_1", + "name": "100-item list", + "rank": 2, + "rme": 2.575858740047051, + "samples": [], + "totalTime": 500.14224899988767, + "min": 0.6634169999961159, + "max": 2.5168330000014976, + "hz": 1355.6143304345246, + "period": 0.7376729336281529, + "mean": 0.7376729336281529, + "variance": 0.06372199058396512, + "sd": 0.2524321504562466, + "sem": 0.009694598333582785, + "df": 677, + "critical": 1.96, + "moe": 0.01900141273382226, + "p75": 0.7000839999964228, + "p99": 2.1183749999981956, + "p995": 2.1748329999973066, + "p999": 2.5168330000014976, + "sampleCount": 678, + "median": 0.6802504999977828 + }, + { + "id": "387541488_12_2", + "name": "1000-item list", + "rank": 3, + "rme": 1.5398292065008632, + "samples": [], + "totalTime": 504.0318339999867, + "min": 11.544583000002604, + "max": 13.32862500000192, + "hz": 81.34406843834607, + "period": 12.293459365853334, + "mean": 12.293459365853334, + "variance": 0.3597034103744516, + "sd": 0.5997527910518229, + "sem": 0.0936656495817543, + "df": 40, + "critical": 2.021, + "moe": 0.18929827780472544, + "p75": 12.876625000004424, + "p99": 13.32862500000192, + "p995": 13.32862500000192, + "p999": 13.32862500000192, + "sampleCount": 41, + "median": 12.020834000002651 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > GC tick (unsubscribed records)", + "benchmarks": [ + { + "id": "387541488_13_0", + "name": "100 records", + "rank": 1, + "rme": 6.732637067537947, + "samples": [], + "totalTime": 500.1133679999766, + "min": 0.138916000003519, + "max": 4.455207999999402, + "hz": 5544.742807195127, + "period": 0.1803510162279036, + "mean": 0.1803510162279036, + "variance": 0.1064254075868371, + "sd": 0.3262290722587996, + "sem": 0.006195091515429192, + "df": 2772, + "critical": 1.96, + "moe": 0.012142379370241216, + "p75": 0.1482079999987036, + "p99": 1.206665999998222, + "p995": 3.8848329999964335, + "p999": 4.12216599999374, + "sampleCount": 2773, + "median": 0.1461249999993015 + }, + { + "id": "387541488_13_1", + "name": "1000 records", + "rank": 2, + "rme": 5.883023125036845, + "samples": [], + "totalTime": 500.14359100002184, + "min": 1.4874579999959678, + "max": 5.429250000001048, + "hz": 545.8432436455795, + "period": 1.8320278058608859, + "mean": 1.8320278058608859, + "variance": 0.8254974523125214, + "sd": 0.9085689034479011, + "sem": 0.05498909156933726, + "df": 272, + "critical": 1.96, + "moe": 0.10777861947590103, + "p75": 1.5874580000017886, + "p99": 5.298833000000741, + "p995": 5.3298329999961425, + "p999": 5.429250000001048, + "sampleCount": 273, + "median": 1.539708000003884 + }, + { + "id": "387541488_13_2", + "name": "10000 records", + "rank": 3, + "rme": 2.520593952270381, + "samples": [], + "totalTime": 525.1796660000109, + "min": 23.669458000003942, + "max": 29.394291999997222, + "hz": 38.082205566579546, + "period": 26.258983300000544, + "mean": 26.258983300000544, + "variance": 2.0001065531895215, + "sd": 1.41425123411278, + "sem": 0.31623618967391454, + "df": 19, + "critical": 2.093, + "moe": 0.6618823449875031, + "p75": 26.71166699999594, + "p99": 29.394291999997222, + "p995": 29.394291999997222, + "p999": 29.394291999997222, + "sampleCount": 20, + "median": 26.25945800000045 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > stale marking", + "benchmarks": [ + { + "id": "387541488_14_0", + "name": "markAllStale (100 records)", + "rank": 1, + "rme": 6.508177756301288, + "samples": [], + "totalTime": 500.00226099975407, + "min": 0.004999999997380655, + "max": 16.57183300000179, + "hz": 183299.17112123832, + "period": 0.0054555620403682935, + "mean": 0.0054555620403682935, + "variance": 0.0030075865696984746, + "sd": 0.05484146761072751, + "sem": 0.00018115187509819692, + "df": 91649, + "critical": 1.96, + "moe": 0.00035505767519246594, + "p75": 0.005167000002984423, + "p99": 0.006875000006402843, + "p995": 0.007250000002386514, + "p999": 0.024375000000873115, + "sampleCount": 91650, + "median": 0.005124999996041879 + }, + { + "id": "387541488_14_1", + "name": "markAllStale (1000 records)", + "rank": 3, + "rme": 0.3966207595344119, + "samples": [], + "totalTime": 500.00699799998984, + "min": 0.05762499999400461, + "max": 0.31950000000506407, + "hz": 16745.765626264634, + "period": 0.05971658879732352, + "mean": 0.05971658879732352, + "variance": 0.00012226715735023735, + "sd": 0.011057448048724324, + "sem": 0.00012084101431427861, + "df": 8372, + "critical": 1.96, + "moe": 0.00023684838805598606, + "p75": 0.058540999998513144, + "p99": 0.07729100000142353, + "p995": 0.08358399999997346, + "p999": 0.26491600000008475, + "sampleCount": 8373, + "median": 0.058250000001862645 + }, + { + "id": "387541488_14_2", + "name": "markAllStale (10000 records)", + "rank": 5, + "rme": 0.44887807625876036, + "samples": [], + "totalTime": 500.2653370000189, + "min": 0.5686669999995502, + "max": 0.9167910000032862, + "hz": 1715.0898464107809, + "period": 0.5830598333333554, + "mean": 0.5830598333333554, + "variance": 0.0015298828716106033, + "sd": 0.039113717179662215, + "sem": 0.0013353202874001532, + "df": 857, + "critical": 1.96, + "moe": 0.0026172277633043, + "p75": 0.5770840000041062, + "p99": 0.8002090000009048, + "p995": 0.8396250000005239, + "p999": 0.9167910000032862, + "sampleCount": 858, + "median": 0.5716455000001588 + }, + { + "id": "387541488_14_3", + "name": "markTypeStale User (100 records)", + "rank": 2, + "rme": 0.4007348903782379, + "samples": [], + "totalTime": 500.0060390003273, + "min": 0.006999999997788109, + "max": 0.3080409999965923, + "hz": 137124.3438120857, + "period": 0.007292651123788739, + "mean": 0.007292651123788739, + "variance": 0.000015242733527074892, + "sd": 0.0039041943505766837, + "sem": 0.000014910304840092926, + "df": 68562, + "critical": 1.96, + "moe": 0.000029224197486582134, + "p75": 0.007208000002719928, + "p99": 0.009124999996856786, + "p995": 0.009916999995766673, + "p999": 0.013833000004524365, + "sampleCount": 68563, + "median": 0.00712500000372529 + }, + { + "id": "387541488_14_4", + "name": "markTypeStale User (1000 records)", + "rank": 4, + "rme": 0.40044254048809613, + "samples": [], + "totalTime": 500.0142159999523, + "min": 0.07654099999490427, + "max": 0.3632499999948777, + "hz": 12637.640686601206, + "period": 0.07912869378065394, + "mean": 0.07912869378065394, + "variance": 0.0001651522983276199, + "sd": 0.012851159415695531, + "sem": 0.00016166579164811058, + "df": 6318, + "critical": 1.96, + "moe": 0.00031686495163029673, + "p75": 0.07766699999774573, + "p99": 0.09829200000240235, + "p995": 0.10312500000145519, + "p999": 0.2970839999979944, + "sampleCount": 6319, + "median": 0.07733300000109011 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > subscribe/unsubscribe churn", + "benchmarks": [ + { + "id": "387541488_15_0", + "name": "10 cycles, flat record", + "rank": 1, + "rme": 5.90275176509323, + "samples": [], + "totalTime": 500.02123199978087, + "min": 0.06387499999982538, + "max": 3.74304100000154, + "hz": 12719.459880861194, + "period": 0.07861969056600328, + "mean": 0.07861969056600328, + "variance": 0.035654690652669956, + "sd": 0.18882449696125223, + "sem": 0.0023677169247936706, + "df": 6359, + "critical": 1.96, + "moe": 0.004640725172595594, + "p75": 0.0662499999962165, + "p99": 0.0894590000025346, + "p995": 0.10179100000095787, + "p999": 3.0129159999996773, + "sampleCount": 6360, + "median": 0.0654999999969732 + }, + { + "id": "387541488_15_1", + "name": "100 cycles, flat record", + "rank": 2, + "rme": 8.813851010609481, + "samples": [], + "totalTime": 502.52813199994125, + "min": 0.6067919999986771, + "max": 17.392249999997148, + "hz": 1287.490110106066, + "period": 0.776704995363124, + "mean": 0.776704995363124, + "variance": 0.7892884441883508, + "sd": 0.8884190701399598, + "sem": 0.034927357695544406, + "df": 646, + "critical": 1.96, + "moe": 0.06845762108326703, + "p75": 0.6327500000043074, + "p99": 3.8661670000001322, + "p995": 3.9598750000004657, + "p999": 17.392249999997148, + "sampleCount": 647, + "median": 0.6187080000017886 + }, + { + "id": "387541488_15_2", + "name": "10 cycles, 100-item list", + "rank": 3, + "rme": 4.599642351535599, + "samples": [], + "totalTime": 507.1397400000278, + "min": 4.571750000002794, + "max": 7.966958000004524, + "hz": 187.3250950517007, + "period": 5.338313052631872, + "mean": 5.338313052631872, + "variance": 1.4524754397252848, + "sd": 1.2051868899574392, + "sem": 0.12364956593131655, + "df": 94, + "critical": 1.9858, + "moe": 0.24554330802640842, + "p75": 4.810000000004948, + "p99": 7.966958000004524, + "p995": 7.966958000004524, + "p999": 7.966958000004524, + "sampleCount": 95, + "median": 4.674584000000323 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > serialize", + "benchmarks": [ + { + "id": "387541488_16_0", + "name": "100-item list", + "rank": 1, + "rme": 5.967716082253165, + "samples": [], + "totalTime": 500.0314450000951, + "min": 0.25791600000229664, + "max": 14.909999999996217, + "hz": 3523.778389576409, + "period": 0.2837862911464785, + "mean": 0.2837862911464785, + "variance": 0.13155061779577928, + "sd": 0.3626990733318453, + "sem": 0.008640591906111321, + "df": 1761, + "critical": 1.96, + "moe": 0.01693556013597819, + "p75": 0.2671249999984866, + "p99": 0.6832080000021961, + "p995": 0.7463329999955022, + "p999": 3.045291999995243, + "sampleCount": 1762, + "median": 0.2609169999996084 + }, + { + "id": "387541488_16_1", + "name": "1000-item list", + "rank": 2, + "rme": 0.8424193185900666, + "samples": [], + "totalTime": 500.1662899999428, + "min": 4.584750000001804, + "max": 5.68683400000009, + "hz": 209.93018142028725, + "period": 4.763488476189932, + "mean": 4.763488476189932, + "variance": 0.04298957430952047, + "sd": 0.20733927343733138, + "sem": 0.02023424120725876, + "df": 104, + "critical": 1.9832, + "moe": 0.04012854716223557, + "p75": 4.813999999998487, + "p99": 5.3151250000009895, + "p995": 5.68683400000009, + "p999": 5.68683400000009, + "sampleCount": 105, + "median": 4.6681660000031115 + } + ] + }, + { + "fullName": "packages/houdini/src/runtime/cache/benchmarks/cache.bench.ts > hydrate", + "benchmarks": [ + { + "id": "387541488_17_0", + "name": "100-item list", + "rank": 2, + "rme": 0.5085274563696642, + "samples": [], + "totalTime": 500.00031999538623, + "min": 0.0005000000019208528, + "max": 0.3809160000018892, + "hz": 1603792.973587296, + "period": 0.0006235218737511005, + "mean": 0.0006235218737511005, + "variance": 0.0000020986433799079355, + "sd": 0.001448669520597412, + "sem": 0.000001617744859436194, + "df": 801896, + "critical": 1.96, + "moe": 0.00000317077992449494, + "p75": 0.0006250000005820766, + "p99": 0.0008750000051804818, + "p995": 0.0009589999972376972, + "p999": 0.0012080000014975667, + "sampleCount": 801897, + "median": 0.0005840000012540258 + }, + { + "id": "387541488_17_1", + "name": "1000-item list", + "rank": 1, + "rme": 0.512016292535792, + "samples": [], + "totalTime": 500.0004549905352, + "min": 0.0004999999946448952, + "max": 0.36083299999882, + "hz": 1604088.540309793, + "period": 0.0006234069846336991, + "mean": 0.0006234069846336991, + "variance": 0.0000021271468933751307, + "sd": 0.0014584741661665217, + "sem": 0.000001628543535780939, + "df": 802044, + "critical": 1.96, + "moe": 0.0000031919453301306402, + "p75": 0.0006250000005820766, + "p99": 0.0008749999979045242, + "p995": 0.0009589999972376972, + "p999": 0.001167000002169516, + "sampleCount": 802045, + "median": 0.0005840000012540258 + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/perf/compare.js b/perf/compare.js new file mode 100644 index 0000000000..8e32fac701 --- /dev/null +++ b/perf/compare.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Compare two vitest benchmark JSON output files and fail on regressions. +// +// Usage: +// node perf/compare.js [baseline] [current] +// +// Defaults: +// baseline = perf/benchmark.json (committed reference run) +// current = perf/benchmark.current.json (produced by pnpm bench:check) +// +// Environment: +// BENCH_THRESHOLD — minimum regression threshold in percent (default: 5). +// A benchmark is only flagged if the drop exceeds BOTH +// this floor AND the noise band (see RME_MULTIPLIER). +// RME_MULTIPLIER — how many combined RME widths must be exceeded before a +// change counts as a real regression (default: 2). +// Effective per-benchmark threshold is: +// max(BENCH_THRESHOLD, RME_MULTIPLIER × combinedRme) +// where combinedRme = sqrt(rme_base² + rme_curr²). +// At 2× you need ~95% confidence the change is real. +// +// Exit codes: +// 0 all benchmarks within threshold +// 1 one or more regressions detected + +import { copyFileSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const floorThreshold = parseFloat(process.env.BENCH_THRESHOLD ?? '7') / 100 +const rmeMultiplier = parseFloat(process.env.RME_MULTIPLIER ?? '2') +const root = resolve(fileURLToPath(import.meta.url), '..', '..') +const [, , baselineArg, currentArg] = process.argv +const baselinePath = resolve(root, baselineArg ?? 'perf/benchmark.json') +const currentPath = resolve(root, currentArg ?? 'perf/benchmark.current.json') + +function flattenReport(path) { + const report = JSON.parse(readFileSync(path, 'utf8')) + const flat = new Map() + for (const file of report.files) { + for (const group of file.groups) { + // Strip the leading "filepath > " prefix so keys are stable across + // machines and worktrees. + const suiteName = group.fullName.split(' > ').slice(1).join(' > ') + for (const bench of group.benchmarks) { + flat.set(`${suiteName} > ${bench.name}`, bench) + } + } + } + return flat +} + +let baseline +try { + baseline = flattenReport(baselinePath) +} catch { + // No baseline yet — treat the current run as the initial snapshot. + copyFileSync(currentPath, baselinePath) + console.log(`no baseline found — wrote initial snapshot to ${baselinePath}\n`) + process.exit(0) +} + +const current = flattenReport(currentPath) + +const regressions = [] +const rows = [] + +for (const [key, curr] of current) { + const base = baseline.get(key) + if (!base) { + rows.push({ key, baseHz: null, currHz: curr.hz, rme: null, effectiveThreshold: null, delta: null, status: 'new' }) + continue + } + + const delta = (curr.hz - base.hz) / base.hz + + // Per-benchmark noise band: sqrt(rme_base² + rme_curr²), expressed as a fraction. + // rme is stored as a percentage in the JSON (e.g. 1.5 means 1.5%). + const combinedRme = Math.sqrt((base.rme / 100) ** 2 + (curr.rme / 100) ** 2) + const effectiveThreshold = Math.max(floorThreshold, rmeMultiplier * combinedRme) + + const status = + delta < -effectiveThreshold ? 'REGRESS' : delta > effectiveThreshold ? 'improve' : 'ok' + if (status === 'REGRESS') regressions.push({ key, delta, effectiveThreshold }) + rows.push({ key, baseHz: base.hz, currHz: curr.hz, rme: combinedRme, effectiveThreshold, delta, status }) +} + +// Format helpers +const fmtHz = (n) => + n == null ? ' -' : Math.round(n).toLocaleString('en-US').padStart(12) +const fmtPct = (d) => + d == null ? ' -' : `${d >= 0 ? '+' : ''}${(d * 100).toFixed(1)}%`.padStart(7) + +const nameWidth = Math.max(25, ...rows.map((r) => r.key.length)) +const header = `${'benchmark'.padEnd(nameWidth)} ${'baseline (hz)'.padStart(12)} ${'current (hz)'.padStart(12)} ${'change'.padStart(7)} ${'threshold'.padStart(9)} status` +const rule = '-'.repeat(header.length) + +console.log(`\ncomparing:\n baseline: ${baselinePath}\n current: ${currentPath}`) +console.log(`floor threshold: ${(floorThreshold * 100).toFixed(0)}% rme multiplier: ${rmeMultiplier}×\n`) +console.log(header) +console.log(rule) + +for (const r of rows) { + const flag = + r.status === 'REGRESS' + ? ' ← REGRESSION' + : r.status === 'new' + ? ' (new)' + : '' + const thresh = r.effectiveThreshold == null ? ' -' : `±${(r.effectiveThreshold * 100).toFixed(1)}%`.padStart(9) + console.log( + `${r.key.padEnd(nameWidth)} ${fmtHz(r.baseHz)} ${fmtHz(r.currHz)} ${fmtPct(r.delta)} ${thresh} ${r.status}${flag}` + ) +} + +console.log(rule) + +if (regressions.length > 0) { + console.error(`\n${regressions.length} regression${regressions.length === 1 ? '' : 's'} detected:\n`) + for (const { key, delta, effectiveThreshold } of regressions) { + console.error(` ${key} (${(delta * 100).toFixed(1)}%, threshold ±${(effectiveThreshold * 100).toFixed(1)}%)`) + } + console.error() + process.exit(1) +} else { + console.log(`\nall ${rows.filter((r) => r.status !== 'new').length} benchmarks within noise band.`) + copyFileSync(currentPath, baselinePath) + console.log(`baseline updated: ${baselinePath}\n`) +} diff --git a/perf/merge.js b/perf/merge.js new file mode 100644 index 0000000000..f298574b1a --- /dev/null +++ b/perf/merge.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Merge multiple vitest benchmark JSON runs into one by taking the median hz +// (and median rme) per benchmark. This smooths out single-run scheduler noise. +// +// Usage: +// node perf/merge.js run1.json run2.json [run3.json ...] > merged.json + +import { readFileSync } from 'node:fs' + +const files = process.argv.slice(2) +if (files.length < 2) { + console.error('usage: merge.js run1.json run2.json [run3.json ...]') + process.exit(1) +} + +const reports = files.map((f) => JSON.parse(readFileSync(f, 'utf8'))) + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +// Build a flat map of name → [bench across runs] for each report +function flatBenchmarks(report) { + const map = new Map() + for (const file of report.files) { + for (const group of file.groups) { + for (const bench of group.benchmarks) { + const key = `${group.fullName} > ${bench.name}` + map.set(key, bench) + } + } + } + return map +} + +const maps = reports.map(flatBenchmarks) + +// Use first report as the structural template, overwrite hz/rme with medians +const merged = JSON.parse(JSON.stringify(reports[0])) + +for (const file of merged.files) { + for (const group of file.groups) { + for (const bench of group.benchmarks) { + const key = `${group.fullName} > ${bench.name}` + const allHz = maps.map((m) => m.get(key)?.hz).filter((v) => v != null) + const allRme = maps.map((m) => m.get(key)?.rme).filter((v) => v != null) + if (allHz.length > 0) bench.hz = median(allHz) + if (allRme.length > 0) bench.rme = median(allRme) + } + } +} + +process.stdout.write(JSON.stringify(merged, null, 4) + '\n') diff --git a/perf/watch-bench.sh b/perf/watch-bench.sh new file mode 100755 index 0000000000..1c6419ff85 --- /dev/null +++ b/perf/watch-bench.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +# Usage: pnpm watch-bench [category] +# Categories: core, subscriptions, lists, multi-doc, optimistic, gc, ssr +# Omit category to watch all suites. +BENCH=${1:-all} BENCH_QUICK=1 vitest bench packages/houdini/src/runtime/cache/benchmarks/ --watch diff --git a/plugins/fs.go b/plugins/fs.go index 5d7ec622c5..d14a8baacd 100644 --- a/plugins/fs.go +++ b/plugins/fs.go @@ -177,12 +177,34 @@ func writeFileIfChanged( return false, err } - // Write file using afero (simplified atomic write for filesystem abstraction) - // For in-memory filesystems, this is effectively atomic - // For OS filesystem, afero.WriteFile handles the basic write operation - if err := afero.WriteFile(filesystem, dst, data, mode); err != nil { + if err := WriteFile(filesystem, dst, data, mode); err != nil { return false, err } return true, nil } + +// WriteFile is a drop-in replacement for afero.WriteFile that writes atomically. +// +// It writes data to a sibling temp file first, then renames it into place. +// On POSIX (Linux/macOS) rename is a single atomic syscall, so concurrent readers +// (e.g. Vite's dev server loading a hot-updated module) always see either the +// complete old content or the complete new content — never a partial write. +// On in-memory afero filesystems (used in tests) Rename is mutex-protected and +// equally safe. On Windows, os.Rename replaces the destination atomically when +// the destination is not open; that is sufficient for our use-case. +// +// Use this instead of afero.WriteFile for any file in the generated output +// directory that Vite may load concurrently while the pipeline is running. +func WriteFile(filesystem afero.Fs, dst string, data []byte, mode iofs.FileMode) error { + tmp := dst + ".houdini_tmp" + if err := afero.WriteFile(filesystem, tmp, data, mode); err != nil { + _ = filesystem.Remove(tmp) + return err + } + if err := filesystem.Rename(tmp, dst); err != nil { + _ = filesystem.Remove(tmp) + return err + } + return nil +} diff --git a/plugins/graphql/conventions.go b/plugins/graphql/conventions.go index 64f73a3660..85ab6211bc 100644 --- a/plugins/graphql/conventions.go +++ b/plugins/graphql/conventions.go @@ -20,6 +20,10 @@ const AllListsDirective = "allLists" const ParentIDDirective = "parentID" +const IncludeListIDDirective = "includeListID" + +const ListIDDirective = "listID" + const WhenDirective = "when" const WhenNotDirective = "when_not" @@ -38,6 +42,10 @@ const LoadingDirective = "loading" const RequiredDirective = "required" +const IncludeDirective = "include" + +const SkipDirective = "skip" + const ComponentFieldDirective = "componentField" const RuntimeScalarDirective = "__houdini__runtimeScalar" @@ -50,6 +58,10 @@ const ListOperationSuffixToggle = "_toggle" const ListOperationSuffixDelete = "_delete" +const ListOperationSuffixUpsert = "_upsert" + +const ListOperationSuffixUpdate = "_update" + const PaginationModeInfinite = "Infinite" const PaginationModeSinglePage = "SinglePage" diff --git a/plugins/handlers.go b/plugins/handlers.go index 2d0949012a..bbeb1d2c80 100644 --- a/plugins/handlers.go +++ b/plugins/handlers.go @@ -350,7 +350,7 @@ func handleIndexFile[PluginConfig any](plugin HoudiniPlugin[PluginConfig]) HookH } newContent := string(existingContent) + "\n" + content - return nil, afero.WriteFile(plugin.Filesystem(), targetPath, []byte(newContent), 0644) + return nil, WriteFile(plugin.Filesystem(), targetPath, []byte(newContent), 0644) } } diff --git a/plugins/run.go b/plugins/run.go index 862ababfa5..1868056dda 100644 --- a/plugins/run.go +++ b/plugins/run.go @@ -147,6 +147,14 @@ func Run[PluginConfig any](plugin HoudiniPlugin[PluginConfig]) error { } } + var includeStaticRuntime any + if staticRuntime, ok := plugin.(StaticRuntime); ok { + includeStaticRuntime, err = staticRuntime.StaticRuntime(ctx) + if err != nil { + return err + } + } + var configModule any if configurer, ok := plugin.(Config); ok { configModule, err = configurer.Config(ctx) @@ -173,17 +181,18 @@ func Run[PluginConfig any](plugin HoudiniPlugin[PluginConfig]) error { // insert the plugin metadata err = db.ExecQuery(ctx, `INSERT INTO plugins ( - name, hooks, port, plugin_order, include_runtime, config_module, client_plugins + name, hooks, port, plugin_order, include_runtime, include_static_runtime, config_module, client_plugins ) VALUES - ($name, $hooks, $port, $plugin_order, $include_runtime, $config_module, $client_plugins)`, + ($name, $hooks, $port, $plugin_order, $include_runtime, $include_static_runtime, $config_module, $client_plugins)`, map[string]any{ - "name": cmp(pluginKey, plugin.Name()), - "hooks": string(hooksStr), - "port": port, - "plugin_order": string(plugin.Order()), - "include_runtime": includeRuntime, - "config_module": configModule, - "client_plugins": clientPlugins, + "name": cmp(pluginKey, plugin.Name()), + "hooks": string(hooksStr), + "port": port, + "plugin_order": string(plugin.Order()), + "include_runtime": includeRuntime, + "include_static_runtime": includeStaticRuntime, + "config_module": configModule, + "client_plugins": clientPlugins, }, ) if err != nil { diff --git a/plugins/stdio.go b/plugins/stdio.go index 3e7c18646b..32999721a8 100644 --- a/plugins/stdio.go +++ b/plugins/stdio.go @@ -27,13 +27,14 @@ type StdioInbound struct { // StdioRegister is written to stdout once on startup. type StdioRegister struct { - Type string `json:"type"` // always "register" - Name string `json:"name"` - Hooks []string `json:"hooks"` - Order string `json:"order"` - IncludeRuntime any `json:"includeRuntime,omitempty"` - ConfigModule any `json:"configModule,omitempty"` - ClientPlugins any `json:"clientPlugins,omitempty"` + Type string `json:"type"` // always "register" + Name string `json:"name"` + Hooks []string `json:"hooks"` + Order string `json:"order"` + IncludeRuntime any `json:"includeRuntime,omitempty"` + IncludeStaticRuntime any `json:"includeStaticRuntime,omitempty"` + ConfigModule any `json:"configModule,omitempty"` + ClientPlugins any `json:"clientPlugins,omitempty"` } // StdioResponse is written to stdout in reply to a "request" message. @@ -172,6 +173,15 @@ func runStdio[PluginConfig any](ctx context.Context, plugin HoudiniPlugin[Plugin includeRuntime = rt } + var includeStaticRuntime any + if sr, ok := plugin.(StaticRuntime); ok { + rt, err := sr.StaticRuntime(ctx) + if err != nil { + return err + } + includeStaticRuntime = rt + } + var configModule any if cfg, ok := plugin.(Config); ok { mod, err := cfg.Config(ctx) @@ -195,13 +205,14 @@ func runStdio[PluginConfig any](ctx context.Context, plugin HoudiniPlugin[Plugin } if err := writeStdio(StdioRegister{ - Type: "register", - Name: cmp(pluginKey, plugin.Name()), - Hooks: hooks, - Order: string(plugin.Order()), - IncludeRuntime: includeRuntime, - ConfigModule: configModule, - ClientPlugins: clientPlugins, + Type: "register", + Name: cmp(pluginKey, plugin.Name()), + Hooks: hooks, + Order: string(plugin.Order()), + IncludeRuntime: includeRuntime, + IncludeStaticRuntime: includeStaticRuntime, + ConfigModule: configModule, + ClientPlugins: clientPlugins, }); err != nil { return err } diff --git a/plugins/tests/run.go b/plugins/tests/run.go index 1f6cc38ad9..2dfc49cef6 100644 --- a/plugins/tests/run.go +++ b/plugins/tests/run.go @@ -124,15 +124,17 @@ func RunTable[PluginConfig any, PluginType plugins.HoudiniPlugin[PluginConfig]]( for _, test := range table.Tests { t.Run(test.Name, func(t *testing.T) { projectConfig := plugins.ProjectConfig{ - ProjectRoot: "/project", - SchemaPath: "schema.graphql", - DefaultKeys: []string{"id"}, - TypeConfig: make(map[string]plugins.TypeConfig), - DefaultCachePolicy: "CacheOrNetwork", - DefaultPartial: false, - DefaultPaginateMode: "Infinite", - RuntimeDir: ".houdini", - PersistedQueriesPath: "persisted_queries.json", + ProjectRoot: "/project", + SchemaPath: "schema.graphql", + DefaultKeys: []string{"id"}, + TypeConfig: make(map[string]plugins.TypeConfig), + DefaultCachePolicy: "CacheOrNetwork", + DefaultPartial: false, + DefaultPaginateMode: "Infinite", + // match the real-world default of defaultFragmentMasking: 'enable' + DefaultFragmentMasking: true, + RuntimeDir: ".houdini", + PersistedQueriesPath: "persisted_queries.json", } if table.ProjectConfig.TypeConfig != nil { diff --git a/plugins/websocket.go b/plugins/websocket.go index 79222b699e..c619649c60 100644 --- a/plugins/websocket.go +++ b/plugins/websocket.go @@ -32,10 +32,19 @@ var ( wsMutex = sync.Mutex{} activeConns = make(map[*websocket.Conn]bool) connMutex = sync.Mutex{} + connWriteMu sync.Map // *websocket.Conn -> *sync.Mutex, serializes writes per connection shutdownChannel = make(chan struct{}) shutdownOnce = sync.Once{} ) +func writeJSON(conn *websocket.Conn, v any) error { + if mu, ok := connWriteMu.Load(conn); ok { + mu.(*sync.Mutex).Lock() + defer mu.(*sync.Mutex).Unlock() + } + return conn.WriteJSON(v) +} + func registerWSHandler(hookName string, handler HookHandler) { wsMutex.Lock() defer wsMutex.Unlock() @@ -66,7 +75,7 @@ func registerWSHandler(hookName string, handler HookHandler) { Type: "response", Result: result, } - _ = conn.WriteJSON(response) + _ = writeJSON(conn, response) } } @@ -75,9 +84,11 @@ func HandleWebSocketConnection(conn *websocket.Conn) { connMutex.Lock() activeConns[conn] = true connMutex.Unlock() + connWriteMu.Store(conn, &sync.Mutex{}) // Ensure cleanup when connection ends defer func() { + connWriteMu.Delete(conn) connMutex.Lock() delete(activeConns, conn) connCount := len(activeConns) @@ -122,14 +133,14 @@ func sendErrorResponse(conn *websocket.Conn, id string, err error) { // if the error is a list of plugin errors then we should serialize the full list if pluginErr, ok := err.(*ErrorList); ok { response := WebSocketResponse{ID: id, Type: "response", Error: pluginErr.GetItems()} - conn.WriteJSON(response) + writeJSON(conn, response) return } // error could be just a single error if pluginErr, ok := err.(*Error); ok { response := WebSocketResponse{ID: id, Type: "response", Error: pluginErr} - conn.WriteJSON(response) + writeJSON(conn, response) return } @@ -139,7 +150,7 @@ func sendErrorResponse(conn *websocket.Conn, id string, err error) { Type: "response", Error: map[string]string{"message": err.Error()}, } - conn.WriteJSON(response) + writeJSON(conn, response) } // WaitForShutdown blocks until all WebSocket connections are closed diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000000..5fca3f84bc --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 86c7cb74d0..be3a2c7d89 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,16 +3,24 @@ import path from 'path' import { defineConfig } from 'vite' export default defineConfig({ - test: { - include: ['./packages/*/src/**/*.test.{ts,js}', './site/**/*.test.{ts,js}'], - setupFiles: [path.resolve('./vitest.setup.ts')], + resolve: { alias: { $houdini: path.resolve('./packages/houdini/src'), 'houdini/test': path.resolve('./packages/houdini/legacy/test'), 'houdini/vite': path.resolve('./packages/houdini/src/vite'), 'houdini/codegen': path.resolve('./packages/houdini/src/codegen'), + 'houdini/runtime': path.resolve('./packages/houdini/src/runtime'), houdini: path.resolve('./packages/houdini/src/lib'), }, + }, + test: { + include: [ + './packages/*/src/**/*.test.{ts,js}', + './packages/houdini-react/runtime/**/*.test.{ts,js}', + './packages/houdini-core/runtime/public/**/*.test.{ts,js}', + './site/**/*.test.{ts,js}', + ], + setupFiles: [path.resolve('./vitest.setup.ts')], coverage: { provider: 'v8', }, From 2a2319e70528c154cdf895287a08e3b433530689 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 18:53:53 +0530 Subject: [PATCH 07/22] moved to houdini-runtime first iteration, only reporting graphql requests now, cache got to come later, the config goes in houdini.client.ts. gotcha-> had to write css as .js string since we dont have a way to copy css files in runtime. --- e2e/react/houdini.config.ts | 4 +- e2e/react/src/+client.ts | 6 +- packages/houdini-react/package/lib/index.ts | 14 +++++ .../houdini-react/runtime/clientPlugin.ts | 25 ++++---- .../runtime/devtools}/HoudiniDevtools.tsx | 0 .../houdini-react/runtime/devtools}/plugin.ts | 59 +++++++++++++------ .../houdini-react/runtime/devtools}/store.ts | 0 .../houdini-react/runtime/devtools/styles.ts | 5 +- .../houdini-react/runtime/devtools}/type.ts | 0 9 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 packages/houdini-react/package/lib/index.ts rename {e2e/react/plugins/devtool => packages/houdini-react/runtime/devtools}/HoudiniDevtools.tsx (100%) rename {e2e/react/plugins/devtool => packages/houdini-react/runtime/devtools}/plugin.ts (69%) rename {e2e/react/plugins/devtool => packages/houdini-react/runtime/devtools}/store.ts (100%) rename e2e/react/plugins/devtool/styles.css => packages/houdini-react/runtime/devtools/styles.ts (99%) rename {e2e/react/plugins/devtool => packages/houdini-react/runtime/devtools}/type.ts (100%) diff --git a/e2e/react/houdini.config.ts b/e2e/react/houdini.config.ts index a97aa81716..423e21c610 100644 --- a/e2e/react/houdini.config.ts +++ b/e2e/react/houdini.config.ts @@ -30,7 +30,9 @@ const config: ConfigFile = { }, plugins: { - 'houdini-react': {}, + 'houdini-react': { + devtools: true, + }, './plugins/node-plugin.mjs': {}, }, diff --git a/e2e/react/src/+client.ts b/e2e/react/src/+client.ts index 58c4ed115d..6e9e951288 100644 --- a/e2e/react/src/+client.ts +++ b/e2e/react/src/+client.ts @@ -1,8 +1,4 @@ import { HoudiniClient } from '$houdini' -import devToolPlugin from '../plugins/devtool/plugin' - // Export the Houdini client -export default new HoudiniClient({ - plugins: [devToolPlugin], -}) +export default new HoudiniClient() diff --git a/packages/houdini-react/package/lib/index.ts b/packages/houdini-react/package/lib/index.ts new file mode 100644 index 0000000000..7bc373d995 --- /dev/null +++ b/packages/houdini-react/package/lib/index.ts @@ -0,0 +1,14 @@ +declare module 'houdini' { + // @ts-ignore + interface HoudiniPluginConfig { + 'houdini-react': HoudiniReactConfig + } +} + +export type HoudiniReactConfig = { + /** + * Show the Houdini React devtools overlay in development. + * @default false + */ + devtools?: boolean +} diff --git a/packages/houdini-react/runtime/clientPlugin.ts b/packages/houdini-react/runtime/clientPlugin.ts index e81c02a802..4cc0a97f6f 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/plugin.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/e2e/react/plugins/devtool/HoudiniDevtools.tsx b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx similarity index 100% rename from e2e/react/plugins/devtool/HoudiniDevtools.tsx rename to packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx diff --git a/e2e/react/plugins/devtool/plugin.ts b/packages/houdini-react/runtime/devtools/plugin.ts similarity index 69% rename from e2e/react/plugins/devtool/plugin.ts rename to packages/houdini-react/runtime/devtools/plugin.ts index 005b7e502c..ebf2879a77 100644 --- a/e2e/react/plugins/devtool/plugin.ts +++ b/packages/houdini-react/runtime/devtools/plugin.ts @@ -4,7 +4,7 @@ import React from 'react' import { createRoot, type Root } from 'react-dom/client' import { HoudiniDevtools } from './HoudiniDevtools' -import styles from './styles.css?inline' +import styles from './styles.js' import { addRequestEvent, createRequest, failRequest, succeedRequest } from './store' import type { RequestKind } from './type' @@ -80,14 +80,23 @@ function normalizeError(error: unknown): Error { return new Error(typeof error === 'string' ? error : JSON.stringify(error)) } +function enabled(ctx: { config: { plugins?: Record } }) { + return ctx.config.plugins?.['houdini-react']?.devtools === true +} + const devToolPlugin: ClientPlugin = () => { + if (typeof window === 'undefined' || (import.meta as any).env?.DEV === false) { + return {} + } + return { start(ctx, { next }) { - if (!isRequestKind(ctx.artifact.kind)) { + if (!enabled(ctx) || !isRequestKind(ctx.artifact.kind)) { next(ctx) return } + scheduleMountOverlay() createRequest(ctx, ctx.artifact.kind) addRequestEvent(ctx, 'start') @@ -95,39 +104,51 @@ const devToolPlugin: ClientPlugin = () => { next(ctx) }, beforeNetwork(ctx, { next }) { - addRequestEvent(ctx, 'beforeNetwork') - renderOverlay() + if (enabled(ctx)) { + addRequestEvent(ctx, 'beforeNetwork') + renderOverlay() + } next(ctx) }, network(ctx, { next }) { - addRequestEvent(ctx, 'network') - renderOverlay() + if (enabled(ctx)) { + addRequestEvent(ctx, 'network') + renderOverlay() + } next(ctx) }, afterNetwork(ctx, { resolve }) { - addRequestEvent(ctx, 'afterNetwork') - renderOverlay() + if (enabled(ctx)) { + addRequestEvent(ctx, 'afterNetwork') + renderOverlay() + } 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) + if (enabled(ctx)) { + addRequestEvent(ctx, 'end') + if (value.errors?.length) { + failRequest(ctx, new Error(value.errors.map((error) => error.message).join('\n'))) + } else { + succeedRequest(ctx, value) + } + renderOverlay() } - renderOverlay() resolve(ctx) }, catch(ctx, { error }) { - addRequestEvent(ctx, 'catch') - failRequest(ctx, normalizeError(error)) - renderOverlay() + if (enabled(ctx)) { + addRequestEvent(ctx, 'catch') + failRequest(ctx, normalizeError(error)) + renderOverlay() + } throw error }, cleanup(ctx) { - addRequestEvent(ctx, 'cleanup') - renderOverlay() + if (enabled(ctx)) { + addRequestEvent(ctx, 'cleanup') + renderOverlay() + } }, } } diff --git a/e2e/react/plugins/devtool/store.ts b/packages/houdini-react/runtime/devtools/store.ts similarity index 100% rename from e2e/react/plugins/devtool/store.ts rename to packages/houdini-react/runtime/devtools/store.ts diff --git a/e2e/react/plugins/devtool/styles.css b/packages/houdini-react/runtime/devtools/styles.ts similarity index 99% rename from e2e/react/plugins/devtool/styles.css rename to packages/houdini-react/runtime/devtools/styles.ts index a0f2288581..ad9e084039 100644 --- a/e2e/react/plugins/devtool/styles.css +++ b/packages/houdini-react/runtime/devtools/styles.ts @@ -1,4 +1,4 @@ -.hdt { +const styles = `.hdt { --hdt-bg: #101113; --hdt-panel: #17191c; --hdt-header: #1f252c; @@ -345,3 +345,6 @@ .hdt-trigger * { color: inherit !important; } +` + +export default styles diff --git a/e2e/react/plugins/devtool/type.ts b/packages/houdini-react/runtime/devtools/type.ts similarity index 100% rename from e2e/react/plugins/devtool/type.ts rename to packages/houdini-react/runtime/devtools/type.ts From a36c2f0dbf3ca69ce0346ae9aaacbfd2888ef7c0 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 20:13:10 +0530 Subject: [PATCH 08/22] improved config options and now copyibg css files into runtime .houdini --- e2e/react/houdini.config.ts | 5 ++- packages/_scripts/buildNode.js | 5 ++- .../react-typescript/houdini.config.js | 2 +- .../templates/react/houdini.config.js | 2 +- packages/houdini-react/package/lib/index.ts | 11 +++++-- .../houdini-react/runtime/clientPlugin.ts | 2 +- .../runtime/devtools/HoudiniDevtools.tsx | 8 ----- .../houdini-react/runtime/devtools/plugin.ts | 31 ++++++++++++++++--- .../devtools/{styles.ts => styles.css} | 5 +-- 9 files changed, 44 insertions(+), 27 deletions(-) rename packages/houdini-react/runtime/devtools/{styles.ts => styles.css} (99%) diff --git a/e2e/react/houdini.config.ts b/e2e/react/houdini.config.ts index 423e21c610..28d8fd6f84 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 = { @@ -31,7 +30,7 @@ const config: ConfigFile = { plugins: { 'houdini-react': { - devtools: true, + devtools: 'dev', }, './plugins/node-plugin.mjs': {}, }, diff --git a/packages/_scripts/buildNode.js b/packages/_scripts/buildNode.js index a8c9963d2f..4034541a99 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 72bd7a21df..923667544f 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 72bd7a21df..923667544f 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 index 7bc373d995..69dc9edb5e 100644 --- a/packages/houdini-react/package/lib/index.ts +++ b/packages/houdini-react/package/lib/index.ts @@ -7,8 +7,13 @@ declare module 'houdini' { export type HoudiniReactConfig = { /** - * Show the Houdini React devtools overlay in development. - * @default false + * Controls when the Houdini React devtools overlay is shown. + * + * - `dev`: show in development + * - `production`: show in production + * - `never`: never show + * + * @default 'dev' */ - devtools?: boolean + devtools?: 'dev' | 'production' | 'never' } diff --git a/packages/houdini-react/runtime/clientPlugin.ts b/packages/houdini-react/runtime/clientPlugin.ts index 4cc0a97f6f..64fd4199b0 100644 --- a/packages/houdini-react/runtime/clientPlugin.ts +++ b/packages/houdini-react/runtime/clientPlugin.ts @@ -15,7 +15,7 @@ const plugin: () => ClientPlugin = () => () => { }) }, }, - devtools(), + devtools, ] } diff --git a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx index 1be4dfd545..b9b5692012 100644 --- a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx +++ b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx @@ -18,14 +18,6 @@ export function HoudiniDevtools() { const latest = snapshot.requests[0] const selected = snapshot.requests.find((request) => request.id === selectedId) ?? latest - React.useEffect(() => { - if (!selected) { - return - } - - console.log('[Houdini Devtools] selected request', selected) - }, [selected]) - return (
{open ? ( diff --git a/packages/houdini-react/runtime/devtools/plugin.ts b/packages/houdini-react/runtime/devtools/plugin.ts index ebf2879a77..05c2ae5e04 100644 --- a/packages/houdini-react/runtime/devtools/plugin.ts +++ b/packages/houdini-react/runtime/devtools/plugin.ts @@ -1,10 +1,11 @@ -import type { ClientPlugin } from '$houdini' +import type { ConfigFile } from 'houdini' +import type { ClientPlugin } from 'houdini/runtime/client' import type { DocumentArtifact } from 'houdini/runtime' import React from 'react' import { createRoot, type Root } from 'react-dom/client' import { HoudiniDevtools } from './HoudiniDevtools' -import styles from './styles.js' +import styles from './styles.css?inline' import { addRequestEvent, createRequest, failRequest, succeedRequest } from './store' import type { RequestKind } from './type' @@ -80,12 +81,32 @@ function normalizeError(error: unknown): Error { return new Error(typeof error === 'string' ? error : JSON.stringify(error)) } -function enabled(ctx: { config: { plugins?: Record } }) { - return ctx.config.plugins?.['houdini-react']?.devtools === true +type DevtoolsMode = 'dev' | 'production' | 'never' + +type HoudiniReactConfig = { devtools?: DevtoolsMode } + +function reactConfig(config: ConfigFile): HoudiniReactConfig | undefined { + return (config.plugins as { 'houdini-react'?: HoudiniReactConfig } | undefined)?.[ + 'houdini-react' + ] +} + +function enabled(ctx: { config: ConfigFile }) { + const mode = reactConfig(ctx.config)?.devtools ?? 'dev' + + if (mode === 'never') { + return false + } + + if (mode === 'production') { + return (import.meta as any).env?.PROD === true + } + + return (import.meta as any).env?.DEV !== false } const devToolPlugin: ClientPlugin = () => { - if (typeof window === 'undefined' || (import.meta as any).env?.DEV === false) { + if (typeof window === 'undefined') { return {} } diff --git a/packages/houdini-react/runtime/devtools/styles.ts b/packages/houdini-react/runtime/devtools/styles.css similarity index 99% rename from packages/houdini-react/runtime/devtools/styles.ts rename to packages/houdini-react/runtime/devtools/styles.css index ad9e084039..a0f2288581 100644 --- a/packages/houdini-react/runtime/devtools/styles.ts +++ b/packages/houdini-react/runtime/devtools/styles.css @@ -1,4 +1,4 @@ -const styles = `.hdt { +.hdt { --hdt-bg: #101113; --hdt-panel: #17191c; --hdt-header: #1f252c; @@ -345,6 +345,3 @@ const styles = `.hdt { .hdt-trigger * { color: inherit !important; } -` - -export default styles From 38509f290896817ca7c643fe9236b7c0e7b05f87 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 23:31:41 +0530 Subject: [PATCH 09/22] formatting and type for css importq --- .../runtime/devtools/HoudiniDevtools.tsx | 52 ++++++++++++++----- .../houdini-react/runtime/devtools/plugin.ts | 5 +- .../houdini-react/runtime/devtools/store.ts | 5 +- .../houdini-react/runtime/devtools/styles.css | 39 ++++++++++---- packages/houdini-react/runtime/vite-env.d.ts | 4 ++ 5 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 packages/houdini-react/runtime/vite-env.d.ts diff --git a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx index b9b5692012..b680f3e59b 100644 --- a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx +++ b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx @@ -29,10 +29,10 @@ export function HoudiniDevtools() { {snapshot.requests.length} requests
- -
@@ -43,16 +43,20 @@ export function HoudiniDevtools() { {snapshot.requests.map((request) => ( ))} @@ -66,23 +70,45 @@ export function HoudiniDevtools() {

{selected.ctx.name}

- {selected.events.length} lifecycle events + + {selected.events.length} lifecycle events +
- setDetailTab('variables')}> + setDetailTab('variables')} + > Variables - setDetailTab('data')}> + setDetailTab('data')} + > Data - setDetailTab('errors')}> + setDetailTab('errors')} + > Errors
- {detailTab === 'variables' ?
: null} - {detailTab === 'data' ?
: null} + {detailTab === 'variables' ? ( +
+ ) : null} + {detailTab === 'data' ? ( +
+ ) : null} {detailTab === 'errors' ? (
) : ( - ) @@ -159,7 +185,7 @@ function Section({ title, value }: { title: string; value: unknown }) {
{title}
-
diff --git a/packages/houdini-react/runtime/devtools/plugin.ts b/packages/houdini-react/runtime/devtools/plugin.ts index 05c2ae5e04..636a7e0efd 100644 --- a/packages/houdini-react/runtime/devtools/plugin.ts +++ b/packages/houdini-react/runtime/devtools/plugin.ts @@ -149,7 +149,10 @@ const devToolPlugin: ClientPlugin = () => { if (enabled(ctx)) { addRequestEvent(ctx, 'end') if (value.errors?.length) { - failRequest(ctx, new Error(value.errors.map((error) => error.message).join('\n'))) + failRequest( + ctx, + new Error(value.errors.map((error) => error.message).join('\n')) + ) } else { succeedRequest(ctx, value) } diff --git a/packages/houdini-react/runtime/devtools/store.ts b/packages/houdini-react/runtime/devtools/store.ts index c577ff84cc..f33fd6cbfe 100644 --- a/packages/houdini-react/runtime/devtools/store.ts +++ b/packages/houdini-react/runtime/devtools/store.ts @@ -45,7 +45,10 @@ function getRequestId(ctx: ClientPluginContext) { return requestIdsBySignal.get(ctx.abortController.signal) } -function updateRequest(requestId: string | undefined, updater: (request: DevToolRequest) => DevToolRequest) { +function updateRequest( + requestId: string | undefined, + updater: (request: DevToolRequest) => DevToolRequest +) { if (!requestId) { return } diff --git a/packages/houdini-react/runtime/devtools/styles.css b/packages/houdini-react/runtime/devtools/styles.css index a0f2288581..2ceea092ae 100644 --- a/packages/houdini-react/runtime/devtools/styles.css +++ b/packages/houdini-react/runtime/devtools/styles.css @@ -15,7 +15,8 @@ position: fixed; z-index: 999999; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + 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); @@ -47,8 +48,15 @@ box-shadow: none !important; } -.hdt--open { left: 0; right: 0; bottom: 0; } -.hdt--closed { right: 16px; bottom: 16px; } +.hdt--open { + left: 0; + right: 0; + bottom: 0; +} +.hdt--closed { + right: 16px; + bottom: 16px; +} .hdt-panel { width: 100vw; @@ -87,11 +95,17 @@ color: var(--hdt-text) !important; } -.hdt-title strong { color: var(--hdt-text) !important; } +.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-muted { + color: var(--hdt-muted) !important; +} +.hdt-actions { + gap: 8px; +} .hdt-button { padding: 5px 10px !important; @@ -272,7 +286,9 @@ background: transparent !important; } -.hdt-section { margin-bottom: 14px; } +.hdt-section { + margin-bottom: 14px; +} .hdt-section-head { display: flex; @@ -316,7 +332,6 @@ line-height: 1.55; } - .hdt-dot { width: 8px; height: 8px; @@ -325,8 +340,12 @@ flex: 0 0 auto; } -.hdt-dot--pending { background: var(--hdt-warn); } -.hdt-dot--error { background: var(--hdt-error); } +.hdt-dot--pending { + background: var(--hdt-warn); +} +.hdt-dot--error { + background: var(--hdt-error); +} .hdt-trigger, .hdt-trigger:hover, 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 0000000000..ac8e774f16 --- /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 +} From e3bd9000cb9e69b34d2a6cdc144266ff0005d080 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 23:34:59 +0530 Subject: [PATCH 10/22] added changeset --- .changeset/slick-llamas-hammer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slick-llamas-hammer.md diff --git a/.changeset/slick-llamas-hammer.md b/.changeset/slick-llamas-hammer.md new file mode 100644 index 0000000000..3c1b2f8684 --- /dev/null +++ b/.changeset/slick-llamas-hammer.md @@ -0,0 +1,5 @@ +--- +"houdini-react": patch +--- + +adding opt-in devtool for react inspecting client requests From aaa479d382d66a8d320de8588765d61d754de196 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 23:35:24 +0530 Subject: [PATCH 11/22] formatting --- .../runtime/devtools/HoudiniDevtools.tsx | 12 ++++++++++-- packages/houdini/package.json | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx index b680f3e59b..5c43372139 100644 --- a/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx +++ b/packages/houdini-react/runtime/devtools/HoudiniDevtools.tsx @@ -32,7 +32,11 @@ export function HoudiniDevtools() { -
@@ -149,7 +153,11 @@ function TabButton({ children: React.ReactNode }) { return ( - ) diff --git a/packages/houdini/package.json b/packages/houdini/package.json index 69d39bd148..2f075be60c 100644 --- a/packages/houdini/package.json +++ b/packages/houdini/package.json @@ -186,4 +186,4 @@ }, "bin": "./build/cmd/index.js", "types": "./build/lib/index.d.ts" -} \ No newline at end of file +} From 0db21e211fdd5502f57c4197abbee5901e5fc246 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 23:47:39 +0530 Subject: [PATCH 12/22] added refrences types --- packages/houdini-react/runtime/devtools/plugin.ts | 2 ++ packages/houdini/package.json | 2 +- packages/houdini/src/lib/config.ts | 9 ++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/houdini-react/runtime/devtools/plugin.ts b/packages/houdini-react/runtime/devtools/plugin.ts index 636a7e0efd..0878aeee14 100644 --- a/packages/houdini-react/runtime/devtools/plugin.ts +++ b/packages/houdini-react/runtime/devtools/plugin.ts @@ -1,3 +1,5 @@ +/// + import type { ConfigFile } from 'houdini' import type { ClientPlugin } from 'houdini/runtime/client' import type { DocumentArtifact } from 'houdini/runtime' diff --git a/packages/houdini/package.json b/packages/houdini/package.json index 2f075be60c..69d39bd148 100644 --- a/packages/houdini/package.json +++ b/packages/houdini/package.json @@ -186,4 +186,4 @@ }, "bin": "./build/cmd/index.js", "types": "./build/lib/index.d.ts" -} +} \ No newline at end of file diff --git a/packages/houdini/src/lib/config.ts b/packages/houdini/src/lib/config.ts index 4ef66b217d..5568ed348c 100644 --- a/packages/houdini/src/lib/config.ts +++ b/packages/houdini/src/lib/config.ts @@ -257,11 +257,15 @@ export type ScalarSpec = { // this type is meant to be extended by plugins to provide type definitions // for config -export interface HoudiniPluginConfig {} +export interface HoudiniPluginConfig { + [plugin: string]: any +} // this type is meant to be extended by client plugins to provide type definitions // for config -export interface HoudiniClientPluginConfig {} +export interface HoudiniClientPluginConfig { + [plugin: string]: any +} // we need to include some extra meta data along with the config file export class Config { @@ -444,7 +448,6 @@ export class Config { } pluginConfig(name: string): ConfigType { - // @ts-expect-error return (this.config_file.plugins?.[name] as ConfigType) ?? {} } } From 41115b59917a072c0e7a577ed39711190fa65f8c Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 23:49:40 +0530 Subject: [PATCH 13/22] formatting fixed --- packages/houdini/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/houdini/package.json b/packages/houdini/package.json index 69d39bd148..2f075be60c 100644 --- a/packages/houdini/package.json +++ b/packages/houdini/package.json @@ -186,4 +186,4 @@ }, "bin": "./build/cmd/index.js", "types": "./build/lib/index.d.ts" -} \ No newline at end of file +} From e9631b951316dce3cd920d3ce85eaa6d0e8d9f45 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 16 Jun 2026 23:58:22 +0530 Subject: [PATCH 14/22] changeset --- .changeset/slick-llamas-hammer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/slick-llamas-hammer.md b/.changeset/slick-llamas-hammer.md index 3c1b2f8684..8276fafd1f 100644 --- a/.changeset/slick-llamas-hammer.md +++ b/.changeset/slick-llamas-hammer.md @@ -2,4 +2,4 @@ "houdini-react": patch --- -adding opt-in devtool for react inspecting client requests +adding opt-in devtool for houdini react inspecting client requests From 4505b9f1f9dfa0e93ce72b100aca079849762105 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 30 Jun 2026 15:12:59 +0530 Subject: [PATCH 15/22] Squash merge main into 611-devtool-react --- .babelrc.json | 11 - .changeset/abort-controller.md | 7 - .changeset/anchor-typed-hrefs.md | 5 - .changeset/angry-zoos-talk.md | 5 - .changeset/auth-header-fix.md | 5 - .changeset/clean-socks-explain.md | 5 - .changeset/cloudflare-adapter-fix.md | 6 - .changeset/cold-carrots-wink.md | 6 - .changeset/cold-poems-wear.md | 5 - .changeset/config.json | 2 +- .changeset/curly-olives-flash.md | 5 - .changeset/cyan-sheep-compare.md | 5 - .changeset/dep-bump-adapter-auto.md | 5 - .changeset/dep-bump-adapter-static.md | 5 - .changeset/dep-bump-create-houdini.md | 5 - .changeset/dep-bump-houdini-core.md | 5 - .changeset/dep-bump-houdini-react.md | 5 - .changeset/dep-bump-houdini-svelte.md | 5 - .changeset/dep-bump-houdini.md | 5 - .changeset/eleven-parents-rest.md | 13 - .changeset/error-extensions.md | 7 - .changeset/fix-addmany-field-visibility.md | 5 - .changeset/fix-atomic-pipeline-writes.md | 6 - .../fix-bin-object-plugin-resolution.md | 7 - .../fix-conditional-spread-null-cascade.md | 5 - .../fix-create-houdini-version-resolution.md | 5 - .changeset/fix-cursor-pagination.md | 5 - .changeset/fix-dev-fouc-css.md | 5 - .changeset/fix-fragment-abstract-types.md | 7 - .changeset/fix-fragment-handle-ts-type.md | 5 - .changeset/fix-fragment-pagination.md | 8 - .changeset/fix-fragment-rerenders.md | 6 - .changeset/fix-hmr-gql-deletions.md | 5 - .changeset/fix-hmr-new-routes.md | 6 - .changeset/fix-hmr-pipeline.md | 5 - .../fix-injected-plugins-null-config.md | 6 - .../fix-list-filter-object-variables.md | 6 - .changeset/fix-mutation-order.md | 6 - .changeset/fix-pageinfo-updates-direction.md | 6 - .../fix-paginated-connection-updates.md | 5 - .changeset/fix-pagination-dedupe.md | 6 - .changeset/fix-pagination-sibling-fields.md | 5 - .changeset/fix-plugin-bin-missing-error.md | 5 - .changeset/fix-react-vite-ssr-fouc.md | 5 - .changeset/fix-refetch-cache-links-leak.md | 5 - .changeset/gitignore-schema-path.md | 5 - .changeset/go-compiler-features.md | 13 - .changeset/go-rewrite.md | 13 - .changeset/graphql-peer-dep.md | 6 - .changeset/hip-fishes-end.md | 5 - .changeset/hip-rockets-stick.md | 5 - .changeset/large-countries-fetch.md | 5 - .changeset/lemon-files-follow.md | 5 - .changeset/many-geese-admire.md | 5 - .changeset/mean-clocks-care.md | 5 - .changeset/mutation-error-handling.md | 5 - .changeset/nasty-tables-fix.md | 5 - .changeset/peer-dep-adapters.md | 8 - .changeset/pre.json | 91 - .changeset/publish-wasm-packages.md | 7 - .changeset/react-routing-errors.md | 5 - .changeset/refresh-cache-record.md | 6 - .../session-transform-ts-annotations.md | 5 - .changeset/sharp-banks-check.md | 5 - .changeset/silver-baboons-open.md | 5 - .changeset/small-falcons-grow.md | 5 - .changeset/stale-lizards-warn.md | 5 - .changeset/stale-trainers-enjoy.md | 5 - .changeset/svelte-async-component-query.md | 5 - .changeset/tsconfig-stub-on-startup.md | 5 - .changeset/upsert-list-operation.md | 5 - .changeset/write-polled-schema.md | 5 - .changeset/yellow-dancers-start.md | 7 - .github/workflows/release.yml | 11 +- .github/workflows/tests.yml | 66 +- .gitignore | 1 + .husky/.gitignore | 1 - CLAUDE.md | 28 +- NOTES.md | 5 - docs/DELETED.md | 43 - .../00-your-first-app/00-getting-started.mdx | 2 +- docs/react/00-your-first-app/01-queries.mdx | 33 +- docs/react/00-your-first-app/02-fragments.mdx | 2 +- docs/react/00-your-first-app/03-mutations.mdx | 6 +- .../react/00-your-first-app/04-pagination.mdx | 6 +- docs/react/01-setup/01-getting-started.mdx | 4 +- docs/react/01-setup/02-deployment.mdx | 45 +- .../react/02-routing/01-pages-and-layouts.mdx | 48 +- docs/react/02-routing/02-navigation.mdx | 66 +- docs/react/02-routing/03-file-conventions.mdx | 14 +- ...-boundaries.mdx => 04-handling-errors.mdx} | 22 +- docs/react/03-loading-data/01-queries.mdx | 42 +- docs/react/03-loading-data/02-fragments.mdx | 101 +- docs/react/03-loading-data/04-pagination.mdx | 4 +- .../03-loading-data/05-subscriptions.mdx | 2 +- .../03-loading-data/06-graphql-server.mdx | 39 +- docs/react/04-updating-data/01-mutations.mdx | 31 +- .../02-optimistic-updates.mdx | 2 +- .../04-updating-data/04-caching-data.mdx | 2 +- docs/react/04-updating-data/05-forms.mdx | 215 ++ docs/react/05-guides/01-authentication.mdx | 339 ++- docs/react/05-guides/04-file-uploads.mdx | 2 +- docs/react/05-guides/07-testing.mdx | 114 + docs/react/06-api-reference/04-Link.mdx | 12 +- .../09-useCurrentVariables.mdx | 23 - .../react/06-api-reference/10-useFragment.mdx | 27 + .../06-api-reference/11-useFragmentHandle.mdx | 25 +- .../react/06-api-reference/12-useLocation.mdx | 31 - .../06-api-reference/12-useLogoutForm.mdx | 43 + .../06-api-reference/14-useMutationForm.mdx | 72 + .../{14-useQuery.mdx => 15-useQuery.mdx} | 0 ...eQueryHandle.mdx => 16-useQueryHandle.mdx} | 0 docs/react/06-api-reference/16-useRoute.mdx | 22 - docs/react/06-api-reference/17-useRoute.mdx | 63 + docs/react/06-api-reference/17-useSession.mdx | 28 - docs/react/06-api-reference/18-useSession.mdx | 37 + ...ubscription.mdx => 19-useSubscription.mdx} | 0 docs/react/OUTLINE.md | 80 - docs/shared/01-core/01-config.mdx | 17 +- docs/shared/01-core/02-cli.mdx | 4 +- docs/shared/01-core/03-vite-plugin.mdx | 4 +- docs/shared/01-core/04-client.mdx | 14 +- docs/shared/01-core/06-cache.mdx | 18 +- docs/shared/01-core/07-architecture.mdx | 14 +- .../01-client-plugins.mdx | 38 +- .../02-codegen-plugins-golang.mdx | 32 +- .../03-codegen-plugins-golang-api.mdx | 36 +- .../04-codegen-plugins-node.mdx | 24 +- docs/shared/03-meta/02-migration.mdx | 145 +- docs/shared/03-meta/03-contributing.mdx | 22 +- docs/shared/_partials/caching-data.mdx | 6 +- docs/shared/_partials/dedupe.mdx | 6 +- docs/shared/_partials/endpoint-directive.mdx | 34 + docs/shared/_partials/error-handling.mdx | 2 +- docs/shared/_partials/list-operations.mdx | 10 +- docs/shared/_partials/nullability.mdx | 8 +- docs/shared/_partials/optimistic-key.mdx | 2 +- .../_partials/plugin-vite-submodule.mdx | 4 +- docs/shared/_partials/refetch.mdx | 24 + docs/shared/_partials/runtime-scalars.mdx | 4 +- docs/shared/_partials/trusted-documents.mdx | 6 +- .../01-your-first-app/00-getting-started.mdx | 4 +- docs/svelte/01-your-first-app/01-queries.mdx | 2 +- .../svelte/01-your-first-app/03-mutations.mdx | 2 +- .../01-your-first-app/04-pagination.mdx | 6 +- docs/svelte/02-setup/01-project-setup.mdx | 4 +- docs/svelte/02-setup/03-svelte-config.mdx | 2 +- docs/svelte/03-loading-data/01-queries.mdx | 20 +- docs/svelte/03-loading-data/02-fragments.mdx | 89 +- .../03-loading-data/03-loading-states.mdx | 46 +- docs/svelte/03-loading-data/04-pagination.mdx | 16 +- docs/svelte/04-updating-data/01-mutations.mdx | 11 +- .../02-optimistic-updates.mdx | 4 +- .../svelte/05-guides/05-trusted-documents.mdx | 4 +- e2e/_api/CHANGELOG.md | 3 + e2e/_api/graphql.mjs | 76 +- e2e/_api/package.json | 2 +- e2e/_api/schema.graphql | 7 + e2e/kit/houdini.config.js | 8 + e2e/kit/src/client.ts | 1 - e2e/kit/src/lib/utils/routes.ts | 8 + .../+page.svelte | 23 + .../paginated-fragment-at-loading/+page.ts | 22 + .../Friends.svelte | 32 + .../bug/paginated-fragment-at-loading/spec.ts | 34 + .../cache/refetch-subscription/+page.svelte | 47 + .../refetch-subscription/UserDetails.svelte | 16 + .../routes/cache/refetch-subscription/spec.ts | 24 + e2e/kit/src/routes/cache/refetch/+page.svelte | 34 + .../routes/cache/refetch/UserDetails.svelte | 16 + e2e/kit/src/routes/cache/refetch/spec.ts | 23 + .../CityInfoWithLoadingState.svelte | 4 +- .../src/routes/plural-fragment/+page.svelte | 47 + e2e/kit/src/routes/plural-fragment/+page.ts | 19 + .../plural-fragment/PluralUserList.svelte | 22 + e2e/kit/src/routes/plural-fragment/spec.ts | 23 + .../refetchable-fragment-custom/+page.svelte | 27 + .../refetchable-fragment-custom/+page.ts | 18 + .../refetchable-fragment-custom/spec.ts | 25 + .../routes/refetchable-fragment/+page.svelte | 28 + .../src/routes/refetchable-fragment/+page.ts | 18 + .../src/routes/refetchable-fragment/spec.ts | 36 + e2e/react/houdini.config.ts | 16 +- e2e/react/oauth-mock.mjs | 23 + e2e/react/package.json | 5 + e2e/react/playwright.config.ts | 25 +- e2e/react/src/+client.ts | 15 +- e2e/react/src/anchor-types.types.tsx | 84 +- e2e/react/src/auth.d.ts | 16 + e2e/react/src/no-secret-leak/test.ts | 42 + e2e/react/src/routes/auth-form/+page.tsx | 41 + e2e/react/src/routes/auth-form/done/+page.tsx | 20 + e2e/react/src/routes/auth-form/test.ts | 62 + .../src/routes/layout_search/+layout.gql | 5 + .../src/routes/layout_search/+layout.tsx | 15 + e2e/react/src/routes/layout_search/+page.gql | 3 + e2e/react/src/routes/layout_search/+page.tsx | 5 + e2e/react/src/routes/layout_search/test.ts | 10 + .../src/routes/loading-error-link/+page.tsx | 11 + e2e/react/src/routes/loading-error/+error.tsx | 5 + e2e/react/src/routes/loading-error/+page.gql | 5 + e2e/react/src/routes/loading-error/+page.tsx | 12 + e2e/react/src/routes/loading-error/test.ts | 26 + .../src/routes/loading-interactive/+page.gql | 5 + .../src/routes/loading-interactive/+page.tsx | 25 + .../src/routes/loading-interactive/test.ts | 23 + .../loading-paginated-fragment/+page.gql | 6 + .../loading-paginated-fragment/+page.tsx | 54 + .../routes/loading-paginated-fragment/test.ts | 48 + e2e/react/src/routes/mutation-form/+page.tsx | 34 + .../routes/mutation-form/created/+page.tsx | 9 + .../src/routes/mutation-form/error/+page.tsx | 37 + .../src/routes/mutation-form/error/test.ts | 22 + .../src/routes/mutation-form/status/+page.tsx | 39 + .../src/routes/mutation-form/status/test.ts | 20 + e2e/react/src/routes/mutation-form/test.ts | 48 + .../src/routes/mutation-form/upload/+page.tsx | 31 + .../src/routes/mutation-form/upload/test.ts | 18 + e2e/react/src/routes/oauth/+page.tsx | 23 + e2e/react/src/routes/oauth/test.ts | 16 + .../src/routes/plural-fragment-args/+page.gql | 6 + .../src/routes/plural-fragment-args/+page.tsx | 6 + .../plural-fragment-args/PluralArgsList.tsx | 26 + .../src/routes/plural-fragment-args/test.ts | 20 + .../routes/plural-fragment-empty/+page.gql | 6 + .../routes/plural-fragment-empty/+page.tsx | 7 + .../src/routes/plural-fragment-empty/test.ts | 12 + .../routes/plural-fragment-guard/+page.gql | 6 + .../routes/plural-fragment-guard/+page.tsx | 7 + .../plural-fragment-guard/GuardList.tsx | 17 + .../src/routes/plural-fragment-guard/test.ts | 12 + .../routes/plural-fragment-rebind/+page.gql | 6 + .../routes/plural-fragment-rebind/+page.tsx | 43 + .../src/routes/plural-fragment-rebind/test.ts | 21 + .../src/routes/plural-fragment/+page.gql | 6 + .../src/routes/plural-fragment/+page.tsx | 53 + .../routes/plural-fragment/PluralUserList.tsx | 30 + e2e/react/src/routes/plural-fragment/test.ts | 26 + .../refetchable-fragment-custom/+page.gql | 5 + .../refetchable-fragment-custom/+page.tsx | 32 + .../refetchable-fragment-custom/test.ts | 25 + .../src/routes/refetchable-fragment/+page.gql | 5 + .../src/routes/refetchable-fragment/+page.tsx | 33 + .../src/routes/refetchable-fragment/test.ts | 36 + .../src/routes/response-headers/+layout.tsx | 12 + .../src/routes/response-headers/+page.tsx | 10 + e2e/react/src/routes/response-headers/test.ts | 18 + .../src/routes/route_params/[id]/+page.tsx | 6 +- .../routes/route_params_date/[day]/+page.gql | 6 + .../routes/route_params_date/[day]/+page.tsx | 17 + .../routes/route_params_date/[day]/test.ts | 15 + .../route_params_with_space/[title]/+page.tsx | 6 +- e2e/react/src/routes/search_params/+page.gql | 6 + e2e/react/src/routes/search_params/+page.tsx | 46 + e2e/react/src/routes/search_params/test.ts | 100 + .../src/routes/search_params_list/+page.gql | 6 + .../src/routes/search_params_list/+page.tsx | 37 + .../src/routes/search_params_list/test.ts | 30 + .../src/routes/session-mutation/+page.tsx | 30 + e2e/react/src/routes/session-mutation/test.ts | 16 + e2e/react/src/routes/session-theme/+page.tsx | 32 + e2e/react/src/routes/session-theme/test.ts | 24 + e2e/react/src/routes/session-to-api/+page.tsx | 61 + e2e/react/src/routes/session-to-api/test.ts | 25 + .../src/routes/subscription-update/+page.tsx | 35 + e2e/react/src/server/+config.ts | 24 + e2e/react/src/{api => server}/+schema.js | 44 +- e2e/react/src/tests/createMock.test.tsx | 434 +++ e2e/react/src/utils/routes.ts | 24 + e2e/react/vite.config.ts | 5 + e2e/svelte/.gitignore | 28 - e2e/svelte/.graphqlrc.yaml | 9 - e2e/svelte/README.md | 1 - e2e/svelte/houdini.config.js | 13 - e2e/svelte/index.html | 13 - e2e/svelte/package.json | 37 - e2e/svelte/playwright.config.ts | 23 - e2e/svelte/public/vite.svg | 1 - e2e/svelte/schema.graphql | 167 -- e2e/svelte/src/App.svelte | 55 - e2e/svelte/src/app.css | 80 - e2e/svelte/src/assets/logo_l.svg | 5 - e2e/svelte/src/assets/svelte.svg | 1 - e2e/svelte/src/client.ts | 15 - e2e/svelte/src/helpers.ts | 6 - e2e/svelte/src/lib/Counter.svelte | 10 - e2e/svelte/src/main.ts | 8 - e2e/svelte/src/spec.ts | 10 - e2e/svelte/src/vite-env.d.ts | 2 - e2e/svelte/svelte.config.js | 7 - e2e/svelte/tsconfig.json | 22 - e2e/svelte/tsconfig.node.json | 8 - e2e/svelte/vite.config.ts | 14 - package.json | 2 +- packages/_scripts/CHANGELOG.md | 8 +- packages/_scripts/buildUtils.js | 4 +- .../_scripts/changeset-fetch-diagnostics.cjs | 59 + packages/_scripts/package.json | 2 +- packages/_scripts/version.js | 81 + packages/adapter-auto/CHANGELOG.md | 242 +- packages/adapter-auto/package.json | 2 +- packages/adapter-cloudflare/CHANGELOG.md | 236 +- packages/adapter-cloudflare/package.json | 2 +- packages/adapter-node/CHANGELOG.md | 234 +- packages/adapter-node/package.json | 3 +- packages/adapter-node/src/app.ts | 13 +- packages/adapter-node/src/assets.test.ts | 30 + packages/adapter-node/src/assets.ts | 15 + packages/adapter-node/tsconfig.json | 7 + packages/adapter-static/CHANGELOG.md | 240 +- packages/adapter-static/package.json | 2 +- packages/create-houdini/CHANGELOG.md | 88 +- packages/create-houdini/bin.js | 14 +- .../src/{api => server}/+schema.ts | 0 .../react/src/{api => server}/+schema.js | 0 packages/create-houdini/package.json | 2 +- packages/houdini-core/CHANGELOG.md | 148 +- packages/houdini-core/package.json | 2 +- packages/houdini-core/plugin/afterValidate.go | 6 + .../plugin/documents/artifacts/artifacts.go | 4 +- .../plugin/documents/artifacts/endpoint.go | 134 + .../plugin/documents/artifacts/print.go | 22 + .../plugin/documents/artifacts/print_test.go | 23 + .../plugin/documents/artifacts/selection.go | 146 +- .../artifacts/selection_conditional_test.go | 34 +- .../artifacts/selection_lists_test.go | 141 + .../artifacts/selection_loading_test.go | 327 ++- .../artifacts/selection_operations_test.go | 1005 ++++++- .../artifacts/selection_pagination_test.go | 105 +- .../artifacts/selection_refetchable_test.go | 181 ++ .../selection_requiredDirective_test.go | 16 + .../documents/artifacts/selection_test.go | 2366 +++++++++++++---- .../plugin/documents/artifacts/session.go | 53 + .../artifacts/typescript/documents.go | 218 +- .../artifacts/typescript/documents_test.go | 372 ++- .../plugin/documents/collected/collect.go | 61 + .../plugin/documents/collected/types.go | 1 + .../houdini-core/plugin/documents/endpoint.go | 348 +++ .../houdini-core/plugin/documents/session.go | 197 ++ .../houdini-core/plugin/documents/validate.go | 221 +- .../plugin/fragmentArguments/transform.go | 229 +- .../fragmentArguments/transform_test.go | 293 +- .../plugin/lists/paginationDocuments.go | 15 +- .../plugin/lists/paginationDocuments_test.go | 227 +- .../plugin/lists/refetchableDocuments.go | 473 ++++ .../plugin/lists/refetchableDocuments_test.go | 184 ++ .../houdini-core/plugin/lists/validate.go | 138 +- .../plugin/runtime/runtimeIndex.go | 8 +- .../plugin/runtime/runtimeIndex_test.go | 4 +- .../plugin/runtime/transformRuntime.go | 30 + .../plugin/runtime/transformRuntime_test.go | 42 + .../plugin/schema/generateDefinitions.go | 10 +- .../plugin/schema/generateDefinitions_test.go | 15 + .../houdini-core/plugin/schema/inputTypes.go | 6 +- .../plugin/schema/inputTypes_test.go | 2 +- .../plugin/schema/renderType_test.go | 28 + .../houdini-core/plugin/schema/typeRef.go | 17 + packages/houdini-core/plugin/schema/write.go | 129 + packages/houdini-core/plugin/validate.go | 6 + packages/houdini-core/plugin/validate_test.go | 467 ++++ packages/houdini-core/runtime/client.ts | 40 +- packages/houdini-core/runtime/config.ts | 7 +- .../houdini-core/runtime/plugins/cache.ts | 68 +- .../houdini-core/runtime/plugins/fetch.ts | 33 + .../houdini-core/runtime/plugins/fragment.ts | 1 + .../houdini-core/runtime/plugins/index.ts | 1 + .../houdini-core/runtime/plugins/query.ts | 1 + .../runtime/plugins/sessionRelay.ts | 60 + .../runtime/public/tests/refetchPath.test.ts | 38 + packages/houdini-react/CHANGELOG.md | 325 +-- packages/houdini-react/package.json | 2 +- packages/houdini-react/package/vite/index.ts | 35 +- .../package/vite/strip-headers.test.ts | 48 + .../package/vite/strip-headers.ts | 39 + .../houdini-react/package/vite/transform.ts | 40 +- packages/houdini-react/plugin/generate.go | 147 +- .../houdini-react/plugin/generate_test.go | 76 +- packages/houdini-react/plugin/manifest.go | 265 +- .../houdini-react/plugin/manifest_test.go | 133 +- packages/houdini-react/plugin/runtime.go | 431 ++- packages/houdini-react/plugin/runtime_test.go | 366 ++- packages/houdini-react/plugin/validate.go | 77 + .../houdini-react/plugin/validate_test.go | 78 + packages/houdini-react/runtime/Link.tsx | 79 +- .../houdini-react/runtime/contexts.test.ts | 49 + packages/houdini-react/runtime/contexts.ts | 54 + packages/houdini-react/runtime/escape.test.ts | 22 + packages/houdini-react/runtime/escape.ts | 15 + packages/houdini-react/runtime/hooks/index.ts | 8 + .../runtime/hooks/useDocumentHandle.ts | 13 +- .../runtime/hooks/useFragment.ts | 170 +- .../runtime/hooks/useFragmentHandle.ts | 159 +- .../runtime/hooks/useLogoutForm.tsx | 60 + .../runtime/hooks/useMutation.ts | 34 +- .../runtime/hooks/useMutationForm.tsx | 163 ++ .../runtime/hooks/useSubscription.ts | 2 +- .../runtime/hooks/useSubscriptionHandle.ts | 16 +- packages/houdini-react/runtime/hydration.tsx | 58 +- packages/houdini-react/runtime/index.tsx | 17 +- packages/houdini-react/runtime/login.ts | 22 + packages/houdini-react/runtime/manifest.ts | 2 +- packages/houdini-react/runtime/mock.ts | 9 + .../runtime/resolve-href.test.ts | 197 +- .../houdini-react/runtime/resolve-href.ts | 147 +- packages/houdini-react/runtime/routes.ts | 93 + .../houdini-react/runtime/routing/Router.tsx | 377 ++- .../houdini-react/runtime/routing/errors.tsx | 8 +- packages/houdini-react/runtime/testing.tsx | 163 ++ packages/houdini-svelte/CHANGELOG.md | 277 +- packages/houdini-svelte/package.json | 3 +- .../package/vite/transform/index.ts | 47 +- .../package/vite/transform/init.test.ts | 8 +- .../package/vite/transform/session.test.ts | 85 +- .../package/vite/transform/sourcemap.test.ts | 35 + packages/houdini-svelte/plugin/config.go | 4 + .../houdini-svelte/plugin/config/config.go | 26 +- .../houdini-svelte/plugin/generate/stores.go | 22 +- .../plugin/generate/stores_test.go | 47 +- packages/houdini-svelte/plugin/runtime.go | 154 -- .../houdini-svelte/plugin/runtime_test.go | 130 - packages/houdini-svelte/runtime/fragments.ts | 48 +- .../houdini-svelte/runtime/stores/fragment.ts | 62 +- .../runtime/stores/pagination/fragment.ts | 20 +- .../runtime/stores/refetchable.ts | 161 ++ packages/houdini/CHANGELOG.md | 305 +-- packages/houdini/package.json | 16 +- packages/houdini/scripts/sync-schema.mjs | 22 + packages/houdini/src/cmd/init.ts | 29 +- packages/houdini/src/cmd/pullSchema.ts | 7 +- packages/houdini/src/lib/codegen.ts | 133 +- packages/houdini/src/lib/config.test.ts | 35 + packages/houdini/src/lib/config.ts | 114 +- packages/houdini/src/lib/database.ts | 77 +- packages/houdini/src/lib/db.ts | 5 + packages/houdini/src/lib/parse.ts | 16 +- packages/houdini/src/lib/project.test.ts | 35 + packages/houdini/src/lib/project.ts | 164 +- packages/houdini/src/lib/schema.test.ts | 16 + packages/houdini/src/oauth/index.ts | 214 ++ packages/houdini/src/oauth/oauth.test.ts | 232 ++ .../houdini/src/router/auth-token.test.ts | 151 ++ packages/houdini/src/router/auth-token.ts | 277 ++ packages/houdini/src/router/conventions.ts | 4 +- packages/houdini/src/router/manifest.test.ts | 4 +- packages/houdini/src/router/match.test.ts | 132 +- packages/houdini/src/router/match.ts | 101 +- .../houdini/src/router/server.noJS.test.ts | 1094 ++++++++ packages/houdini/src/router/server.test.ts | 253 ++ packages/houdini/src/router/server.ts | 656 ++++- .../houdini/src/router/session-proxy.test.ts | 183 ++ packages/houdini/src/router/session.ts | 500 +++- packages/houdini/src/router/types.ts | 38 +- packages/houdini/src/runtime/cache/index.ts | 61 +- packages/houdini/src/runtime/cache/storage.ts | 4 + .../src/runtime/cache/tests/refresh.test.ts | 99 + packages/houdini/src/runtime/client.ts | 8 +- packages/houdini/src/runtime/coerce.ts | 48 + packages/houdini/src/runtime/config.test.ts | 105 + packages/houdini/src/runtime/config.ts | 58 + packages/houdini/src/runtime/documentStore.ts | 26 +- packages/houdini/src/runtime/endpoint.test.ts | 30 + packages/houdini/src/runtime/endpoint.ts | 40 + packages/houdini/src/runtime/formData.test.ts | 182 ++ packages/houdini/src/runtime/formData.ts | 165 ++ packages/houdini/src/runtime/index.ts | 12 + .../houdini/src/runtime/multipart.test.ts | 68 + packages/houdini/src/runtime/multipart.ts | 68 + packages/houdini/src/runtime/pagination.ts | 55 + packages/houdini/src/runtime/scalars.test.ts | 21 +- packages/houdini/src/runtime/types.ts | 114 +- packages/houdini/src/vite/hmr.ts | 7 +- packages/houdini/src/vite/houdini.test.ts | 42 + packages/houdini/src/vite/houdini.ts | 29 +- packages/houdini/src/vite/index.ts | 15 +- packages/houdini/src/vite/schema.ts | 10 +- plugins/graphql/conventions.go | 16 + plugins/graphql/redirect.go | 61 + plugins/tests/expected.go | 49 +- plugins/tests/parse.go | 4 +- plugins/tests/run.go | 13 +- plugins/tests/schema.sql | 505 ++++ plugins/tests/test.go | 415 +-- pnpm-lock.yaml | 238 +- test-results/.last-run.json | 4 - vite.config.ts | 4 +- vitest.setup.ts | 9 +- 486 files changed, 23084 insertions(+), 5979 deletions(-) delete mode 100644 .babelrc.json delete mode 100644 .changeset/abort-controller.md delete mode 100644 .changeset/anchor-typed-hrefs.md delete mode 100644 .changeset/angry-zoos-talk.md delete mode 100644 .changeset/auth-header-fix.md delete mode 100644 .changeset/clean-socks-explain.md delete mode 100644 .changeset/cloudflare-adapter-fix.md delete mode 100644 .changeset/cold-carrots-wink.md delete mode 100644 .changeset/cold-poems-wear.md delete mode 100644 .changeset/curly-olives-flash.md delete mode 100644 .changeset/cyan-sheep-compare.md delete mode 100644 .changeset/dep-bump-adapter-auto.md delete mode 100644 .changeset/dep-bump-adapter-static.md delete mode 100644 .changeset/dep-bump-create-houdini.md delete mode 100644 .changeset/dep-bump-houdini-core.md delete mode 100644 .changeset/dep-bump-houdini-react.md delete mode 100644 .changeset/dep-bump-houdini-svelte.md delete mode 100644 .changeset/dep-bump-houdini.md delete mode 100644 .changeset/eleven-parents-rest.md delete mode 100644 .changeset/error-extensions.md delete mode 100644 .changeset/fix-addmany-field-visibility.md delete mode 100644 .changeset/fix-atomic-pipeline-writes.md delete mode 100644 .changeset/fix-bin-object-plugin-resolution.md delete mode 100644 .changeset/fix-conditional-spread-null-cascade.md delete mode 100644 .changeset/fix-create-houdini-version-resolution.md delete mode 100644 .changeset/fix-cursor-pagination.md delete mode 100644 .changeset/fix-dev-fouc-css.md delete mode 100644 .changeset/fix-fragment-abstract-types.md delete mode 100644 .changeset/fix-fragment-handle-ts-type.md delete mode 100644 .changeset/fix-fragment-pagination.md delete mode 100644 .changeset/fix-fragment-rerenders.md delete mode 100644 .changeset/fix-hmr-gql-deletions.md delete mode 100644 .changeset/fix-hmr-new-routes.md delete mode 100644 .changeset/fix-hmr-pipeline.md delete mode 100644 .changeset/fix-injected-plugins-null-config.md delete mode 100644 .changeset/fix-list-filter-object-variables.md delete mode 100644 .changeset/fix-mutation-order.md delete mode 100644 .changeset/fix-pageinfo-updates-direction.md delete mode 100644 .changeset/fix-paginated-connection-updates.md delete mode 100644 .changeset/fix-pagination-dedupe.md delete mode 100644 .changeset/fix-pagination-sibling-fields.md delete mode 100644 .changeset/fix-plugin-bin-missing-error.md delete mode 100644 .changeset/fix-react-vite-ssr-fouc.md delete mode 100644 .changeset/fix-refetch-cache-links-leak.md delete mode 100644 .changeset/gitignore-schema-path.md delete mode 100644 .changeset/go-compiler-features.md delete mode 100644 .changeset/go-rewrite.md delete mode 100644 .changeset/graphql-peer-dep.md delete mode 100644 .changeset/hip-fishes-end.md delete mode 100644 .changeset/hip-rockets-stick.md delete mode 100644 .changeset/large-countries-fetch.md delete mode 100644 .changeset/lemon-files-follow.md delete mode 100644 .changeset/many-geese-admire.md delete mode 100644 .changeset/mean-clocks-care.md delete mode 100644 .changeset/mutation-error-handling.md delete mode 100644 .changeset/nasty-tables-fix.md delete mode 100644 .changeset/peer-dep-adapters.md delete mode 100644 .changeset/pre.json delete mode 100644 .changeset/publish-wasm-packages.md delete mode 100644 .changeset/react-routing-errors.md delete mode 100644 .changeset/refresh-cache-record.md delete mode 100644 .changeset/session-transform-ts-annotations.md delete mode 100644 .changeset/sharp-banks-check.md delete mode 100644 .changeset/silver-baboons-open.md delete mode 100644 .changeset/small-falcons-grow.md delete mode 100644 .changeset/stale-lizards-warn.md delete mode 100644 .changeset/stale-trainers-enjoy.md delete mode 100644 .changeset/svelte-async-component-query.md delete mode 100644 .changeset/tsconfig-stub-on-startup.md delete mode 100644 .changeset/upsert-list-operation.md delete mode 100644 .changeset/write-polled-schema.md delete mode 100644 .changeset/yellow-dancers-start.md delete mode 100644 .husky/.gitignore delete mode 100644 NOTES.md delete mode 100644 docs/DELETED.md rename docs/react/02-routing/{04-error-boundaries.mdx => 04-handling-errors.mdx} (73%) create mode 100644 docs/react/04-updating-data/05-forms.mdx create mode 100644 docs/react/05-guides/07-testing.mdx delete mode 100644 docs/react/06-api-reference/09-useCurrentVariables.mdx delete mode 100644 docs/react/06-api-reference/12-useLocation.mdx create mode 100644 docs/react/06-api-reference/12-useLogoutForm.mdx create mode 100644 docs/react/06-api-reference/14-useMutationForm.mdx rename docs/react/06-api-reference/{14-useQuery.mdx => 15-useQuery.mdx} (100%) rename docs/react/06-api-reference/{15-useQueryHandle.mdx => 16-useQueryHandle.mdx} (100%) delete mode 100644 docs/react/06-api-reference/16-useRoute.mdx create mode 100644 docs/react/06-api-reference/17-useRoute.mdx delete mode 100644 docs/react/06-api-reference/17-useSession.mdx create mode 100644 docs/react/06-api-reference/18-useSession.mdx rename docs/react/06-api-reference/{18-useSubscription.mdx => 19-useSubscription.mdx} (100%) delete mode 100644 docs/react/OUTLINE.md create mode 100644 docs/shared/_partials/endpoint-directive.mdx create mode 100644 docs/shared/_partials/refetch.mdx create mode 100644 e2e/_api/CHANGELOG.md create mode 100644 e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.svelte create mode 100644 e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts create mode 100644 e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte create mode 100644 e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts create mode 100644 e2e/kit/src/routes/cache/refetch-subscription/+page.svelte create mode 100644 e2e/kit/src/routes/cache/refetch-subscription/UserDetails.svelte create mode 100644 e2e/kit/src/routes/cache/refetch-subscription/spec.ts create mode 100644 e2e/kit/src/routes/cache/refetch/+page.svelte create mode 100644 e2e/kit/src/routes/cache/refetch/UserDetails.svelte create mode 100644 e2e/kit/src/routes/cache/refetch/spec.ts create mode 100644 e2e/kit/src/routes/plural-fragment/+page.svelte create mode 100644 e2e/kit/src/routes/plural-fragment/+page.ts create mode 100644 e2e/kit/src/routes/plural-fragment/PluralUserList.svelte create mode 100644 e2e/kit/src/routes/plural-fragment/spec.ts create mode 100644 e2e/kit/src/routes/refetchable-fragment-custom/+page.svelte create mode 100644 e2e/kit/src/routes/refetchable-fragment-custom/+page.ts create mode 100644 e2e/kit/src/routes/refetchable-fragment-custom/spec.ts create mode 100644 e2e/kit/src/routes/refetchable-fragment/+page.svelte create mode 100644 e2e/kit/src/routes/refetchable-fragment/+page.ts create mode 100644 e2e/kit/src/routes/refetchable-fragment/spec.ts create mode 100644 e2e/react/oauth-mock.mjs create mode 100644 e2e/react/src/auth.d.ts create mode 100644 e2e/react/src/no-secret-leak/test.ts create mode 100644 e2e/react/src/routes/auth-form/+page.tsx create mode 100644 e2e/react/src/routes/auth-form/done/+page.tsx create mode 100644 e2e/react/src/routes/auth-form/test.ts create mode 100644 e2e/react/src/routes/layout_search/+layout.gql create mode 100644 e2e/react/src/routes/layout_search/+layout.tsx create mode 100644 e2e/react/src/routes/layout_search/+page.gql create mode 100644 e2e/react/src/routes/layout_search/+page.tsx create mode 100644 e2e/react/src/routes/layout_search/test.ts create mode 100644 e2e/react/src/routes/loading-error-link/+page.tsx create mode 100644 e2e/react/src/routes/loading-error/+error.tsx create mode 100644 e2e/react/src/routes/loading-error/+page.gql create mode 100644 e2e/react/src/routes/loading-error/+page.tsx create mode 100644 e2e/react/src/routes/loading-error/test.ts create mode 100644 e2e/react/src/routes/loading-interactive/+page.gql create mode 100644 e2e/react/src/routes/loading-interactive/+page.tsx create mode 100644 e2e/react/src/routes/loading-interactive/test.ts create mode 100644 e2e/react/src/routes/loading-paginated-fragment/+page.gql create mode 100644 e2e/react/src/routes/loading-paginated-fragment/+page.tsx create mode 100644 e2e/react/src/routes/loading-paginated-fragment/test.ts create mode 100644 e2e/react/src/routes/mutation-form/+page.tsx create mode 100644 e2e/react/src/routes/mutation-form/created/+page.tsx create mode 100644 e2e/react/src/routes/mutation-form/error/+page.tsx create mode 100644 e2e/react/src/routes/mutation-form/error/test.ts create mode 100644 e2e/react/src/routes/mutation-form/status/+page.tsx create mode 100644 e2e/react/src/routes/mutation-form/status/test.ts create mode 100644 e2e/react/src/routes/mutation-form/test.ts create mode 100644 e2e/react/src/routes/mutation-form/upload/+page.tsx create mode 100644 e2e/react/src/routes/mutation-form/upload/test.ts create mode 100644 e2e/react/src/routes/oauth/+page.tsx create mode 100644 e2e/react/src/routes/oauth/test.ts create mode 100644 e2e/react/src/routes/plural-fragment-args/+page.gql create mode 100644 e2e/react/src/routes/plural-fragment-args/+page.tsx create mode 100644 e2e/react/src/routes/plural-fragment-args/PluralArgsList.tsx create mode 100644 e2e/react/src/routes/plural-fragment-args/test.ts create mode 100644 e2e/react/src/routes/plural-fragment-empty/+page.gql create mode 100644 e2e/react/src/routes/plural-fragment-empty/+page.tsx create mode 100644 e2e/react/src/routes/plural-fragment-empty/test.ts create mode 100644 e2e/react/src/routes/plural-fragment-guard/+page.gql create mode 100644 e2e/react/src/routes/plural-fragment-guard/+page.tsx create mode 100644 e2e/react/src/routes/plural-fragment-guard/GuardList.tsx create mode 100644 e2e/react/src/routes/plural-fragment-guard/test.ts create mode 100644 e2e/react/src/routes/plural-fragment-rebind/+page.gql create mode 100644 e2e/react/src/routes/plural-fragment-rebind/+page.tsx create mode 100644 e2e/react/src/routes/plural-fragment-rebind/test.ts create mode 100644 e2e/react/src/routes/plural-fragment/+page.gql create mode 100644 e2e/react/src/routes/plural-fragment/+page.tsx create mode 100644 e2e/react/src/routes/plural-fragment/PluralUserList.tsx create mode 100644 e2e/react/src/routes/plural-fragment/test.ts create mode 100644 e2e/react/src/routes/refetchable-fragment-custom/+page.gql create mode 100644 e2e/react/src/routes/refetchable-fragment-custom/+page.tsx create mode 100644 e2e/react/src/routes/refetchable-fragment-custom/test.ts create mode 100644 e2e/react/src/routes/refetchable-fragment/+page.gql create mode 100644 e2e/react/src/routes/refetchable-fragment/+page.tsx create mode 100644 e2e/react/src/routes/refetchable-fragment/test.ts create mode 100644 e2e/react/src/routes/response-headers/+layout.tsx create mode 100644 e2e/react/src/routes/response-headers/+page.tsx create mode 100644 e2e/react/src/routes/response-headers/test.ts create mode 100644 e2e/react/src/routes/route_params_date/[day]/+page.gql create mode 100644 e2e/react/src/routes/route_params_date/[day]/+page.tsx create mode 100644 e2e/react/src/routes/route_params_date/[day]/test.ts create mode 100644 e2e/react/src/routes/search_params/+page.gql create mode 100644 e2e/react/src/routes/search_params/+page.tsx create mode 100644 e2e/react/src/routes/search_params/test.ts create mode 100644 e2e/react/src/routes/search_params_list/+page.gql create mode 100644 e2e/react/src/routes/search_params_list/+page.tsx create mode 100644 e2e/react/src/routes/search_params_list/test.ts create mode 100644 e2e/react/src/routes/session-mutation/+page.tsx create mode 100644 e2e/react/src/routes/session-mutation/test.ts create mode 100644 e2e/react/src/routes/session-theme/+page.tsx create mode 100644 e2e/react/src/routes/session-theme/test.ts create mode 100644 e2e/react/src/routes/session-to-api/+page.tsx create mode 100644 e2e/react/src/routes/session-to-api/test.ts create mode 100644 e2e/react/src/routes/subscription-update/+page.tsx create mode 100644 e2e/react/src/server/+config.ts rename e2e/react/src/{api => server}/+schema.js (83%) create mode 100644 e2e/react/src/tests/createMock.test.tsx delete mode 100644 e2e/svelte/.gitignore delete mode 100644 e2e/svelte/.graphqlrc.yaml delete mode 100644 e2e/svelte/README.md delete mode 100644 e2e/svelte/houdini.config.js delete mode 100644 e2e/svelte/index.html delete mode 100644 e2e/svelte/package.json delete mode 100644 e2e/svelte/playwright.config.ts delete mode 100644 e2e/svelte/public/vite.svg delete mode 100644 e2e/svelte/schema.graphql delete mode 100644 e2e/svelte/src/App.svelte delete mode 100644 e2e/svelte/src/app.css delete mode 100644 e2e/svelte/src/assets/logo_l.svg delete mode 100644 e2e/svelte/src/assets/svelte.svg delete mode 100644 e2e/svelte/src/client.ts delete mode 100644 e2e/svelte/src/helpers.ts delete mode 100644 e2e/svelte/src/lib/Counter.svelte delete mode 100644 e2e/svelte/src/main.ts delete mode 100644 e2e/svelte/src/spec.ts delete mode 100644 e2e/svelte/src/vite-env.d.ts delete mode 100644 e2e/svelte/svelte.config.js delete mode 100644 e2e/svelte/tsconfig.json delete mode 100644 e2e/svelte/tsconfig.node.json delete mode 100644 e2e/svelte/vite.config.ts create mode 100644 packages/_scripts/changeset-fetch-diagnostics.cjs create mode 100644 packages/_scripts/version.js create mode 100644 packages/adapter-node/src/assets.test.ts create mode 100644 packages/adapter-node/src/assets.ts create mode 100644 packages/adapter-node/tsconfig.json rename packages/create-houdini/fragments/localSchema/react-typescript/src/{api => server}/+schema.ts (100%) rename packages/create-houdini/fragments/localSchema/react/src/{api => server}/+schema.js (100%) create mode 100644 packages/houdini-core/plugin/documents/artifacts/endpoint.go create mode 100644 packages/houdini-core/plugin/documents/artifacts/selection_refetchable_test.go create mode 100644 packages/houdini-core/plugin/documents/artifacts/session.go create mode 100644 packages/houdini-core/plugin/documents/endpoint.go create mode 100644 packages/houdini-core/plugin/documents/session.go create mode 100644 packages/houdini-core/plugin/lists/refetchableDocuments.go create mode 100644 packages/houdini-core/plugin/lists/refetchableDocuments_test.go create mode 100644 packages/houdini-core/plugin/schema/renderType_test.go create mode 100644 packages/houdini-core/runtime/plugins/sessionRelay.ts create mode 100644 packages/houdini-core/runtime/public/tests/refetchPath.test.ts create mode 100644 packages/houdini-react/package/vite/strip-headers.test.ts create mode 100644 packages/houdini-react/package/vite/strip-headers.ts create mode 100644 packages/houdini-react/plugin/validate.go create mode 100644 packages/houdini-react/plugin/validate_test.go create mode 100644 packages/houdini-react/runtime/contexts.test.ts create mode 100644 packages/houdini-react/runtime/contexts.ts create mode 100644 packages/houdini-react/runtime/escape.test.ts create mode 100644 packages/houdini-react/runtime/escape.ts create mode 100644 packages/houdini-react/runtime/hooks/useLogoutForm.tsx create mode 100644 packages/houdini-react/runtime/hooks/useMutationForm.tsx create mode 100644 packages/houdini-react/runtime/login.ts create mode 100644 packages/houdini-react/runtime/mock.ts create mode 100644 packages/houdini-react/runtime/routes.ts create mode 100644 packages/houdini-react/runtime/testing.tsx create mode 100644 packages/houdini-svelte/package/vite/transform/sourcemap.test.ts create mode 100644 packages/houdini-svelte/runtime/stores/refetchable.ts create mode 100644 packages/houdini/scripts/sync-schema.mjs create mode 100644 packages/houdini/src/lib/config.test.ts create mode 100644 packages/houdini/src/lib/project.test.ts create mode 100644 packages/houdini/src/lib/schema.test.ts create mode 100644 packages/houdini/src/oauth/index.ts create mode 100644 packages/houdini/src/oauth/oauth.test.ts create mode 100644 packages/houdini/src/router/auth-token.test.ts create mode 100644 packages/houdini/src/router/auth-token.ts create mode 100644 packages/houdini/src/router/server.noJS.test.ts create mode 100644 packages/houdini/src/router/server.test.ts create mode 100644 packages/houdini/src/router/session-proxy.test.ts create mode 100644 packages/houdini/src/runtime/coerce.ts create mode 100644 packages/houdini/src/runtime/config.test.ts create mode 100644 packages/houdini/src/runtime/endpoint.test.ts create mode 100644 packages/houdini/src/runtime/endpoint.ts create mode 100644 packages/houdini/src/runtime/formData.test.ts create mode 100644 packages/houdini/src/runtime/formData.ts create mode 100644 packages/houdini/src/runtime/multipart.test.ts create mode 100644 packages/houdini/src/runtime/multipart.ts create mode 100644 packages/houdini/src/vite/houdini.test.ts create mode 100644 plugins/graphql/redirect.go create mode 100644 plugins/tests/schema.sql delete mode 100644 test-results/.last-run.json diff --git a/.babelrc.json b/.babelrc.json deleted file mode 100644 index 0907a4f2f2..0000000000 --- a/.babelrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "presets": [ - ["@babel/preset-env", { "targets": { "node": "current" } }], - "@babel/preset-typescript" - ], - "env": { - "test": { - "plugins": ["babel-plugin-transform-import-meta"] - } - } -} diff --git a/.changeset/abort-controller.md b/.changeset/abort-controller.md deleted file mode 100644 index f5837bc204..0000000000 --- a/.changeset/abort-controller.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'houdini': minor -'houdini-react': minor -'houdini-svelte': minor ---- - -add abortController to query and mutation args diff --git a/.changeset/anchor-typed-hrefs.md b/.changeset/anchor-typed-hrefs.md deleted file mode 100644 index 3618157662..0000000000 --- a/.changeset/anchor-typed-hrefs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': minor ---- - -Add a `` component with a typed `to` prop checked at compile time against your app's route manifest, with `params` interpolation and custom scalar support. diff --git a/.changeset/angry-zoos-talk.md b/.changeset/angry-zoos-talk.md deleted file mode 100644 index ce94a65dd5..0000000000 --- a/.changeset/angry-zoos-talk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': minor ---- - -Converted react plugin to new go compiler diff --git a/.changeset/auth-header-fix.md b/.changeset/auth-header-fix.md deleted file mode 100644 index d12654c051..0000000000 --- a/.changeset/auth-header-fix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -fix authentication header name in init template (Authentication → Authorization) diff --git a/.changeset/clean-socks-explain.md b/.changeset/clean-socks-explain.md deleted file mode 100644 index e9a94bbd01..0000000000 --- a/.changeset/clean-socks-explain.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini-core": patch ---- - -Fix validation bug when field has default value defined in the schema diff --git a/.changeset/cloudflare-adapter-fix.md b/.changeset/cloudflare-adapter-fix.md deleted file mode 100644 index 12d9f6612d..0000000000 --- a/.changeset/cloudflare-adapter-fix.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-adapter-cloudflare': patch -'houdini-adapter-auto': patch ---- - -fix cloudflare adapter to detect CLOUDFLARE_PAGES env var and output worker.js diff --git a/.changeset/cold-carrots-wink.md b/.changeset/cold-carrots-wink.md deleted file mode 100644 index 3603b0168a..0000000000 --- a/.changeset/cold-carrots-wink.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"create-houdini": patch -"houdini": patch ---- - -Fix document count when generating ; fix scaffold config runtime" diff --git a/.changeset/cold-poems-wear.md b/.changeset/cold-poems-wear.md deleted file mode 100644 index 79e1e5319c..0000000000 --- a/.changeset/cold-poems-wear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini": patch ---- - -Fix document change count in hmr diff --git a/.changeset/config.json b/.changeset/config.json index b31cc6f362..718ae0ec25 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "minor", - "ignore": ["e2e-api", "e2e-react", "e2e-kit", "e2e-svelte"] + "ignore": ["e2e-api", "e2e-react", "e2e-kit"] } diff --git a/.changeset/curly-olives-flash.md b/.changeset/curly-olives-flash.md deleted file mode 100644 index baf00384c0..0000000000 --- a/.changeset/curly-olives-flash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Add support for nested types in scalar definitions diff --git a/.changeset/cyan-sheep-compare.md b/.changeset/cyan-sheep-compare.md deleted file mode 100644 index 70694adad8..0000000000 --- a/.changeset/cyan-sheep-compare.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -Fix documents validation for schema that use custom operation types names for query/mutaiton/subscription diff --git a/.changeset/dep-bump-adapter-auto.md b/.changeset/dep-bump-adapter-auto.md deleted file mode 100644 index 24fb20f091..0000000000 --- a/.changeset/dep-bump-adapter-auto.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-adapter-auto': patch ---- - -Bump dependencies to latest: import-meta-resolve ^4 diff --git a/.changeset/dep-bump-adapter-static.md b/.changeset/dep-bump-adapter-static.md deleted file mode 100644 index 7a1b183833..0000000000 --- a/.changeset/dep-bump-adapter-static.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-adapter-static': patch ---- - -Bump dependencies to latest: react ^19.2.7, vite peer dependency ^8 diff --git a/.changeset/dep-bump-create-houdini.md b/.changeset/dep-bump-create-houdini.md deleted file mode 100644 index b04b18eee4..0000000000 --- a/.changeset/dep-bump-create-houdini.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'create-houdini': patch ---- - -Bump dependencies to latest: commander ^15, graphql 16.14.1, @clack/prompts ^1.5.1 diff --git a/.changeset/dep-bump-houdini-core.md b/.changeset/dep-bump-houdini-core.md deleted file mode 100644 index 8dd6c9433a..0000000000 --- a/.changeset/dep-bump-houdini-core.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -Bump dependencies to latest: graphql-yoga ^5, @whatwg-node/server ^0.11, minimatch ^10 diff --git a/.changeset/dep-bump-houdini-react.md b/.changeset/dep-bump-houdini-react.md deleted file mode 100644 index efcad5a0bf..0000000000 --- a/.changeset/dep-bump-houdini-react.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': patch ---- - -Bump dependencies to latest: express ^5, graphql-yoga ^5, @whatwg-node/server ^0.11, react ^19.2.7 diff --git a/.changeset/dep-bump-houdini-svelte.md b/.changeset/dep-bump-houdini-svelte.md deleted file mode 100644 index bcae5fbc71..0000000000 --- a/.changeset/dep-bump-houdini-svelte.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-svelte': patch ---- - -Bump dependencies to latest: svelte ^5.56.2, @sveltejs/kit ^2.63.0, vite ^8, rollup ^4.61.1 diff --git a/.changeset/dep-bump-houdini.md b/.changeset/dep-bump-houdini.md deleted file mode 100644 index 6913f6e94f..0000000000 --- a/.changeset/dep-bump-houdini.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Bump dependencies to latest: graphql-yoga ^5, @graphql-tools/schema ^10, @whatwg-node/server ^0.11, commander ^15, fs-extra ^11, memfs ^4, minimatch ^10, glob ^13 diff --git a/.changeset/eleven-parents-rest.md b/.changeset/eleven-parents-rest.md deleted file mode 100644 index aa9b750512..0000000000 --- a/.changeset/eleven-parents-rest.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'houdini-adapter-cloudflare': minor -'houdini-adapter-static': minor -'create-houdini': minor -'houdini-svelte': minor -'houdini-react': minor -'houdini-adapter-auto': minor -'houdini-adapter-node': minor -'houdini-core': minor -'houdini': minor ---- - -Bump Vite version diff --git a/.changeset/error-extensions.md b/.changeset/error-extensions.md deleted file mode 100644 index 45d0bde4c0..0000000000 --- a/.changeset/error-extensions.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'houdini': patch -'houdini-react': patch -'houdini-svelte': patch ---- - -GraphQL errors now expose `locations`, `path`, and `extensions` per the spec; augment `App.GraphQLErrorExtensions` to type your server's extensions. diff --git a/.changeset/fix-addmany-field-visibility.md b/.changeset/fix-addmany-field-visibility.md deleted file mode 100644 index 0a845f6f56..0000000000 --- a/.changeset/fix-addmany-field-visibility.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -fix addMany ignoring field visibility when subscribing, preventing hidden fields from leaking into list updates diff --git a/.changeset/fix-atomic-pipeline-writes.md b/.changeset/fix-atomic-pipeline-writes.md deleted file mode 100644 index 2f8c55965c..0000000000 --- a/.changeset/fix-atomic-pipeline-writes.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini': patch -'houdini-react': patch ---- - -write generated files atomically to prevent partial-read parse errors when Vite loads a module mid-pipeline diff --git a/.changeset/fix-bin-object-plugin-resolution.md b/.changeset/fix-bin-object-plugin-resolution.md deleted file mode 100644 index ee85101cb0..0000000000 --- a/.changeset/fix-bin-object-plugin-resolution.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'houdini': patch -'houdini-svelte': patch -'houdini-react': patch ---- - -Fix plugin resolution when npm normalizes bin field to object form diff --git a/.changeset/fix-conditional-spread-null-cascade.md b/.changeset/fix-conditional-spread-null-cascade.md deleted file mode 100644 index 46bb787565..0000000000 --- a/.changeset/fix-conditional-spread-null-cascade.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -fix null cascade when combining @mask_disable with @include/@skip (#1550), and restore correct runtime masking behavior in artifacts diff --git a/.changeset/fix-create-houdini-version-resolution.md b/.changeset/fix-create-houdini-version-resolution.md deleted file mode 100644 index ccdd6c00b0..0000000000 --- a/.changeset/fix-create-houdini-version-resolution.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'create-houdini': patch ---- - -Resolve each template package version independently to avoid stamping versions that haven't been published yet diff --git a/.changeset/fix-cursor-pagination.md b/.changeset/fix-cursor-pagination.md deleted file mode 100644 index 2f6db4ccbe..0000000000 --- a/.changeset/fix-cursor-pagination.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -fix cursor pagination: @paginate path now wins over @list, listPaginated and direction are correctly computed for bidirectional cursor fields diff --git a/.changeset/fix-dev-fouc-css.md b/.changeset/fix-dev-fouc-css.md deleted file mode 100644 index a4bcc64656..0000000000 --- a/.changeset/fix-dev-fouc-css.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': patch ---- - -Fix FOUC in dev mode by collecting CSS from the Vite module graph and passing them as React 19 stylesheet links that get hoisted to during SSR diff --git a/.changeset/fix-fragment-abstract-types.md b/.changeset/fix-fragment-abstract-types.md deleted file mode 100644 index 7ef0b0b9e2..0000000000 --- a/.changeset/fix-fragment-abstract-types.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'houdini': patch -'houdini-svelte': patch -'houdini-core': patch ---- - -fix fragment masking types on abstract and interface fields diff --git a/.changeset/fix-fragment-handle-ts-type.md b/.changeset/fix-fragment-handle-ts-type.md deleted file mode 100644 index 6785b4cc56..0000000000 --- a/.changeset/fix-fragment-handle-ts-type.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': patch ---- - -Fix TS2304 error in generated useFragmentHandle.ts by importing DocumentHandle type from useDocumentHandle diff --git a/.changeset/fix-fragment-pagination.md b/.changeset/fix-fragment-pagination.md deleted file mode 100644 index d5f284f92c..0000000000 --- a/.changeset/fix-fragment-pagination.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'houdini': patch -'houdini-react': patch -'houdini-svelte': patch -'houdini-core': patch ---- - -fixed fragment pagination diff --git a/.changeset/fix-fragment-rerenders.md b/.changeset/fix-fragment-rerenders.md deleted file mode 100644 index d9604cf53b..0000000000 --- a/.changeset/fix-fragment-rerenders.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-react': patch -'houdini': patch ---- - -prevent unnecessary re-renders on fragments by stabilizing returned values and skipping subscription updates when data hasn't changed diff --git a/.changeset/fix-hmr-gql-deletions.md b/.changeset/fix-hmr-gql-deletions.md deleted file mode 100644 index 6037080577..0000000000 --- a/.changeset/fix-hmr-gql-deletions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Handle `.gql` file deletions and moves in the HMR pipeline so removing or renaming a GraphQL file no longer leaves stale artifacts diff --git a/.changeset/fix-hmr-new-routes.md b/.changeset/fix-hmr-new-routes.md deleted file mode 100644 index 498603481c..0000000000 --- a/.changeset/fix-hmr-new-routes.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini': patch -'houdini-react': patch ---- - -fix HMR not regenerating the router manifest when a new `+page` or `+layout` file is added; invalidate component fields cache after each HMR cycle diff --git a/.changeset/fix-hmr-pipeline.md b/.changeset/fix-hmr-pipeline.md deleted file mode 100644 index 3a4105a5c0..0000000000 --- a/.changeset/fix-hmr-pipeline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -fix HMR pipeline: targeted js-update instead of full-reload, handle file deletions and cleanup files, serialize concurrent pipeline runs diff --git a/.changeset/fix-injected-plugins-null-config.md b/.changeset/fix-injected-plugins-null-config.md deleted file mode 100644 index b09bc16595..0000000000 --- a/.changeset/fix-injected-plugins-null-config.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-core': patch -'houdini-react': patch ---- - -Fix TS2554 in generated injectedPlugins.ts by omitting arguments when a client plugin's config is null diff --git a/.changeset/fix-list-filter-object-variables.md b/.changeset/fix-list-filter-object-variables.md deleted file mode 100644 index e4e9321750..0000000000 --- a/.changeset/fix-list-filter-object-variables.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-core': patch -'houdini': patch ---- - -fix list filters and @when conditions that contain object values or variable references nested inside objects diff --git a/.changeset/fix-mutation-order.md b/.changeset/fix-mutation-order.md deleted file mode 100644 index db44a75913..0000000000 --- a/.changeset/fix-mutation-order.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-react': patch -'houdini': patch ---- - -Fix `useMutation` to return `[mutate, pending]` instead of `[pending, mutate]`, and fix list toggle operations accumulating across resolved optimistic mutation layers causing subsequent toggles to appear stuck. diff --git a/.changeset/fix-pageinfo-updates-direction.md b/.changeset/fix-pageinfo-updates-direction.md deleted file mode 100644 index a5d82fbd1f..0000000000 --- a/.changeset/fix-pageinfo-updates-direction.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-core': patch -'houdini': patch ---- - -encode per-field pagination direction in pageInfo updates arrays; runtime now drives cache behavior from the artifact instead of hardcoded field names diff --git a/.changeset/fix-paginated-connection-updates.md b/.changeset/fix-paginated-connection-updates.md deleted file mode 100644 index 1d9284b6d8..0000000000 --- a/.changeset/fix-paginated-connection-updates.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -Fix several bugs in paginated connection artifact generation: `@paginate` on a nested field no longer produces an empty refetch path; `hasNextPage`/`hasPreviousPage` updates now propagate correctly; `endCursor`/`startCursor` no longer receive wrong-direction updates; and cache updates no longer leak to grandchildren of paginated connections diff --git a/.changeset/fix-pagination-dedupe.md b/.changeset/fix-pagination-dedupe.md deleted file mode 100644 index 050e77d42f..0000000000 --- a/.changeset/fix-pagination-dedupe.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini-react': patch -'houdini': patch ---- - -fix gaps in pagination request deduplication: stale inflight entries no longer block new requests, and ssr_signals now covers client-side concurrent renders to prevent duplicate observer/send pairs diff --git a/.changeset/fix-pagination-sibling-fields.md b/.changeset/fix-pagination-sibling-fields.md deleted file mode 100644 index c3a27e88d7..0000000000 --- a/.changeset/fix-pagination-sibling-fields.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -strip sibling fields from generated pagination query documents so only the paginated field is included diff --git a/.changeset/fix-plugin-bin-missing-error.md b/.changeset/fix-plugin-bin-missing-error.md deleted file mode 100644 index 2949b0511f..0000000000 --- a/.changeset/fix-plugin-bin-missing-error.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -show a clear error when a plugin is found but has no bin field, calling out local monorepo packages as the likely cause diff --git a/.changeset/fix-react-vite-ssr-fouc.md b/.changeset/fix-react-vite-ssr-fouc.md deleted file mode 100644 index 9f850dedb3..0000000000 --- a/.changeset/fix-react-vite-ssr-fouc.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': patch ---- - -Fix SSR middleware intercepting Vite module requests and missing Content-Type header; fix FOUC by enforcing correct CSS link precedence and deduplicating links; silence pre-warm noise by checking file existence before ssrLoadModule; set HOUDINI_PORT on server listen diff --git a/.changeset/fix-refetch-cache-links-leak.md b/.changeset/fix-refetch-cache-links-leak.md deleted file mode 100644 index fa1fec609f..0000000000 --- a/.changeset/fix-refetch-cache-links-leak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Fix cache link leak when refetching connections — embedded edge records now reuse their existing keys on write instead of generating new ones, and records that fall out of the list are cleaned up immediately. diff --git a/.changeset/gitignore-schema-path.md b/.changeset/gitignore-schema-path.md deleted file mode 100644 index 868dd58323..0000000000 --- a/.changeset/gitignore-schema-path.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -add remote schema path to .gitignore when initializing with a remote endpoint diff --git a/.changeset/go-compiler-features.md b/.changeset/go-compiler-features.md deleted file mode 100644 index 118dbaf69d..0000000000 --- a/.changeset/go-compiler-features.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'houdini': minor -'houdini-core': minor -'houdini-react': minor -'houdini-svelte': minor -'houdini-adapter-auto': minor -'houdini-adapter-cloudflare': minor -'houdini-adapter-node': minor -'houdini-adapter-static': minor -'create-houdini': minor ---- - -Add scalar module imports, align DocumentHandle with fetching and errors fields diff --git a/.changeset/go-rewrite.md b/.changeset/go-rewrite.md deleted file mode 100644 index 2d1361541d..0000000000 --- a/.changeset/go-rewrite.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'houdini-adapter-cloudflare': major -'houdini-adapter-static': major -'create-houdini': major -'houdini-svelte': major -'houdini-react': major -'houdini-adapter-auto': major -'houdini-adapter-node': major -'houdini-core': major -'houdini': major ---- - -Rewrote entire codegen pipeline in golang diff --git a/.changeset/graphql-peer-dep.md b/.changeset/graphql-peer-dep.md deleted file mode 100644 index 1adef381a8..0000000000 --- a/.changeset/graphql-peer-dep.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini': minor -'houdini-react': minor ---- - -move graphql to peerDependencies with >=16 range, automatically compatible with v17 when it releases diff --git a/.changeset/hip-fishes-end.md b/.changeset/hip-fishes-end.md deleted file mode 100644 index a1219f6d95..0000000000 --- a/.changeset/hip-fishes-end.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': minor ---- - -Implemented stdio-based protocol switch for wasm compatability diff --git a/.changeset/hip-rockets-stick.md b/.changeset/hip-rockets-stick.md deleted file mode 100644 index 6db58e414b..0000000000 --- a/.changeset/hip-rockets-stick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini": patch ---- - -Prevent panic in the presence of concurrent writes to dev server websocket diff --git a/.changeset/large-countries-fetch.md b/.changeset/large-countries-fetch.md deleted file mode 100644 index 53585e6af3..0000000000 --- a/.changeset/large-countries-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -generate artifacts pipeline deadlock condition resolve diff --git a/.changeset/lemon-files-follow.md b/.changeset/lemon-files-follow.md deleted file mode 100644 index 6689ba025a..0000000000 --- a/.changeset/lemon-files-follow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini-core": patch ---- - -Add support for @includeListID directive diff --git a/.changeset/many-geese-admire.md b/.changeset/many-geese-admire.md deleted file mode 100644 index f22d8a14ed..0000000000 --- a/.changeset/many-geese-admire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Align Result and QueryResult diff --git a/.changeset/mean-clocks-care.md b/.changeset/mean-clocks-care.md deleted file mode 100644 index ea5c33014c..0000000000 --- a/.changeset/mean-clocks-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"create-houdini": patch ---- - -Improve init flow and fix dependency issues diff --git a/.changeset/mutation-error-handling.md b/.changeset/mutation-error-handling.md deleted file mode 100644 index a71af045d7..0000000000 --- a/.changeset/mutation-error-handling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-react': patch ---- - -throw RuntimeGraphQLError from useMutation when response contains errors diff --git a/.changeset/nasty-tables-fix.md b/.changeset/nasty-tables-fix.md deleted file mode 100644 index 3c8b8ec3d9..0000000000 --- a/.changeset/nasty-tables-fix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Fix runtime subpath exports for Node ESM. diff --git a/.changeset/peer-dep-adapters.md b/.changeset/peer-dep-adapters.md deleted file mode 100644 index f4a6955491..0000000000 --- a/.changeset/peer-dep-adapters.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'houdini-adapter-auto': patch -'houdini-adapter-cloudflare': patch -'houdini-adapter-node': patch -'houdini-adapter-static': patch ---- - -Move houdini from dependencies to peerDependencies to prevent duplicate installs when adapter and houdini versions differ diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index e8f1a45b79..0000000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "mode": "pre", - "tag": "next", - "initialVersions": { - "e2e-api": "0.0.1", - "e2e-kit": "0.0.1", - "e2e-react": "0.0.0", - "e2e-svelte": "0.0.1", - "scripts": "1.0.0", - "houdini-adapter-auto": "1.3.7", - "houdini-adapter-cloudflare": "1.3.7", - "houdini-adapter-node": "1.3.7", - "houdini-adapter-static": "1.5.4", - "create-houdini": "1.2.65", - "houdini": "1.5.4", - "houdini-core": "1.5.4", - "houdini-react": "1.3.8", - "houdini-svelte": "2.1.12", - "site": "0.0.1" - }, - "changesets": [ - "abort-controller", - "anchor-typed-hrefs", - "angry-zoos-talk", - "auth-header-fix", - "clean-socks-explain", - "cloudflare-adapter-fix", - "cold-carrots-wink", - "cold-poems-wear", - "curly-olives-flash", - "cyan-sheep-compare", - "dep-bump-adapter-auto", - "dep-bump-adapter-static", - "dep-bump-create-houdini", - "dep-bump-houdini-core", - "dep-bump-houdini-react", - "dep-bump-houdini-svelte", - "dep-bump-houdini", - "eleven-parents-rest", - "error-extensions", - "fix-addmany-field-visibility", - "fix-atomic-pipeline-writes", - "fix-bin-object-plugin-resolution", - "fix-conditional-spread-null-cascade", - "fix-create-houdini-version-resolution", - "fix-cursor-pagination", - "fix-dev-fouc-css", - "fix-fragment-abstract-types", - "fix-fragment-handle-ts-type", - "fix-fragment-rerenders", - "fix-hmr-gql-deletions", - "fix-hmr-new-routes", - "fix-hmr-pipeline", - "fix-injected-plugins-null-config", - "fix-list-filter-object-variables", - "fix-mutation-order", - "fix-pageinfo-updates-direction", - "fix-paginated-connection-updates", - "fix-pagination-dedupe", - "fix-pagination-sibling-fields", - "fix-plugin-bin-missing-error", - "fix-react-vite-ssr-fouc", - "fix-refetch-cache-links-leak", - "gitignore-schema-path", - "go-compiler-features", - "go-rewrite", - "graphql-peer-dep", - "hip-fishes-end", - "hip-rockets-stick", - "large-countries-fetch", - "lemon-files-follow", - "many-geese-admire", - "mean-clocks-care", - "mutation-error-handling", - "nasty-tables-fix", - "peer-dep-adapters", - "publish-wasm-packages", - "refresh-cache-record", - "session-transform-ts-annotations", - "sharp-banks-check", - "silver-baboons-open", - "small-falcons-grow", - "stale-lizards-warn", - "stale-trainers-enjoy", - "svelte-async-component-query", - "tsconfig-stub-on-startup", - "upsert-list-operation", - "write-polled-schema", - "yellow-dancers-start" - ] -} diff --git a/.changeset/publish-wasm-packages.md b/.changeset/publish-wasm-packages.md deleted file mode 100644 index 5c68ac70f3..0000000000 --- a/.changeset/publish-wasm-packages.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'houdini-core': patch -'houdini-react': patch -'houdini-svelte': patch ---- - -publish wasm packages diff --git a/.changeset/react-routing-errors.md b/.changeset/react-routing-errors.md deleted file mode 100644 index 78135bd7e7..0000000000 --- a/.changeset/react-routing-errors.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini-react": minor ---- - -Add `+error.tsx` route-level error boundaries and a full routing error toolkit (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`, `httpError()`, `isRoutingError`, `isApiError`) for the React adapter. diff --git a/.changeset/refresh-cache-record.md b/.changeset/refresh-cache-record.md deleted file mode 100644 index 3f0ba81bd7..0000000000 --- a/.changeset/refresh-cache-record.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'houdini': minor -'houdini-core': minor ---- - -Add `record.refresh()` to refetch every document that contains a given cache record, including those that reference it only through a fragment spread. diff --git a/.changeset/session-transform-ts-annotations.md b/.changeset/session-transform-ts-annotations.md deleted file mode 100644 index c1aa1ededa..0000000000 --- a/.changeset/session-transform-ts-annotations.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-svelte': patch ---- - -strip TypeScript type annotations from load event parameter in session transform diff --git a/.changeset/sharp-banks-check.md b/.changeset/sharp-banks-check.md deleted file mode 100644 index 9f0763a430..0000000000 --- a/.changeset/sharp-banks-check.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-core': patch ---- - -Rework argument type validation to follow the GraphQL spec, fixing coercions, `@with` checks, and unknown type/enum reporting ([#1645](https://github.com/HoudiniGraphql/houdini/issues/1645)). diff --git a/.changeset/silver-baboons-open.md b/.changeset/silver-baboons-open.md deleted file mode 100644 index b832ef366c..0000000000 --- a/.changeset/silver-baboons-open.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini-react": patch ---- - -Fix preload conflicting with navigations diff --git a/.changeset/small-falcons-grow.md b/.changeset/small-falcons-grow.md deleted file mode 100644 index 22a73b92ed..0000000000 --- a/.changeset/small-falcons-grow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"create-houdini": patch ---- - -Update create script to reflect new init flow diff --git a/.changeset/stale-lizards-warn.md b/.changeset/stale-lizards-warn.md deleted file mode 100644 index 009c7044b4..0000000000 --- a/.changeset/stale-lizards-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"houdini": patch ---- - -Fix type error causing build error diff --git a/.changeset/stale-trainers-enjoy.md b/.changeset/stale-trainers-enjoy.md deleted file mode 100644 index 18fabed6b4..0000000000 --- a/.changeset/stale-trainers-enjoy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'scripts': patch ---- - -fixed /runtime resolution for node esm diff --git a/.changeset/svelte-async-component-query.md b/.changeset/svelte-async-component-query.md deleted file mode 100644 index 3b1aabb319..0000000000 --- a/.changeset/svelte-async-component-query.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini-svelte': patch ---- - -fix async component query for latest Svelte version diff --git a/.changeset/tsconfig-stub-on-startup.md b/.changeset/tsconfig-stub-on-startup.md deleted file mode 100644 index 18ac8d6f20..0000000000 --- a/.changeset/tsconfig-stub-on-startup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -Write .houdini/tsconfig.json stub in the Vite config hook so TypeScript tools don't warn about a missing extended config before the Go pipeline runs for the first time diff --git a/.changeset/upsert-list-operation.md b/.changeset/upsert-list-operation.md deleted file mode 100644 index cf72d7f8d4..0000000000 --- a/.changeset/upsert-list-operation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': minor ---- - -add `_upsert` list operation (insert if absent, update in place if present) and `_update` fragment (write field values to an existing cached record without affecting list membership) diff --git a/.changeset/write-polled-schema.md b/.changeset/write-polled-schema.md deleted file mode 100644 index 854d152f9e..0000000000 --- a/.changeset/write-polled-schema.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'houdini': patch ---- - -add writePolledSchema config option to control whether schema polling writes to disk diff --git a/.changeset/yellow-dancers-start.md b/.changeset/yellow-dancers-start.md deleted file mode 100644 index 4c004bebeb..0000000000 --- a/.changeset/yellow-dancers-start.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"houdini-react": patch -"houdini-core": patch -"houdini": patch ---- - -Added WebContainer compatible database layer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47653ac8d0..1d30de7870 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,17 +54,17 @@ jobs: - name: Check prerelease mode id: prerelease-check run: | - if [ -f ".changeset/pre.json" ]; then - echo "✅ Prerelease mode detected (.changeset/pre.json exists)" + if [ -f ".changeset/pre.json" ] && [ "$(jq -r .mode .changeset/pre.json)" = "pre" ]; then + echo "✅ Prerelease mode detected (.changeset/pre.json mode=pre)" echo "📝 Will use prerelease titles and skip snapshot releases" echo "is_prerelease=true" >> $GITHUB_OUTPUT echo "title=🚧 Prerelease Version Update" >> $GITHUB_OUTPUT echo "commit=🚧 Version Packages" >> $GITHUB_OUTPUT else - echo "✅ Regular release mode detected (no .changeset/pre.json)" + echo "✅ Regular release mode detected (not in pre mode)" echo "📝 Will use regular titles and publish snapshot releases" echo "is_prerelease=false" >> $GITHUB_OUTPUT - echo "title=📦 Release Version Update" >> $GITHUB_OUTPUT + echo "title=📦 Release latest versions" >> $GITHUB_OUTPUT echo "commit=📦 Version Packages" >> $GITHUB_OUTPUT fi @@ -108,7 +108,8 @@ jobs: createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_CONFIG_PROVENANCE: true + NPM_CONFIG_PROVENANCE: true + HOUDINI_CHANGESET_DIAGNOSTICS: '1' - name: Publish Package if: steps.changesets.outputs.hasChangesets == 'false' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cf557b6deb..0e008d058e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,37 @@ env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/ms-playwright jobs: + verify_changesets: + name: Verify Changeset Consistency + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Check houdini-core changesets include houdini + run: | + FAILED=0 + ADDED=$(git diff --name-only --diff-filter=A "origin/${{ github.base_ref }}...HEAD" -- '.changeset/*.md' | grep -v 'README.md' || true) + + if [ -z "$ADDED" ]; then + echo "No new changeset files." + exit 0 + fi + + while IFS= read -r file; do + [ -z "$file" ] && continue + frontmatter=$(awk '/^---$/{f++; if(f==2) exit; next} f==1{print}' "$file") + if echo "$frontmatter" | grep -qE "^('houdini-core'|\"houdini-core\"):"; then + if ! echo "$frontmatter" | grep -qE "^('houdini'|\"houdini\"):"; then + echo "::error file=$file::Changeset bumps houdini-core without bumping houdini. Every houdini-core bump must include a houdini bump so consumers pick it up." + FAILED=1 + fi + fi + done <<< "$ADDED" + + exit $FAILED + format: name: Format runs-on: ubuntu-latest @@ -75,8 +106,41 @@ jobs: key: pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: pnpm- - run: pnpm install --frozen-lockfile --prefer-offline + - run: pnpm run build + - run: find ./packages -path "*/bin/houdini-*" -type f | xargs chmod +x 2>/dev/null || true + - run: pnpm --filter e2e-react generate - run: pnpm run tests + e2e_unit_tests: + name: Unit Tests (e2e-react) + runs-on: ubuntu-latest + needs: [format] + if: always() + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref || github.sha }} + - uses: actions/setup-node@v3 + with: + node-version: 24.9.0 + - uses: actions/setup-go@v4 + with: + go-version: '1.23.2' + cache: true + - uses: pnpm/action-setup@v4.1.0 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.dir }} + key: pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm- + - run: pnpm install --frozen-lockfile --prefer-offline + - run: pnpm run build + - run: find ./packages -path "*/bin/houdini-*" -type f | xargs chmod +x 2>/dev/null || true + - run: pnpm --filter e2e-react unit + go_tests: name: Go Tests runs-on: ubuntu-latest @@ -162,7 +226,7 @@ jobs: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm playwright install --with-deps - - if: matrix.framework != 'e2e-react' + - if: matrix.framework == 'e2e-kit' run: pnpm run --filter ${{ matrix.framework }} build && pnpm --filter ${{ matrix.framework }} tests - if: matrix.framework == 'e2e-react' run: pnpm --filter ${{ matrix.framework }} tests diff --git a/.gitignore b/.gitignore index d2a195ee72..54ca625758 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ bin .pnp.* __tests__ test-results/ +.husky/ .svelte-kit functions diff --git a/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec138..0000000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/CLAUDE.md b/CLAUDE.md index b9e7bab552..55637d368e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,13 @@ ## Database -**Schema location**: `plugins/tests/test.go` (`WriteDatabaseSchema` const). No migration system — update it directly when adding/changing tables. +**Schema location**: the canonical schema is the `create_schema` const in `packages/houdini/src/lib/database.ts` (node is the authority — it's what runs in production). `plugins/tests/schema.sql` is generated from it via `pnpm --filter houdini sync-schema` and embedded by the Go test harness; never edit the `.sql` by hand. A vitest (`src/lib/schema.test.ts`) fails if the two drift. No migration system — on a schema change the orchestration DB is rebuilt; it's version-stamped via `schema_version` / `PRAGMA user_version` (see `connect_db`), so persisted databases from older compilers are detected as stale and recreated. **Dual SQLite backends**: `plugins/db_zombiezen.go` (native, `!wasip1`) and `plugins/db_ncruces.go` (WASI, `wasip1`). Both implement the `Conn`/`Stmt`/`Row` interfaces in `plugins/conn.go`. All DB code must go through the interface. -**FK indices**: SQLite does not auto-create indices on FK columns, and none exist in this schema. Add an explicit `CREATE INDEX` in the schema const for any FK column that appears in a `WHERE` or `JOIN`. +**FK indices**: SQLite does not auto-create indices on FK columns. Add an explicit `CREATE INDEX IF NOT EXISTS` in `create_schema` for any FK column that appears in a `WHERE` or `JOIN`. -**DEFERRABLE constraints**: Nearly all FKs are `DEFERRABLE INITIALLY DEFERRED` — constraint checks happen at `COMMIT`, not per-statement. This is intentional; pipeline steps batch-insert rows that temporarily violate FK integrity. +**FK deferral**: FKs use `ON DELETE CASCADE`; deferral is achieved at the connection level via `PRAGMA defer_foreign_keys = ON` (set in `openDb` on the TS side and in the Go connection pragmas), so constraint checks happen at `COMMIT`, not per-statement. This is intentional; pipeline steps batch-insert rows that temporarily violate FK integrity. (The Go test pool doesn't enforce FKs at all.) ## Testing @@ -22,6 +22,14 @@ Canonical example: `packages/houdini-core/plugin/validate_test.go`. TypeScript test helpers: `testConfig()` / `testConfigFile()` in `packages/houdini/src/test/index.ts`. +**Updating a golden artifact**: the artifact table tests (e.g. `selection_*_test.go`) compare the whole generated artifact with `require.Equal`, so when a codegen change shifts the expected output, do NOT hand-edit the golden surgically — that's error-prone and has caused confusion. Instead: replace the entire expected value for that case with a placeholder (`tests.Dedent(\`PLACEHOLDER\`)`), run the test, copy the `actual:` string from the failure into place, then re-run and eyeball the diff to confirm the shape changed only the way you intended. Two tips: the failure prints `actual` already Go-escaped, so it can be pasted as a plain double-quoted string (no `Dedent`/backtick juggling needed); and when a change ripples across several cases, capture each `actual:` and splice it in (anchor on the case's unique hash if keys repeat) rather than editing by hand. + +## React route typing + +Per-route TypeScript typing (which `params` and `search` a route accepts, the `RouteHrefs` union, scalar resolution) lives in **one** place: `packages/houdini-react/runtime/routes.ts`. It derives everything from the generated manifest's shape via `typeof rawManifest`, and exports `RouteHrefs`, `ParamsForRoute`, `SearchForRoute`, `NavTarget`, and `Goto`. + +Anything that navigates to or references a route (``, `goto`, `createMock`, and any future navigation/href API) must consume these shared types rather than re-deriving the rules or generating per-route type maps in Go. `formatMockFile` in `packages/houdini-react/plugin/runtime.go` is the example to follow: it imports the shared types and only generates its own mock-data types. URL construction (filling params, appending search, marshaling custom scalars) similarly goes through `buildHref` in `runtime/resolve-href.ts` — don't hand-roll it. + ## Documentation Docs live in `/docs` — framework-specific content under `/docs/svelte` and `/docs/react`, shared content (reference, extending-houdini, meta) under `/docs/shared`. @@ -33,10 +41,20 @@ When making changes, update the relevant doc pages alongside the code. This incl **Mandatory check**: before finishing any code task, run `grep -rn /docs` to find pages that reference it and verify they reflect the change. Do not skip this step. -**Internal links**: always use `~/path` (not `/path`) for cross-links between doc pages. Example: `[custom scalars](~/guides/custom-scalars)`. +**Internal links**: always use `~/path` (not `/path`) for cross-links between doc pages. Example: `[custom scalars](~/guides/custom-scalars)`. The path's section is the page's directory name with the numeric prefix stripped — `docs/shared/01-core/07-architecture.mdx` is linked as `~/core/architecture`, not `~/api/architecture`. Verify the section, not just the `~/` form. For anchor links, the `#` attaches directly to the path with no slash before it: `~/core/cache#stale-data`, never `~/core/cache/#stale-data` — a slash before the `#` breaks the anchor. + +**Prose punctuation**: avoid em-dashes in doc prose. Reach for the mark that fits the clause relationship: a period between two complete sentences, a semicolon between two closely-linked independent clauses, a colon to introduce an explanation/example/list, a comma for an appositive or trailing dependent clause, and parentheses for a mid-sentence aside. In `- term — description` bullet lists, use a colon (`- term: description`). Keep an em-dash only when nothing else reads as well (emphasis or a conversational beat). Don't trade one awkward mark for another: no double colon (a mid-sentence colon directly before a code-fence-introducing colon — use a period there), no double comma (an intro phrase like "For mutations," followed by ", since …" — keep the em-dash), and use a semicolon before conjunctive adverbs like "otherwise"/"however" to avoid a comma splice. The marketing site at `../marketing` symlinks directly into these directories, so doc changes are reflected immediately in the local dev server. ## Changesets -Every non-documentation change needs a changeset. Doc-only changes do not need one. Each branch should have exactly one changeset. Keep the description to one or two sentences — no bullet lists. +Every non-documentation change needs a changeset. Doc-only changes do not need one. Keep the description to one or two sentences — no bullet lists. + +A changeset is a **release note for users, not a development log.** Describe the feature and its user-facing API at the level someone reading a changelog cares about — never the implementation phases, validation rules, artifact internals, or which files/packages changed. For an entire feature, one high-level sentence is the goal, e.g. "Add support for progressively enhanced mutations using `@endpoint` and `useMutationForm`." + +**One changeset per user-facing API surface, not one per commit, phase, or addition.** A changeset's description is rendered verbatim into the changelog of *every* package it bumps, so the unit is the API a reader sees, not the branch. When a feature gives different packages genuinely different user-facing APIs, write one changeset per package (scoped to just that package in its frontmatter) with a description for *that* package's API, so each changelog tells its own story. Example: `@refetchable` ships as three changesets — the directive (`houdini` + `houdini-core`), `refetchableFragment` (`houdini-svelte`), and `useFragmentHandle().refetch` (`houdini-react`). When the change is the same across the packages it touches (a shared version bump, or a directive whose runtime the `houdini` package merely carries alongside `houdini-core`), keep it as a single multi-package changeset — splitting would just duplicate one sentence across changelogs. + +Within a single package, still collapse a branch's many commits/phases into one changeset for that package; check `ls .changeset/*.md` (ignoring `README.md`) before adding one, and once a package's changeset exists **leave its description alone** as later slices land. + +Whenever a changeset bumps `houdini-core`, it must include a matching bump for `houdini` (the published runtime ships from `houdini`, so a core change needs a corresponding `houdini` release). diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index 11e6b69728..0000000000 --- a/NOTES.md +++ /dev/null @@ -1,5 +0,0 @@ -- log level - -- pagination refetch - -- document inputTypes on type config diff --git a/docs/DELETED.md b/docs/DELETED.md deleted file mode 100644 index e8a4130c96..0000000000 --- a/docs/DELETED.md +++ /dev/null @@ -1,43 +0,0 @@ -# Deleted Documentation - -A record of pages that have been explicitly removed during the 2.0 docs restructure, and why. - -## Svelte-specific pages - -| Former path | Title | Reason | -|---|---|---| -| `svelte/04-advanced-topics/06-svelte-5.mdx` | Svelte 5 | Outdated; removed | -| `svelte/04-advanced-topics/07-sveltekit.mdx` | SvelteKit | Outdated; removed | -| `svelte/04-advanced-topics/03-code-generation.mdx` | Code Generation | Duplicate of Reference → Vite Plugin | -| `svelte/04-advanced-topics/04-configuration.mdx` | Configuration | Duplicate of Reference → Config | -| `svelte/04-advanced-topics/09-plugins.mdx` | Plugin Directory | Page listed third-party plugins; removed pending new plugin registry | -| `svelte/04-advanced-topics/10-custom-scalars.mdx` | Custom Scalars | Thin wrapper; relevant content merged into Reference → Config | -| `svelte/04-advanced-topics/02-graphql-documents.mdx` | Working with GraphQL Documents | Outdated API guidance; content superseded by Core Topics pages | -| `svelte/04-advanced-topics/11-subscriptions.mdx` | Subscriptions (Advanced) | Duplicate of Core Topics → Subscription | -| `svelte/05-api/06-directives.mdx` | GraphQL Directives reference | Dissolved; each directive migrated to the relevant Core/Advanced Topic page | - -## Shared pages - -| Former path | Title | Reason | -|---|---|---| -| `shared/02-custom-scalars/01-custom-scalars.mdx` | Custom Scalars | Content already covered in Reference → Config; standalone removed | - -## Extending Houdini — individual plugin pages - -These four pages were merged into a single **Default Plugins** reference page (`shared/01-reference/05-default-plugins.mdx`): - -| Former path | Title | -|---|---| -| `shared/extending-houdini/02-fetch.mdx` | Fetch Plugin | -| `shared/extending-houdini/03-query.mdx` | Query Plugin | -| `shared/extending-houdini/04-mutation.mdx` | Mutation Plugin | -| `shared/extending-houdini/05-subscription.mdx` | Subscription Plugin | - -## Directives removed from docs - -These directives were removed from the docs because they are no longer supported: - -| Directive | Former location | Reason | -|---|---|---| -| `@blocking` | `svelte/03-core-topics/01-query.mdx` | No longer supported | -| `@blocking_disable` | `svelte/05-api/06-directives.mdx` | No longer supported | diff --git a/docs/react/00-your-first-app/00-getting-started.mdx b/docs/react/00-your-first-app/00-getting-started.mdx index d81ac2429d..56b2b4822c 100644 --- a/docs/react/00-your-first-app/00-getting-started.mdx +++ b/docs/react/00-your-first-app/00-getting-started.mdx @@ -33,7 +33,7 @@ cd hello-houdini npm install ``` -If you look inside of this directory, you'll see a barebones Houdini React application with a few extra config files as well as some components we'll use to lay out our Pokédex. Don't worry too much about the extra bits right now - we'll highlight the important things as we work through this guide. When you're ready to set up your own application, head over to the [Setup](~/setup) guide. +If you look inside of this directory, you'll see a barebones Houdini React application with a few extra config files as well as some components we'll use to lay out our Pokédex. Don't worry too much about the extra bits right now - we'll highlight the important things as we work through this guide. When you're ready to set up your own application, head over to the [Setup](~/setup/getting-started) guide. Once you're ready to go, navigate to the project directory and start the dev server with `npm run dev`. diff --git a/docs/react/00-your-first-app/01-queries.mdx b/docs/react/00-your-first-app/01-queries.mdx index e09539156f..0c18da4e65 100644 --- a/docs/react/00-your-first-app/01-queries.mdx +++ b/docs/react/00-your-first-app/01-queries.mdx @@ -159,6 +159,7 @@ a variable. Doing this is relatively simple, just update the query inside of `+p query Info($id: Int! = 1) { species(id: $id) { id + pokedexNumber name flavor_text sprites { @@ -200,27 +201,29 @@ Then copy and paste this block as the last child of the `Container` component: ## Loading State -Since Houdini's router starts fetching data the moment navigation begins, your component always receives fully-loaded data as props — there is no window where `Info.species` could be `null` on the initial render. You won't crash clicking those nav buttons. +By default a route doesn't render until all of its data has resolved, which means the user stares at the previous page (or a blank screen on first load) while the request is in flight. Houdini can do better: with the `@loading` directive it renders the page right away and streams each value in as it arrives, so the Pokédex shell appears immediately and fills in as the data lands. -If you want to show a visual indicator while a navigation is in flight, wrap the subtree in a `` boundary with a fallback: +You opt in by tagging fields with `@loading`. Only tagged fields are part of the loading shape, so mark every field your component reads while the query is pending. When the directive is present, Houdini wraps the route in a Suspense boundary and streams the response: the layout and shell flush first, then the marked fields arrive as pending placeholders. -```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" -import { Suspense } from 'react' +{/* TODO(example): add `@loading` to the relevant fields in `+page.gql` */} -export default function Page({ Info }: PageProps) { - return ( - }> - {/* your content */} - - ) -} -``` +In the component, reach for `isPending` from `$houdini` to tell a placeholder apart from a resolved value and render a skeleton in its place. It's a type guard, so TypeScript narrows the field to its real type on the loaded branch. + +{/* TODO(example): import `isPending` and guard each tagged field in `+page.tsx` */} + +In the next chapter we'll push these directives down into component fragments so each component owns its own loading shape and the route doesn't have to know about it. The [Loading States guide](~/loading-data/loading-states) covers the rest: placeholder counts for lists, cascading the directive across a whole subtree, and composing loading shapes through fragments. ## Error Handling -{/* TODO: error handling via route-level hooks is not yet implemented for the React adapter. - When it lands, add a section here showing how to validate the `id` param (1–151) - and throw a 400 response for out-of-range values, mirroring the SvelteKit beforeLoad hook. */} +Not every id maps to a Pokémon. The `species` field is nullable in the schema, so navigating to an id outside the first generation (try `/152`) resolves `Info.species` to `null`, and reading `Info.species.name` throws. There are two layers worth knowing about. + +The first is graceful degradation in the page itself. Guard the nullable result so the view renders a friendly empty state instead of crashing when there's no match. + +{/* TODO(example): guard `Info.species` being null in `+page.tsx` and render a "not found" state */} + +The second is a safety net for anything you didn't anticipate. The starter already wraps the app in a top-level error boundary (`src/+index.tsx`) that catches any error thrown while rendering and shows a fallback, so an unexpected failure degrades to a single "something went wrong" page rather than a blank one. + +When you want dedicated, route-scoped error pages with real HTTP status codes, Houdini has you covered there too: a `+error.tsx` next to your route renders an error boundary for that branch of the tree, and helpers like `notFound()` let you signal a specific status from within a page. See the [Handling Errors guide](~/routing/handling-errors) for the full story. ## What's Next? diff --git a/docs/react/00-your-first-app/02-fragments.mdx b/docs/react/00-your-first-app/02-fragments.mdx index 68f54aeda4..a6abc00924 100644 --- a/docs/react/00-your-first-app/02-fragments.mdx +++ b/docs/react/00-your-first-app/02-fragments.mdx @@ -110,7 +110,7 @@ That was pretty quick so let's review what we just did: 1. We defined a new fragment in the `Sprite` component which ensured that its parent always delivered the two pieces of information it needs: the front image source and the name of the species. -1. Instead of asking for those bits of data directly as two individual props, the component now has a single prop, `species`, which we pass to `useFragment` in order to get the data we need. Notice we don't use this prop for anything in our component except to pass it into Houdini — this ensures we are only using data we asked for in our fragment. +1. Instead of asking for those bits of data directly as two individual props, the component now has a single prop, `species`, which we pass to `useFragment` in order to get the data we need. Notice we don't use this prop for anything in our component except to pass it into Houdini. This might seem surprising but it ensures we are only using data we asked for in our fragment. 1. We then updated the route's query to use the fragment we defined in the component and passed the `Info.species` reference we got from the query into our `Sprite` component as the new prop. diff --git a/docs/react/00-your-first-app/03-mutations.mdx b/docs/react/00-your-first-app/03-mutations.mdx index 70f3b538a2..4e16451718 100644 --- a/docs/react/00-your-first-app/03-mutations.mdx +++ b/docs/react/00-your-first-app/03-mutations.mdx @@ -96,7 +96,7 @@ export default function Page({ Info }: PageProps) { Now, try clicking on the grey star for any species. It should flip between gold and grey every time you click it. -That's all there is to it! You see, Houdini maintains an in-memory representation of all of the data being shown in our UI as well as which components rely on each field. Since we asked for the fields that could change as part of our mutation, Houdini was able to detect that it needed to use the new `favorite` value to update the field of the species with the matching `id` and keep our view up to date. By the way, we could have omitted that `id` in the selection — Houdini will add it behind the scenes if we don't include it explicitly. +That's all there is to it! You see, Houdini maintains an in-memory representation of all of the data being shown in our UI as well as which components rely on each field. Since we asked for the fields that could change as part of our mutation, Houdini was able to detect that it needed to use the new `favorite` value to update the field of the species with the matching `id` and keep our view up to date. By the way, we could have omitted that `id` in the selection. Houdini will add it behind the scenes if we don't include it explicitly. ## Mutating List Values @@ -157,7 +157,7 @@ import { FavoritePreview, FavoritesContainer } from '~/components' ``` -Don't worry about the `@list` directive just yet — we'll explain what it does in a bit. For now, just confirm that you have to refresh your browser in order to see the effect of clicking the star on the section at the top. Hopefully that's not too surprising since we haven't told Houdini how to update our view in response to the mutation. Connecting those dots just requires updating the mutation to look like this: +Don't worry about the `@list` directive just yet; we'll explain what it does in a bit. For now, just confirm that you have to refresh your browser in order to see the effect of clicking the star on the section at the top. Hopefully that's not too surprising since we haven't told Houdini how to update our view in response to the mutation. Connecting those dots just requires updating the mutation to look like this: ```tsx title="src/routes/[[id]]/+page.tsx&typescriptToggle=true" const toggleFavoriteMutation = graphql(` @@ -177,7 +177,7 @@ Go ahead, save the file and try clicking on the star. You should see the species If you look closely at the mutation you'll notice that we are using a fragment in the payload that you never defined. That fragment name follows a very specific form (`ListName_operation`) and it acts as a special instruction to the Houdini runtime. That fragment name references the `name` specified with the `@list` decorator and then an operation that you want to perform on the list is appended to the end of the fragment name. So the `FavoriteSpecies_toggle` fragment name will toggle the object that is referenced by the `species` field in the mutation, which will add or remove the object in the list named `FavoriteSpecies`. -We didn't even have to worry about asking for all of the right fields — once we told Houdini which list we wanted to add it to, it was able to take care of the rest. This was the reason for the `@list` decorator in the query above: we needed a way to identify the field as a target for a list operation. If we had named that list `AllFavorites` instead, the mutation would reference `AllFavorites_toggle`. +We didn't even have to worry about asking for all of the right fields. Once we told Houdini which list we wanted to add it to, it was able to take care of the rest. This was the reason for the `@list` decorator in the query above: we needed a way to identify the field as a target for a list operation. If we had named that list `AllFavorites` instead, the mutation would reference `AllFavorites_toggle`. `toggle` is not the only operation you can perform on a list. Houdini also supports `insert` and `remove` as well as more advanced features such as specifying conditions for these operations. For more information, check out the [Updating Lists](~/updating-data/updating-lists) guide. diff --git a/docs/react/00-your-first-app/04-pagination.mdx b/docs/react/00-your-first-app/04-pagination.mdx index 2f2784947d..0a021a9079 100644 --- a/docs/react/00-your-first-app/04-pagination.mdx +++ b/docs/react/00-your-first-app/04-pagination.mdx @@ -198,7 +198,7 @@ query Info($id: Int! = 1) { } ``` -When a query has `@paginate`, Houdini threads a handle prop alongside the query result — `Info$handle` in our case. The handle gives you `loadNext` and `loadPrevious` functions to step through the list. We also ask for `pageInfo` in the query so we know whether there are more pages in either direction. For a more in-depth summary of what you can do with `@paginate`, you can check out the [Pagination Guide](~/loading-data/pagination). +When a query has `@paginate`, Houdini threads a handle prop alongside the query result (`Info$handle` in our case). The handle gives you `loadNext` and `loadPrevious` functions to step through the list. We also ask for `pageInfo` in the query so we know whether there are more pages in either direction. For a more in-depth summary of what you can do with `@paginate`, you can check out the [Pagination Guide](~/loading-data/pagination). It's time to add some visuals. Update the component to destructure `Info$handle` instead of `Info`, then add an import for `MoveDisplay` and copy the following block as the second child in the right panel (between `div#species-evolution-chain` and the `nav`): @@ -257,8 +257,8 @@ You can now verify that it all works by opening the network tab and clicking on ## That's it! -This is the last topic we wanted to cover as part of the guide! Thank you so much for getting all the way through — -we really appreciate the dedication. You can 🪄 [share your achievement](http://twitter.com/intent/tweet?text=I%20just%20completed%20Houdini%27s%20guide%20%F0%9F%8E%A9%0A%F0%9F%AA%84%20And%20it%20was%20%5BYOUR%20MESSAGE%5D%0A%0AHandling%20data%20like%20a%20pro%21%20%0AWhat%20about%20you%3F%20%F0%9F%AB%B5%0Ahttps%3A%2F%2Fwww.houdinigraphql.com%2Fintro%0A%0A%F0%9F%91%80%20%40AlecAivazis%20%40jycouet) to help us! +This is the last topic we wanted to cover as part of the guide! Thank you so much for getting all the way through. +We really appreciate the dedication. You can 🪄 [share your achievement](http://twitter.com/intent/tweet?text=I%20just%20completed%20Houdini%27s%20guide%20%F0%9F%8E%A9%0A%F0%9F%AA%84%20And%20it%20was%20%5BYOUR%20MESSAGE%5D%0A%0AHandling%20data%20like%20a%20pro%21%20%0AWhat%20about%20you%3F%20%F0%9F%AB%B5%0Ahttps%3A%2F%2Fwww.houdinigraphql.com%2Fintro%0A%0A%F0%9F%91%80%20%40AlecAivazis%20%40jycouet) to help us! If there were any sections that were confusing, or changes you think would be helpful, please open up a discussion on GitHub. If you want to read more about what Houdini can do, we recommend checking out the [Queries](~/loading-data/queries) guide next. diff --git a/docs/react/01-setup/01-getting-started.mdx b/docs/react/01-setup/01-getting-started.mdx index a00e91166e..ed0b30f67c 100644 --- a/docs/react/01-setup/01-getting-started.mdx +++ b/docs/react/01-setup/01-getting-started.mdx @@ -3,7 +3,7 @@ title: Getting Started description: Setting up a Houdini project --- -Houdini is a full-stack React framework built around GraphQL. It handles routing, server-side rendering, and data fetching — and because it's built for GraphQL specifically, it can make some assumptions that general-purpose frameworks can't, which means a lot less wiring for you. +Houdini is a full-stack React framework built around GraphQL. It handles routing, server-side rendering, and data fetching. Because it's built for GraphQL specifically, it can make some assumptions that general-purpose frameworks can't, which means a lot less wiring for you. The fastest way to start a project is: @@ -36,7 +36,7 @@ vite.config.js # Vite config with the Houdini plugin houdini.config.js # Houdini configuration ``` -Routes live in `src/routes`. The filesystem is the router — new routes are new files. We'll cover how that works in detail in [Routing](~/routing/pages-and-layouts). +Routes live in `src/routes`. The filesystem is the router: new routes are new files. We'll cover how that works in detail in [Routing](~/routing/pages-and-layouts). ## IDE Setup diff --git a/docs/react/01-setup/02-deployment.mdx b/docs/react/01-setup/02-deployment.mdx index a2cfd63e3c..2a01af3ded 100644 --- a/docs/react/01-setup/02-deployment.mdx +++ b/docs/react/01-setup/02-deployment.mdx @@ -3,7 +3,7 @@ title: Preparing for Deployment description: Deploying a Houdini application --- -Houdini is deployed through platform adapters passed to the Vite plugin. For the most part, the adapter takes care of the details — it wires up a server that renders your app on the platform you're targeting. Install your adapter and pass it to the Houdini plugin in your Vite config: +Houdini is deployed through platform adapters passed to the Vite plugin. For the most part, the adapter takes care of the details: it wires up a server that renders your app on the platform you're targeting. Install your adapter and pass it to the Houdini plugin in your Vite config: ```javascript title="vite.config.js" import { houdini } from 'houdini/vite' @@ -20,7 +20,7 @@ The following adapters are available: ## Single-Page Applications -If you want to build a traditional single-page application with no server-side rendering, use `houdini-adapter-static`. The build output lands in `dist/` with a static `index.html` at the root — ready to deploy to any static host. +If you want to build a traditional single-page application with no server-side rendering, use `houdini-adapter-static`. The build output lands in `dist/` with a static `index.html` at the root, ready to deploy to any static host. ```javascript title="vite.config.js" import { houdini } from 'houdini/vite' @@ -31,4 +31,43 @@ export default { } ``` -One constraint: the static adapter is incompatible with [local APIs](/react/your-graphql-server). Since there's no server to resolve queries against, your app must point at an external GraphQL endpoint. +One constraint: the static adapter is incompatible with [local APIs](~/loading-data/graphql-server). Since there's no server to resolve queries against, your app must point at an external GraphQL endpoint. + +## Cloudflare + +`houdini-adapter-cloudflare` builds a Worker (`worker.js`) alongside a `build/assets` directory of client files. Point your `wrangler` static-assets binding at the **client assets directory**, not the build root: + +```toml title="wrangler.toml" +main = "build/worker.js" + +[assets] +directory = "build/assets" +binding = "ASSETS" +``` + +The Worker bundle carries your server-only configuration (the session signing keys from `src/server/+config.ts`, plus anything else you put there). It runs as the Worker; it must never also be served as a static file. Binding `ASSETS` at a directory that contains `worker.js` would publish those secrets over HTTP. + +## Keeping secrets out of what you serve + +Everything under `src/server` (including [`src/server/+config.ts`](~/guides/authentication)) compiles into the server bundle only, so the session signing keys never land in `houdini.config` or the client bundle. The one rule to honor at deploy time is the same on every platform: serve only the **client assets** directory as static files, and never expose the server bundle (the Cloudflare Worker, or the Node server's `build/ssr`) over your static-file route. The built-in Node adapter already confines its static route to `build/assets` for you; on platforms where you configure static serving yourself, scope it to the client assets directory. + +## Scaling to multiple instances + +Most of deployment is identical whether you run one server or many. Two things only start to matter once requests can land on **more than one instance** (load-balanced, autoscaled, or serverless), and both live in `src/server/+config.ts`. On a single long-lived server you can skip this. + +**Set `auth.sessionKeys` explicitly.** Without them, Houdini signs sessions with a random key generated per process. That's fine on one server, but across instances the keys won't match (a cookie signed by one instance won't verify on another), and they don't survive a restart or redeploy. Point them at a secret your instances share, usually from the environment: + +```ts title="src/server/+config.ts&typescriptToggle=true" +import type { ServerConfigFile } from 'houdini' + +export default { + auth: { + // pass more than one to rotate: the first signs, the rest still verify + sessionKeys: [process.env.SESSION_SECRET!], + }, +} satisfies ServerConfigFile +``` + +**Give `auth.consumedTokenStore` a shared store.** The single-use check that stops `@session` token replays is kept in memory by default, so one instance can't see a token another already honored. Point it at something shared (a few lines of Redis). It's a one-method interface that expires its own entries, so there's nothing to maintain; see the [authentication guide](~/guides/authentication) for the shape. + +Even on a single instance, configuring `sessionKeys` is good practice so sessions survive a redeploy rather than resetting with the process. diff --git a/docs/react/02-routing/01-pages-and-layouts.mdx b/docs/react/02-routing/01-pages-and-layouts.mdx index bdbad3ab3c..022972f528 100644 --- a/docs/react/02-routing/01-pages-and-layouts.mdx +++ b/docs/react/02-routing/01-pages-and-layouts.mdx @@ -3,6 +3,8 @@ title: Pages and Layouts description: File-based routing in Houdini's React framework --- +import Notice from '@components/docs/Notice.astro' + Houdini uses a filesystem-based router. Routes live in `src/routes`, and the path of a file maps directly to the URL it handles. ## Pages and Layouts @@ -42,16 +44,52 @@ src/routes/(app)/show/+page.jsx → /show src/routes/(auth)/login/+page.jsx → /login ``` -The `(app)` and `(auth)` segments are invisible to the router — they exist purely to let you share a `+layout.jsx` across a subset of routes without that grouping appearing in the URL. +The `(app)` and `(auth)` segments are invisible to the router; they exist purely to let you share a `+layout.jsx` across a subset of routes without that grouping appearing in the URL. ## Route Parameters Dynamic segments are marked with square brackets: -- `src/routes/show/[id]/+page.jsx` — matches `/show/1`, `/show/abc`, etc. -- `src/routes/assets/[...filepath]/+page.jsx` — rest syntax, matches any number of segments -- `src/routes/[[lang]]/home/+page.jsx` — double brackets mark an optional parameter +- `src/routes/show/[id]/+page.jsx` matches `/show/1`, `/show/abc`, etc. +- `src/routes/assets/[...filepath]/+page.jsx` uses rest syntax, matching any number of segments +- `src/routes/[[lang]]/home/+page.jsx` uses double brackets to mark an optional parameter + +Optional parameters cannot follow rest parameters, because the rest segment is greedy and will consume everything after it. + +A query's variables can come from the URL's query string too, not just its path. See [Search Params](~/loading-data/queries#search-params) for how a route's nullable variables map onto `?key=value`. + +## Response Headers + +A `+page.jsx` or `+layout.jsx` can export a `headers()` function to set HTTP response headers for the route. This is the place for cache directives, security headers, and anything else you'd otherwise have to configure at the CDN or adapter level: + +```jsx title="src/routes/+page.tsx&typescriptToggle=true" +export function headers() { + return { + 'Cache-Control': 'public, max-age=3600', + 'X-Frame-Options': 'DENY', + } +} + +export default () =>
Hello Houdini!
+``` + +When a page renders, Houdini evaluates the `headers()` exports of the page and every layout in its chain, then merges the results. On a conflict the page wins over its layouts, and an inner layout wins over an outer one. + +Because layouts apply to everything beneath them, the root `+layout.jsx` is the natural place for app-wide headers like `Content-Security-Policy`, `Strict-Transport-Security`, or `X-Frame-Options` that you want on every document response: + +```jsx title="src/routes/+layout.tsx&typescriptToggle=true" +export function headers() { + return { + 'Content-Security-Policy': "default-src 'self'", + 'Strict-Transport-Security': 'max-age=63072000', + } +} +``` + +These headers apply to the page (document) responses Houdini renders. They do not affect responses from your GraphQL endpoint, which is handled separately. -Optional parameters cannot follow rest parameters — the rest segment is greedy and will consume everything after it. + + `headers()` only ever runs on the server (it is stripped from the client bundle), so it's safe to read server-only secrets or environment variables inside it. It runs before the response is streamed, so it can't depend on query data; headers must be determinable from the request alone. + diff --git a/docs/react/02-routing/02-navigation.mdx b/docs/react/02-routing/02-navigation.mdx index c32f5d8536..cf39e511cd 100644 --- a/docs/react/02-routing/02-navigation.mdx +++ b/docs/react/02-routing/02-navigation.mdx @@ -16,10 +16,10 @@ import { Link } from '$houdini' Houdini knows every valid route in your app, so `to` is type-checked at compile time. For parameterized routes, pass a `params` object and Houdini interpolates it into the URL at render time: ```tsx -// static route — TypeScript verifies "/shows" is a real page +// static route: TypeScript verifies "/shows" is a real page All Shows -// parameterized route — TypeScript requires params.id +// parameterized route: TypeScript requires params.id {show.title} @@ -45,6 +45,42 @@ External URLs, fragments, relative paths, and other non-app hrefs are accepted w Relative ``` +## Search Params + +The `search` prop configures the query string of a `Link`. Pass an object and Houdini will serialize it into the URL: + +```tsx +// → /shows?genre=comedy +Comedies +``` + +The route's nullable query variables show up as typed keys, so a mistyped value (a string where the query wants an `Int`) is a compile error rather than a silently empty result. `search` isn't limited to them, though: extra keys are allowed so the query string can also hold UI-only state that no query reads (a selected tab, an open modal). A list variable accepts an array and serializes as repeated keys: + +```tsx +// → /shows?tag=comedy&tag=drama +Both +``` + +A search param backed by a [custom scalar](~/guides/custom-scalars) is marshaled into the URL the same way a path param is, so a `DateTime` accepts a `Date` and serializes with your config's `marshal` function. On the read side, [`useRoute().search`](~/api-reference/useRoute) unmarshals it back to its runtime type (a `Date`). One caveat: because the URL is string-only, values are decoded with `JSON.parse` before unmarshaling, so a custom scalar whose value is `"true"` or `"123"` comes back as a boolean or number. + +These params aren't just decoration on the URL. They flow straight back into the route's query, and changing them re-runs it, so a `` that swaps `?genre=comedy` for `?genre=drama` refetches the page with the new filter. See [Search Params](~/loading-data/queries#search-params) for the data side of the story. + +`goto` accepts the same typed target as `` (a `to` route plus its `params` and `search`) and builds the URL for you, with the same compile-time checks: + +```tsx +// → /shows?genre=comedy +goto({ to: '/shows', search: { genre: 'comedy' } }) + +// → /shows/123 +goto({ to: '/shows/[id]', params: { id: show.id } }) +``` + +It also accepts a ready-made URL string as an escape hatch, which works the same way for query strings as it does for paths. Pass the whole URL, search string included: + +```tsx +goto(`/shows?genre=${encodeURIComponent(genre)}`) +``` + ## Disabled Pass `disabled` to prevent navigation. The `href` attribute is omitted so the element is inert, and you can add a class to style it: @@ -70,9 +106,9 @@ Add `preload` to a `` and Houdini will begin fetching when the user hovers By default this fetches both the page component and its data. You can narrow it: -- `"data"` — only the GraphQL data -- `"component"` — only the JavaScript bundle -- `"page"` — both (default) +- `"data"`: only the GraphQL data +- `"component"`: only the JavaScript bundle +- `"page"`: both (default) ```tsx All Shows @@ -80,18 +116,19 @@ By default this fetches both the page component and its data. You can narrow it: ## Imperative Navigation -When you need to navigate in response to something other than a click — after a form submission, inside an effect, or from a callback — call `goto` from `useLocation`: +When you need to navigate in response to something other than a click (after a form submission, inside an effect, or from a callback), call `goto` from `useRoute`: -```tsx -import { useLocation } from '$houdini' +```tsx title="src/routes/search/+page.tsx&typescriptToggle=true" +import type { PageRoute } from './$types' +import { useRoute } from '$houdini' export function SearchForm() { - const { goto } = useLocation() + const { goto } = useRoute() function handleSubmit(e: React.FormEvent) { e.preventDefault() const query = new FormData(e.currentTarget).get('q') - goto(`/search?q=${encodeURIComponent(query as string)}`) + goto({ to: '/search', search: { q: query as string } }) } return ( @@ -103,13 +140,14 @@ export function SearchForm() { } ``` -`useLocation` also exposes `pathname` and `params` if you need to read the current URL — for example, to mark a link as active: +`useRoute` also exposes `pathname`, `params`, and `search` if you need to read the current URL, for example to mark a link as active: ```tsx -import { useLocation } from '$houdini' +import type { PageRoute } from './$types' +import { useRoute } from '$houdini' export function NavLink({ href, label }: { href: string; label: string }) { - const { pathname } = useLocation() + const { pathname } = useRoute() return ( {label} @@ -118,4 +156,4 @@ export function NavLink({ href, label }: { href: string; label: string }) { } ``` -See [`useLocation`](~/api-reference/useLocation) for the full API. +See [`useRoute`](~/api-reference/useRoute) for the full API. diff --git a/docs/react/02-routing/03-file-conventions.mdx b/docs/react/02-routing/03-file-conventions.mdx index abc2c6b95f..2cc71d6bc2 100644 --- a/docs/react/02-routing/03-file-conventions.mdx +++ b/docs/react/02-routing/03-file-conventions.mdx @@ -3,7 +3,7 @@ title: File Conventions description: Special files in Houdini's React framework --- -Every file Houdini treats as special starts with `+`. Everything else — utility functions, shared components, stylesheets — is ignored by the router and can live wherever makes sense for your project. The `+` prefix might look a little odd at first, but it serves two purposes: it makes Houdini files instantly recognizable in any directory listing, and it lets Houdini add new reserved filenames in the future without any risk of colliding with files you've already created. +Every file Houdini treats as special starts with `+`. Everything else (utility functions, shared components, stylesheets) is ignored by the router and can live wherever makes sense for your project. The `+` prefix might look a little odd at first, but it serves two purposes: it makes Houdini files instantly recognizable in any directory listing, and it lets Houdini add new reserved filenames in the future without any risk of colliding with files you've already created. ## Route Files @@ -13,17 +13,21 @@ These files go inside `src/routes/` and can appear in any route directory. |---|---| | `+page.jsx` | The component rendered at this route. Receives query results as props when a `+page.gql` is present. | | `+layout.jsx` | Wraps all child routes. Receives a `children` prop for the nested content, plus query results from `+layout.gql` if defined. | +| `+error.jsx` | Renders an [error boundary](~/routing/handling-errors) for this route and everything nested below it. Receives an `errors` prop plus any layout query results in scope. | | `+page.gql` | GraphQL query (or queries) for this page. Each query's result is passed as a prop named after the query. | | `+layout.gql` | GraphQL query for this layout. Results are available to the layout component and all of its descendants. | -## API Files +A `+page.jsx` or `+layout.jsx` can also export a `headers()` function to set [response headers](~/routing/pages-and-layouts#response-headers) for the route. -These files go in `src/api/` and are only relevant if you're using a [local GraphQL server](~/loading-data/graphql-server). +## Server Files + +These files go in `src/server/`. Houdini compiles this directory into the server bundle and **never imports it from the client**, so it is the one place that is safe to read environment variables, embed secrets, or hold credentials (session signing keys, OAuth client secrets, database URLs) with no risk of any of it reaching the browser. | File | Purpose | |---|---| -| `+schema.ts` | Exports an executable GraphQL schema. Houdini wraps it in a Yoga instance automatically. | -| `+yoga.ts` | Exports a custom Yoga instance — use this to inject context, add plugins, or customize behavior. Requires `+schema.ts` to also be present. | +| `+config.ts` | Server-only configuration (typed `ServerConfigFile`): the session signing keys, the session and GraphQL endpoints, OAuth `providers` and `onSignIn`, and the form CSRF settings. See [authentication](~/guides/authentication). | +| `+schema.ts` | Exports an executable GraphQL schema. Houdini wraps it in a Yoga instance automatically. Only relevant with a [local GraphQL server](~/loading-data/graphql-server). | +| `+yoga.ts` | Exports a custom Yoga instance. Use this to inject context, add plugins, or customize behavior. Requires `+schema.ts` to also be present. | ## App-Level Files diff --git a/docs/react/02-routing/04-error-boundaries.mdx b/docs/react/02-routing/04-handling-errors.mdx similarity index 73% rename from docs/react/02-routing/04-error-boundaries.mdx rename to docs/react/02-routing/04-handling-errors.mdx index 58ae31c734..027fab174f 100644 --- a/docs/react/02-routing/04-error-boundaries.mdx +++ b/docs/react/02-routing/04-handling-errors.mdx @@ -1,5 +1,5 @@ --- -title: Error Boundaries +title: Handling Errors description: Handling errors at the route level in Houdini's React framework --- @@ -18,11 +18,11 @@ export default function DashboardError({ errors, children }: ErrorProps) { } ``` -The `errors` prop is `Array` — it will contain whichever error was thrown, whether that's a network failure, a GraphQL response error, or anything else thrown inside the page tree. +The `errors` prop is `Array`. It will contain whichever error was thrown, whether that's a network failure, a GraphQL response error, or anything else thrown inside the page tree. ## The `children` prop -The error component also receives `children` — the page component that failed. You can render it if you want to show partial content alongside the error, but in most cases you won't. It's there when you need it. +The error component also receives `children`, the page component that failed. You can render it if you want to show partial content alongside the error, but in most cases you won't. It's there when you need it. ## Query data @@ -35,7 +35,7 @@ import type { ErrorProps } from './$types' export default function RootError({ RootQuery, errors }: ErrorProps) { return (
-

Welcome, {RootQuery.viewer.name} — but something went wrong.

+

Welcome, {RootQuery.viewer.name}, but something went wrong.

{errors[0].message}

) @@ -58,15 +58,15 @@ src/routes/ ## GraphQL errors -By default Houdini throws GraphQL response errors so they're catchable by the error boundary. The thrown value is unwrapped into the `errors` array, so each entry is a `GraphQLError` object with at least a `message` field (and optionally `path`, `locations`, and `extensions`). [Learn how to type the `extensions` field.](~/guides/error-handling/#typing-error-extensions) +By default Houdini throws GraphQL response errors so they're catchable by the error boundary. The thrown value is unwrapped into the `errors` array, so each entry is a `GraphQLError` object with at least a `message` field (and optionally `path`, `locations`, and `extensions`). [Learn how to type the `extensions` field.](~/guides/error-handling#typing-error-extensions) ### SSR and query errors -Error boundaries catch GraphQL errors correctly during client-side navigation. Direct page loads (SSR) are different: Houdini streams the page shell to the browser immediately while queries are in flight. Components waiting on data are suspended and retried asynchronously once the response arrives. If a query returns an error during that retry, React is already mid-stream and cannot route the error to a class-based error boundary — the server returns a 500 and the browser shows a blank error page. +Error boundaries catch GraphQL errors correctly during client-side navigation. Direct page loads (SSR) are different: Houdini streams the page shell to the browser immediately while queries are in flight. Components waiting on data are suspended and retried asynchronously once the response arrives. If a query returns an error during that retry, React is already mid-stream and cannot route the error to a class-based error boundary, so the server returns a 500 and the browser shows a blank error page. Routing errors (`notFound()`, `redirect()`, etc.) are not affected because they throw synchronously before any suspension happens. -The recommended fix is to add a `@loading` directive to your page query. This tells Houdini to generate a Suspense boundary in the right place — between the error boundary and the suspended component — so that React can hand off errors correctly during SSR. It also gives the browser something to show while the query is in flight. We can't exactly tell you where to put it in your app, that depends on how error-prone certain fields are in your API. But you should try to put some kind of loading state high up in your component tree so you load the minimal amount of information for a reasonable experience. +The recommended fix is to add a `@loading` directive to your page query. This tells Houdini to generate a Suspense boundary in the right place (between the error boundary and the suspended component) so React can hand off errors correctly during SSR. It also gives the browser something to show while the query is in flight. Place it as high up in your component tree as makes sense: the goal is to suspend on the minimum data needed for a usable first paint, so that lower-level errors have a boundary to land on. ```graphql title="src/routes/dashboard/+page.gql" query DashboardQuery @loading { @@ -76,9 +76,9 @@ query DashboardQuery @loading { } ``` -With `@loading` in place, Houdini generates a loading state component from your `+page.tsx` (see the [loading states guide](~/routing/loading-states) for how to define it). React sends that loading state in the initial HTML, holds the stream open until the query resolves, and either replaces it with the page content or lets the error reach the nearest `+error.tsx`. It's important to note that when you do this, your application will return a status code 200 even if an error is bubbled up to the error state. This is just how React's current implementation of streaming SSR works. There's no way around it. +With `@loading` in place, Houdini generates a loading state component from your `+page.tsx` (see the [loading states guide](~/loading-data/loading-states) for how to define it). React sends that loading state in the initial HTML, holds the stream open until the query resolves, and either replaces it with the page content or lets the error reach the nearest `+error.tsx`. It's important to note that when you do this, your application will return a status code 200 even if an error is bubbled up to the error state. This is just how React's current implementation of streaming SSR works. There's no way around it. -Client-side navigations don't need any of this — the error boundary catches query errors there regardless. +Client-side navigations don't need any of this; the error boundary catches query errors there regardless. ## Routing utilities @@ -116,7 +116,7 @@ export default function ShowError({ errors }: ErrorProps) { ### 404 and static URL matching -When no exact route matches the incoming URL, Houdini automatically finds the deepest layout whose static URL prefix matches, renders its `+error.tsx` as the 404 page, and returns HTTP 404 before streaming begins — no catch-all route needed. +When no exact route matches the incoming URL, Houdini automatically finds the deepest layout whose static URL prefix matches, renders its `+error.tsx` as the 404 page, and returns HTTP 404 before streaming begins. No catch-all route needed. ### redirect() @@ -128,7 +128,7 @@ import type { PageProps } from './$types' export default function DashboardPage({ ViewerQuery }: PageProps) { if (!ViewerQuery.viewer) { - redirect(302, '/login') + return redirect(302, '/login') } return
Welcome, {ViewerQuery.viewer.name}
} diff --git a/docs/react/03-loading-data/01-queries.mdx b/docs/react/03-loading-data/01-queries.mdx index 7f749da6ea..9379b49d3e 100644 --- a/docs/react/03-loading-data/01-queries.mdx +++ b/docs/react/03-loading-data/01-queries.mdx @@ -9,7 +9,7 @@ import AsideAccordion from "@components/docs/AsideAccordion.astro" The core idea behind Houdini's data loading model is that queries live next to the routes that use them. A `+page.gql` file defines what data a route needs, and Houdini handles the fetching, caching, and prop-threading before your component ever renders. -Fetching starts at the moment navigation begins — not when the component mounts. This follows the [render-as-you-fetch](https://react.dev/reference/rsc/server-components) pattern recommended by the React docs: by the time your component renders for the first time, the data is already in the cache and arrives as props with no loading state needed. +Fetching starts at the moment navigation begins, not when the component mounts. This follows the [render-as-you-fetch](https://react.dev/reference/rsc/server-components) pattern recommended by the React docs: by the time your component renders for the first time, the data is already in the cache and arrives as props with no loading state needed. ## Basic Usage @@ -26,8 +26,10 @@ query ShowList { The result arrives as a prop named after the query. Destructure it directly in the function signature: -```jsx title="src/routes/shows/+page.jsx" -export default function ShowsPage({ ShowList }) { +```tsx title="src/routes/shows/+page.tsx&typescriptToggle=true" +import type { PageProps } from './$types' + +export default function ShowsPage({ ShowList }: PageProps) { return (
    {ShowList.shows.map((show) => ( @@ -38,13 +40,13 @@ export default function ShowsPage({ ShowList }) { } ``` -The prop name matches the query name exactly — `ShowList` not `data` or `props.ShowList`. Houdini enforces this through static analysis, so it needs to appear in the destructuring pattern in the function signature. +The prop name matches the query name exactly: `ShowList`, not `data` or `props.ShowList`. This isn't just a convention. Houdini discovers which queries a route depends on by statically analyzing your component's signature, so the query name **must** appear in the destructuring pattern. If you accept a generic `props` argument and reach for `props.ShowList`, Houdini won't see the dependency and the data won't be wired up. The prevailing wisdom is that queries should live inside the components that use them. Houdini agrees with the principle (we still colocate fragments in our component source, after all) but disagrees about where the boundary should sit. -The problem is timing. By the time JavaScript is loaded and runs, it's too late to start a network request without a waterfall: navigate → download JS → parse → run component → discover query → fetch → render. The query has to live somewhere the router can find it before any JavaScript runs — outside the component tree entirely. Once you're inside a JavaScript runtime, it's too late. +The problem is timing. By the time JavaScript is loaded and runs, it's too late to start a network request without a waterfall: navigate → download JS → parse → run component → discover query → fetch → render. The query has to live somewhere the router can find it before any JavaScript runs, outside the component tree entirely. Once you're inside a JavaScript runtime, it's too late. The `.gql` file still lives next to the `.jsx` file. This is just colocation by directory rather than file. @@ -74,7 +76,7 @@ export default function RootLayout({ AppShell, children }) { } ``` -All queries in the chain — the page query, the layout query, any ancestor layout queries — run in parallel before the route renders. Child pages don't need to re-fetch this data either; it's already in the cache from the layout's load. +All queries in the chain (the page query, the layout query, any ancestor layout queries) run in parallel before the route renders. Child pages don't need to re-fetch this data either; it's already in the cache from the layout's load. ## Query Variables @@ -91,9 +93,27 @@ query ShowDetail($id: ID!) { The `$id` variable is populated from the `[id]` segment with no extra configuration. +### Search Params + +A nullable variable whose name doesn't match a route parameter is populated from the query string, so a search route with an optional filter needs no extra wiring: + +```graphql title="src/routes/shows/+page.gql" +query ShowSearch($genre: String, $sort: SortOrder) { + shows(genre: $genre, sort: $sort) { + title + } +} +``` + +Visiting `/shows?genre=comedy` runs the query with `$genre` set to `"comedy"` and `$sort` left null. Because the values come straight from the URL, changing the query string re-runs the query: navigating from `/shows?genre=comedy` to `/shows?genre=drama` refetches with the new value, exactly the way a route parameter would. Repeated keys fill list variables, so `?tag=a&tag=b` lands in a `[String!]` variable as `["a", "b"]`. + +Only nullable variables are eligible. A missing search param resolves to null, which means it can never turn a valid URL into a failing request. That restriction is also the reason the distinction shows up at build time. A required variable has to be satisfiable from the URL alone, so if a `+page.gql` declares a non-null variable that isn't backed by a route segment and has no default, Houdini reports it during generation rather than letting the query fail later. The fix is one of three things: add the matching `[segment]`, give the variable a default value, or make it nullable so it can ride in on the query string. + +To construct these URLs with type safety rather than hand-writing query strings, the [`Link`](~/routing/navigation#search-params) component takes a typed `search` prop derived from the destination route's variables. + ## Imperative Handles -Sometimes you need to do more than display the initial data — trigger a refetch, load the next page, or respond to user input. The `$handle` prop gives you an imperative handle for the query: +Sometimes you need to do more than display the initial data: trigger a refetch, load the next page, or respond to user input. The `$handle` prop gives you an imperative handle for the query: ```jsx title="src/routes/shows/+page.jsx" export default function ShowsPage({ ShowList$handle }) { @@ -112,13 +132,13 @@ export default function ShowsPage({ ShowList$handle }) { If you'd rather avoid the `.data` accessor, destructure both props and use them side by side. -The methods available on the handle depend on the query's directives. A query with `@paginate` will also have `loadNextPage` and `loadPreviousPage`. See [Pagination](~/loading-data/pagination) for details. +The methods available on the handle depend on the query's directives. A query with `@paginate` will also have `loadNextPage` and `loadPreviousPage`. See [Pagination](~/loading-data/pagination) for details. For the full handle API, see [`useQueryHandle`](~/api-reference/useQueryHandle). ## TypeScript -Every route gets a generated `./$types` module that exports typed props for that file. Import `PageProps` in a `+page.tsx` and your query results, handles, and route params are all typed automatically: +Every route gets a generated `./$types` module that exports typed props for that file. Import `PageProps` in a `+page.tsx` and your query results and handles are typed automatically. (Route params and search live on the generated `PageRoute` type, read via [`useRoute`](~/api-reference/useRoute), so they can't be accidentally destructured off the component props.) -```tsx title="src/routes/shows/+page.tsx" +```tsx title="src/routes/shows/+page.tsx&typescriptToggle=false" import type { PageProps } from './$types' export default function ShowsPage({ ShowList }: PageProps) { @@ -134,7 +154,7 @@ export default function ShowsPage({ ShowList }: PageProps) { Layout components use `LayoutProps` from the same module, which includes `children` alongside any layout query results: -```tsx title="src/routes/+layout.tsx" +```tsx title="src/routes/+layout.tsx&typescriptToggle=false" import type { LayoutProps } from './$types' export default function RootLayout({ AppShell, children }: LayoutProps) { diff --git a/docs/react/03-loading-data/02-fragments.mdx b/docs/react/03-loading-data/02-fragments.mdx index 2314eb95d7..f3eb26a1c6 100644 --- a/docs/react/03-loading-data/02-fragments.mdx +++ b/docs/react/03-loading-data/02-fragments.mdx @@ -55,11 +55,11 @@ export default function ShowsPage({ ShowList }) { } ``` -The `show` prop passed to `ShowCard` is an opaque reference — `useFragment` unwraps it into the typed data the component actually needs. +The `show` prop passed to `ShowCard` is an opaque reference; `useFragment` unwraps it into the typed data the component actually needs. ## Fragment Arguments -When a component needs to parameterize its data requirements — for example, requesting a profile picture at a specific size — use `@arguments` to declare the parameters and `@with` to pass values when spreading the fragment. +When a component needs to parameterize its data requirements (for example, requesting a profile picture at a specific size), use `@arguments` to declare the parameters and `@with` to pass values when spreading the fragment. Default values are provided with the `default` key: @@ -91,9 +91,98 @@ query AllUsers { If you use fragment arguments on a field that is also marked for list operations, you must pass the variable value when performing the operation. +## Plural Fragments + +A fragment is normally spread on a single record, so `useFragment` hands back a single object. When the fragment lives on a list field, though, we end up with an array of references and no clean way to read them all at once. We can't call `useFragment` inside a `.map()` (that breaks the rules of hooks), so without any help we'd be stuck threading each item through its own component. + +The `@plural` directive solves this by marking the fragment as list shaped. The reference becomes an array and `useFragment` returns an array of data, so a component can render the whole list: + +```jsx title="src/lib/ShowList.jsx" +import { graphql, useFragment } from '$houdini' + +export function ShowList({ shows }) { + const data = useFragment(shows, graphql(` + fragment ShowListRow on Show @plural { + title + posterUrl + } + `)) + + return ( +
      + {data.map((show) => ( +
    • {show.title}
    • + ))} +
    + ) +} +``` + +The parent spreads the fragment inside the list field and passes the whole list down in one go: + +```graphql title="src/routes/shows/+page.gql" +query ShowList { + shows { + ...ShowListRow + } +} +``` + +```jsx title="src/routes/shows/+page.jsx" +import { ShowList } from '../../lib/ShowList' + +export default function ShowsPage({ ShowList: data }) { + return +} +``` + +A `@plural` fragment has to be spread on a list field, since that's the only place an array of references can come from. Spreading it anywhere else is a codegen error. + +## Refetchable Fragments + +Sometimes a component needs to reload its own data with different arguments. Mark the fragment with `@refetchable` and read it with [`useFragmentHandle`](~/api-reference/useFragmentHandle) instead of `useFragment`. The handle hands back a `refetch` method alongside the data: + +```tsx +import { graphql, useFragmentHandle } from '$houdini' +import type { UserInfo } from '$houdini' + +export function UserInfo({ user }: { user: UserInfo }) { + const { data, refetch } = useFragmentHandle(user, graphql(` + fragment UserInfo on User @refetchable @arguments(filter: { type: "String" }) { + friends(filter: $filter) { + name + } + } + `)) + + return ( + <> + {data?.friends.map((friend) =>
    {friend.name}
    )} + + + ) +} +``` + +Calling `refetch` re-runs the fragment against the network with the arguments we hand it, layered over whatever it was last loaded with, so we only pass the values we actually want to change. The record's id is figured out for us from the data already on screen. + +The initial values come from wherever the fragment is spread, the same as any fragment that takes arguments. The parent passes them with `@with`: + +```graphql +query UserPage { + user { + ...UserInfo @with(filter: "aki") + } +} +``` + +The fragment has to live on a type Houdini can look up on its own, which in practice means one that implements `Node` or has a custom resolver configured. That's the same requirement paginated fragments carry: under the hood we embed the fragment in a query keyed by the record's id and re-run that. + +`@refetchable` can't be combined with `@paginate` on the same fragment. A paginated fragment is already refetchable on its own, so the two together is a compile-time error. + ## Fragment Masking -Fragment masking keeps components properly encapsulated by ensuring they can only access the fields they explicitly declared — not fields pulled in by sibling fragments. For a deeper look at why this matters, see the [Fragment Colocation guide](https://gql-tada.0no.co/guides/fragment-colocation) from gql.tada. +Fragment masking keeps components properly encapsulated by ensuring they can only access the fields they explicitly declared, not fields pulled in by sibling fragments. For a deeper look at why this matters, see the [Fragment Colocation guide](https://gql-tada.0no.co/guides/fragment-colocation) from gql.tada. By default, fields selected by a fragment are not accessible directly on the parent object. You can disable masking globally with `defaultFragmentMasking: "disable"` in your config, or per-fragment with `@mask_disable`: @@ -109,7 +198,7 @@ query CurrentUser { With `@mask_disable`, all fields from `UserProfile` are accessible directly on `me` alongside `uuid`. `UserMeta` fields remain masked. This does not apply recursively unless inner fragments also carry the directive. -{/* TODO: wire up the TS/JS toggle for the Component Fields examples — the JS version uses graphql() + prop param, not derivable by simple transformation */} +{/* TODO: wire up the TS/JS toggle for the Component Fields examples; the JS version uses graphql() + prop param, not derivable by simple transformation */} ## Component Fields @@ -117,7 +206,7 @@ Component fields let components register themselves as fields directly on GraphQ Define the component's data requirements using the `@componentField` directive. In TypeScript, use the `GraphQL` type utility: -```tsx title="src/lib/UserAvatar.tsx" +```tsx title="src/lib/UserAvatar.tsx&typescriptToggle=true" import type { GraphQL } from '$houdini' type Props = { @@ -149,7 +238,7 @@ export default function UserAvatar({ user }) { } ``` -The registered field can then be used directly in queries and rendered as a component — no imports needed in the route: +The registered field can then be used directly in queries and rendered as a component, with no imports needed in the route: ```graphql query Profile { diff --git a/docs/react/03-loading-data/04-pagination.mdx b/docs/react/03-loading-data/04-pagination.mdx index 299fe80e0e..45ba45d527 100644 --- a/docs/react/03-loading-data/04-pagination.mdx +++ b/docs/react/03-loading-data/04-pagination.mdx @@ -3,7 +3,7 @@ title: Pagination description: Paginating data in Houdini's React framework --- -Add `@paginate` to any list field and Houdini handles the cursor management, cache merging, and page tracking. The handle gives you the controls. Houdini supports both cursor-based and offset/limit pagination — the directive is the same either way, Houdini detects the strategy from the field's arguments. +Add `@paginate` to any list field and Houdini handles the cursor management, cache merging, and page tracking. The handle gives you the controls. Houdini supports both cursor-based and offset/limit pagination. The directive is the same either way; Houdini detects the strategy from the field's arguments. ## Cursor-Based Pagination @@ -78,7 +78,7 @@ query ShowList { ## Fragment Pagination -Pagination can live in a fragment rather than the page query. Use `useFragmentHandle` instead of `useFragment` — it returns an object with the data and handle methods merged together: +Pagination can live in a fragment rather than the page query. Use `useFragmentHandle` instead of `useFragment`; it returns an object with the data and handle methods merged together: ```jsx title="src/lib/ShowEpisodes.tsx&typescriptToggle=true" import { graphql, useFragmentHandle } from '$houdini' diff --git a/docs/react/03-loading-data/05-subscriptions.mdx b/docs/react/03-loading-data/05-subscriptions.mdx index 45b524200f..ae8a0a3d15 100644 --- a/docs/react/03-loading-data/05-subscriptions.mdx +++ b/docs/react/03-loading-data/05-subscriptions.mdx @@ -58,7 +58,7 @@ The subscription is active for as long as the component is mounted and tears dow ## Cache Integration -Subscription bodies support the same list operations as mutations — `@append`, `@prepend`, `@remove`, and `@allLists`. This means a subscription can maintain a live list without any manual state management: +Subscription bodies support the same list operations as mutations: `@append`, `@prepend`, `@remove`, and `@allLists`. This means a subscription can maintain a live list without any manual state management: ```graphql subscription NewComment($postId: ID!) { diff --git a/docs/react/03-loading-data/06-graphql-server.mdx b/docs/react/03-loading-data/06-graphql-server.mdx index e17ec3ee19..2faff18db0 100644 --- a/docs/react/03-loading-data/06-graphql-server.mdx +++ b/docs/react/03-loading-data/06-graphql-server.mdx @@ -3,13 +3,13 @@ title: Your GraphQL Server description: Co-locating your GraphQL API with your Houdini app --- -If your backend lives in the same codebase — or doesn't exist yet — Houdini includes a built-in GraphQL server. Export a schema, and Houdini wraps it in a [Yoga](https://the-guild.dev/graphql/yoga-server) instance, serves it at `/_api`, and points the client at it automatically. No URL configuration required. +If your backend lives in the same codebase (or doesn't exist yet), Houdini includes a built-in GraphQL server. Export a schema, and Houdini wraps it in a [Yoga](https://the-guild.dev/graphql/yoga-server) instance, serves it at `/_api`, and points the client at it automatically. No URL configuration required. ## Setup -Export an executable schema from `src/api/+schema`: +Export an executable schema from `src/server/+schema`: -```typescript title="src/api/+schema.ts&typescriptToggle=true" +```typescript title="src/server/+schema.ts&typescriptToggle=true" import { makeExecutableSchema } from '@graphql-tools/schema' export default makeExecutableSchema({ @@ -26,13 +26,13 @@ export default makeExecutableSchema({ }) ``` -That's the entire setup. Houdini configures the endpoint and wires the client — don't set `url` in your client config or `watchSchema` in `houdini.config.js`, since Houdini and Vite handle those respectively. +That's the entire setup. Houdini configures the endpoint and wires the client, so don't set `url` in your client config or `watchSchema` in `houdini.config.js`, since Houdini and Vite handle those respectively. ## Custom Yoga Instance -To inject context, add plugins, or otherwise customize the server, export a Yoga instance from `src/api/+yoga`. The `+schema` file is still required for codegen: +To inject context, add plugins, or otherwise customize the server, export a Yoga instance from `src/server/+yoga`. The `+schema` file is still required for codegen: -```typescript title="src/api/+yoga.ts&typescriptToggle=true" +```typescript title="src/server/+yoga.ts&typescriptToggle=true" import { createYoga } from 'graphql-yoga' import { schema } from './+schema' import { db } from '../db' @@ -48,20 +48,33 @@ export default createYoga({ ## A True BFF -This isn't just a co-deployed service — during SSR, queries resolve in-process with no network hop. The server renders the page and fulfills the GraphQL requests from the same runtime, which means your resolvers have direct access to the session, your database, and any internal services without exposing them to the outside world. +This isn't just a co-deployed service. During SSR, queries resolve in-process with no network hop. The server renders the page and fulfills the GraphQL requests from the same runtime, which means your resolvers have direct access to the session, your database, and any internal services without exposing them to the outside world. ## Security -Because resolvers run exclusively on the server, there's no risk of leaking sensitive information to the client. API keys, database credentials, and internal service tokens stay in the server environment — the client only ever receives the fields your queries explicitly ask for. The schema itself never has to be publicly reachable at all. +Because resolvers run exclusively on the server, there's no risk of leaking sensitive information to the client. API keys, database credentials, and internal service tokens stay in the server environment, and the client only ever receives the fields your queries explicitly ask for. The schema itself never has to be publicly reachable at all. ## Configuration -To change the default `/_api` endpoint, set `apiEndpoint` in `houdini.config.js`: +To change the default `/_api` path the local API is served at, set `endpoint` in the server-only `src/server/+config.ts` (typed `ServerConfigFile`). Codegen bakes it into the client, so you only set it in one place: + +```ts title="src/server/+config.ts&typescriptToggle=true" +import type { ServerConfigFile } from 'houdini' -```javascript title="houdini.config.js" export default { - router: { - apiEndpoint: '/_graphql' - } + endpoint: '/_graphql' +} satisfies ServerConfigFile +``` + +## Using a remote API + +If your GraphQL API is hosted elsewhere (no local `src/server/+schema`), point Houdini at it with the top-level `url`. It ships in the client bundle, so switch it per environment with a `VITE_`-prefixed variable; the value you pass is the default: + +```js title="houdini.config.js" +/** @type {import('houdini').ConfigFile} */ +export default { + url: import.meta.env.VITE_API_URL ?? 'http://localhost:4000/graphql' } ``` + +`create-houdini` scaffolds this line for you when you start from a remote endpoint. Queries and mutations go directly to that `url`; `@session` mutations are routed through Houdini's server so it can write the session cookie from the result (the value stays server-authoritative). Session signing keys still belong in `src/server/+config.ts` or the environment, never the public config. diff --git a/docs/react/04-updating-data/01-mutations.mdx b/docs/react/04-updating-data/01-mutations.mdx index dba5c1e4de..3c652c3b70 100644 --- a/docs/react/04-updating-data/01-mutations.mdx +++ b/docs/react/04-updating-data/01-mutations.mdx @@ -4,6 +4,7 @@ description: Sending mutations in Houdini's React framework --- import Dedupe from '@shared/_partials/dedupe.mdx' +import Refetch from '@shared/_partials/refetch.mdx' The `useMutation` hook wraps a GraphQL mutation and returns a tuple of `[mutate, pending]`. Call `mutate` with your variables to execute it. @@ -42,7 +43,7 @@ export function AddCommentForm({ postId }: { postId: string }) { ## Error Handling -Mutations always throw on error — unlike queries, there's no opt-in. Wrap calls in `try/catch` and inspect the `RuntimeGraphQLError`: +Mutations always throw on error; unlike queries, there's no opt-in. Wrap calls in `try/catch` and inspect the `RuntimeGraphQLError`: ```jsx import { graphql, useMutation, RuntimeGraphQLError } from '$houdini' @@ -66,6 +67,10 @@ After a successful mutation, Houdini automatically updates any cached fields tha For mutations that add or remove items from lists, see [Updating Lists](~/updating-data/updating-lists). +## Refetching Changed Data + + + ## Optimistic Updates Pass an `optimisticResponse` to apply an immediate cache update before the server responds. If the mutation fails, the optimistic update is rolled back: @@ -84,6 +89,30 @@ await mutate({ See [Optimistic Updates](~/updating-data/optimistic-updates) for a full walkthrough. +## Forms + +To back a `
    ` with a mutation — including one that works before JavaScript loads — reach +for `useMutationForm` instead of wiring up `onSubmit` by hand. See [Forms](~/updating-data/forms). + +## Writing the Session + +A mutation can set the user's session by marking it `@session`. The field named by the directive's +`path` becomes the session, which the server writes into an `httpOnly` cookie: + +```graphql +mutation Login($email: String!, $password: String!) @session(path: "login.session") { + login(email: $email, password: $password) { + session { token } + } +} +``` + +Login is the obvious case, but anything that ends in "set the session from a mutation result" works +the same way, including merging a preference into the existing session or clearing it on logout. The +value always comes from the resolver, never from client input. The full story (replace vs. merge vs. +clear, and running it without JavaScript via `@endpoint`) lives in the +[authentication guide](~/guides/authentication#mutation-based-login). + ## Deduplication diff --git a/docs/react/04-updating-data/02-optimistic-updates.mdx b/docs/react/04-updating-data/02-optimistic-updates.mdx index 68eb2e98f8..d391dcc1b9 100644 --- a/docs/react/04-updating-data/02-optimistic-updates.mdx +++ b/docs/react/04-updating-data/02-optimistic-updates.mdx @@ -32,7 +32,7 @@ await mutate({ }) ``` -The cache updates immediately and any components watching those fields re-render — no `useState`, no manual rollback logic. +The cache updates immediately and any components watching those fields re-render with no `useState` and no manual rollback logic. ## Creating New Records diff --git a/docs/react/04-updating-data/04-caching-data.mdx b/docs/react/04-updating-data/04-caching-data.mdx index 5d768c1d85..b284c8ccfe 100644 --- a/docs/react/04-updating-data/04-caching-data.mdx +++ b/docs/react/04-updating-data/04-caching-data.mdx @@ -9,4 +9,4 @@ import CachingData from '@shared/_partials/caching-data.mdx' -For a full API reference — including all available methods and configuration options — see the [Cache API reference](~/api/cache). +For a full API reference, including all available methods and configuration options, see the [Cache API reference](~/core/cache). diff --git a/docs/react/04-updating-data/05-forms.mdx b/docs/react/04-updating-data/05-forms.mdx new file mode 100644 index 0000000000..ef906839e9 --- /dev/null +++ b/docs/react/04-updating-data/05-forms.mdx @@ -0,0 +1,215 @@ +--- +title: Forms +description: Progressively enhanced forms backed by a Houdini mutation +--- + +import EndpointDirective from '@shared/_partials/endpoint-directive.mdx' + +The usual way to drive a mutation from a form is to call `useMutation`, write an `onSubmit` +handler, `preventDefault`, pull the values out of the event, and hand them over as variables. +It works, but it's a fair amount of plumbing, and the form it produces does nothing at all +until the JavaScript bundle has loaded. + +`useMutationForm` collapses that down to one component. We write a mutation, mark it with +`@endpoint`, and render the `Form` the hook hands back: + +```tsx +import { graphql, useMutationForm } from '$houdini' + +export function NewUser() { + const { Form, state, pending } = useMutationForm(graphql(` + mutation CreateUser($name: String!, $email: String!) + @endpoint(redirect: "/users/{ createUser.id }") { + createUser(name: $name, email: $email) { + id + } + } + `)) + + return ( + + + + {state?.errors &&

    {state.errors[0].message}

    } + + + ) +} +``` + +There's no `onSubmit`, no reading values off the event, and no list of variables to keep in +sync with the inputs. The `name` attributes are the variables. `
    ` is a real `` +underneath — it takes all the usual attributes (`className`, `data-*`, and so on) and passes +them straight through. + +The quieter payoff is that this form works **before the page has hydrated**. Submit it with +JavaScript disabled, or in the half-second before the bundle loads, and it still creates a +user and redirects, because the browser is doing what browsers have always done with a +``. Once the bundle is in, the same form upgrades into an ordinary client-side Houdini +mutation, with optimistic updates and cache writes, and the user never notices the handoff. + +## How it works + +A single form behaves two different ways depending on whether JavaScript has loaded yet: + +| | Before hydration (no JS) | After hydration | +|---|---|---| +| What submits | a native browser POST to the page | the hook's `onSubmit` intercepts | +| Where the mutation runs | on the server | on the client (optimistic + cache) | +| Result / redirect | the server re-renders or `303`s | `state` from the hook, client navigation | + +Both paths read their behavior from the same compiled artifact, so they can't drift. The +form element is byte-for-byte identical on the server and the client (a real string `action` +plus a `method`), which is what lets it hydrate cleanly and pick up where the no-JS path left +off. `` also renders a couple of hidden marker fields the server uses to recognize the +submission, so there's nothing extra for us to wire up. + +## The `@endpoint` directive + + + +## Pending and error state + +The hook returns `pending` and `state` directly, which is enough for a single submit button +and a top-level error message, as in the first example. `state` is `{ data, errors }` after a +submit and `null` before, and it converges on the same shape whether the result came back from +the no-JS server round trip or the client mutation. + +When the submit button lives in its own component, threading `pending` down as a prop gets old +fast. Because `` publishes its status through context, any child can read it with +`useMutationFormStatus` instead: + +```tsx +const { Form, state } = useMutationForm(doc) + +function Submit() { + const { pending } = useMutationFormStatus() + return +} + +// +``` + +It's the same shape as React's own `useFormStatus`, except it can actually see our forms (the +built-in only tracks function-action submissions). + +## Integrating with an existing form + +`
    ` is the recommended way in, and most of the time it's all we need. But sometimes the +form element isn't ours to choose — a design-system ``, or some wrapper the codebase +already standardizes on. For those cases the hook also returns the raw pieces `` is built +from: `form`, a set of props to spread onto a ``, and `hidden`, the marker fields to +render inside it. + +```tsx +const { form, hidden, state, pending } = useMutationForm(doc) + +return ( + + {hidden} + + +
    +) +``` + +The `form` spread carries the `action`, `method`, `onSubmit`, and (for uploads) `encType`; +`hidden` carries the markers and CSRF token. Spread both onto any element that forwards them to +a real `
    ` and the two paths work exactly as before. + +The one thing we give up is the context. A plain spread can't provide the `FormStatusContext` +that `` does, so `useMutationFormStatus` has nothing to read here. Take `pending` (and +`state`) from the hook's return and pass them down to children the old-fashioned way. + +## Redirecting after a submit + +Most forms want to go somewhere on success, and `@endpoint(redirect:)` is how we say where. +Because it's a static directive argument, the compiler bakes the same destination into both +paths: the server answers the no-JS POST with a `303`, and the client navigates after the +mutation resolves. We get a redirect either way without writing any navigation code. + +```graphql +@endpoint(redirect: "/users/{ createUser.id }") +``` + +Anything in `{ … }` is interpolated from the result, so `createUser.id` becomes the id of the +record we just made. Interpolated values are URL-encoded, and if one comes back `null` the +redirect is skipped rather than sending anyone to `/users/undefined`. A redirect only fires on +success; an error always re-renders the form with `state.errors` populated instead. + +## Field names + +The `name` on each input is the variable it fills, and a small path convention covers nested +inputs and lists: + +```tsx + {/* $name */} + {/* $input: { address: { city } } */} + {/* $tags: [...] (repeat the input per value) */} +``` + +The values get coerced to the right types on the way to the mutation, guided by the schema, so +we don't hand-parse anything: + +- Numbers, booleans, and enums are converted from their string form. An unchecked checkbox + (which the browser omits entirely) becomes `false` for a `Boolean` field. +- An empty string becomes `null` for a numeric, enum, or custom-scalar field; `String` and + `ID` fields keep the empty string. +- A **custom scalar** runs through its `unmarshal`, the same one the router uses for route + params, so a `DateTime` input arrives at the resolver as a real `Date`. +- Required fields lean on the native HTML `required` attribute, which the browser enforces on + both paths before anything is submitted. + +## File uploads + +If a mutation has an `Upload`-typed variable, `` sets its encoding to +`multipart/form-data` automatically and both paths handle the file: the enhanced path through +Houdini's normal upload machinery, the no-JS path by assembling the GraphQL multipart request +on the server. See [File Uploads](~/guides/file-uploads) for the details that apply to every +mutation, form or not. + +## Going to production + +Most of what keeps these forms safe is automatic. Cross-site requests are turned away by an +`Origin` check and a signed token that a cross-origin page can't read, so we don't configure +CSRF protection; it's just on. There are three things worth doing before you ship, though: + +- **Set `sessionKeys`.** Without configured keys Houdini signs sessions and form tokens with a + random per-process key, which works but doesn't survive a restart or hold up across more than + one server. Configuring `auth.sessionKeys` in `src/server/+config.ts` is what makes them + persistent. This is a correctness requirement in production, not only a security one. +- **Keep privileged fields out of form mutations.** Because the input type is the trust + boundary, never put a field like `isAdmin` or an ownership id in an `@endpoint` mutation's + input. Set those server-side from the session context (see [Authentication](~/guides/authentication)), + or split them into a separate mutation. The [`fields` allowlist](#restricting-which-fields-are-accepted) + is a backstop, not a substitute for this. +- **Pin `allowedOrigins` behind a proxy.** When TLS is terminated upstream, the origin Houdini + derives can read as `http` while the browser sends `https`, and the `Origin` check would then + reject legitimate submissions. List your real public origin(s) in `allowedOrigins` (in + `src/server/+config.ts`) to settle it. + +And remember that `@endpoint` exposes a mutation to first-party browser forms: anyone who +passes those checks can invoke it by name, exactly like any other GraphQL request. Authorization +is still the resolver's job, via the session context, the same as everywhere else in Houdini. + +### Restricting which fields are accepted + +This one is optional, and most forms never need it. It's here for the cautious. + +The thing to know is that `coerceFormData` reads from the mutation's **input type**, not from +the inputs we happened to render. A field that exists on the input type is accepted from the +POST whether or not there's a visible `` for it. The markup is not the trust boundary; +the input type is. For a `CreateUser(name, email)` that's a non-issue, because every field is +one we'd render anyway. + +When a mutation's input happens to include a field we never want coming from the browser, the +`fields` allowlist pins the form down to an explicit set: + +```graphql +@endpoint(redirect: "/users/{ createUser.id }", fields: ["name", "email"]) +``` + +With `fields` present, anything submitted outside the list is dropped, on both paths, because +the list is baked into the artifact rather than enforced only in the hook. It's a belt-and- +suspenders hatch, though, not the real fix: keeping authorization-sensitive fields out of form +mutations in the first place is. diff --git a/docs/react/05-guides/01-authentication.mdx b/docs/react/05-guides/01-authentication.mdx index e5db953a5e..f1f62ef9cc 100644 --- a/docs/react/05-guides/01-authentication.mdx +++ b/docs/react/05-guides/01-authentication.mdx @@ -3,41 +3,334 @@ title: Authentication description: Handling authentication in Houdini's React framework --- -The challenge when building user sessions in a modern app is how to share that information between the server and client. Really, there is only one answer once security starts mattering: `httpOnly` cookies. While traditionally apps could use local storage for this, the initial request (the one that gets rendered on the server) doesn't have access to the client's local storage and so we must rely on something that's automatically included in the initial response. +import Notice from '@components/docs/Notice.astro' +import AsideAccordion from '@components/docs/AsideAccordion.astro' -Wiring up everything up by hand is possible in Houdini but it can be cumbersome and error prone. In order to help, Houdini provides a few strategies out of the box that we hope covers most situations. Once you have configured your strategy, you can use the session in your client as shown here. +Adding login to a Houdini app is mostly a matter of picking a strategy and writing a few lines of config. The part that takes any thought at all is where the session lives. -- **Redirect Based Authentication** - Users authorize with a third party provider which then redirects the user back to the Houdini application with any number of query parameters that define the session -- **Mutation Based Authentication** (not yet implemented) - Users authorize by sending a mutation to the api. The session is defined by one of the fields in the response +It has to live in two places at once. The server renders the first page, the client runtime takes over once the page is live, and the two have to agree on who is logged in before the first byte of HTML leaves the building. Once security starts to matter there is really only one place that answer can sit: an `httpOnly` cookie. Local storage is tempting, but the very first request (the one rendered on the server) can't see it, so we need something that rides along with every request on its own. -## Redirect Based Authentication +Wiring all of that up by hand is possible. It is also the kind of thing we get subtly wrong at 5pm on a Friday, so Houdini ships a couple of strategies out of the box that cover most situations. -To configure your application to use a redirect-based strategy, you must set the `auth` field of the router config to an object like so: +There are two ways a user can authorize. Pick whichever fits and jump straight to it: -```js title="vite.config.js" -/** @type {import('houdini').ConfigFile} */ -const config = { - router: { - auth: { - // the URL that the user will be redirected to by the third-party provider - redirect: '/auth/token', - // the secret to use for signing/unsigning the session - sessionKeys: ['supersecret'], - }, - }, -} +- **[Mutation based](#mutation-based-login).** The user logs in by running a mutation marked `@session`, and the field named by its `path` becomes the session. This is the path for an app with its own backend (email and password, a magic link, anything that ends in a mutation). Pair it with `@endpoint` and it works with no client-side JavaScript at all. +- **[Third-party provider](#logging-in-through-a-provider-oauthoidc).** The user authorizes with a third party (Google, GitHub, an external OAuth worker) which redirects back to the app. Houdini runs the whole exchange and hands us a verified user to turn into a session. + +Whichever we choose, [reading the session](#reading-the-session) afterward works the same way, so we'll cover it first, right after the one-time server setup. [Logging out](#logging-out), the [config reference](#server-config-reference), and the [security model](#how-it-stays-secure) follow below. + +## Configuring your server + +Auth configuration lives in the server config file at `src/server/+config.ts`, alongside the rest of our server-only settings. This file is never imported by the client, so it's safe to reach for environment variables or hold secrets in memory without any risk of them reaching the browser. + +The one thing both strategies need is `sessionKeys`, the secret (or secrets) that sign and verify the session cookie: -export default config +```ts title="src/server/+config.ts&typescriptToggle=true" +import type { ServerConfigFile } from 'houdini' + +export default { + auth: { + // the secret(s) that sign and verify the session cookie. The first one signs; the rest are + // still accepted on verify, which is how key rotation works. + sessionKeys: [process.env.SESSION_SECRET!], + }, +} satisfies ServerConfigFile ``` -## Reading Session Data +Everything else under `auth` is optional and depends on which strategy we're using. There's a full [config reference](#server-config-reference) at the end of the guide. -To access the current session, you can use the `useSession` hook: +## Reading the session + +Before we set a session, it helps to see how we'll read one, since that part is the same no matter which login path we pick. We reach for the `useSession` hook: ```js import { useSession } from '$houdini' -const [ session, setSession ] = useSession() +const [ session, updateSession ] = useSession() +``` + +Calling `updateSession(values)` merges the values into the client-side session and persists them in the cookie so they survive the next load. Calling `updateSession(null)` logs the user out: it empties the client-side session and deletes the cookie. + + + **The cookie is the source of truth, not `useSession()`.** The `httpOnly` cookie is signed by the server, and the server gets its verified contents as `ctx.session`. What `useSession()` returns is in-memory UI state that mirrors the cookie, which is great for rendering but is not where an authorization decision belongs. We authorize against `ctx.session`, on the server. Always. + + +## Mutation based login + +If our app already has a backend that can check a password, login is just a mutation. We mark it `@session` and Houdini takes the result and writes it into the cookie for us: + +```graphql +mutation Login($email: String!, $password: String!) @session(path: "login.session") { + login(email: $email, password: $password) { + session { token } + } +} +``` + +The `path` argument names the field in the result whose object value becomes the session, dotted just like property access (`login.session` is `result.login.session`). That value comes from the server that runs the mutation, never from anything the client sends, which is the whole point: a client can't talk Houdini into a session the server didn't issue. There's nothing to wire up either; `@session` mutations hand their result to a built-in endpoint that Houdini mounts for us. + +This works whether the GraphQL API is local (your `+schema`) or a remote endpoint. When the API is remote, Houdini routes the `@session` mutation through its own server so it can read the real result and write the cookie; the session stays server-authoritative either way, and nothing changes in how you write the mutation. + +Running the mutation looks like any other. From a component: + +```jsx +import { useMutation, graphql } from '$houdini' + +function LoginForm() { + const login = useMutation(graphql(` + mutation Login($email: String!, $password: String!) @session(path: "login.session") { + login(email: $email, password: $password) { + session { token } + } + } + `)) + + return ( + { + e.preventDefault() + const data = new FormData(e.currentTarget) + await login({ email: data.get('email'), password: data.get('password') }) + }}> + + + +
    + ) +} +``` + +The same mutation works no matter how we run it (`useMutation`, `useMutationForm`, or the client's lower-level `send`). Once it runs, Houdini hands the result to that built-in endpoint, which sets the cookie. What happens to the session depends on the value that comes back: + +- **Replace (the default).** A non-null object replaces the session, which is exactly what login wants since it starts from a clean slate. +- **Merge.** `@session(path:, merge: true)` upserts the object into the existing session and keeps the rest, which is right for something like a preference that shouldn't knock out the auth token: + + ```graphql + mutation SetTheme($theme: Theme!) @session(path: "setTheme.session", merge: true) { + setTheme(theme: $theme) { session { theme } } + } + ``` + +- **Clear.** A `null` session field clears the cookie, which is a server-side logout. + +One subtlety worth knowing: a *failed* login is not a clear. A login that goes wrong should come back as a GraphQL error, not a null session, and an errored `@session` mutation never touches the session at all. That way a login that fails can't accidentally log the user out. + +### Making it work without JavaScript + +The form above needs JavaScript to submit. To make the very same login work before (or without) hydration, we add `@endpoint`, which also runs the mutation as a [progressively-enhanced form](~/api-reference/useMutationForm): + +```graphql +mutation Login($email: String!, $password: String!) + @endpoint(redirect: "/dashboard") + @session(path: "login.session") { + login(email: $email, password: $password) { + session { token } + } +} ``` -Calling `setSession` updates the client-side session as well as perists the new values in the application cookie so that its available on the next load. +Now the native form POST sets the session and redirects with no JS at all, and after hydration the enhanced submit takes over. Both routes converge on the same session. `@endpoint` owns the form story, `@session` owns the session story, and together they add up to progressively-enhanced login. + + + +It would read more naturally to tag the field itself, something like `session @session` sitting deep in the selection, and let Houdini pick that object up. We deliberately don't, for two reasons. + +The first is autocomplete. GraphQL declares directives against locations like `FIELD`, and that location is all-or-nothing: a directive that's valid on a field is valid on every field, in every document. There's no location that means "a field inside a mutation but not inside a query." So a field-level `@session` would get suggested while we're writing an ordinary query, where writing the session makes no sense at all, and nothing in the schema could talk us out of it. Hanging the directive off the operation keeps it where it belongs, so it only shows up when we're actually defining a mutation. + +The second is ambiguity. Once the marker lives in the selection, "which object becomes the session?" stops having one obvious answer. What happens when two fields are tagged? What about when one tagged field is an ancestor of another, so the session would be written twice from nested pieces of the same response? Every one of those cases needs a rule, and none of the rules is the sort of thing we want to keep in our head while reading a mutation. A single `path` on the operation sidesteps the whole question: there is exactly one session object, named in exactly one place. + + + +## Logging in through a provider (OAuth/OIDC) + +For "log in with Google," the user authorizes with a third party that redirects back to us. Houdini runs the whole round trip and hands us a verified user; all we decide is what the session should be. + +Providers are configured under `auth.providers`. Out of the box Houdini ships two adapters from `houdini/oauth`: `oidc`, which works with any OpenID Connect provider (Google, Microsoft, Auth0, Okta, Apple), and `github`. GitHub doesn't speak OIDC, which is what makes it a good worked example for [writing a custom provider](#a-custom-provider-adapter) later on. + +```ts title="src/server/+config.ts&typescriptToggle=true" +import { github, oidc } from 'houdini/oauth' + +export default { + auth: { + sessionKeys: [process.env.SESSION_SECRET!], + providers: { + github: github({ + clientId: process.env.GH_ID!, + clientSecret: process.env.GH_SECRET!, + }), + google: oidc({ + issuer: 'https://accounts.google.com', + clientId: process.env.GOOGLE_ID!, + clientSecret: process.env.GOOGLE_SECRET!, + }), + }, + onSignIn: async ({ user }) => { + // save the user to the database, etc. + return { userId: user.sub } // only this opaque value goes in the session cookie + }, + }, +} satisfies ServerConfigFile +``` + +The `oidc` adapter figures out the provider's endpoints from the issuer URL on its own, so every OpenID Connect provider is config-only in exactly this shape. Auth0 is `oidc({ issuer: 'https://YOUR_TENANT.us.auth0.com', clientId, clientSecret })`, Okta and Microsoft Entra are the same with their own issuer, and so on. Remember that we also have to register Houdini's callback (`https://yourapp.com/_auth`, or whatever `auth.url` resolves to) as an allowed callback URL in the provider's dashboard. + +### The round trip + +It helps to have the whole flow in view, since the config above only describes the two ends of it. When a user logs in: + +``` +1. user clicks a loginURL(...) link → Houdini's /login entry point +2. Houdini redirects to the provider → accounts.google.com, github.com, ... +3. user approves; provider redirects back → Houdini's callback (auth.url) +4. Houdini validates everything it got → signatures, nonce, the browser-bound cookie +5. Houdini calls onSignIn({ user, tokens })→ we return the session object +6. Houdini signs the session into the cookie and lands the user on redirectTo +``` + +Steps 2 through 4 are the standard, fiddly OAuth machinery (PKCE, nonces, the `id_token` signature check), and they're entirely Houdini's job. You never configure any of it. The only step that's yours is step 5. + +### Starting the flow + +To build the links that kick off step 1, Houdini generates a `loginURL` helper, typed to the providers we configured: + +```jsx +import { loginURL } from '$houdini' + +
    Log in with GitHub +Log in with Google +``` + +The `provider` value is checked against the configured providers at compile time (`loginURL({ provider: 'twitter' })` is a type error) and again at runtime. `redirectTo` is where the user lands afterward; leave it off to return them to the page they came from. + +### Turning a user into a session + +`onSignIn` is the one piece of the provider flow we own. It receives the verified `user` (and the `provider` name and the provider's `tokens`, if we need them) and returns the session object the app will use: + +```ts +onSignIn: async ({ user, tokens }) => { + return { userId: user.sub } +}, +``` + +The session cookie should hold an opaque reference like `{ userId }` and nothing else, and in particular never the provider's tokens. We only keep `tokens` if the app later calls the provider's API on the user's behalf (reading their repos, their calendar, that sort of thing), and when we do, they belong in our own database, not the cookie. + + + **Key accounts off `user.sub`, not `user.email`.** `user.sub` is the provider's stable, unique id and it is always present, which makes it the safe thing to look an account up by. `user.email` only shows up when the provider says it's verified (`user.emailVerified === true`); an unverified or unconfirmed email is dropped, because anyone who can register an unverified address at the provider could otherwise walk into an account keyed by that email. So `user.email` can be `undefined` even for a user who clearly has one. We handle that case (fall back to `sub`, or verify an email ourselves) rather than assuming it's set. + + +## Logging out + +There are three ways to clear the session, one for each way it gets set, and `null` means "clear" in all of them: + +- **`updateSession(null)`** from `useSession`. Clears the session on the client and deletes the cookie. This is the one for a logout button in a client component. +- **`useLogoutForm`**, a progressively-enhanced logout form. Without JavaScript the native POST deletes the cookie and redirects; after hydration it clears the client and navigates. This is the one for a logout button that has to work without JS: + + ```jsx + import { useLogoutForm } from '$houdini' + + function LogoutButton() { + const { form, hidden } = useLogoutForm({ redirectTo: '/login' }) + return ( +
    + {hidden} + +
    + ) + } + ``` + +- **A `@session` mutation that returns a null session.** When logging out has a server-side piece (invalidating a refresh token, say), we mark that mutation `@session` and let its session field come back `null`, and the successful mutation clears the cookie. Add `@endpoint` to make it work without JS too. + +## Server config reference + +Everything auth-related lives under `auth` in `src/server/+config.ts`. The keys, and which strategy each one belongs to: + +| Key | What it does | Default | +|---|---|---| +| `auth.sessionKeys` | Secrets that sign and verify the session cookie (and provider relay tokens). The first signs; the rest still verify, which is how key rotation works. | required to use auth | +| `auth.url` | Base path for the endpoint that sets the cookie. Override it to match a provider's callback URL. | `/_auth` | +| `auth.providers` | First-class OAuth/OIDC providers, keyed by name. Enables the typed `loginURL({ provider })`. | none | +| `auth.onSignIn` | Turns a provider's verified `{ user, tokens, provider }` into the session object. | none | +| `auth.redirect.url` | Delegates the whole OAuth exchange to a [trusted integration](#delegating-to-a-trusted-worker) at this URL. | none | +| `auth.consumedTokenStore` | A shared store for the single-use `@session` relay tokens (see below). Only needed when you run more than one instance. | in-memory | + +A few neighbors live in the same server-only config and matter for auth: `allowedOrigins` (the origins allowed to POST to the session endpoint), `apiEndpoint` (the GraphQL endpoint), and `formMaxBodyBytes` (the form body cap). + +## Advanced: building a custom integration + +When neither built-in adapter fits, there are two ways to extend the flow without giving up Houdini's session machinery. We can write our own provider adapter, or we can hand the whole exchange off to an integration we already operate. A service like Clerk that would rather own the login UI and the session itself is a third case, and the lightest one: skip `providers` entirely, verify its token on the server, and use the result as the session, which is the same shape as the escape hatch below. + +### A custom provider adapter + +A provider adapter is just an object matching the exported `OAuthProvider` type. It names the authorization server's endpoints (`server()`) and maps the token response to a user (`user()`); the `github` adapter is the worked example to copy. This is the path for a provider that speaks plain OAuth 2.0 rather than OpenID Connect (no `issuer`, so no `id_token`, so identity comes from an API call): + +```ts title="src/server/providers.ts&typescriptToggle=true" +import type { OAuthProvider } from 'houdini/oauth' + +export function discord(config: { clientId: string; clientSecret: string }): OAuthProvider { + return { + clientId: config.clientId, + clientSecret: config.clientSecret, + scopes: ['identify', 'email'], + pkce: 'S256', + // no issuer, so Houdini won't expect an id_token; identity comes from the API below + server: async () => ({ + issuer: 'https://discord.com', + authorization_endpoint: 'https://discord.com/oauth2/authorize', + token_endpoint: 'https://discord.com/api/oauth2/token', + }), + user: async ({ tokens }) => { + const me = await fetch('https://discord.com/api/users/@me', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }).then((r) => r.json()) + return { sub: me.id, name: me.username, email: me.verified ? me.email : undefined } + }, + } +} +``` + +It then goes in `providers` alongside the built-ins. Houdini still runs the Authorization Code flow with PKCE, the browser-bound nonce, and the token exchange; the adapter only fills in the provider-specific endpoints and the identity mapping. (Note the `me.verified` check on the email: the same "only a verified email, otherwise `undefined`" rule from the built-in adapters applies here, since we own the mapping now.) + +### Delegating to a trusted worker + +If we already run an OAuth worker (say a Cloudflare Worker that does the provider dance), we can delegate to it instead of configuring `providers`. We point `auth.redirect` at the integration's login endpoint: + +```ts title="src/server/+config.ts&typescriptToggle=true" +export default { + auth: { + sessionKeys: ['supersecret'], // shared with the integration + redirect: { url: 'https://auth.example.com/login' }, + }, +} satisfies ServerConfigFile +``` + +Setting `auth.redirect` turns the flow on and generates a `loginURL()` helper exported from `$houdini` (it isn't generated otherwise). Because Houdini stays out of the provider business here, this `loginURL` is the generic one: there's no typed `provider`, just a `params` object forwarded verbatim to the integration. + +```jsx +import { loginURL } from '$houdini' + + + Log in with GitHub + +``` + +`redirectTo` is the landing page (omit it to return the user to where they started), and `params` are passed straight through to the integration. The round trip is short: Houdini sends the user to the integration with a single-use nonce, the integration runs the actual OAuth and sends the user back with a session token signed using the shared `sessionKeys`, and Houdini sets the session only if both the signature and the nonce check out. + + + **The integration has to validate the `return` URL itself.** Houdini sends `return` as its own callback, but the integration receives it as input and can't, on its own, tell a real Houdini callback from an attacker's. If it sends the session token off to an unvalidated `return`, the token leaks. Keep an allowlist of the callback URLs that are actually ours and reject the rest. Unfortunately this is one thing Houdini can't enforce on the integration's behalf. + + +## How it stays secure + +None of the day-to-day code above has to think about this section, but it's worth knowing what's holding the line. + +**The provider exchange.** For the built-in providers, Houdini runs the standard Authorization Code flow with PKCE. For OIDC providers it checks the signature on the returned `id_token` against the provider's published keys, along with the usual `iss`/`aud`/`exp`/`nonce` claims. The whole round trip is tied to the browser that started it through a single-use, browser-bound transaction cookie, so a completed login can't be replayed into someone else's session. We don't configure any of this; it's the default. + +**`@session` writes are server-authoritative.** The value is relayed as a server-signed, session-bound, single-use token, so a client can't forge or replay what becomes the session. The redirect callback (`auth.redirect`) is off unless we turn it on, and when it's on it accepts only that kind of token, behind the two gates described in the [escape hatch](#delegating-to-a-trusted-worker) section. + +**Single-use is tracked in memory by default.** "Used once" is enforced with an in-memory record, which is per-process, so a single server needs nothing extra. If you run more than one instance (load-balanced or serverless), point `auth.consumedTokenStore` at a shared store so the check holds across them. The interface is one method, `consume(jti, ttlMs)`, that records the id and returns `true` only the first time; against Redis it's a single `SET jti 1 NX PX `. It's not a token registry you maintain: entries expire on their own with the token, so there's nothing to clean up. + +**The session is a same-origin-writable store.** `updateSession()`, and therefore any same-origin script, can write whatever it likes into it. So we store credentials the backend issued and can re-verify (an opaque token, a signed user id), not raw claims like `role: "admin"` that we then trust at face value. Privileges get derived on the server from the verified value. + +**`allowedOrigins` entries are full session-write delegates.** Any origin we add (in `src/server/+config.ts`) can POST to the session endpoint and set a session, so the list should only contain origins we completely trust. + +**Don't share `sessionKeys` across trust domains.** The keys sign both the session cookie and the relay token, so any service holding them can mint sessions for the app. diff --git a/docs/react/05-guides/04-file-uploads.mdx b/docs/react/05-guides/04-file-uploads.mdx index bf22044b35..5223d5068b 100644 --- a/docs/react/05-guides/04-file-uploads.mdx +++ b/docs/react/05-guides/04-file-uploads.mdx @@ -9,7 +9,7 @@ import FileUploads from '@shared/_partials/file-uploads.mdx' ## Wiring a File Input -Pass a `File` directly as a mutation variable. Houdini detects it and switches to a multipart request automatically — no extra configuration needed: +Pass a `File` directly as a mutation variable. Houdini detects it and switches to a multipart request automatically with no extra configuration needed: ```tsx meta="typescriptToggle=true" import { graphql, useMutation } from '$houdini' diff --git a/docs/react/05-guides/07-testing.mdx b/docs/react/05-guides/07-testing.mdx new file mode 100644 index 0000000000..80582d5118 --- /dev/null +++ b/docs/react/05-guides/07-testing.mdx @@ -0,0 +1,114 @@ +--- +title: Testing +description: How to write tests for Houdini components and routes +--- + +# Testing + +Houdini ships first-class testing utilities for React. The `createMock` function returns the full page composition including any layouts for any route in your app, wired with a fresh cache, a mock network client, and the correct router context. + +## Setup + +Testing with Houdini requires [Vitest](https://vitest.dev/) and a DOM environment such as [happy-dom](https://github.com/capricorn86/happy-dom). Your `vitest.config.ts` must include the Houdini Vite plugin so that codegen runs before tests: + +```typescript +// vitest.config.ts +import { defineConfig } from 'vite' +import houdini from 'houdini/vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [houdini(), react()], + test: { + environment: 'happy-dom', + }, +}) +``` + +Houdini's codegen (including the `createMock` types) runs automatically when Vitest starts, so no separate `houdini generate` step is needed. + +## Basic usage + +```typescript +import { createMock } from '$houdini' +import { render, screen } from '@testing-library/react' + +test('renders show name', async () => { + const App = createMock({ + url: '/shows/[id]', + params: { id: '1' }, + data: { + LayoutQuery: { viewer: { name: 'Alec' } }, + ShowDetailQuery: { show: { id: '1', name: 'Breaking Bad' } }, + }, + }) + + render() + await screen.findByText('Breaking Bad') +}) +``` + +## Mental model + +Mocks are full API responses. They must contain the entire unmasked JSON the server would return for a given document, including any fields Houdini includes behind the scenes (ids, pageInfo, etc). That data is written to a fresh cache instance per test. Components read from the cache through their normal hooks, so masking, normalization, and cache behavior all work exactly as in production. + +## Type safety + +`createMock` is fully typed. The `url` argument is a union of valid routes in your app, `params` must match the route's URL parameters, and `data` requires a key for every query that belongs to that route (the page's own queries plus all ancestor layout queries). Missing query keys are type errors, and `createMock` also throws at runtime with the names of any missing queries before any component is created. + +## Variable-dependent responses + +Pass a function instead of a plain object to return different data based on the query's variables: + +```typescript +const App = createMock({ + url: '/shows', + data: { + SearchQuery: (variables) => ({ + results: variables.query === 'breaking' ? [{ name: 'Breaking Bad' }] : [], + }), + }, +}) +``` + +## Mutation handlers + +Mutations are not required in `data` but can be provided. If a mutation fires and no handler is registered, the mock client throws immediately, failing the test loudly. + +```typescript +let callCount = 0 + +const App = createMock({ + url: '/shows/[id]', + params: { id: '1' }, + data: { + ShowDetailQuery: { show: { id: '1', name: 'Breaking Bad' } }, + AddToFavoritesMutation: (variables) => { + callCount++ + return { addToFavorites: { id: variables.showId, favorited: true } } + }, + }, +}) +``` + +## Subscriptions + +Pass an async iterable as the handler for a subscription. Each value yielded by the iterable is pushed to the component as a new subscription event. + +```typescript +async function* fakeEvents() { + yield { userUpdated: { id: '1', name: 'Alice' } } + yield { userUpdated: { id: '1', name: 'Bob' } } +} + +const App = createMock({ + url: '/profile/[id]', + params: { id: '1' }, + data: { + ProfileQuery: { user: { id: '1', name: 'Alice' } }, + UserUpdates: fakeEvents(), + }, +}) +``` + +If a subscription fires and no handler is provided, the mock client throws immediately, failing the test loudly. diff --git a/docs/react/06-api-reference/04-Link.mdx b/docs/react/06-api-reference/04-Link.mdx index 942d9043ea..f52a5d3522 100644 --- a/docs/react/06-api-reference/04-Link.mdx +++ b/docs/react/06-api-reference/04-Link.mdx @@ -24,10 +24,20 @@ export function Nav() { |---|---|---| | `to` | route href | The destination route. Must be a known route in your manifest. | | `params` | object | Required when `to` contains dynamic segments. Typed per-route. | +| `search` | object | Optional. Search params for the destination route, typed from its query variables. Serialized onto the query string. | | `disabled` | `boolean` | When true, renders without an `href` (effectively inert). | | `preload` | `boolean \| 'data' \| 'component' \| 'page'` | Start loading on hover. `true` is equivalent to `'page'`. | -All standard `` attributes are also accepted (except `href`, which is derived from `to` and `params`). +All standard `` attributes are also accepted (except `href`, which is derived from `to`, `params`, and `search`). + +**Search params** + +`search` serializes an object onto the query string. The route's nullable query variables appear as typed keys (their value types come from the `+page.gql`), and a list variable accepts an array that serializes as repeated keys. Extra keys are allowed too, for query string state that drives the UI rather than the query. See [Query Variables](~/loading-data/queries#search-params) for how the declared values flow back into the query. + +```tsx +Comedies +Both +``` **External links** diff --git a/docs/react/06-api-reference/09-useCurrentVariables.mdx b/docs/react/06-api-reference/09-useCurrentVariables.mdx deleted file mode 100644 index 260a11edd3..0000000000 --- a/docs/react/06-api-reference/09-useCurrentVariables.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: useCurrentVariables -description: Read the query variables used to load the current route. ---- - -Returns the query variables that were used to load the current route. Useful inside deeply nested components that need access to route-level variables without prop-drilling. - -```tsx -import { useCurrentVariables } from '$houdini' - -export function DebugPanel() { - const variables = useCurrentVariables() - return
    {JSON.stringify(variables, null, 2)}
    -} -``` - -**Signature** - -```ts -function useCurrentVariables(): GraphQLVariables -``` - -Returns the variables object for the query that loaded the current page. Returns `null` outside a route context. diff --git a/docs/react/06-api-reference/10-useFragment.mdx b/docs/react/06-api-reference/10-useFragment.mdx index 990d8a51d8..f11bece8f0 100644 --- a/docs/react/06-api-reference/10-useFragment.mdx +++ b/docs/react/06-api-reference/10-useFragment.mdx @@ -36,3 +36,30 @@ function useFragment(reference, document): data | null | `document` | `graphql()` result | The fragment document | Returns the fragment data, or `null` if the reference is null. + +## Plural fragments + +If the fragment is marked with `@plural`, it is spread on a list field and the reference is an array. In that case `useFragment` accepts the whole array and returns an array of data: + +```tsx +import { graphql, useFragment } from '$houdini' +import type { ShowListRow } from '$houdini' + +export function ShowList({ shows }: { shows: ShowListRow }) { + const data = useFragment(shows, graphql(` + fragment ShowListRow on Show @plural { + title + } + `)) + + return ( +
      + {data.map((show) => ( +
    • {show.title}
    • + ))} +
    + ) +} +``` + +See [Plural Fragments](~/loading-data/fragments#plural-fragments) for the full picture. diff --git a/docs/react/06-api-reference/11-useFragmentHandle.mdx b/docs/react/06-api-reference/11-useFragmentHandle.mdx index dd3a477f9d..229d00c277 100644 --- a/docs/react/06-api-reference/11-useFragmentHandle.mdx +++ b/docs/react/06-api-reference/11-useFragmentHandle.mdx @@ -33,10 +33,33 @@ export function ShowList({ show }: { show: ShowList_show }) { } ``` +## Refetch + +When the fragment is marked with `@refetchable`, the handle also carries a `refetch` method that re-runs the fragment with new argument values: + +```tsx +import { graphql, useFragmentHandle } from '$houdini' +import type { UserInfo } from '$houdini' + +export function UserInfo({ user }: { user: UserInfo }) { + const { data, refetch } = useFragmentHandle(user, graphql(` + fragment UserInfo on User @refetchable @arguments(filter: { type: "String" }) { + friends(filter: $filter) { + name + } + } + `)) + + return +} +``` + +We only pass the arguments we want to change; the record's id is derived from the data on screen. See [Refetchable Fragments](~/loading-data/fragments#refetchable-fragments) for the details. + **Signature** ```ts function useFragmentHandle(reference, document): handle ``` -Returns the same `DocumentHandle` shape as [`useQueryHandle`](~/api-reference/useQueryHandle) — `data`, `fetch`, `variables`, plus pagination methods if the fragment uses `@paginate`. +Returns the same `DocumentHandle` shape as [`useQueryHandle`](~/api-reference/useQueryHandle): `data`, `fetch`, `variables`, plus pagination methods if the fragment uses `@paginate`, or a `refetch` method if it uses `@refetchable`. diff --git a/docs/react/06-api-reference/12-useLocation.mdx b/docs/react/06-api-reference/12-useLocation.mdx deleted file mode 100644 index 8c656310d2..0000000000 --- a/docs/react/06-api-reference/12-useLocation.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: useLocation -description: Read the current URL pathname and navigate imperatively. ---- - -Returns the current location. Use this to read the pathname or navigate imperatively. - -```tsx -import { useLocation } from '$houdini' - -export function ActiveLink({ href, label }: { href: string; label: string }) { - const { pathname } = useLocation() - return ( -
    - {label} - - ) -} -``` - -**Signature** - -```ts -function useLocation(): { pathname: string; params: Record; goto: (url: string) => void } -``` - -| Field | Type | Description | -|---|---|---| -| `pathname` | `string` | The current URL path | -| `params` | `Record` | The current route params (untyped) | -| `goto` | `(url: string) => void` | Navigate to a URL imperatively | diff --git a/docs/react/06-api-reference/12-useLogoutForm.mdx b/docs/react/06-api-reference/12-useLogoutForm.mdx new file mode 100644 index 0000000000..c7600163a4 --- /dev/null +++ b/docs/react/06-api-reference/12-useLogoutForm.mdx @@ -0,0 +1,43 @@ +--- +title: useLogoutForm +description: A progressively-enhanced logout form. +--- + +Renders a logout form that works with or without client-side JavaScript. Without JS the native +form POST deletes the session cookie and redirects; after hydration it clears the session +client-side and navigates. It's the logout counterpart to +[`useMutationForm`](~/api-reference/useMutationForm) — there's no mutation, logout is just "clear the +session". See [Authentication](~/guides/authentication) for the full picture. + +```tsx +import { useLogoutForm } from '$houdini' + +export function LogoutButton() { + const { form, hidden } = useLogoutForm({ redirectTo: '/login' }) + + return ( +
    + {hidden} + +
    + ) +} +``` + +**Options** + +| Option | Type | Description | +|---|---|---| +| `redirectTo` | `string` | Where to navigate after logout (default `/`). The no-JS path 303s here from the server; the enhanced path navigates with `goto`. | +| `onSuccess` | `() => void` | Enhanced-path-only side effect after logout (no-op without JS). | + +**Returns** + +| Field | Type | Description | +|---|---|---| +| `form` | object | Spread onto a `
    `: a real string `action`, `method`, and an `onSubmit` that intercepts after hydration. | +| `hidden` | `ReactNode` | The hidden marker fields the no-JS POST needs; render inside the ``. | + +Logout goes through the always-on auth endpoint (Origin-gated). For a programmatic logout in a +client component, `updateSession(null)` from [`useSession`](~/api-reference/useSession) does the same +thing without a form. diff --git a/docs/react/06-api-reference/14-useMutationForm.mdx b/docs/react/06-api-reference/14-useMutationForm.mdx new file mode 100644 index 0000000000..f3d7acb0a8 --- /dev/null +++ b/docs/react/06-api-reference/14-useMutationForm.mdx @@ -0,0 +1,72 @@ +--- +title: useMutationForm +description: Back a with a Houdini mutation, with progressive enhancement. +--- + +Turns a mutation marked with [`@endpoint`](~/updating-data/forms#the-endpoint-directive) into +a progressively enhanced form: it submits natively before JavaScript loads, then runs the +mutation client-side once hydrated. See [Forms](~/updating-data/forms) for the full guide. + +```tsx +import { graphql, useMutationForm } from '$houdini' + +export function NewUser() { + const { Form, state, pending } = useMutationForm(graphql(` + mutation CreateUser($name: String!) + @endpoint(redirect: "/users/{ createUser.id }") { + createUser(name: $name) { + id + } + } + `)) + + return ( + + + {state?.errors &&

    {state.errors[0].message}

    } + +
    + ) +} +``` + +**Signature** + +```ts +function useMutationForm(document, opts?): MutationForm +``` + +The second argument is optional: + +| Option | Type | Description | +|---|---|---| +| `id` | `string` | Distinguishes multiple forms for the same mutation on one page, and keys the server-injected result. Defaults to the `@endpoint(id:)` or the mutation name. | +| `onSuccess` | `(data) => void` | Enhanced-path-only side effect after a successful submit (no-op without JS). | +| `onError` | `(errors) => void` | Enhanced-path-only side effect after a failed submit (no-op without JS). | + +**Returns** + +| Field | Type | Description | +|---|---|---| +| `Form` | component | The recommended way to render: a `
    ` with the markers injected and `pending` published to `useMutationFormStatus`. Forwards any `` props. | +| `state` | `{ data, errors } \| null` | The submit result; `null` before the first submit. Same shape on both paths. | +| `pending` | `boolean` | True while a submit is in flight (enhanced path). | +| `form` | object | Escape hatch for spreading onto your own ``: `action`, `method`, `onSubmit`, and `encType` when the mutation takes an `Upload`. No status context. | +| `hidden` | `ReactNode` | Escape-hatch companion to `form` — the marker fields the no-JS POST needs; render inside the form. | + +## useMutationFormStatus + +Reads the `pending` state of the nearest `` from any child component, without prop +drilling. Shaped like React's `useFormStatus`, but it can see Houdini's forms. + +```tsx +import { useMutationFormStatus } from '$houdini' + +function Submit() { + const { pending } = useMutationFormStatus() + return +} +``` + +Only works inside the `Form` component returned by `useMutationForm` (a plain `{...form}` +spread provides no context); use the `pending` returned by the hook otherwise. diff --git a/docs/react/06-api-reference/14-useQuery.mdx b/docs/react/06-api-reference/15-useQuery.mdx similarity index 100% rename from docs/react/06-api-reference/14-useQuery.mdx rename to docs/react/06-api-reference/15-useQuery.mdx diff --git a/docs/react/06-api-reference/15-useQueryHandle.mdx b/docs/react/06-api-reference/16-useQueryHandle.mdx similarity index 100% rename from docs/react/06-api-reference/15-useQueryHandle.mdx rename to docs/react/06-api-reference/16-useQueryHandle.mdx diff --git a/docs/react/06-api-reference/16-useRoute.mdx b/docs/react/06-api-reference/16-useRoute.mdx deleted file mode 100644 index 140fba3eff..0000000000 --- a/docs/react/06-api-reference/16-useRoute.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: useRoute -description: Read typed route params for the current page. ---- - -Returns the typed params for the current route. The type parameter should be the `PageProps` type generated for the current route file. - -```tsx -import type { PageProps } from './$types' -import { useRoute } from '$houdini' - -export function ShowBreadcrumb() { - const { params } = useRoute() - return Show #{params.id} -} -``` - -**Signature** - -```ts -function useRoute(): { params: PageProps['Params'] } -``` diff --git a/docs/react/06-api-reference/17-useRoute.mdx b/docs/react/06-api-reference/17-useRoute.mdx new file mode 100644 index 0000000000..ee7bfce96f --- /dev/null +++ b/docs/react/06-api-reference/17-useRoute.mdx @@ -0,0 +1,63 @@ +--- +title: useRoute +description: Read the current route's params, search, and navigate imperatively. +--- + +`useRoute` is the single hook for reading the current route. It returns the route's `params` (the path segments), its `search` (the query string), the current `pathname`, and `goto` for imperative navigation. + +Pass the `PageRoute` (or `LayoutRoute`) type generated for the current route file to type `params` and `search` against that route's query variables. + +```tsx +import type { PageRoute } from './$types' +import { useRoute } from '$houdini' + +export function ShowBreadcrumb() { + const { params, search } = useRoute() + // params.id: typed from the route's [id] segment + // search.tab: typed from the query's nullable variables + return Show #{params.id} +} +``` + +`params` and `search` are derived from the route's query, so they carry the exact scalar types, including [custom scalars](~/guides/custom-scalars), which are unmarshaled back to their runtime type (e.g. a `DateTime` search param comes back as a `Date`). See [Search Params](~/routing/navigation#search-params) for how they get into the URL. + +Calling `useRoute()` without a type argument still works for `pathname` and `goto`, which is handy for navigation-only code. `params` and `search` fall back to empty objects, so reading a key off them without passing your `PageRoute` is a compile error (a nudge to type it): + +```tsx +const { goto } = useRoute() +goto('/login') +``` + +### Route-agnostic components + +A reusable component (say a paginator that assumes its route exposes `after`/`first` search params) isn't tied to a single route, so there's no generated `PageRoute` to pass. Use `GenericRoute` to type the axis you depend on, and leave the other `never` (it falls back to a loose record). Search comes first, since that's the usual reason to reach for it: + +```tsx +import { useRoute, type GenericRoute } from '$houdini' + +export function Paginator() { + const { search } = useRoute>() + // search.after / search.first are typed; the component carries the assumption that it's + // rendered in a route that declares them. params was opted out, so it's loose. +} +``` + +A params-only component writes `GenericRoute`, and you can type both at once with `GenericRoute<{ tab?: string }, { id: string }>` (search, then params). + +**Signature** + +```ts +function useRoute(): { + pathname: string + params: Route['params'] + search: Route['search'] + goto: Goto +} +``` + +| Field | Type | Description | +|---|---|---| +| `pathname` | `string` | The current URL path (including the query string) | +| `params` | `Route['params']` | The current route's path params, scoped to this route's segments | +| `search` | `Route['search']` | The parsed query string: declared search params coerced/unmarshaled, other keys raw, repeated keys as arrays | +| `goto` | `Goto` | Navigate imperatively; accepts a string or a typed `{ to, params, search }` target | diff --git a/docs/react/06-api-reference/17-useSession.mdx b/docs/react/06-api-reference/17-useSession.mdx deleted file mode 100644 index 90644caf55..0000000000 --- a/docs/react/06-api-reference/17-useSession.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: useSession -description: Read and update the current session from any component. ---- - -Returns the current session and an updater function. Calling the updater patches the local session and syncs the change to the server. - -```tsx -import { useSession } from '$houdini' - -export function LogoutButton() { - const [session, updateSession] = useSession() - - return ( - - ) -} -``` - -**Signature** - -```ts -function useSession(): [App.Session, (patch: Partial) => void] -``` - -The updater sends a `POST` request to the session endpoint defined in your router config, clears the data cache, and triggers a re-fetch of any active queries. The session type is defined by your `App.Session` declaration. diff --git a/docs/react/06-api-reference/18-useSession.mdx b/docs/react/06-api-reference/18-useSession.mdx new file mode 100644 index 0000000000..309278e73c --- /dev/null +++ b/docs/react/06-api-reference/18-useSession.mdx @@ -0,0 +1,37 @@ +--- +title: useSession +description: Read and update the current session from any component. +--- + +Returns the current session and an updater function. Calling the updater patches the local session and syncs the change to the server. + +```tsx +import { useSession } from '$houdini' + +export function Account() { + const [session, updateSession] = useSession() + + return ( +
    + Signed in as {session.user?.name} + +
    + ) +} +``` + +**Signature** + +```ts +function useSession(): [ + App.Session, + (patch: Partial | null) => Promise, +] +``` + +The updater sends a `POST` request to the session endpoint defined in your server config (defaulting to `/_auth`), clears the data cache, and triggers a re-fetch of any active queries. The session type is defined by your `App.Session` declaration. + +- **`updateSession(patch)`** — merges `patch` into the session and persists it to the `httpOnly` cookie. +- **`updateSession(null)`** — logs out: empties the local session and deletes the cookie. + +The updater is awaitable (`Promise`), so you can wait for the cookie to settle before navigating. For a logout button that works without JavaScript, use [`useLogoutForm`](~/api-reference/useLogoutForm). To set the session from a mutation, see [`@session`](~/guides/authentication). diff --git a/docs/react/06-api-reference/18-useSubscription.mdx b/docs/react/06-api-reference/19-useSubscription.mdx similarity index 100% rename from docs/react/06-api-reference/18-useSubscription.mdx rename to docs/react/06-api-reference/19-useSubscription.mdx diff --git a/docs/react/OUTLINE.md b/docs/react/OUTLINE.md deleted file mode 100644 index be4f340dfb..0000000000 --- a/docs/react/OUTLINE.md +++ /dev/null @@ -1,80 +0,0 @@ -# Houdini React Docs — Agreed Outline - -## Status legend -- ✅ drafted (written to file) -- 🗒️ stub exists (notes only) -- 🔲 not started - ---- - -## 1. Setting Up Your Project -- 01-getting-started.mdx ✅ -- 02-deployment.mdx ✅ -- Quick start (`npm create houdini@latest`) -- Project structure walkthrough -- IDE setup -- Configuration -- Deployment / adapters (picked early, project-level decision) - -## 2. Routing -- 01-pages-and-layouts.mdx ✅ — pages, layouts, route groups, params, navigation + preloading -- 02-file-conventions.mdx 🗒️ — reference table of every + file and what it does -- 03-error-boundaries.mdx 🗒️ — no +error.jsx, just React error boundaries in +layout.jsx - -## 3. Loading Data -- Queries 🗒️ - - +page.gql / +layout.gql model - - Props injected by query name, must be destructured in signature - - Layout queries wrap child routes - - Variables wired from route params automatically - - Imperative handles ($handle variant) -- Fragments 🔲 - - useFragment() hook - - Colocation: each component declares exactly the data it needs - - Loading states through fragments (@loading on fragment spreads) - - Component fields (experimental) -- Loading States 🗒️ - - Shared partials for framework-agnostic content (@loading, count, cascade, fragment composition) - - React-specific: isPending (not === PendingValue), @loading implies Suspense boundary -- Pagination 🗒️ - - Cursor-based and offset/limit - - $handle.loadNextPage / loadPreviousPage - - Fragment pagination -- Subscriptions 🔲 - - useSubscription() hook - - Real-time cache updates -- Your GraphQL Server 🗒️ - - +schema exports executable schema, Houdini wraps in Yoga - - +yoga for custom instance (context injection, plugins) — +schema still required - - True BFF: in-memory SSR resolution, no network hop - - Security: schema not publicly reachable, no client-visible surface area beyond queries - -## 4. Updating Data 🔲 -- Mutations -- Optimistic Updates (@optimisticResponse, auto-rollback on error) -- Updating Lists (@list, @append, @prepend, @remove, @allLists) -- Cache (policies, record identity, manual writes) - -## 5. Guides 🔲 -- Authentication (React-specific) -- TypeScript (React-specific) -- Error Handling (shared partial — also pulled into Svelte docs) -- Nullability (shared partial — also pulled into Svelte docs) -- File Uploads (shared partial — also pulled into Svelte docs) -- Trusted Documents (shared partial — also pulled into Svelte docs) -- Custom Scalars (shared partial — already exists at shared/_partials/custom-scalars.mdx) - -## 6. Reference 🔲 -- Config -- CLI -- Vite Plugin -- Client API -- Directives -- Codegen Plugins - ---- - -## Notes -- Introduction comes last (write after everything else so framing is sharp) -- Loading States partials also need to be extracted from the Svelte doc -- "Your GraphQL Server" title chosen over "Local APIs" — more self-explanatory diff --git a/docs/shared/01-core/01-config.mdx b/docs/shared/01-core/01-config.mdx index 50ac3764d0..14c93acda3 100644 --- a/docs/shared/01-core/01-config.mdx +++ b/docs/shared/01-core/01-config.mdx @@ -32,17 +32,18 @@ By default, your config file can contain the following values: - `include` (optional, default: `"src/**/*.{svelte,graphql,gql,ts,js}"`): a pattern (or list of patterns) to identify source code files. - `exclude` (optional): a pattern (or list of patterns) that filters out files that match the include pattern - `schemaPath` (optional, default: `"./schema.graphql"`): the path to the static representation of your schema, can be a glob pointing to multiple files -- `watchSchema` (optional, an object): configure the development server to poll a remote url for changes in the schema. When a change is detected, the dev server will automatically regenerate your runtime. For more information see [Schema Polling](#schema-polling). +- `url` (optional): the URL of the GraphQL API. It's public (it ships in the client bundle), so switch it per environment with a `VITE_`-prefixed variable, e.g. `import.meta.env.VITE_API_URL ?? 'http://localhost:4000/graphql'`. `watchSchema.url` defaults to this when omitted. +- `watchSchema` (optional, an object): configure the development server to poll a remote url for changes in the schema. When a change is detected, the dev server will automatically regenerate your runtime. When its `url` is omitted, the top-level `url` is used. For more information see [Schema Polling](#schema-polling). - `persistedQueriesPath` (optional, default: `/persisted_queries.json`): Configure the path of the persisted queries file. - `module` (optional, default: `"esm"`): One of `"esm"` or `"commonjs"`. Used to tell the artifact generator what kind of modules to create. - `definitionsPath` (optional, default: `"/graphql"`): a path that the generator will use to write `schema.graphql` and `documents.gql` files containing all of the internal fragment and directive definitions used in the project. - `scalars` (optional): An object describing custom scalars for your project (see below). -- `cacheBufferSize` (optional, default: `10`): The number of queries that must occur before a value is removed from the cache. For more information, see the [Cache API](/svelte/api/cache/). -- `defaultCachePolicy` (optional, default: `"CacheOrNetwork"`): The default cache policy to use for queries. For a list of the policies or other information see the [Cache API](/svelte/api/cache/). -- `defaultPartial` (optional, default: `false`): specifies whether or not the cache should always use partial data. For more information, check out the [Partial Data guide](/svelte/api/cache/#partial-data). -- `defaultLifetime` (optional, default: `undefined`): Specifies after how long a data goes stale in miliseconds. [Cache stale](/svelte/api/cache#stale). -- `defaultKeys` (optional, default: `["id"]`): A list of fields to use when computing a record's id. The default value is `['id']`. For more information see the [Cache API](/svelte/api/cache/#custom-ids). -- `types` (optional): an object that customizes the resolution behavior for a specific type. For more information see the [Cache API](/svelte/api/cache/#custom-ids). +- `cacheBufferSize` (optional, default: `10`): The number of queries that must occur before a value is removed from the cache. For more information, see the [Cache API](~/core/cache). +- `defaultCachePolicy` (optional, default: `"CacheOrNetwork"`): The default cache policy to use for queries. For a list of the policies or other information see the [Cache API](~/core/cache). +- `defaultPartial` (optional, default: `false`): specifies whether or not the cache should always use partial data. For more information, check out the [Partial Data guide](~/updating-data/caching-data#partial-data). +- `defaultLifetime` (optional, default: `undefined`): Specifies after how long a data goes stale in miliseconds. [Cache stale](~/core/cache#stale-data). +- `defaultKeys` (optional, default: `["id"]`): A list of fields to use when computing a record's id. The default value is `['id']`. For more information see the [Cache API](~/updating-data/caching-data#custom-ids). +- `types` (optional): an object that customizes the resolution behavior for a specific type. For more information see the [Cache API](~/updating-data/caching-data#custom-ids). - `logLevel` (optional, default: `"summary"`): Specifies the style of logging houdini will use when generating your file. One of "quiet", "full", "summary", or "short-summary". - `defaultFragmentMasking` (optional, default: `"enable"`): `"enable"` to mask fragment and use collocated data requirement as best or `"disable"` to access fragment data directly in operation. Can be overridden individually at fragment level. - `defaultListTarget` (optional): Can be set to `"all"` for all list operations to ignore parent ID and affect all lists with the name. @@ -102,7 +103,7 @@ You can pass the following parameters to `watchSchema`: - `url` (a string or function): Configures the url to use to pull the schema. If you don't pass an `apiUrl`, the kit plugin will not poll for schema changes. If you want to access an environment variable, you can either prefix your string with `env:` or set it to a function that takes the current environment and returns a string. For more information see the [section below](#environment-variables). - `headers` (optional): An object specifying the headers to use when pulling your schema. Keys of the object are header names and its values can be either a strings or a function that takes the current `process.env` and returns the the value to use. If you want to access an environment variable, prefix your string with `env:`, ie `env:API_KEY`. For more information see the [section below](#environment-variables). -- `interval` (optional, default: `2000`): Configures the schema polling behavior for the kit plugin. If its value is greater than `0`, the plugin will poll the set number of milliseconds. If set to `0`, the plugin will only pull the schema when you first run `dev`. If you set to `null`, the plugin will never look for schema changes. You can see use the [pull-schema command](/svelte/api/cli#pull-schema) to get updates. +- `interval` (optional, default: `2000`): Configures the schema polling behavior for the kit plugin. If its value is greater than `0`, the plugin will poll the set number of milliseconds. If set to `0`, the plugin will only pull the schema when you first run `dev`. If you set to `null`, the plugin will never look for schema changes. You can see use the [pull-schema command](~/core/cli#pull-schema) to get updates. - `timeout` (optional, default: `30000`): Sets a custom timeout in milliseconds which is used to cancel fetching the schema. If the timeout is reached before the remote API has responded, the request is cancelled and an error is displayed. The default is 30 seconds. - `writePolledSchema` (optional, default: `true`): Save the updated schema to `schemaPath` locally whenever it is updated (on watchSchema pull). diff --git a/docs/shared/01-core/02-cli.mdx b/docs/shared/01-core/02-cli.mdx index cf7c59868c..71e1942175 100644 --- a/docs/shared/01-core/02-cli.mdx +++ b/docs/shared/01-core/02-cli.mdx @@ -16,7 +16,7 @@ Generates the runtime and the artifacts for every document in your project ### Flags: - `--pull-schema` or `-p` pulls the latest schema before generating. Keep in mind you only need to do this if you don't already have the most up to date version of your schema in your local filesystem. If you are defining your graphql schema inside of your application, such as a SvelteKit application, or are in a monorepo, you don't need to use this flag. -- `--output` or `-o` specifies a location for the runtime generator to leave a query map for certain flavors of [persisted queries](/svelte/persisted-queries/). +- `--output` or `-o` specifies a location for the runtime generator to leave a query map for certain flavors of [persisted queries](~/guides/trusted-documents). - `--headers` or `-h` specifies headers to use when pulling your schema. Should be passed as KEY=VALUE - `--log` or `-l` specifies the log level for the generation. One of "summary", "short-summary", "quiet" or "full". @@ -39,7 +39,7 @@ houdini pull-schema ``` Pull the latest schema for your project. In order for this command to run, you must have an `apiUrl` value -set in your [config file](/svelte/api/config). +set in your [config file](~/core/config). ### Flags: diff --git a/docs/shared/01-core/03-vite-plugin.mdx b/docs/shared/01-core/03-vite-plugin.mdx index 837a48b627..b936d97ae2 100644 --- a/docs/shared/01-core/03-vite-plugin.mdx +++ b/docs/shared/01-core/03-vite-plugin.mdx @@ -4,7 +4,7 @@ description: A summary of configuration values for Houdini's kit plugin --- Houdini's Vite Plugin is responsible for generating the code necessary to -power [its GraphQL APIs](/svelte/graphql-documents/). +power [its GraphQL APIs](~/loading-data/queries). ## SvelteKit example @@ -28,5 +28,5 @@ The plugin is primarily responsible for a few tasks: ## Configuration -The plugin can be optionally configured with an object containing any of the [config values](/svelte/api/config) as well as +The plugin can be optionally configured with an object containing any of the [config values](~/core/config) as well as the `configFile` key which can be used to provide an absolute path to your `houdini.config.js` (useful in monorepos). diff --git a/docs/shared/01-core/04-client.mdx b/docs/shared/01-core/04-client.mdx index 1246050431..0046072c18 100644 --- a/docs/shared/01-core/04-client.mdx +++ b/docs/shared/01-core/04-client.mdx @@ -32,7 +32,7 @@ The `HoudiniClient` constructor takes the following arguments - `url` (required): the URL that your application will use to query the API - `fetchParams` (optional): a function that takes a [FetchParamsInput](#type-definitions) and returns additional parameters to `fetch` and other network calls made by the client. -- `plugins` (optional): a list of [ClientPlugins](/svelte/api/client-plugins) that will be added to the Client's default list +- `plugins` (optional): a list of [ClientPlugins](~/extending-houdini/client-plugins) that will be added to the Client's default list - `pipeline` (optional): a function that returns the full list of plugins that the client will use. This is only for very advanced use cases. If you find yourself needing this level of control, please open an issue to discuss your situation. - `throwOnError` (optional): takes an object of type [ThrowOnErrorParams](#type-definitions) and configures your client's error handling (see [Error Handling](#error-handling) below) @@ -100,12 +100,12 @@ export default new HoudiniClient({ Houdini's default pipeline is built from plugins exported from `$houdini/plugins`. You can import any of these to use in a custom `pipeline`: -- `fetch` — resolves the pipeline by sending a standard HTTP request. The default terminating plugin. Accepts an optional handler function for fully custom request logic. -- `query` — core behavior for queries: establishing cache subscriptions and accumulating variables. -- `mutation` — core behavior for mutations, including optimistic responses. -- `subscription` — core behavior for subscriptions. Accepts a `SubscriptionHandler` to wire up a WebSocket client such as `graphql-ws`. -- `throwOnError` — included when `throwOnError` is configured on the client. -- `fetchParams` — included when `fetchParams` is configured on the client. +- `fetch`: resolves the pipeline by sending a standard HTTP request. The default terminating plugin. Accepts an optional handler function for fully custom request logic. +- `query`: core behavior for queries, establishing cache subscriptions and accumulating variables. +- `mutation`: core behavior for mutations, including optimistic responses. +- `subscription`: core behavior for subscriptions. Accepts a `SubscriptionHandler` to wire up a WebSocket client such as `graphql-ws`. +- `throwOnError`: included when `throwOnError` is configured on the client. +- `fetchParams`: included when `fetchParams` is configured on the client. ## Adding Plugins diff --git a/docs/shared/01-core/06-cache.mdx b/docs/shared/01-core/06-cache.mdx index 57ba408441..65c2658508 100644 --- a/docs/shared/01-core/06-cache.mdx +++ b/docs/shared/01-core/06-cache.mdx @@ -5,8 +5,8 @@ description: Programmatic access to Houdini's runtime cache for reading, writing ## Cache API -There are times where Houdini's [automatic cache updates](/svelte/api/mutation#updating-fields) -or [list operation fragments](/svelte/api/mutation#lists) are not sufficient. For those times, +There are times where Houdini's [automatic cache updates](~/updating-data/mutations) +or [list operation fragments](~/updating-data/updating-lists#operations) are not sufficient. For those times, Houdini provides a programatic API for interacting with the cache directly. If you find yourself relying on this API for a considerable amount of your business logic, please open an issue or discussion on GitHub so we can try to figure out if there @@ -98,7 +98,7 @@ cache.get('User', { id: '1' }).read({ }) ``` -For more information about fragment variables, head over to the [fragment api reference](/svelte/api/fragment#fragment-arguments). +For more information about fragment variables, head over to the [fragment api reference](~/loading-data/fragments#fragment-arguments). ## Updating Cache Values @@ -185,7 +185,7 @@ cache.get('User', { id: '1' }).write({ }) ``` -For more information about fragment variables, head over to the [fragment api reference](/svelte/api/fragment#fragment-arguments). +For more information about fragment variables, head over to the [fragment api reference](~/loading-data/fragments#fragment-arguments). ### Updating Relationships @@ -243,7 +243,7 @@ user.delete() If you want to reload a record's values from your API (for example, after a mutation that you know changed data on the server), you can use the `refresh` method. Every -document whose data contains the record will refetch itself over the network — +document whose data contains the record will refetch itself over the network, including documents that only include the record through a fragment spread: ```typescript @@ -255,10 +255,14 @@ user.refresh() Unlike `markStale` (which waits for the next fetch to reload the data), `refresh` triggers the network requests immediately. +If a mutation or subscription already returns the record that changed, you can get +this same behavior declaratively by tagging the field with `@refetch` instead of +calling `refresh()` yourself. See [Refetching Changed Data](~/updating-data/mutations#refetching-changed-data). + ## Lists Another primitive provided by the `cache` instance is `List` and it provide a programatic -API for the same operations supported by the [list operation fragments](/svelte/api/mutation#lists). +API for the same operations supported by the [list operation fragments](~/updating-data/updating-lists#operations). Accessing a list can be done with the `list` method: @@ -297,7 +301,7 @@ allFriends.when({ favorites: true }).append(user1) ## Stale Data If you want fine-grained logic for marking data as stale, you can use the programmatic api. For more -information on stale data in Houdini, check out the [Svelte Caching Data guide](/svelte/cache/#stale-data). +information on stale data in Houdini, check out the [Svelte Caching Data guide](~/core/cache#stale-data). ```typescript import { cache, graphql } from '$houdini' diff --git a/docs/shared/01-core/07-architecture.mdx b/docs/shared/01-core/07-architecture.mdx index 16db45c9fd..2a4dde6c05 100644 --- a/docs/shared/01-core/07-architecture.mdx +++ b/docs/shared/01-core/07-architecture.mdx @@ -49,33 +49,33 @@ For example, in a Svelte component we might write: {$UserList.data?.users?.map((user) => user.name).join(', ')} ``` -From that single tagged template literal, Houdini generates the `UserList` result and variable types, a store for loading the query, and cache metadata for every selected field. The important idea is that the document is the source of truth — everything else is derived. +From that single tagged template literal, Houdini generates the `UserList` result and variable types, a store for loading the query, and cache metadata for every selected field. The important idea is that the document is the source of truth; everything else is derived. ## How the compiler works This is where things get interesting. The compiler has to satisfy two constraints that pull in different directions: it's deeply integrated with Vite, which means there will always be a Node.js layer involved. But parsing and validating thousands of GraphQL documents in a hot-reload loop is the kind of work that really wants a compiled language. -The solution is a process-per-plugin model with a Node.js orchestrator. Node handles Vite integration, pipeline sequencing, and spawning. The heavy pipeline work — extraction, validation, codegen — runs in Go plugins (or any compiled binary). +The solution is a process-per-plugin model with a Node.js orchestrator. Node handles Vite integration, pipeline sequencing, and spawning. The heavy pipeline work (extraction, validation, codegen) runs in Go plugins (or any compiled binary). ### Plugins as long-running processes -When the compiler starts, it spawns each plugin as a child process and keeps it alive. During `houdini generate`, that means the processes persist for the duration of one run. During `vite dev`, they persist for the entire dev session — startup cost is paid once, and each incremental build is just the orchestrator sending hook invocations to already-running processes. +When the compiler starts, it spawns each plugin as a child process and keeps it alive. During `houdini generate`, that means the processes persist for the duration of one run. During `vite dev`, they persist for the entire dev session. Startup cost is paid once, and each incremental build is just the orchestrator sending hook invocations to already-running processes. Each plugin registers itself with its name, the hooks it implements, and how it wants to be ordered relative to other plugins. After that, the orchestrator knows exactly which processes to call for each stage of the pipeline. ### WebSocket communication -The orchestrator and plugins communicate over WebSockets. The persistent connection means there's no per-call handshake overhead — we connect once and send hook invocations over the open socket for as long as the session runs. When the orchestrator goes away (Vite restarts, process killed, `generate` finishes), the connection close propagates to every plugin and they exit cleanly. No orphaned processes to hunt down. +The orchestrator and plugins communicate over WebSockets. The persistent connection means there's no per-call handshake overhead: we connect once and send hook invocations over the open socket for as long as the session runs. When the orchestrator goes away (Vite restarts, process killed, `generate` finishes), the connection close propagates to every plugin and they exit cleanly. No orphaned processes to hunt down. ### SQLite as shared memory -Plugin processes need to share data — schema definitions, extracted documents, artifact metadata — across process boundaries and across language runtimes. We use a single SQLite database file for this. The path is passed to every plugin as a flag at startup, and each plugin connects directly. +Plugin processes need to share data (schema definitions, extracted documents, artifact metadata) across process boundaries and across language runtimes. We use a single SQLite database file for this. The path is passed to every plugin as a flag at startup, and each plugin connects directly. -SQLite in WAL mode supports concurrent reads without blocking, which matters for hooks like `Validate` and `GenerateDocuments` that are independent of each other and can run in parallel. More broadly, the database schema is the contract between the orchestrator and every plugin. A plugin written in Go, Rust, or anything else just needs to open the same file — no serialization layer, no bespoke protocol for state transfer. +SQLite in WAL mode supports concurrent reads without blocking, which matters for hooks like `Validate` and `GenerateDocuments` that are independent of each other and can run in parallel. More broadly, the database schema is the contract between the orchestrator and every plugin. A plugin written in Go, Rust, or anything else just needs to open the same file: no serialization layer, no bespoke protocol for state transfer. ### Vite's role during dev -During development, Vite drives the compiler. When source files change, Vite's HMR pipeline calls the orchestrator, which triggers an incremental pipeline run starting from the appropriate hook. Because plugins are already running and the database already has the previous build's state, only the work that's actually stale gets redone. The orchestrator also serializes concurrent triggers — if a schema watcher and a file watcher fire at the same time, they queue rather than racing. +During development, Vite drives the compiler. When source files change, Vite's HMR pipeline calls the orchestrator, which triggers an incremental pipeline run starting from the appropriate hook. Because plugins are already running and the database already has the previous build's state, only the work that's actually stale gets redone. The orchestrator also serializes concurrent triggers: if a schema watcher and a file watcher fire at the same time, they queue rather than racing. During `houdini generate`, the same pipeline runs end-to-end once and exits. diff --git a/docs/shared/02-extending-houdini/01-client-plugins.mdx b/docs/shared/02-extending-houdini/01-client-plugins.mdx index d1d24f569b..6cb64f9a15 100644 --- a/docs/shared/02-extending-houdini/01-client-plugins.mdx +++ b/docs/shared/02-extending-houdini/01-client-plugins.mdx @@ -3,17 +3,17 @@ title: Client Plugins description: How to write custom plugins that hook into Houdini's request pipeline --- -Client plugins let us customize the runtime behavior of our application's documents — integrating with a logging service, adding retry logic, or even adding support for entirely new network capabilities like [Live Queries](https://the-guild.dev/blog/subscriptions-and-live-queries-real-time-with-graphql). +Client plugins let us customize the runtime behavior of our application's documents, whether that means integrating with a logging service, adding retry logic, or even adding support for entirely new network capabilities like [Live Queries](https://the-guild.dev/blog/subscriptions-and-live-queries-real-time-with-graphql). ## Overview Every document in a Houdini app is backed by an observable value called a "Document Store". The store holds the latest value of the document and sends new queries to update its state. Client plugins modify this structure by hooking into five phases of the request pipeline: -- `start` — runs at the beginning of every request, regardless of cache -- `beforeNetwork` — runs when there was no cache hit and a network request is about to be made -- `network` — performs the actual network request -- `afterNetwork` — runs after the network request but before the cache processes the result -- `end` — runs at the end of the request regardless of whether data came from cache or network +- `start`: runs at the beginning of every request, regardless of cache +- `beforeNetwork`: runs when there was no cache hit and a network request is about to be made +- `network`: performs the actual network request +- `afterNetwork`: runs after the network request but before the cache processes the result +- `end`: runs at the end of the request regardless of whether data came from cache or network For some documents, `beforeNetwork`, `network`, and `afterNetwork` are short-circuited when the cache policy allows it. Think of the cache as a gatekeeper that decides whether a request can be resolved before reaching the network phases. @@ -32,7 +32,7 @@ While preparing a request, plugins are iterated in the order they are passed to A client plugin is a function that returns an object with hooks for the phases we want to intercept: -```typescript title="src/plugins/custom_plugin.ts" +```typescript title="src/plugins/custom_plugin.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' const sayHello: ClientPlugin = () => { @@ -56,9 +56,9 @@ export default new HoudiniClient({ }) ``` -That's the basic shape. Now let's look at a more useful example — a retry plugin that re-runs a query when the response contains errors: +That's the basic shape. Now let's look at a more useful example: a retry plugin that re-runs a query when the response contains errors: -```typescript title="src/client.ts" +```typescript title="src/client.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' const retry: ClientPlugin = () => { @@ -83,7 +83,7 @@ The five phases split into two directions. **Enter hooks** (`start`, `beforeNetwork`, `network`) carry information toward the server. We call `next` to pass to the next plugin, or `resolve` to short-circuit the chain and start returning a value: -```typescript title="src/client.ts" +```typescript title="src/client.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' const simpleFetch: ClientPlugin = () => { @@ -99,13 +99,13 @@ const simpleFetch: ClientPlugin = () => { ``` A few rules for enter hooks: -- At least one hook in the chain must call `resolve` — otherwise the pipeline hangs indefinitely +- At least one hook in the chain must call `resolve`; otherwise the pipeline hangs indefinitely - We can call both `resolve` and `next` in the same hook; prefer calling `resolve` first when it makes sense - `resolve` requires a full `QueryResult` (see [Type Definitions](#type-definitions)) **Exit hooks** (`afterNetwork`, `end`) process a value as it returns from the pipeline. We use `resolve` to keep the chain flowing back to the user: -```typescript title="src/client.ts" +```typescript title="src/client.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' const logErrors: ClientPlugin = () => { @@ -121,7 +121,7 @@ const logErrors: ClientPlugin = () => { ``` Exit hook rules: -- We don't have to pass a value to `resolve` — the last known value is used automatically +- We don't have to pass a value to `resolve`; the last known value is used automatically - Exit hooks that never call `resolve` create a pipeline black hole; no data reaches the user ## Choosing a Phase @@ -138,7 +138,7 @@ If it's unclear which phase to use, work through these questions: To share state between phases of the same request, store values on `ctx.stuff`: -```typescript title="src/client.ts" +```typescript title="src/client.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' const timer: ClientPlugin = () => { @@ -160,7 +160,7 @@ const timer: ClientPlugin = () => { To track state across multiple network requests, initialize it before returning the hooks object. Houdini calls the outer function once when the store is created: -```typescript title="src/client.ts" +```typescript title="src/client.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' const trackVariables: ClientPlugin = () => { @@ -178,13 +178,13 @@ const trackVariables: ClientPlugin = () => { ## Multiple Values -A store can receive multiple updates for a given set of inputs — subscriptions and live queries both push multiple results through the cache. Each payload travels through the full chain using the same `resolve` function. If the original request hasn't resolved when a payload arrives, the promise resolves with that first value. This means we can call `resolve` inside event listeners to push multiple values. +A store can receive multiple updates for a given set of inputs; subscriptions and live queries both push multiple results through the cache. Each payload travels through the full chain using the same `resolve` function. If the original request hasn't resolved when a payload arrives, the promise resolves with that first value. This means we can call `resolve` inside event listeners to push multiple values. ## Composing Plugins -A plugin can return any combination of hook objects, `null`, or arrays of hooks — useful for toggling functionality based on configuration: +A plugin can return any combination of hook objects, `null`, or arrays of hooks. This is useful for toggling functionality based on configuration: -```typescript title="src/plugins/custom_plugin.ts" +```typescript title="src/plugins/custom_plugin.ts&typescriptToggle=true" import type { ClientPlugin } from '$houdini' import externalPlugin from 'third-party' @@ -202,7 +202,7 @@ const conditional: ClientPlugin = (config) => () => { ## Type Definitions -The authoritative source is exported from `$houdini`. The definitions below may be slightly out of date — if there's a discrepancy, the package wins. +The authoritative source is exported from `$houdini`. The definitions below may be slightly out of date; if there's a discrepancy, the package wins. ```typescript type ClientPlugin = () => { diff --git a/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx b/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx index 513bf694d5..59a12d3733 100644 --- a/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx +++ b/docs/shared/02-extending-houdini/02-codegen-plugins-golang.mdx @@ -5,7 +5,7 @@ description: Writing Houdini codegen plugins in Go import ViteSubmodule from '@shared/_partials/plugin-vite-submodule.mdx' -Houdini's codegen pipeline is built in Go, and you can extend it by writing your own Go plugin. A plugin is a standalone binary that registers itself with the pipeline and responds to lifecycle hooks — schema modifications, validation, document generation, and more. +Houdini's codegen pipeline is built in Go, and you can extend it by writing your own Go plugin. A plugin is a standalone binary that registers itself with the pipeline and responds to lifecycle hooks: schema modifications, validation, document generation, and more. ## Defining a Plugin @@ -31,19 +31,19 @@ func (p *MyPlugin) Order() plugins.PluginOrder { } ``` -`plugins.Plugin[PluginConfig]` is a generic base that wires up the database and filesystem — embed it and you get `p.DB` and `p.Fs` for free. The type parameter is your plugin's config shape (see [Plugin Config](#plugin-config) below). If your plugin has no config, use `struct{}`. +`plugins.Plugin[PluginConfig]` is a generic base that wires up the database and filesystem. Embed it and you get `p.DB` and `p.Fs` for free. The type parameter is your plugin's config shape (see [Plugin Config](#plugin-config) below). If your plugin has no config, use `struct{}`. ### Plugin Order The `Order()` method controls when your plugin runs relative to others: -- `PluginOrderBefore` — runs before `houdini-core` -- `PluginOrderCore` — reserved for `houdini-core` and framework plugins -- `PluginOrderAfter` — runs after `houdini-core` (the right choice for most plugins) +- `PluginOrderBefore`: runs before `houdini-core` +- `PluginOrderCore`: reserved for `houdini-core` and framework plugins +- `PluginOrderAfter`: runs after `houdini-core` (the right choice for most plugins) ### Entry Point -The binary's `main.go` is minimal — create the plugin, set the filesystem, and hand it to `plugins.Run`: +The binary's `main.go` is minimal: create the plugin, set the filesystem, and hand it to `plugins.Run`: ```go title="main.go" package main @@ -72,7 +72,7 @@ func main() { ## Hook Interfaces -Hooks are opt-in — implement only the interfaces for the lifecycle events you care about. The pipeline inspects your plugin at startup and only invokes the hooks you've registered. +Hooks are opt-in: implement only the interfaces for the lifecycle events you care about. The pipeline inspects your plugin at startup and only invokes the hooks you've registered. | Interface | When it fires | |---|---| @@ -99,11 +99,11 @@ func (p *MyPlugin) Validate(ctx context.Context) error { } ``` -The pipeline uses Go's interface system to detect which hooks you've implemented — no registration step needed. +The pipeline uses Go's interface system to detect which hooks you've implemented, so no registration step is needed. ## Distributing via npm -Go produces platform-native binaries, but npm packages need to work across operating systems and architectures. The standard approach — the same one Houdini uses for its own plugins — is to cross-compile once for every target platform and split the output into per-platform npm packages, then wire them together with a JavaScript shim. +Go produces platform-native binaries, but npm packages need to work across operating systems and architectures. The standard approach (the same one Houdini uses for its own plugins) is to cross-compile once for every target platform and split the output into per-platform npm packages, then wire them together with a JavaScript shim. ### Cross-Compiling @@ -116,17 +116,17 @@ GOOS=windows GOARCH=amd64 go build -o bin/my-plugin.exe # ... and so on for each target ``` -Each binary goes into its own package — `my-plugin-darwin-arm64`, `my-plugin-linux-x64`, etc. — with a `package.json` that declares the matching `os` and `cpu` fields. The root package lists all of them as `optionalDependencies`, so package managers install only the one that matches the current machine. +Each binary goes into its own package (`my-plugin-darwin-arm64`, `my-plugin-linux-x64`, etc.) with a `package.json` that declares the matching `os` and `cpu` fields. The root package lists all of them as `optionalDependencies`, so package managers install only the one that matches the current machine. ### The Shim The root package's `bin` field points to a small Node.js shim rather than a binary directly. The shim's job is to find the right binary for the current platform and hand off execution to it via `execFileSync`. As a post-install optimization, the shim replaces itself with a hard link to the actual binary so subsequent invocations skip Node entirely. -The shim also supports a `HOUDINI_PLATFORM` environment variable, which lets callers force a specific platform — useful in CI environments where the host architecture doesn't match the target. +The shim also supports a `HOUDINI_PLATFORM` environment variable, which lets callers force a specific platform. This is useful in CI environments where the host architecture doesn't match the target. ## Static Runtimes -The `StaticRuntime` interface lets a plugin copy a directory of files into the project during the `afterLoad` phase — before document discovery and codegen run. +The `StaticRuntime` interface lets a plugin copy a directory of files into the project during the `afterLoad` phase, before document discovery and codegen run. ```go func (p *MyPlugin) StaticRuntime(ctx context.Context) (string, error) { @@ -162,8 +162,8 @@ The main distinction from `IncludeRuntime` is timing: `IncludeRuntime` is copied Rather than maintaining these files by hand, Houdini generates everything through a build script. The templates and tooling live at: -- [`packages/_scripts/buildGo.js`](https://github.com/HoudiniGraphQL/houdini/blob/main/packages/_scripts/buildGo.js) — cross-compiles for all platforms, generates per-platform `package.json` files, and assembles the root package -- [`packages/_scripts/templates/shim.cjs`](https://github.com/HoudiniGraphQL/houdini/blob/main/packages/_scripts/templates/shim.cjs) — the shim template, with placeholders that `buildGo.js` replaces with the actual package and binary names -- [`packages/_scripts/templates/postInstall.js`](https://github.com/HoudiniGraphQL/houdini/blob/main/packages/_scripts/templates/postInstall.js) — the post-install script that downloads the binary if the platform package wasn't available, then attempts the hard-link optimization +- [`packages/_scripts/buildGo.js`](https://github.com/HoudiniGraphQL/houdini/blob/main/packages/_scripts/buildGo.js): cross-compiles for all platforms, generates per-platform `package.json` files, and assembles the root package +- [`packages/_scripts/templates/shim.cjs`](https://github.com/HoudiniGraphQL/houdini/blob/main/packages/_scripts/templates/shim.cjs): the shim template, with placeholders that `buildGo.js` replaces with the actual package and binary names +- [`packages/_scripts/templates/postInstall.js`](https://github.com/HoudiniGraphQL/houdini/blob/main/packages/_scripts/templates/postInstall.js): the post-install script that downloads the binary if the platform package wasn't available, then attempts the hard-link optimization -These files are written as templates — `my-package` and `my-binary` are replaced with the actual package name at build time — so you can adapt them directly for your own plugin. +These files are written as templates (`my-package` and `my-binary` are replaced with the actual package name at build time), so you can adapt them directly for your own plugin. diff --git a/docs/shared/02-extending-houdini/03-codegen-plugins-golang-api.mdx b/docs/shared/02-extending-houdini/03-codegen-plugins-golang-api.mdx index 1214086b92..2f2b9c044e 100644 --- a/docs/shared/02-extending-houdini/03-codegen-plugins-golang-api.mdx +++ b/docs/shared/02-extending-houdini/03-codegen-plugins-golang-api.mdx @@ -5,11 +5,11 @@ description: API reference for Houdini's Go plugin utilities ## Database -Houdini's pipeline stores everything — documents, schema types, validation results, project config — in a shared SQLite database. Every plugin gets access to it through `p.DB`, a `DatabasePool[PluginConfig]` that the runtime injects before any hooks fire. +Houdini's pipeline stores everything (documents, schema types, validation results, project config) in a shared SQLite database. Every plugin gets access to it through `p.DB`, a `DatabasePool[PluginConfig]` that the runtime injects before any hooks fire. ### Why the abstraction -The pipeline runs in two environments that need different SQLite drivers: native binaries use `zombiezen.com/go/sqlite` (a connection pool built on CGo), while WASM builds require a pure-Go driver that works inside a WASI sandbox. Rather than scattering build-tag conditionals through plugin code, the `plugins` package exposes three small interfaces — `Row`, `Stmt`, and `Conn` — and the right driver is wired in at compile time. Plugin code should never import a SQLite driver directly; it only ever sees these interfaces. +The pipeline runs in two environments that need different SQLite drivers: native binaries use `zombiezen.com/go/sqlite` (a connection pool built on CGo), while WASM builds require a pure-Go driver that works inside a WASI sandbox. Rather than scattering build-tag conditionals through plugin code, the `plugins` package exposes three small interfaces (`Row`, `Stmt`, and `Conn`) and the right driver is wired in at compile time. Plugin code should never import a SQLite driver directly; it only ever sees these interfaces. ### Checking out connections @@ -29,11 +29,11 @@ if err != nil { defer stmt.Finalize() ``` -For most queries you won't need to manage connections manually — the higher-level helpers do it for you. +For most queries you won't need to manage connections manually; the higher-level helpers do it for you. ### Querying rows -`StepQuery` is the standard way to read data. It checks out a connection, prepares the query, binds parameters, iterates over rows, and cleans up — all in one call: +`StepQuery` is the standard way to read data. It checks out a connection, prepares the query, binds parameters, iterates over rows, and cleans up, all in one call: ```go err := p.DB.StepQuery(ctx, ` @@ -65,7 +65,7 @@ err := p.DB.ExecQuery(ctx, ` }) ``` -When you need to write many rows in a loop, prepare the statement once and call `ExecStatement` for each row — this avoids reparsing the SQL on every iteration: +When you need to write many rows in a loop, prepare the statement once and call `ExecStatement` for each row. This avoids reparsing the SQL on every iteration: ```go conn, err := p.DB.Take(ctx) @@ -92,7 +92,7 @@ for _, problem := range problems { ### Transactions -Use `Transaction` to wrap a block of writes in a single commit. The returned function is designed to be deferred — it commits on success and rolls back if the error pointer is non-nil: +Use `Transaction` to wrap a block of writes in a single commit. The returned function is designed to be deferred: it commits on success and rolls back if the error pointer is non-nil: ```go conn, err := p.DB.Take(ctx) @@ -152,7 +152,7 @@ A few helpers cover the common wrapping cases: `plugins.Errorf(format, args...)` ## GraphQL Constants -The `plugins/graphql` package exports named constants for every directive and list operation suffix Houdini defines internally — use these instead of hardcoding strings: +The `plugins/graphql` package exports named constants for every directive and list operation suffix Houdini defines internally. Use these instead of hardcoding strings: ```go import gql "code.houdinigraphql.com/plugins/graphql" @@ -190,13 +190,13 @@ config.DefinitionsDirectory() // path to schema.graphql / document ## Filesystem Utilities -`plugins.RecursiveCopy(ctx, fs, from, to, transform)` copies a directory tree in parallel, applying a transform function to each file's contents before writing. It only writes files whose content has changed and returns the list of paths that were updated — this is what the runtime uses internally when a plugin implements `IncludeRuntime`. Useful if your `GenerateRuntime` hook needs to copy and patch a set of template files. +`plugins.RecursiveCopy(ctx, fs, from, to, transform)` copies a directory tree in parallel, applying a transform function to each file's contents before writing. It only writes files whose content has changed and returns the list of paths that were updated. This is what the runtime uses internally when a plugin implements `IncludeRuntime`. Useful if your `GenerateRuntime` hook needs to copy and patch a set of template files. `PluginDirFromContext(ctx)` returns the absolute path to the directory containing your plugin binary. Use it to resolve assets or templates that you bundle alongside the binary. ## Testing -The `plugins/tests` package provides a table-driven test harness that spins up a full in-memory pipeline — schema, extraction, parsing, and validation — and then hands control to your plugin. +The `plugins/tests` package provides a table-driven test harness that spins up a full in-memory pipeline (schema, extraction, parsing, and validation) and then hands control to your plugin. ### RunTable @@ -236,9 +236,9 @@ The harness creates an in-memory SQLite database, loads the schema, extracts and Three optional hooks on `Table` let you override any phase: -- `SetupTest` — runs after the database is populated but before your plugin executes. Use it to insert additional rows or configure state. -- `PerformTest` — replaces the default execution sequence entirely. Use it when you only want to test a single hook (e.g. just `Validate`) or when you need to assert on error details. -- `VerifyTest` — replaces the default assertion. By default it calls `ValidateExpectedDocuments`; override it to make custom assertions against the database. +- `SetupTest`: runs after the database is populated but before your plugin executes. Use it to insert additional rows or configure state. +- `PerformTest`: replaces the default execution sequence entirely. Use it when you only want to test a single hook (e.g. just `Validate`) or when you need to assert on error details. +- `VerifyTest`: replaces the default assertion. By default it calls `ValidateExpectedDocuments`; override it to make custom assertions against the database. ```go tests.RunTable(t, tests.Table[MyPluginConfig, *MyPlugin]{ @@ -272,7 +272,7 @@ Each `Test` can override the project config for that case alone: ## Glob Walking -The `plugins/glob` package provides a parallel filesystem walker that understands picomatch-style glob patterns — the same format used in `houdini.config.js`'s `include` and `exclude` fields. +The `plugins/glob` package provides a parallel filesystem walker that understands picomatch-style glob patterns, the same format used in `houdini.config.js`'s `include` and `exclude` fields. ### Basic usage @@ -295,11 +295,11 @@ err := walker.Walk(ctx, afero.NewOsFs(), "/project", func(relPath string) error The walker supports the same glob syntax used by Vite and picomatch: -- `*` — any characters within a single path segment -- `**` — any number of path segments (globstar) -- `?` — any single character -- `[abc]` — character class -- `{a,b,c}` — brace expansion (expanded at `AddInclude`/`AddExclude` time) +- `*`: any characters within a single path segment +- `**`: any number of path segments (globstar) +- `?`: any single character +- `[abc]`: character class +- `{a,b,c}`: brace expansion (expanded at `AddInclude`/`AddExclude` time) ### Checking a single path diff --git a/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx b/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx index 976d8d1a07..d2b7fd828f 100644 --- a/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx +++ b/docs/shared/02-extending-houdini/04-codegen-plugins-node.mdx @@ -28,13 +28,13 @@ plugin({ }) ``` -`plugin()` handles all the transport plumbing — registering with the orchestrator, receiving hook invocations, and sending results back — so the script itself stays minimal. +`plugin()` handles all the transport plumbing (registering with the orchestrator, receiving hook invocations, and sending results back) so the script itself stays minimal. ### Plugin Order -- `'before'` — runs before `houdini-core` -- `'core'` — reserved for `houdini-core` and framework plugins -- `'after'` — runs after `houdini-core` (the right choice for most plugins) +- `'before'`: runs before `houdini-core` +- `'core'`: reserved for `houdini-core` and framework plugins +- `'after'`: runs after `houdini-core` (the right choice for most plugins) ### Entry Point @@ -116,8 +116,8 @@ type PluginContext = { } ``` -- `pluginDirectory` — absolute path to the directory containing your plugin's entry point. Use it to resolve bundled assets. -- `db` — a connection to the shared SQLite database. Exposes `get`, `all`, and `run` for reading and writing pipeline data directly: +- `pluginDirectory`: absolute path to the directory containing your plugin's entry point. Use it to resolve bundled assets. +- `db`: a connection to the shared SQLite database. Exposes `get`, `all`, and `run` for reading and writing pipeline data directly: ```typescript async validate(ctx, payload) { @@ -134,16 +134,16 @@ async validate(ctx, payload) { }, ``` -- `invokeHook` — call another pipeline hook from within your handler. Results are keyed by plugin name. +- `invokeHook`: call another pipeline hook from within your handler. Results are keyed by plugin name. ## Optional Fields The plugin config accepts a few additional fields for plugins that need to ship runtime code: -- `includeRuntime` — a path (relative to your plugin entry point) to a directory that Houdini will copy into the project's generated runtime -- `staticRuntime` — a path (relative to your plugin entry point) to a directory whose contents are copied **before codegen runs** (see below) -- `configModule` — a path to a JavaScript module that exports config values to merge into the project config -- `clientPlugins` — an object of client-side plugins to inject into the user's `HoudiniClient` +- `includeRuntime`: a path (relative to your plugin entry point) to a directory that Houdini will copy into the project's generated runtime +- `staticRuntime`: a path (relative to your plugin entry point) to a directory whose contents are copied **before codegen runs** (see below) +- `configModule`: a path to a JavaScript module that exports config values to merge into the project config +- `clientPlugins`: an object of client-side plugins to inject into the user's `HoudiniClient` ```typescript plugin({ @@ -159,7 +159,7 @@ plugin({ ## Static Runtimes -`staticRuntime` points to a directory (relative to your plugin entry point) whose contents are copied into the project during the `afterLoad` phase — before document discovery and codegen run. +`staticRuntime` points to a directory (relative to your plugin entry point) whose contents are copied into the project during the `afterLoad` phase, before document discovery and codegen run. ```typescript plugin({ diff --git a/docs/shared/03-meta/02-migration.mdx b/docs/shared/03-meta/02-migration.mdx index 2adcc4aebd..465a3af6a2 100644 --- a/docs/shared/03-meta/02-migration.mdx +++ b/docs/shared/03-meta/02-migration.mdx @@ -1,4 +1,147 @@ --- title: Migration Guide -description: How to upgrade between major versions of Houdini. +description: How to upgrade from Houdini 1.x to 2.0. --- + +# Migration Guide + +Houdini 2.0 rewrites the entire codegen pipeline in Go. The payoff is a much faster compiler and a single engine shared across every framework, but the important thing for an upgrade is what _doesn't_ change: your GraphQL documents, fragments, list operations, and the bulk of your runtime code all carry over untouched. The breaking changes are small and concentrated, and this guide walks through each one. + +If you only read one section, read [The API URL moved into your config](#the-api-url-moved-into-your-config). It has the widest blast radius. + +## Core + +These changes apply to every Houdini app regardless of framework. + +### Dependency versions + +Houdini 2.0 raises several dependency floors. Update these before regenerating: + +| Dependency | Minimum version | Notes | +| --- | --- | --- | +| Vite | `^8.0.0` | also bump your Houdini adapter | +| graphql | `>=16` | now a peer dependency; add it to your own `dependencies` | +| react, react-dom | `^19.2.7` | React adapter | +| svelte | `^5.56.2` | Svelte adapter; runes (`$props`, `$effect`) required | +| @sveltejs/kit | `^2.63.0` | Svelte adapter | + +### The API URL moved into your config + +In 1.x you passed your API's `url` directly to `HoudiniClient`. In 2.0 the URL lives in your config so the compiler can bake it into the generated runtime. Passing a `url` to `HoudiniClient` now throws, rather than being silently ignored. + +```js title="houdini.config.js" +/** @type {import('houdini').ConfigFile} */ +export default { + // for a remote API, set the endpoint here + url: import.meta.env.API_URL ?? 'https://localhost:4000', +} +``` + +```js title="src/client.js" +// before (1.x) +export default new HoudiniClient({ + url: 'http://localhost:4000/graphql', + fetchParams() { + /* ... */ + }, +}) + +// after (2.0): no url, everything else is unchanged +export default new HoudiniClient({ + fetchParams() { + /* ... */ + }, +}) +``` + +If you run Houdini's local API rather than a remote one, the mount path is configured with `endpoint` in `src/server/+config` instead of `url`: + +```js title="src/server/+config.js" +export default { + endpoint: '/_graphql', +} +``` + +## Svelte + +### `@load` and `@blocking` are no longer supported + +These two directives are removed. Loading and blocking are now expressed with native Svelte and SvelteKit primitives, which means there is no Houdini-specific behavior to learn: the query is just a store you drive yourself. + +- **Blocking (data ready before the route renders):** load the query in a SvelteKit `load` function (`+page.js` / `+page.ts`). The route waits on it the same way it waits on any other load. +- **Streaming / non-blocking:** fetch the query inside the component, from an `$effect` or an async component, and render a loading state while it resolves. + +```svelte + + +{#if $user.fetching} + Loading... +{:else} + {$user.data.user.name} +{/if} +``` + +## React + +### `useCurrentVariables` and `useLocation` are gone + +Route variables, params, and search params are all read through a single `useRoute()` hook, typed per route. + +```tsx +// before (1.x) +const variables = useCurrentVariables() +const location = useLocation() + +// after (2.0) +const { params, search } = useRoute() +// params.id -> typed from the route's [id] segment +// search.genre -> typed from the route's search params +``` + +### `auth.redirect` is now `auth.url` + +The redirect-based auth field was replaced by a single `auth.url` that defaults to a built-in endpoint. Auth configuration also now lives in your server-only config. + +```ts title="src/server/+config.ts" +// before (1.x): auth.redirect +// after (2.0) +export default { + auth: { + url: '/_auth', + }, +} +``` + +## What's new + +Everything else in 2.0 is additive, so it needs no migration. Highlights worth adopting once you've upgraded: + +**Core** + +- `@refetchable` fragments and `@refetch` for cache-driven refetching, plus `record.refresh()`. +- The `@plural` fragment directive for reading a fragment off a list field as an array. +- `_upsert` and `_update` list operations. + +**React** + +- A typed `` component and search-param integration. +- Route-level `headers()` and `+error.tsx` error boundaries. +- `createMock` for testing routes. +- Server-backed sessions with first-class OAuth, and progressively enhanced mutations through `@endpoint` and `useMutationForm`. diff --git a/docs/shared/03-meta/03-contributing.mdx b/docs/shared/03-meta/03-contributing.mdx index c6145b0757..809f1942d0 100644 --- a/docs/shared/03-meta/03-contributing.mdx +++ b/docs/shared/03-meta/03-contributing.mdx @@ -7,9 +7,9 @@ First off, thanks for the interest in contributing to Houdini. This document should provide some guidance for working on the project, including tips for local development and an introduction to the internal architecture and relevant files. -**Note**: this document contains links to files that could easily be invalidated by future work. If you run into a broken link, please open a PR to fix it — keeping documentation up to date is as important as any bug fix or new feature. +**Note**: this document contains links to files that could easily be invalidated by future work. If you run into a broken link, please open a PR to fix it. Keeping documentation up to date is as important as any bug fix or new feature. -Before diving in, the [architecture guide](/api/architecture) is worth a read — it explains how the compiler's process model works, how plugins communicate, and how the shared database fits together. That context makes the rest of this document a lot easier to follow. +Before diving in, the [architecture guide](~/core/architecture) is worth a read. It explains how the compiler's process model works, how plugins communicate, and how the shared database fits together. That context makes the rest of this document a lot easier to follow. ## General Introduction @@ -17,7 +17,7 @@ At a high level, Houdini is broken up into a few parts. The core compiler pipeli ## Local Development -The quickest way to test and develop new features is by using the [end-to-end tests](https://github.com/HoudiniGraphQL/houdini/tree/main/e2e/kit). Starting with `pnpm i && pnpm build` at the root of the repository will handle linking everything up. Once that's done, run `pnpm dev` inside the `e2e/kit` directory to start both the web app and API development servers. After all of this, visiting `localhost:5173` should show the end-to-end test suite. We recommend creating a route in that application to work against — don't worry about where it "belongs", we'll sort that out when the PR is open. +The quickest way to test and develop new features is by using the [end-to-end tests](https://github.com/HoudiniGraphQL/houdini/tree/main/e2e/kit). Starting with `pnpm i && pnpm build` at the root of the repository will handle linking everything up. Once that's done, run `pnpm dev` inside the `e2e/kit` directory to start both the web app and API development servers. After all of this, visiting `localhost:5173` should show the end-to-end test suite. We recommend creating a route in that application to work against. Don't worry about where it "belongs"; we'll sort that out when the PR is open. Make sure you're using Node.js and pnpm versions compatible with the `engines` config in the repo's `package.json`, otherwise behavior between local, CI, and deploy environments may diverge. Some good options for managing multiple Node versions: @@ -27,7 +27,7 @@ Make sure you're using Node.js and pnpm versions compatible with the `engines` c ## Code Generation -Houdini's code generation pipeline is written in Go. The core pipeline logic lives in [packages/houdini-core](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-core), with framework-specific codegen in the corresponding package (e.g. [packages/houdini-react](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-react)). The shared library for building plugins lives in [plugins/](https://github.com/HoudiniGraphQL/houdini/tree/main/plugins). Each plugin runs as a long-running process and communicates with the Node.js orchestrator over WebSocket, sharing state through a common SQLite database — see the [architecture guide](/api/architecture) for the full picture. +Houdini's code generation pipeline is written in Go. The core pipeline logic lives in [packages/houdini-core](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-core), with framework-specific codegen in the corresponding package (e.g. [packages/houdini-react](https://github.com/HoudiniGraphQL/houdini/tree/main/packages/houdini-react)). The shared library for building plugins lives in [plugins/](https://github.com/HoudiniGraphQL/houdini/tree/main/plugins). Each plugin runs as a long-running process and communicates with the Node.js orchestrator over WebSocket, sharing state through a common SQLite database. See the [architecture guide](~/core/architecture) for the full picture. The pipeline tasks fall into three categories: @@ -53,7 +53,7 @@ The cache is built on two core operations: writing data and subscribing to a sel When writing, the cache walks the result and stores every field value in a normalized map keyed by entity ID. References to other entities are stored as references rather than inline values, so updates only need to happen in one place regardless of how many times an entity appears across queries. -Subscriptions keep stores up to date as values change. The cache walks a given selection and embeds a reference to the store's `set` function alongside the field values for each entity. When data is written, the cache captures every relevant `set` function and calls it with the updated selection. For a good conceptual introduction to normalized caching, the [urql docs on Normalized Caching](https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/) are worth a read — the concepts carry over even if some implementation details differ. +Subscriptions keep stores up to date as values change. The cache walks a given selection and embeds a reference to the store's `set` function alongside the field values for each entity. When data is written, the cache captures every relevant `set` function and calls it with the updated selection. For a good conceptual introduction to normalized caching, the [urql docs on Normalized Caching](https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/) are worth a read; the concepts carry over even if some implementation details differ. ## End-to-End Tests @@ -82,7 +82,7 @@ The Go pipeline tests live in `plugin/` subdirectories of each package (e.g. `pa go test ./... ``` -Each test uses `tests.RunTable` from `plugins/tests/` — it spins up an in-memory SQLite database, runs the pipeline steps (extract → validate → generate), and asserts on the resulting artifacts. These are the right tests to write when you change Go plugin logic. +Each test uses `tests.RunTable` from `plugins/tests/`. It spins up an in-memory SQLite database, runs the pipeline steps (extract → validate → generate), and asserts on the resulting artifacts. These are the right tests to write when you change Go plugin logic. ### Playwright e2e tests (e2e apps) @@ -91,12 +91,12 @@ The `e2e/kit/` (SvelteKit) and `e2e/react/` apps each have a family of scripts f | Script | What it does | |---|---| | `build:` | Rebuilds all packages from the repo root (`pnpm run build`) | -| `build:test` | `build:` then runs Playwright (`pnpm test`) — use this to verify changes against a fresh build | +| `build:test` | `build:` then runs Playwright (`pnpm test`): use this to verify changes against a fresh build | | `build:build` | `build:` then builds the e2e app itself (produces a static output) | -| `build:tests` | `build:build` then runs Playwright — use this when you also need the app compiled | +| `build:tests` | `build:build` then runs Playwright: use this when you also need the app compiled | | `build:dev` | `build:` then starts the dev server | | `build:generate` | `build:` then runs `houdini generate` | -| `tests` / `test` | Playwright only, no rebuild — use when packages are already built | +| `tests` / `test` | Playwright only, no rebuild: use when packages are already built | For faster iteration when only one package changed, the `compile:*` variants recompile a single package without doing a full root build: @@ -134,7 +134,7 @@ A CI job runs automatically on any PR that touches `packages/houdini/src/runtime When thinking about adding a feature, a few questions help frame the work: 1. **Does the feature appear in GraphQL documents?** If so, figure out how to persist what the user writes in the generated artifacts. The runtime walks the selection field when writing values to the cache and can look for special keys to perform additional logic when processing a response. -2. **Are there validation steps needed?** Validators protect users but also provide guarantees to the runtime — they can save a lot of conditional checks when processing server responses. +2. **Are there validation steps needed?** Validators protect users but also provide guarantees to the runtime; they can save a lot of conditional checks when processing server responses. 3. **Can the framework layer help the runtime?** Because the generated code lives in the user's project, things like reactive statements and lifecycle functions work out of the box. -An end-to-end feature will typically touch the artifact generator and the runtime at minimum. It's easy to get lost in how the pieces fit together — the implementation of list operations in the codebase is a good worked example to trace through when getting oriented. +An end-to-end feature will typically touch the artifact generator and the runtime at minimum. It's easy to get lost in how the pieces fit together, so the implementation of list operations in the codebase is a good worked example to trace through when getting oriented. diff --git a/docs/shared/_partials/caching-data.mdx b/docs/shared/_partials/caching-data.mdx index c320195edf..1ba55c9ffc 100644 --- a/docs/shared/_partials/caching-data.mdx +++ b/docs/shared/_partials/caching-data.mdx @@ -17,7 +17,7 @@ There are a few different policies that can be specified: - `CacheOnly` will only ever return cache data which can be a partial response - `NoCache` is like `NetworkOnly` but also will never write to the cache -The default cache policy as well as other parameters can be changed in your [config file](/svelte/api/config). +The default cache policy as well as other parameters can be changed in your [config file](~/core/config). ### Partial Data @@ -155,7 +155,7 @@ it will be refetched over the network. This can be done in two ways: #### Globally after a timeout -If you set `defaultLifetime` in your [config file](/svelte/api/config#fields) then data will +If you set `defaultLifetime` in your [config file](~/core/config#fields) then data will get automatically marked stale after a certain time (in milliseconds). For example, you can configure so that any data older than 7 minutes is refreshed (example: `defaultLifetime: 7 * 60 * 1000`). When this happens, the cached data will still be returned but a new query will be sent @@ -194,7 +194,7 @@ user.markStale('name', { when: { pattern: 'capitalize' } }) There are times where Houdini's automatic cache updates or list operation fragments are not sufficient. For those times, Houdini provides a programatic API for interacting with the cache directly. For more -information, check out the [Cache API reference](/svelte/api/cache) +information, check out the [Cache API reference](~/core/cache) . ```typescript diff --git a/docs/shared/_partials/dedupe.mdx b/docs/shared/_partials/dedupe.mdx index 92e1a3e6e5..bf9d0a7e2a 100644 --- a/docs/shared/_partials/dedupe.mdx +++ b/docs/shared/_partials/dedupe.mdx @@ -20,6 +20,6 @@ query UserProfile($id: ID!) @dedupe(cancelFirst: true) { The `match` argument controls what counts as "identical": -- `Operation` — dedupe if any execution of this operation is pending, regardless of variables (default) -- `Variables` — dedupe only if the variables also match -- `None` — never dedupe +- `Operation`: dedupe if any execution of this operation is pending, regardless of variables (default) +- `Variables`: dedupe only if the variables also match +- `None`: never dedupe diff --git a/docs/shared/_partials/endpoint-directive.mdx b/docs/shared/_partials/endpoint-directive.mdx new file mode 100644 index 0000000000..9bd65043a5 --- /dev/null +++ b/docs/shared/_partials/endpoint-directive.mdx @@ -0,0 +1,34 @@ +The `@endpoint` directive is what marks a mutation as form-submittable. It tells the +compiler to generate the server endpoint the form posts to before JavaScript loads, and to +wire up the runtime that takes over after hydration. Without it, a mutation is just a +mutation; with it, the same document can back a ``. + +```graphql +mutation CreateUser($name: String!) @endpoint(redirect: "/users/{ createUser.id }") { + createUser(name: $name) { + id + } +} +``` + +It accepts three arguments, all optional: + +- **`redirect`** — where to send the browser after a successful submit. It's a relative path + and can interpolate any leaf field from the result with `{ path.to.field }`. The compiler + bakes the same target into both paths, so the no-JS server response and the client + navigation always agree. See [Redirecting after a submit](#redirecting-after-a-submit). +- **`fields`** — an allowlist of the form-field names the mutation will accept (`["name", + "input.email", "tags[]"]`). When present, anything submitted outside the list is dropped + on both paths. See [Restricting which fields are accepted](#restricting-which-fields-are-accepted). +- **`id`** — a stable identity for the form, used when more than one form on a page drives + the same mutation. Defaults to the mutation name. + +A few rules the compiler enforces at build time, so the mistakes surface before they ship: + +- `@endpoint` only goes on a mutation. +- Every `redirect` interpolation path has to exist in the selection set and resolve to a + leaf scalar — no redirecting to `/users/{ createUser }` where `createUser` is an object. +- `redirect` has to be a relative path (a single leading `/`, no scheme, no `//`). That's + what makes an open redirect impossible to write. +- Each `fields` entry has to name a real variable of the mutation, so a typo is a build + error rather than a silently empty allowlist. diff --git a/docs/shared/_partials/error-handling.mdx b/docs/shared/_partials/error-handling.mdx index 108f6091b4..b73a5f91ec 100644 --- a/docs/shared/_partials/error-handling.mdx +++ b/docs/shared/_partials/error-handling.mdx @@ -36,4 +36,4 @@ declare namespace App { } ``` -Once declared, `errors[n].extensions` will be typed as `App.GraphQLErrorExtensions` everywhere Houdini exposes errors — query results, mutation responses, subscriptions, and the `throwOnError` callback. +Once declared, `errors[n].extensions` will be typed as `App.GraphQLErrorExtensions` everywhere Houdini exposes errors: query results, mutation responses, subscriptions, and the `throwOnError` callback. diff --git a/docs/shared/_partials/list-operations.mdx b/docs/shared/_partials/list-operations.mdx index 90ce027a47..ab15ec2f6c 100644 --- a/docs/shared/_partials/list-operations.mdx +++ b/docs/shared/_partials/list-operations.mdx @@ -18,7 +18,7 @@ Some things worth mentioning: ## `@parentID` -When the same list name appears under multiple parent records — for example, `friends @list(name: "User_Friends")` loaded for several different users — the runtime can't tell which parent's list a mutation should target. Pass `@parentID` on the fragment spread to identify the specific parent: +When the same list name appears under multiple parent records (for example, `friends @list(name: "User_Friends")` loaded for several different users), the runtime can't tell which parent's list a mutation should target. Pass `@parentID` on the fragment spread to identify the specific parent: ```graphql mutation AddFriend($friendID: ID!, $userID: ID!) { @@ -34,7 +34,7 @@ The `value` argument must be the ID of the parent record that owns the list. Wit ## `@listID` and `@includeListID` -`@parentID` requires knowing the parent record's ID at mutation time, but that isn't always possible — the parent type may not expose an ID field, or the ID may not appear anywhere in the query document. `@includeListID` and `@listID` solve this by letting the cache identify the list for you. +`@parentID` requires knowing the parent record's ID at mutation time, but that isn't always possible. The parent type may not expose an ID field, or the ID may not appear anywhere in the query document. `@includeListID` and `@listID` solve this by letting the cache identify the list for you. Add `@includeListID` to the list field alongside `@list` or `@paginate`. The cache will stamp an opaque `__id` value directly onto the returned list, and the generated TypeScript type will include `__id: string` so you can read it without any casting: @@ -50,7 +50,7 @@ query AllItems { ```tsx const items = data?.userNodes.items -const listId = items?.__id // string | undefined — typed by codegen +const listId = items?.__id // string | undefined, typed by codegen ``` Then pass that value to `@listID` on the mutation fragment spread instead of `@parentID`: @@ -65,11 +65,11 @@ mutation NewItem($input: AddItemInput!, $listId: ID!) { } ``` -Unlike `@parentID`, `@listID` works even when the parent has no usable ID in the document — the opaque key encodes everything the cache needs to find the right list instance. +Unlike `@parentID`, `@listID` works even when the parent has no usable ID in the document: the opaque key encodes everything the cache needs to find the right list instance. ## Operations -Once a list is tagged, Houdini generates a set of fragments named after the list — `All_Items_insert`, `All_Items_remove`, `All_Items_toggle`, `All_Items_upsert`, `All_Items_update` — that you spread into mutation responses to tell the cache what to do. The cache updates immediately on the client without waiting for a refetch. +Once a list is tagged, Houdini generates a set of fragments named after the list (`All_Items_insert`, `All_Items_remove`, `All_Items_toggle`, `All_Items_upsert`, `All_Items_update`) that you spread into mutation responses to tell the cache what to do. The cache updates immediately on the client without waiting for a refetch. ### Inserting a record diff --git a/docs/shared/_partials/nullability.mdx b/docs/shared/_partials/nullability.mdx index 02748b46bb..6847de4ce4 100644 --- a/docs/shared/_partials/nullability.mdx +++ b/docs/shared/_partials/nullability.mdx @@ -1,4 +1,4 @@ -By default, a nullable field that returns `null` from the server stays `null` on that specific field — leaving the rest of the object intact. This means you often end up with deeply nested null checks scattered through your components. +By default, a nullable field that returns `null` from the server stays `null` on that specific field, leaving the rest of the object intact. This means you often end up with deeply nested null checks scattered through your components. ```graphql type Query { @@ -24,7 +24,7 @@ query UserProfile($id: ID!) { } ``` -Because `bestFriend` is nullable, you can't safely write `user.bestFriend.name` — you have to guard against `bestFriend` being null at every call site, even when your component only makes sense if a best friend exists. +Because `bestFriend` is nullable, you can't safely write `user.bestFriend.name`. You have to guard against `bestFriend` being null at every call site, even when your component only makes sense if a best friend exists. The `@required` directive gives you control over where null surfaces. When a `@required` field is null, Houdini nulls out the parent object instead: @@ -40,11 +40,11 @@ query UserProfile($id: ID!) { } ``` -If `bestFriend` is null, the entire `user` object becomes null. You only need to null-check `user` — if it's present, `bestFriend` is guaranteed to be non-null and `user.bestFriend.name` is safe to access. For more background on this pattern, see [this post](https://relay.dev/blog/2023/01/03/resilient-relay-apps/) on the Relay blog. +If `bestFriend` is null, the entire `user` object becomes null. You only need to null-check `user`; if it's present, `bestFriend` is guaranteed to be non-null and `user.bestFriend.name` is safe to access. For more background on this pattern, see [this post](https://relay.dev/blog/2023/01/03/resilient-relay-apps/) on the Relay blog. ### Bubbling Through Parents -Null bubbles up to the nearest nullable ancestor. If the parent is non-nullable in your schema, you can still use `@required` — as long as the parent is also marked `@required` to allow the null to continue propagating: +Null bubbles up to the nearest nullable ancestor. If the parent is non-nullable in your schema, you can still use `@required`, as long as the parent is also marked `@required` to allow the null to continue propagating: ```graphql query UserProfile($id: ID!) { diff --git a/docs/shared/_partials/optimistic-key.mdx b/docs/shared/_partials/optimistic-key.mdx index c65ea243af..82f18807bf 100644 --- a/docs/shared/_partials/optimistic-key.mdx +++ b/docs/shared/_partials/optimistic-key.mdx @@ -26,7 +26,7 @@ mutation CreateTodoItem($text: String!) { } ``` -With `@optimisticKey`, you no longer need to provide an `id` in the `optimisticResponse` — Houdini generates one, immediately inserts the record into the list, and replaces the temporary ID with the real one when the mutation resolves. +With `@optimisticKey`, you no longer need to provide an `id` in the `optimisticResponse`. Houdini generates one, immediately inserts the record into the list, and replaces the temporary ID with the real one when the mutation resolves. The important distinction from making up your own ID is that Houdini tracks these generated keys internally. If you were to reference the generated key in another mutation before the create resolves (say, to mark the new item as complete), the second mutation will block until the real ID comes back from the server. diff --git a/docs/shared/_partials/plugin-vite-submodule.mdx b/docs/shared/_partials/plugin-vite-submodule.mdx index 1be028a62e..37081ba52d 100644 --- a/docs/shared/_partials/plugin-vite-submodule.mdx +++ b/docs/shared/_partials/plugin-vite-submodule.mdx @@ -1,10 +1,10 @@ ## Vite Integration -If your plugin needs to add transforms to the user's Vite build, export a `/vite` sub-module from your npm package. Houdini picks it up automatically and includes it in the project's Vite config — no manual wiring required on the user's end. +If your plugin needs to add transforms to the user's Vite build, export a `/vite` sub-module from your npm package. Houdini picks it up automatically and includes it in the project's Vite config, with no manual wiring required on the user's end. The sub-module just needs to export a default function that returns a Vite plugin: -```typescript title="src/vite.ts" +```typescript title="src/vite.ts&typescriptToggle=true" import type { Plugin } from 'vite' export default function myPlugin(): Plugin { diff --git a/docs/shared/_partials/refetch.mdx b/docs/shared/_partials/refetch.mdx new file mode 100644 index 0000000000..3e0102905b --- /dev/null +++ b/docs/shared/_partials/refetch.mdx @@ -0,0 +1,24 @@ +Most of the time, you're best off including the changed fields in the mutation itself, +either by requesting them directly or by spreading the fragments your components already +define. That keeps everything to a single round trip and gives the fastest experience. +Sometimes that isn't possible, though, or it gets unwieldy. In those cases you can use the +`@refetch` directive, which tells Houdini to find the queries that depend on the returned +entity and refetch them. + +```graphql +mutation FavoriteBook($id: ID!) { + favoriteBook(id: $id) { + book @refetch { + id + } + } +} +``` + +A few things to keep in mind: + +- `@refetch` goes on the field that **returns the entity**, not on its `id`. That's how + Houdini knows which record changed. +- It also works on a field that returns a **list** of entities, in which case every record + in the list is refreshed. +- It can't be combined with `@list` or `@paginate`, which already keep their data in sync. diff --git a/docs/shared/_partials/runtime-scalars.mdx b/docs/shared/_partials/runtime-scalars.mdx index 52089f4e62..f94a35872c 100644 --- a/docs/shared/_partials/runtime-scalars.mdx +++ b/docs/shared/_partials/runtime-scalars.mdx @@ -1,4 +1,4 @@ -Runtime scalars let you define custom scalar types whose values are resolved at runtime — from the current session, request context, or any other source — rather than passed explicitly by the caller. This is useful for variables like the current user's organization ID that are always available from session state and would otherwise need to be threaded through every query manually. +Runtime scalars let you define custom scalar types whose values are resolved at runtime (from the current session, request context, or any other source) rather than passed explicitly by the caller. This is useful for variables like the current user's organization ID that are always available from session state and would otherwise need to be threaded through every query manually. Define runtime scalars in your `houdini.config.js` under `features.runtimeScalars`: @@ -17,7 +17,7 @@ export default { The `resolve` function receives the same context as your client's `fetchParams`, including `session`, `metadata`, and `fetch`. -Once defined, use the scalar as a variable type in any query. Houdini calls the configured `resolve` function automatically — no need to pass the value at the call site: +Once defined, use the scalar as a variable type in any query. Houdini calls the configured `resolve` function automatically, so there's no need to pass the value at the call site: ```graphql query OrganizationInfo($id: OrganizationFromSession!) { diff --git a/docs/shared/_partials/trusted-documents.mdx b/docs/shared/_partials/trusted-documents.mdx index a1677b1a76..f18b19effc 100644 --- a/docs/shared/_partials/trusted-documents.mdx +++ b/docs/shared/_partials/trusted-documents.mdx @@ -12,7 +12,7 @@ query for every document that your client will send. Two ways to generate this list: 1. Configuring the `persistedQueriesPath` option in your -`houdini.config.js` file. More info in the [config section](/svelte/api/config#fields). +`houdini.config.js` file. More info in the [config section](~/core/config#fields). 2. Running the `generate` command with the `--output` flag and provide a path to save the map: @@ -45,10 +45,10 @@ export default new HoudiniClient({ An approach to Persisted Queries, popularized by Apollo, is known as [Automatic Persisted Queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/). This involves first sending a query's hash and if its unrecognized, sending the full -query string. The easiest way to do this is to define a [client plugin](/svelte/api/client-plugins). +query string. The easiest way to do this is to define a [client plugin](~/extending-houdini/client-plugins). This might look something like: -```typescript title="src/client.ts" +```typescript title="src/client.ts&typescriptToggle=true" import { HoudiniClient } from '$houdini' import type { ClientPlugin } from '$houdini' diff --git a/docs/svelte/01-your-first-app/00-getting-started.mdx b/docs/svelte/01-your-first-app/00-getting-started.mdx index 7c79b0b280..6d7f7b89bf 100644 --- a/docs/svelte/01-your-first-app/00-getting-started.mdx +++ b/docs/svelte/01-your-first-app/00-getting-started.mdx @@ -14,7 +14,7 @@ What we’ll cover here: - Sending a mutation to the server - How to paginate large lists of data -If you are looking for a more exhaustive coverage of Houdini’s features and APIs, you should check out the [API docs](/svelte/api/query/). +If you are looking for a more exhaustive coverage of Houdini’s features and APIs, you should check out the [API docs](~/loading-data/queries). ## What We'll Build @@ -35,6 +35,6 @@ cd hello-houdini npm i ``` -If you look inside of this directory, you’ll see its a barebones SvelteKit application with a few extra config files as well as some components we'll use to lay out our Pokédex. Don’t worry too much about the extra bits right now - we’ll highlight the important things as we work through this guide. When you’re ready to set up your own application, head over to the [Setting Up Your Project](/svelte/project-setup/) guide. +If you look inside of this directory, you’ll see its a barebones SvelteKit application with a few extra config files as well as some components we'll use to lay out our Pokédex. Don’t worry too much about the extra bits right now - we’ll highlight the important things as we work through this guide. When you’re ready to set up your own application, head over to the [Setting Up Your Project](~/setup/project-setup) guide. Once you're ready to go, navigate to the project directory and start the dev server with `npm run dev`. diff --git a/docs/svelte/01-your-first-app/01-queries.mdx b/docs/svelte/01-your-first-app/01-queries.mdx index b6e6f8bfa0..654983f2d9 100644 --- a/docs/svelte/01-your-first-app/01-queries.mdx +++ b/docs/svelte/01-your-first-app/01-queries.mdx @@ -50,7 +50,7 @@ query Info { ``` -You're already starting to see some of the very exciting things Houdini offers. Houdini picked up your `Info.gql` file and generated an `InfoStore` class that you can import and use to fetch data. The store's value is reactive — any time the data changes, your component updates automatically. +You're already starting to see some of the very exciting things Houdini offers. Houdini picked up your `Info.gql` file and generated an `InfoStore` class that you can import and use to fetch data. The store's value is reactive, so any time the data changes, your component updates automatically. diff --git a/docs/svelte/01-your-first-app/03-mutations.mdx b/docs/svelte/01-your-first-app/03-mutations.mdx index 9ffe18f058..7243d35ce6 100644 --- a/docs/svelte/01-your-first-app/03-mutations.mdx +++ b/docs/svelte/01-your-first-app/03-mutations.mdx @@ -172,7 +172,7 @@ If you look closely at the mutation you'll notice that we are using a fragment i We didn't even have to worry about asking for all of the right fields, once we told Houdini which list we wanted to add it to, it was able to take care of the rest. This was the reason for the `@list` decorator in the query above: we needed a way to identify the field as a target for a list operation. If we had named that list `AllFavorites` instead, the query would reference `AllFavorites_toggle`. -`toggle` is not the only operation you can perform on a list. Houdini also supports `insert` and `remove` as well as more advanced features such as specifying conditions for these operations. For more information, check out the [mutations docs](/svelte/api/mutation#lists). +`toggle` is not the only operation you can perform on a list. Houdini also supports `insert` and `remove` as well as more advanced features such as specifying conditions for these operations. For more information, check out the [mutations docs](~/updating-data/updating-lists#operations). ## What's Next? diff --git a/docs/svelte/01-your-first-app/04-pagination.mdx b/docs/svelte/01-your-first-app/04-pagination.mdx index 36a73a1db9..61aa5aece8 100644 --- a/docs/svelte/01-your-first-app/04-pagination.mdx +++ b/docs/svelte/01-your-first-app/04-pagination.mdx @@ -13,7 +13,7 @@ the list and leave it up to the client to keep a running total if the situation As GraphQL has matured, these arguments have somewhat standardized and fall roughly into two categories: cursor-based pagination and offset-based pagination. Houdini supports both but since our API relies on cursor-based pagination that's what we're going to show here. -If you want to read more about pagination, head over to the [pagination guide](/svelte/pagination/). +If you want to read more about pagination, head over to the [pagination guide](~/loading-data/pagination). @@ -203,7 +203,7 @@ At the surface, a paginated query store is basically the same thing as a normal They're pretty self-explanatory but just in case there's any confusion: `loadNextPage` is an async function that will load the next page and append the result of the field tagged with `@paginate` to the existing value in our cache. The `pageInfo` object lives at `$Info.data.species.moves.pageInfo` and contains meta data about the current page. -For a more in-depth summary of what you can do with `@paginate`, you can check out the [Pagination Guide](/svelte/pagination/). +For a more in-depth summary of what you can do with `@paginate`, you can check out the [Pagination Guide](~/loading-data/pagination). It's time to add some visuals. Add an import for the `MoveDisplay` component and copy the following block as the second child in the right panel (between `div#species-evolution-chain` and the `nav`): @@ -270,4 +270,4 @@ This is the last topic we wanted to cover as part of the guide! Thank you so muc we really appreciate the dedication. You can 🪄 [share your achievement](http://twitter.com/intent/tweet?text=I%20just%20completed%20Houdini%27s%20guide%20%F0%9F%8E%A9%0A%F0%9F%AA%84%20And%20it%20was%20%5BYOUR%20MESSAGE%5D%0A%0AHandling%20data%20like%20a%20pro%21%20%0AWhat%20about%20you%3F%20%F0%9F%AB%B5%0Ahttps%3A%2F%2Fwww.houdinigraphql.com%2Fintro%0A%0A%F0%9F%91%80%20%40AlecAivazis%20%40jycouet) to help us! If there were any sections that were confusing, or changes you think would be helpful, please open up a discussion on GitHub. -If you want to read more about what Houdini can do, we recommending checking out the [Working with GraphQL](/svelte/graphql-documents/) guide next. +If you want to read more about what Houdini can do, we recommending checking out the [Working with GraphQL](~/loading-data/queries) guide next. diff --git a/docs/svelte/02-setup/01-project-setup.mdx b/docs/svelte/02-setup/01-project-setup.mdx index 5986f3da93..5035649cc3 100644 --- a/docs/svelte/02-setup/01-project-setup.mdx +++ b/docs/svelte/02-setup/01-project-setup.mdx @@ -59,7 +59,7 @@ const config = { export default config ``` -_More information about the config file can be found [here](/svelte/api/config)._ +_More information about the config file can be found [here](~/core/config)._ **Step 3: create HoudiniClient** @@ -73,7 +73,7 @@ export default new HoudiniClient({ }) ``` -_More information about the client file can be found [here](/svelte/api/client)._ +_More information about the client file can be found [here](~/core/client)._ **Step 4: gitignore** diff --git a/docs/svelte/02-setup/03-svelte-config.mdx b/docs/svelte/02-setup/03-svelte-config.mdx index 10bc420f8a..874bd5b933 100644 --- a/docs/svelte/02-setup/03-svelte-config.mdx +++ b/docs/svelte/02-setup/03-svelte-config.mdx @@ -45,4 +45,4 @@ export default { ## More Configuration -For all other configuration options — scalars, schema polling, cache defaults, and more — see the [Config Reference](~/reference/config/). +For all other configuration options (scalars, schema polling, cache defaults, and more), see the [Config Reference](~/core/config). diff --git a/docs/svelte/03-loading-data/01-queries.mdx b/docs/svelte/03-loading-data/01-queries.mdx index bfa2faef33..3161b2b3fd 100644 --- a/docs/svelte/03-loading-data/01-queries.mdx +++ b/docs/svelte/03-loading-data/01-queries.mdx @@ -50,11 +50,11 @@ query MyProfileInfo { A query store holds the following fields, accessed as `$store.fieldName`: -- `data` — the result of the query, updated as mutations, subscriptions, and other queries bring in more recent values -- `fetching` — `true` while a network request is in flight -- `errors` — any errors returned by the server -- `partial` — `true` if the result is a partial cache hit -- `variables` — the variables used in the last request +- `data`: the result of the query, updated as mutations, subscriptions, and other queries bring in more recent values +- `fetching`: `true` while a network request is in flight +- `errors`: any errors returned by the server +- `partial`: `true` if the result is a partial cache hit +- `variables`: the variables used in the last request ## Store Methods @@ -90,7 +90,7 @@ The cleanest pattern for loading data in a component: ### Using `$effect` -When the query depends on reactive state — a prop, a search input, a selected filter — use `$effect` to re-fetch whenever the dependency changes. This is the right pattern for non-SSR components like typeahead search or prop-driven queries: +When the query depends on reactive state, such as a prop, a search input, or a selected filter, use `$effect` to re-fetch whenever the dependency changes. This is the right pattern for non-SSR components like typeahead search or prop-driven queries: ```svelte title="src/lib/UserProfile.svelte" + +
      + {#each $data as user} +
    • {user.name}
    • + {/each} +
    +``` + +The parent spreads the fragment inside the list field and passes the whole list down: + +```graphql +query AllUsers { + users { + ...UserListRow + } +} +``` + +A `@plural` fragment has to be spread on a list field, since that's the only place the array of references can come from. Spreading it anywhere else is a codegen error. + +## Refetchable Fragments + +Sometimes a component needs to reload its own data with different arguments without dragging the route that rendered it into the conversation. Mark the fragment with `@refetchable` and load it with `refetchableFragment` instead of `fragment`: + +```svelte + + +{#each $userInfo.data.friends as friend} +
    {friend.name}
    +{/each} + + +``` + +`refetchableFragment` returns a store carrying the fragment's `data` and `variables`, plus a `refetch` method. Calling `refetch` re-runs the fragment against the network with the arguments we hand it, layered over whatever it was last loaded with, so we only pass the values we actually want to change. The record's id is figured out for us from the data already on screen. + +The initial values come from wherever the fragment is spread, the same as any fragment that takes arguments. The parent passes them with `@with`: + +```graphql +query UserPage { + user { + ...UserInfo @with(filter: "aki") + } +} +``` + +The catch is that the fragment has to live on a type Houdini can look up on its own, which in practice means one that implements `Node` or has a [custom resolver](~/core/config) configured. That's the same requirement paginated fragments carry, and for the same reason: under the hood we embed the fragment in a query keyed by the record's id and re-run that. + +`@refetchable` can't be combined with `@paginate` on the same fragment. A paginated fragment is already refetchable on its own, so the two together is a compile-time error. + ## Fragment Masking -Fragment masking keeps components properly encapsulated by ensuring they can only access the fields they explicitly declared — not fields pulled in by sibling fragments. For a deeper look at why this matters, see the [Fragment Colocation guide](https://gql-tada.0no.co/guides/fragment-colocation) from gql.tada. +Fragment masking keeps components properly encapsulated by ensuring they can only access the fields they explicitly declared, not fields pulled in by sibling fragments. For a deeper look at why this matters, see the [Fragment Colocation guide](https://gql-tada.0no.co/guides/fragment-colocation) from gql.tada. By default fields from a fragment are not included in a query to encourage separation of concerns. You can override this default behavior with the config option `defaultFragmentMasking: "disable"` for all fragment usages or individually per fragment: diff --git a/docs/svelte/03-loading-data/03-loading-states.mdx b/docs/svelte/03-loading-data/03-loading-states.mdx index 61f0b7c18b..acc6ff7314 100644 --- a/docs/svelte/03-loading-data/03-loading-states.mdx +++ b/docs/svelte/03-loading-data/03-loading-states.mdx @@ -13,7 +13,7 @@ This guide will go over all of the tools that Houdini provides to help you build ## A Concrete Example Before we get too far, let's look at a concrete example so we can have a goal in mind. For this guide, -we're going to be building a loading screen for the Pokédex that we constructed in the [Getting Started](/svelte/getting-started/) guide: +we're going to be building a loading screen for the Pokédex that we constructed in the [Getting Started](~/your-first-app/getting-started) guide: The examples below build a skeleton version of the Pokédex UI while the species data is loading. @@ -91,7 +91,7 @@ version of the application [here](http://houdini-intro.pages.dev). ## The Simplest Solution -If you followed along in the [Getting Started Guide](/svelte/getting-started/) then you know that if we click on the +If you followed along in the [Getting Started Guide](~/your-first-app/getting-started) then you know that if we click on the `next` button in that example then our application will crash. This is because `data` is `null` when the query is loading a new value which causes `$SpeciesInfo.data.species` to explode. The easiest way to protect against @@ -336,7 +336,7 @@ import { PendingValue } from '$houdini' With this change, `data.species` is **always an object**. We now have to look at one of its fields to know if we are loading: -```svelte title="src/routes/[[id]]/+page.svelte" +```svelte title="src/routes/[[id]]/+page.svelte&typescriptToggle=true" + +{#if isPending($data)} + loading... +{:else} + {$data.name}, mayor {$data.mayor.name} +{/if} +``` + +Here `$data` is an object whether or not it's loading, so `$data === PendingValue` would never be `true`; +`isPending($data)` walks it for you. Reach for `isPending` whenever you're checking a whole object, a list +element, or a fragment that might still be loading, and keep `=== PendingValue` for comparing a specific +scalar field. + ## Final Thoughts Thanks for making it all the way through this guide! I hope you found it useful and that it illustrated the diff --git a/docs/svelte/03-loading-data/04-pagination.mdx b/docs/svelte/03-loading-data/04-pagination.mdx index 525ef62422..a6de52e8e3 100644 --- a/docs/svelte/03-loading-data/04-pagination.mdx +++ b/docs/svelte/03-loading-data/04-pagination.mdx @@ -7,7 +7,7 @@ Most APIs window large lists rather than returning everything at once, leaving i ## Cursor-based Pagination -Cursor-based pagination is the approach popularized by GraphQL's [Relay connection model](https://relay.dev/graphql/connections.htm). Instead of page numbers, the server returns a cursor — an opaque pointer into the list — that you pass with the next request to pick up where you left off. +Cursor-based pagination is the approach popularized by GraphQL's [Relay connection model](https://relay.dev/graphql/connections.htm). Instead of page numbers, the server returns a cursor (an opaque pointer into the list) that you pass with the next request to pick up where you left off. A field that supports cursor-based pagination returns a connection type with `edges`, `pageInfo`, and usually a `totalCount`: @@ -67,7 +67,7 @@ query UserList { {/await} ``` -Houdini automatically includes the `pageInfo` fields — you don't need to select them yourself. The store gains: +Houdini automatically includes the `pageInfo` fields, so you don't need to select them yourself. The store gains: ```ts type UserListStore = QueryStore & { @@ -153,12 +153,12 @@ Fragments can also paginate. Use `paginatedFragment` instead of `fragment` and m `paginatedFragment` returns a store with the following fields: -- `data` — the fragment's data -- `loading` — `true` while a pagination request is in flight -- `pageInfo` — current page info (`hasNextPage`, `hasPreviousPage`, etc.). Only valid for cursor-based pagination. -- `partial` — `true` if the result is a partial cache hit +- `data`: the fragment's data +- `loading`: `true` while a pagination request is in flight +- `pageInfo`: current page info (`hasNextPage`, `hasPreviousPage`, etc.). Only valid for cursor-based pagination. +- `partial`: `true` if the result is a partial cache hit And one of the following methods depending on the pagination direction: -- `loadNextPage(pageSize?)` — loads the next page -- `loadPreviousPage(pageSize?)` — loads the previous page +- `loadNextPage(pageSize?)`: loads the next page +- `loadPreviousPage(pageSize?)`: loads the previous page diff --git a/docs/svelte/04-updating-data/01-mutations.mdx b/docs/svelte/04-updating-data/01-mutations.mdx index c98fd93521..5e2b2d2c08 100644 --- a/docs/svelte/04-updating-data/01-mutations.mdx +++ b/docs/svelte/04-updating-data/01-mutations.mdx @@ -4,6 +4,7 @@ description: Mutations in Houdini --- import Dedupe from '@shared/_partials/dedupe.mdx' +import Refetch from '@shared/_partials/refetch.mdx' Send a mutation to the server and update your client-side cache with any changes. @@ -32,15 +33,15 @@ Send a mutation to the server and update your client-side cache with any changes `mutate` invokes the mutation with the variables passed as the first argument. The second argument configures its behavior: -- `optimisticResponse` — a value to apply to the cache immediately, before the server responds. See [Optimistic Updates](~/updating-data/optimistic-updates). +- `optimisticResponse`: a value to apply to the cache immediately, before the server responds. See [Optimistic Updates](~/updating-data/optimistic-updates). -Mutations usually do best when combined with at least one fragment grabbing the information needed for the mutation — see [Updating Fields](#updating-fields) below for an example of this pattern. +Mutations usually do best when combined with at least one fragment grabbing the information needed for the mutation. See [Updating Fields](#updating-fields) below for an example of this pattern. ## SvelteKit Form Actions Using a mutation inside a form action looks the same as anywhere else. Pass the `RequestEvent` to `mutate` so SvelteKit's `fetch` handling works correctly (header forwarding, `handleFetch` hook, etc.): -```typescript title="src/routes/+page.server.ts" +```typescript title="src/routes/+page.server.ts&typescriptToggle=true" import { graphql } from '$houdini' import { fail } from '@sveltejs/kit' import type { Actions } from './$types' @@ -113,6 +114,10 @@ Here's a typical pattern combining a fragment with a mutation in the same compon ``` +## Refetching Changed Data + + + ## Deduplication diff --git a/docs/svelte/04-updating-data/02-optimistic-updates.mdx b/docs/svelte/04-updating-data/02-optimistic-updates.mdx index e312dc86c1..a22ef146da 100644 --- a/docs/svelte/04-updating-data/02-optimistic-updates.mdx +++ b/docs/svelte/04-updating-data/02-optimistic-updates.mdx @@ -45,7 +45,7 @@ When you know what a mutation will return assuming everything goes right, you ca When the mutation resolves, the optimistic values are replaced with the real response. If the mutation fails, the optimistic changes are reverted and the promise rejects with the error. -Always include `id` in your selection when using optimistic responses so the cache knows which record to update. You don't need to provide a complete response — the cache will write whatever fields you include. +Always include `id` in your selection when using optimistic responses so the cache knows which record to update. You don't need to provide a complete response; the cache will write whatever fields you include. ## Optimistic Keys @@ -53,6 +53,6 @@ Always include `id` in your selection when using optimistic responses so the cac ## Why is TypeScript Missing Fields? -The generated types for optimistic responses don't include fields from fragments you've spread in. This is intentional — tightly coupling a mutation invocation to a fragment defined elsewhere creates a fragile dependency. If the fragment changes, the type mismatch won't surface until runtime. +The generated types for optimistic responses don't include fields from fragments you've spread in. This is intentional: tightly coupling a mutation invocation to a fragment defined elsewhere creates a fragile dependency. If the fragment changes, the type mismatch won't surface until runtime. The safe practice is to duplicate any fields you need in the mutation's own selection set. diff --git a/docs/svelte/05-guides/05-trusted-documents.mdx b/docs/svelte/05-guides/05-trusted-documents.mdx index 48c8733c47..211ae05612 100644 --- a/docs/svelte/05-guides/05-trusted-documents.mdx +++ b/docs/svelte/05-guides/05-trusted-documents.mdx @@ -17,7 +17,7 @@ query for every document that your client will send. Two ways to generate this list: 1. Configuring the `persistedQueriesPath` option in your -`houdini.config.js` file. More info in the [config section](/svelte/api/config#fields). +`houdini.config.js` file. More info in the [config section](~/core/config#fields). 2. Running the `generate` command with the `--output` flag and provide a path to save the map: @@ -50,7 +50,7 @@ export default new HoudiniClient({ An approach to Persisted Queries, popularized by Apollo, is known as [Automatic Persisted Queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/). This involves first sending a query's hash and if its unrecognized, sending the full -query string. The easiest way to do this is to define a [client plugin](/svelte/api/client-plugins). +query string. The easiest way to do this is to define a [client plugin](~/extending-houdini/client-plugins). This might look something like: ```typescript title="src/client.ts&typescriptToggle=true" diff --git a/e2e/_api/CHANGELOG.md b/e2e/_api/CHANGELOG.md new file mode 100644 index 0000000000..c3c12e9ef0 --- /dev/null +++ b/e2e/_api/CHANGELOG.md @@ -0,0 +1,3 @@ +# e2e-api + +## 0.0.2 diff --git a/e2e/_api/graphql.mjs b/e2e/_api/graphql.mjs index e7293dbc19..5f17146f71 100644 --- a/e2e/_api/graphql.mjs +++ b/e2e/_api/graphql.mjs @@ -40,7 +40,40 @@ export const typeDefs = /* GraphQL */ ` ERROR } + type SessionUser { + id: ID! + username: String! + } + + type AuthSession { + user: SessionUser! + } + + type LoginResult { + session: AuthSession! + } + + type LogoutResult { + session: AuthSession + } + + type ThemeSession { + theme: String! + } + + type ThemeResult { + session: ThemeSession! + } + type Mutation { + # @session mutations — exercised by the react e2e (which shares these resolvers); + # defined here so the shared schema matches the resolvers and the server can boot. + login(username: String!): LoginResult! + logout: LogoutResult! + setTheme(theme: String!): ThemeResult! + # echoes back the session the client sent (via the fetchParams header) so the e2e can + # assert the managed session is what actually reaches the api on a mutation + requestSession: String addUser( """ The users birth date @@ -152,12 +185,23 @@ export const typeDefs = /* GraphQL */ ` Get a monkey by its id """ monkey(id: ID!): Monkey + """ + A non-Node entity resolved by a custom query (exercises @refetchable on a + type that is refetchable via a resolve config rather than Node). + """ + refetchableEntity(id: ID!): RefetchableEntity } type Subscription { userUpdate(id: ID!, snapshot: String): User } + "A non-Node type that is refetchable via a custom resolve query." + type RefetchableEntity { + id: ID! + avatarURL(size: Int): String! + } + type User implements Node { birthDate: DateTime friendsConnection(after: String, before: String, first: Int, last: Int): UserConnection! @@ -499,7 +543,7 @@ export const resolvers = { } if (!user) { - throw new Error('User not found', { code: 404 }) + throw new GraphQLError('User not found', { code: 404 }) } return user }, @@ -519,6 +563,9 @@ export const resolvers = { cities: () => { return cities }, + refetchableEntity: (_, { id }) => { + return { id } + }, userNodesResult: async (_, args) => { if (args.forceMessage) { return { @@ -610,7 +657,34 @@ export const resolvers = { }, }, + RefetchableEntity: { + avatarURL: (entity, { size }) => { + const base = `https://entity.test/${entity.id}.jpg` + return !size ? base : base + `?size=${size}` + }, + }, + Mutation: { + // @session login: the resolver is the authority on the session payload. it returns a + // whole user object (not a token) — Houdini signs the entire @session subtree, so the + // cookie is trusted regardless of shape, and useSession() reads the user back. + login: (_, { username }) => ({ session: { user: { id: 'user-' + username, username } } }), + // @session logout: a successful mutation with a null session clears the cookie + logout: () => ({ session: null }), + // @session(merge: true): a preference upsert — keeps the rest of the session + setTheme: (_, { theme }) => ({ session: { theme } }), + // echo back the session the client sent us via fetchParams (the `x-session-theme` header). + // the e2e asserts this matches the session it set client-side — proving the managed session + // is what the client plugin pipeline actually sends to the api. + requestSession: (_, args, info) => { + let value = '' + info.request.headers.forEach((headerValue, key) => { + if (key === 'x-session-theme') { + value = headerValue + } + }) + return value + }, addNonNullUser(...args) { return this.addUser(...args) }, diff --git a/e2e/_api/package.json b/e2e/_api/package.json index 65d8b70950..50f9f03498 100644 --- a/e2e/_api/package.json +++ b/e2e/_api/package.json @@ -1,7 +1,7 @@ { "name": "e2e-api", "private": true, - "version": "0.0.1", + "version": "0.0.2", "description": "", "bin": "./server.mjs", "keywords": [], diff --git a/e2e/_api/schema.graphql b/e2e/_api/schema.graphql index ee58599fea..caac85b853 100644 --- a/e2e/_api/schema.graphql +++ b/e2e/_api/schema.graphql @@ -139,12 +139,19 @@ type Query { Get a monkey by its id """ monkey(id: ID!): Monkey + refetchableEntity(id: ID!): RefetchableEntity } type Subscription { userUpdate(id: ID!, snapshot: String): User } +"A non-Node type that is refetchable via a custom resolve query." +type RefetchableEntity { + id: ID! + avatarURL(size: Int): String! +} + type User implements Node { birthDate: DateTime friendsConnection(after: String, before: String, first: Int, last: Int): UserConnection! diff --git a/e2e/kit/houdini.config.js b/e2e/kit/houdini.config.js index b087671623..970d91e978 100644 --- a/e2e/kit/houdini.config.js +++ b/e2e/kit/houdini.config.js @@ -4,6 +4,7 @@ /** @type {import('houdini').ConfigFile} */ const config = { schemaPath: '../_api/schema.graphql', + url: 'http://localhost:4000/graphql', defaultPartial: true, runtimeDir: '.houdini', // logLevel: 'Full', @@ -32,6 +33,13 @@ const config = { UnionAorB: { keys: [], }, + RefetchableEntity: { + keys: ['id'], + resolve: { + queryField: 'refetchableEntity', + arguments: (entity) => ({ id: entity.id }), + }, + }, }, plugins: { diff --git a/e2e/kit/src/client.ts b/e2e/kit/src/client.ts index f8caf0c480..a3361dbd61 100644 --- a/e2e/kit/src/client.ts +++ b/e2e/kit/src/client.ts @@ -16,7 +16,6 @@ const logMetadata: ClientPlugin = () => ({ // Export the Houdini client export default new HoudiniClient({ - url: 'http://localhost:4000/graphql', fetchParams({ session, hash, variables }) { // if we're ever unauthenticated, a request was sent that didn't thread // the session through so let's error diff --git a/e2e/kit/src/lib/utils/routes.ts b/e2e/kit/src/lib/utils/routes.ts index cce63066c4..4d9bb3bb0d 100644 --- a/e2e/kit/src/lib/utils/routes.ts +++ b/e2e/kit/src/lib/utils/routes.ts @@ -13,8 +13,11 @@ export const routes = { fragment_masking_partial: '/fragment-masking-partial', conditional_fragment_spread: '/conditional-fragment-spread', loading_state: '/loading-state', + paginated_fragment_at_loading: '/bug/paginated-fragment-at-loading', required_field: '/required-field', Cache_Refresh: '/cache/refresh', + Cache_Refetch: '/cache/refetch', + Cache_Refetch_Subscription: '/cache/refetch-subscription', Lists_fragment: '/lists/fragment', Lists_mutation_insert: '/lists/mutation-insert', @@ -71,6 +74,9 @@ export const routes = { Pagination_fragment_required_arguments: '/pagination/fragment/required-arguments', Pagination_fragment_forward_cursor_singlepage: '/pagination/fragment/forward-cursor-singlepage', + refetchable_fragment: '/refetchable-fragment', + refetchable_fragment_custom: '/refetchable-fragment-custom', + nested_argument_fragments: '/nested-argument-fragments', nested_argument_fragments_masking: '/nested-argument-fragments-masking', @@ -88,4 +94,6 @@ export const routes = { Svelte5_Runes_Pagination: '/svelte5-runes/pagination', Svelte5_Runes_Fragment: '/svelte5-runes/fragment', Svelte5_Runes_Mutation: '/svelte5-runes/mutation', + + Plural_fragment: '/plural-fragment', } diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.svelte b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.svelte new file mode 100644 index 0000000000..78f79687ea --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.svelte @@ -0,0 +1,23 @@ + + +{#if $store.data} +
    + {#if $store.data.user.name === PendingValue} + loading... + {:else} + {$store.data.user.name} + {/if} +
    + + + +{/if} diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts new file mode 100644 index 0000000000..92f4b6a840 --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/+page.ts @@ -0,0 +1,22 @@ +import type { PageLoad } from './$types' +import { graphql } from '$houdini' + +// issue #1408: a @paginate fragment spread on a document-level @loading query. the fragment +// declares @loading so it can be rendered during the parent's loading frame; its pagination +// handlers must no-op while the data is still pending. +const store = graphql(` + query PaginatedFragmentAtLoading @loading { + user(id: "1", snapshot: "paginated-fragment-at-loading", delay: 2000) { + name + ...PaginatedFragmentAtLoading_Friends + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + PaginatedFragmentAtLoading: store, + } +} diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte new file mode 100644 index 0000000000..05d80115be --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/Friends.svelte @@ -0,0 +1,32 @@ + + + +{#if $store.data && !isPending($store.data)} +
    + {$store.data.friendsConnection.edges.map(({ node }) => node?.name).join(', ')} +
    +{/if} + + + diff --git a/e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts new file mode 100644 index 0000000000..816ffd016a --- /dev/null +++ b/e2e/kit/src/routes/bug/paginated-fragment-at-loading/spec.ts @@ -0,0 +1,34 @@ +import { test } from '@playwright/test' +import { routes } from '../../../lib/utils/routes.js' +import { + clientSideNavigation, + expect_0_gql, + expect_1_gql, + expect_to_be, + goto, +} from '../../../lib/utils/testsHelper.js' + +test.describe('paginated fragment under a document-level @loading query', () => { + // issue #1408: a @paginate fragment spread on an @loading query. The child is rendered + // unguarded, so its pagination handlers can be invoked while the parent entity is still a + // PendingValue placeholder. They must no-op then (instead of firing node(id: PendingValue)) + // and resume working once @loading resolves — all without an `if (!loading)` guard. + test('no-ops while loading, renders and paginates after', async ({ page }) => { + // must navigate client-side to actually see the @loading frame + await goto(page, routes.Home) + await clientSideNavigation(page, routes.paginated_fragment_at_loading) + + // we're in the loading frame + await expect_to_be(page, 'loading...', '#name') + + // clicking next while the parent is still loading must not fire a request + await expect_0_gql(page, 'button[id=next]') + + // once @loading resolves the friends render + await expect_to_be(page, 'Bruce Willis, Samuel Jackson', '#result') + + // and pagination works from there + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks', '#result') + }) +}) diff --git a/e2e/kit/src/routes/cache/refetch-subscription/+page.svelte b/e2e/kit/src/routes/cache/refetch-subscription/+page.svelte new file mode 100644 index 0000000000..839471594c --- /dev/null +++ b/e2e/kit/src/routes/cache/refetch-subscription/+page.svelte @@ -0,0 +1,47 @@ + + +

    Cache Refetch (subscription)

    + + + + +{#if $store.data} + +{/if} diff --git a/e2e/kit/src/routes/cache/refetch-subscription/UserDetails.svelte b/e2e/kit/src/routes/cache/refetch-subscription/UserDetails.svelte new file mode 100644 index 0000000000..afaaaa252b --- /dev/null +++ b/e2e/kit/src/routes/cache/refetch-subscription/UserDetails.svelte @@ -0,0 +1,16 @@ + + +
    {$data?.name}
    diff --git a/e2e/kit/src/routes/cache/refetch-subscription/spec.ts b/e2e/kit/src/routes/cache/refetch-subscription/spec.ts new file mode 100644 index 0000000000..803e5ca9d5 --- /dev/null +++ b/e2e/kit/src/routes/cache/refetch-subscription/spec.ts @@ -0,0 +1,24 @@ +import { test } from '@playwright/test' +import { sleep } from '$lib/utils/sleep' +import { routes } from '../../../lib/utils/routes.js' +import { expect_to_be, goto_expect_n_gql } from '../../../lib/utils/testsHelper.js' + +test.describe('cache @refetch in a subscription', () => { + test('a subscription with @refetch refetches the dependent query', async ({ page }) => { + // load the page and wait for the initial query + await goto_expect_n_gql(page, routes.Cache_Refetch_Subscription, 1) + await expect_to_be(page, 'Bruce Willis', 'div[id=user-name]') + + // start listening to the subscription + await page.click('#listen') + await sleep(100) + + // change the user on the server and publish the subscription event. neither + // the mutation nor the subscription returns `name`, so the only way the query + // can show the new name is by refetching — which @refetch triggers + await page.click('#mutate') + await sleep(300) + + await expect_to_be(page, 'Samuel Jackson', 'div[id=user-name]') + }) +}) diff --git a/e2e/kit/src/routes/cache/refetch/+page.svelte b/e2e/kit/src/routes/cache/refetch/+page.svelte new file mode 100644 index 0000000000..e153c3d212 --- /dev/null +++ b/e2e/kit/src/routes/cache/refetch/+page.svelte @@ -0,0 +1,34 @@ + + +

    Cache Refetch

    + +{#if $store.data} + +{/if} + + diff --git a/e2e/kit/src/routes/cache/refetch/UserDetails.svelte b/e2e/kit/src/routes/cache/refetch/UserDetails.svelte new file mode 100644 index 0000000000..67890a4780 --- /dev/null +++ b/e2e/kit/src/routes/cache/refetch/UserDetails.svelte @@ -0,0 +1,16 @@ + + +
    {$data?.name}
    diff --git a/e2e/kit/src/routes/cache/refetch/spec.ts b/e2e/kit/src/routes/cache/refetch/spec.ts new file mode 100644 index 0000000000..6754c6bfd2 --- /dev/null +++ b/e2e/kit/src/routes/cache/refetch/spec.ts @@ -0,0 +1,23 @@ +import { test } from '@playwright/test' +import { routes } from '../../../lib/utils/routes.js' +import { expect_n_gql, expect_to_be, goto_expect_n_gql } from '../../../lib/utils/testsHelper.js' + +test.describe('cache @refetch', () => { + test('a mutation with @refetch refetches the query that depends on the record', async ({ + page, + }) => { + // load the page and wait for the initial query + await goto_expect_n_gql(page, routes.Cache_Refetch, 1) + + // the fragment renders the user's name behind the query's mask + await expect_to_be(page, 'Bruce Willis', 'div[id=user-name]') + + // clicking fires the mutation and, because the response is tagged with + // @refetch, the query that depends on the user refetches itself: two + // network requests in total + await expect_n_gql(page, 'button[id=mutate]', 2) + + // the refetched query renders the updated name + await expect_to_be(page, 'Samuel Jackson', 'div[id=user-name]') + }) +}) diff --git a/e2e/kit/src/routes/loading-state/CityInfoWithLoadingState.svelte b/e2e/kit/src/routes/loading-state/CityInfoWithLoadingState.svelte index 5594445c6a..3358c40d20 100644 --- a/e2e/kit/src/routes/loading-state/CityInfoWithLoadingState.svelte +++ b/e2e/kit/src/routes/loading-state/CityInfoWithLoadingState.svelte @@ -1,5 +1,5 @@ + +
    + {#if $PluralListUsers.data} + + {/if} +
    + + + diff --git a/e2e/kit/src/routes/plural-fragment/+page.ts b/e2e/kit/src/routes/plural-fragment/+page.ts new file mode 100644 index 0000000000..9a3ee13573 --- /dev/null +++ b/e2e/kit/src/routes/plural-fragment/+page.ts @@ -0,0 +1,19 @@ +import type { PageLoad } from './$types' +import { graphql } from '$houdini' + +const store = graphql(` + query PluralListUsers { + usersList(snapshot: "plural-fragment", limit: 4) @list(name: "PluralUsersKit") { + id + ...PluralUserRow + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + PluralListUsers: store, + } +} diff --git a/e2e/kit/src/routes/plural-fragment/PluralUserList.svelte b/e2e/kit/src/routes/plural-fragment/PluralUserList.svelte new file mode 100644 index 0000000000..64850eecfe --- /dev/null +++ b/e2e/kit/src/routes/plural-fragment/PluralUserList.svelte @@ -0,0 +1,22 @@ + + +
      + {#each $data as user} +
    • {user.name}
    • + {/each} +
    diff --git a/e2e/kit/src/routes/plural-fragment/spec.ts b/e2e/kit/src/routes/plural-fragment/spec.ts new file mode 100644 index 0000000000..9c78d974eb --- /dev/null +++ b/e2e/kit/src/routes/plural-fragment/spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test' +import { routes } from '../../lib/utils/routes.js' +import { goto } from '../../lib/utils/testsHelper.js' + +// one exhaustive-enough test: initial render through a single fragment(), a single-member +// cache update reflecting in place, and an insert growing the list. +test('@plural fragment renders a list and reacts to updates and inserts', async ({ page }) => { + await goto(page, routes.Plural_fragment) + + // initial render: the whole list comes through one fragment() call + await expect(page.locator('#plural-list li')).toHaveCount(4) + await expect(page.locator('#plural-list li').first()).toHaveText('Bruce Willis') + + // updating one record updates just that row + await page.click('[data-test-action="update-first"]') + await expect(page.locator('#plural-list li').first()).toHaveText('Updated Bruce') + await expect(page.locator('#plural-list li')).toHaveCount(4) + + // inserting a record grows the rendered list + await page.click('[data-test-action="add-new"]') + await expect(page.locator('#plural-list li')).toHaveCount(5) + await expect(page.locator('#plural-list li').first()).toHaveText('Brand New User') +}) diff --git a/e2e/kit/src/routes/refetchable-fragment-custom/+page.svelte b/e2e/kit/src/routes/refetchable-fragment-custom/+page.svelte new file mode 100644 index 0000000000..54d85ca99f --- /dev/null +++ b/e2e/kit/src/routes/refetchable-fragment-custom/+page.svelte @@ -0,0 +1,27 @@ + + +
    {$entity.data?.avatarURL}
    + +
    size={$entity.variables?.size};id={($entity.variables as any)?.id ?? 'none'}
    + + + diff --git a/e2e/kit/src/routes/refetchable-fragment-custom/+page.ts b/e2e/kit/src/routes/refetchable-fragment-custom/+page.ts new file mode 100644 index 0000000000..66d57f4573 --- /dev/null +++ b/e2e/kit/src/routes/refetchable-fragment-custom/+page.ts @@ -0,0 +1,18 @@ +import type { PageLoad } from './$types' +import { graphql } from '$houdini' + +const store = graphql(` + query RefetchableCustomQuery { + refetchableEntity(id: "1") { + ...RefetchableEntityInfo @with(size: 50) + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + RefetchableCustomQuery: store, + } +} diff --git a/e2e/kit/src/routes/refetchable-fragment-custom/spec.ts b/e2e/kit/src/routes/refetchable-fragment-custom/spec.ts new file mode 100644 index 0000000000..6066b0d594 --- /dev/null +++ b/e2e/kit/src/routes/refetchable-fragment-custom/spec.ts @@ -0,0 +1,25 @@ +import { test } from '@playwright/test' +import { routes } from '../../lib/utils/routes.js' +import { expect_1_gql, expectToContain, goto } from '../../lib/utils/testsHelper.js' + +test.describe('refetchableFragment (custom resolve)', () => { + test('refetch works on a non-Node type resolved by a custom query', async ({ page }) => { + await goto(page, routes.refetchable_fragment_custom) + + // the fragment loads with the default size argument + await expectToContain(page, '?size=50', 'div[id=result]') + + // variables expose the fragment's args only — the resolve-derived id must not leak + await expectToContain(page, 'size=50;id=none', 'div[id=vars]') + + // refetching re-runs the embedded refetchableEntity(id:) query with new arguments + await expect_1_gql(page, 'button[id=refetch]') + await expectToContain(page, '?size=100', 'div[id=result]') + await expectToContain(page, 'size=100;id=none', 'div[id=vars]') + + // a second refetch with a different size also works + await expect_1_gql(page, 'button[id=refetch-large]') + await expectToContain(page, '?size=200', 'div[id=result]') + await expectToContain(page, 'size=200;id=none', 'div[id=vars]') + }) +}) diff --git a/e2e/kit/src/routes/refetchable-fragment/+page.svelte b/e2e/kit/src/routes/refetchable-fragment/+page.svelte new file mode 100644 index 0000000000..4bd4278a51 --- /dev/null +++ b/e2e/kit/src/routes/refetchable-fragment/+page.svelte @@ -0,0 +1,28 @@ + + +
    {$userInfo.data?.avatarURL}
    + +
    {$userInfo.data?.testField}
    + +
    size={$userInfo.variables?.size};param={$userInfo.variables?.param}
    + + + diff --git a/e2e/kit/src/routes/refetchable-fragment/+page.ts b/e2e/kit/src/routes/refetchable-fragment/+page.ts new file mode 100644 index 0000000000..fb576cb015 --- /dev/null +++ b/e2e/kit/src/routes/refetchable-fragment/+page.ts @@ -0,0 +1,18 @@ +import type { PageLoad } from './$types' +import { graphql } from '$houdini' + +const store = graphql(` + query RefetchableFragmentQuery { + user(id: "1", snapshot: "refetchable-fragment") { + ...RefetchableUserInfo @with(size: 50, param: true) + } + } +`) + +export const load: PageLoad = async (event) => { + await store.fetch({ event }) + + return { + RefetchableFragmentQuery: store, + } +} diff --git a/e2e/kit/src/routes/refetchable-fragment/spec.ts b/e2e/kit/src/routes/refetchable-fragment/spec.ts new file mode 100644 index 0000000000..ba7f482dad --- /dev/null +++ b/e2e/kit/src/routes/refetchable-fragment/spec.ts @@ -0,0 +1,36 @@ +import { test } from '@playwright/test' +import { routes } from '../../lib/utils/routes.js' +import { expect_1_gql, expectToContain, goto } from '../../lib/utils/testsHelper.js' + +test.describe('refetchableFragment', () => { + test('refetch re-runs the fragment with new arguments', async ({ page }) => { + await goto(page, routes.refetchable_fragment) + + // the fragment loads with the default size argument + await expectToContain(page, '?size=50', 'div[id=result]') + + // the `param` argument (passed via @with) drives testField + await expectToContain(page, 'Hello world', 'div[id=merge]') + + // the handle exposes the fragment's current args (no synthetic id key) + await expectToContain(page, 'size=50;param=true', 'div[id=vars]') + + // refetching with a new size hits the network and swaps in the result + await expect_1_gql(page, 'button[id=refetch]') + + await expectToContain(page, '?size=100', 'div[id=result]') + + // refetch only changed `size`; the previously-set `param` must be preserved (merge) + await expectToContain(page, 'Hello world', 'div[id=merge]') + + // variables update with the new size while the merged `param` persists + await expectToContain(page, 'size=100;param=true', 'div[id=vars]') + + // refetching again with a different size works and replaces the result + await expect_1_gql(page, 'button[id=refetch-large]') + + await expectToContain(page, '?size=200', 'div[id=result]') + + await expectToContain(page, 'size=200;param=true', 'div[id=vars]') + }) +}) diff --git a/e2e/react/houdini.config.ts b/e2e/react/houdini.config.ts index 28d8fd6f84..1fe747be25 100644 --- a/e2e/react/houdini.config.ts +++ b/e2e/react/houdini.config.ts @@ -26,6 +26,13 @@ const config: ConfigFile = { Sponsor: { keys: ['name'], }, + RefetchableEntity: { + keys: ['id'], + resolve: { + queryField: 'refetchableEntity', + arguments: (entity) => ({ id: entity.id }), + }, + }, }, plugins: { @@ -37,12 +44,9 @@ const config: ConfigFile = { pluginTransport: 'env:HOUDINI_PLUGIN_TRANSPORT', - router: { - auth: { - redirect: '/auth/token', - sessionKeys: ['supersecret'], - }, - }, + // no router/auth config here — the session keys, session endpoint, and GraphQL apiEndpoint are + // all server-only now (src/server/+config.ts, typed ServerConfigFile). This file is bundled into + // the client for scalars, so it holds no secrets and no server-owned routing. } export default config diff --git a/e2e/react/oauth-mock.mjs b/e2e/react/oauth-mock.mjs new file mode 100644 index 0000000000..0793bfc0ae --- /dev/null +++ b/e2e/react/oauth-mock.mjs @@ -0,0 +1,23 @@ +// A real third-party OIDC provider mock (oauth2-mock-server) for the first-class OAuth e2e. It +// implements discovery, /authorize, /token (a properly RS256-signed id_token + nonce propagation), +// and /jwks — so the browser round-trip is verified against an INDEPENDENT implementation of the +// spec, not a stub we wrote. Started as a Playwright webServer alongside the app. +import { OAuth2Server } from 'oauth2-mock-server' + +const PORT = Number(process.env.MOCK_PORT ?? 8081) + +const server = new OAuth2Server() +await server.issuer.keys.generate('RS256') + +// stamp the id_token with the user the e2e asserts on. The mock carries the `nonce` from /authorize +// into the token itself, so we only set identity claims here. +server.service.on('beforeTokenSigning', (token) => { + token.payload.sub = 'stub-user-1' + token.payload.email = 'stub@example.com' + // a real provider asserts verification for a login email; the oidc adapter drops an + // unverified/unmarked email, so the mock must mark it verified for it to flow to the session + token.payload.email_verified = true +}) + +await server.start(PORT, 'localhost') +console.log(`oauth2 mock provider on ${server.issuer.url}`) diff --git a/e2e/react/package.json b/e2e/react/package.json index 2f2caaf3b5..6c6376e89d 100644 --- a/e2e/react/package.json +++ b/e2e/react/package.json @@ -24,6 +24,7 @@ "houdini": "node ensureLinks.js && houdini", "dev": "node ./ensureLinks.js && vite dev", "build": "node ./ensureLinks.js && vite build", + "unit": "node ensureLinks.js && vitest run", "tests": "npm run build && playwright test", "test": "npm run tests", "tw": "npx tailwindcss -i ./src/styles.css -o ./public/assets/output.css --watch", @@ -50,6 +51,7 @@ "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.60.1", @@ -60,11 +62,14 @@ "cross-env": "^10.1.0", "e2e-api": "workspace:^", "eslint": "^10.4.1", + "happy-dom": "^20.10.4", "hono": "^4.12.23", + "oauth2-mock-server": "^9.0.0", "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "vite": "^8.0.16", + "vitest": "^4.1.8", "wrangler": "^4.98.0" } } diff --git a/e2e/react/playwright.config.ts b/e2e/react/playwright.config.ts index a61c6ddfa4..f3148cd4e5 100644 --- a/e2e/react/playwright.config.ts +++ b/e2e/react/playwright.config.ts @@ -4,14 +4,25 @@ export default defineConfig({ retries: process.env.CI ? 3 : 0, workers: 5, reporter: process.env.CI ? [['list'], ['html'], ['github']] : [['list']], - use: { screenshot: 'only-on-failure' }, + // baseURL must be explicit now that webServer is an array (Playwright only auto-derives it from + // a single server) + use: { screenshot: 'only-on-failure', baseURL: 'http://localhost:3008' }, testIgnore: '**/$houdini/**', testMatch: 'test.ts', - webServer: { - command: 'NODE_ENV=production PORT=3008 node build/index.js', - port: 3008, - timeout: 120 * 1000, - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'NODE_ENV=production PORT=3008 node build/index.js', + port: 3008, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + { + // the third-party OIDC provider mock the first-class OAuth e2e drives against + command: 'node oauth-mock.mjs', + port: 8081, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + ], }) diff --git a/e2e/react/src/+client.ts b/e2e/react/src/+client.ts index 6e9e951288..edc9762f90 100644 --- a/e2e/react/src/+client.ts +++ b/e2e/react/src/+client.ts @@ -1,4 +1,15 @@ import { HoudiniClient } from '$houdini' -// Export the Houdini client -export default new HoudiniClient() +// Export the Houdini client. fetchParams reads the CURRENT session on every request and forwards +// session.theme to the api as a header — the session-to-api e2e asserts the api receives whatever +// we last set client-side (imperatively or via a @session mutation), closing the loop between the +// session infrastructure and the client plugin pipeline. +export default new HoudiniClient({ + fetchParams({ session }) { + return { + headers: { + 'x-session-theme': session?.theme ?? '', + }, + } + }, +}) diff --git a/e2e/react/src/anchor-types.types.tsx b/e2e/react/src/anchor-types.types.tsx index de2846c615..f9b73d2abd 100644 --- a/e2e/react/src/anchor-types.types.tsx +++ b/e2e/react/src/anchor-types.types.tsx @@ -1,10 +1,29 @@ -// Compile-time type assertions for prop typing. +// Compile-time type assertions for , createMock, and goto prop typing. // Verified by `tsc --noEmit` — not a Playwright test. -import { Link } from '$houdini' +import { Link, createMock, useRoute, type GenericRoute } from '$houdini' export {} +// useRoute() with no Route type still gives pathname + goto for navigation-only code +const { goto } = useRoute() + +// ...but params/search are empty without a PageRoute generic, so reading a key is an error +const _bare = useRoute() +// @ts-expect-error -- search is {} until you pass useRoute() +_bare.search.offset +// @ts-expect-error -- params is {} until you pass useRoute() +_bare.params.id + +// a route-agnostic component (e.g. a reusable paginator) can type just the search keys it +// depends on via GenericRoute — search comes first, params defaults to never (no assumption) +const _paginator = useRoute>() +const _after: string | null | undefined = _paginator.search.after +// @ts-expect-error -- limit was not declared on the GenericRoute search shape +_paginator.search.limit +// params was opted out (defaulted to never), so it's a loose record — reading a key is allowed +const _anyParam: unknown = _paginator.params.anything + // ── valid usages ───────────────────────────────────────────────────────────── // known static routes @@ -60,3 +79,64 @@ const _e7 = // @ts-expect-error -- params required for parameterized route Bad +// ── search params ───────────────────────────────────────────────────────────── + +// the route's nullable query variables are typed: /search_params declares offset/limit +const _sp1 = Page +const _sp2 = Page +// search is always optional, so omitting it is fine +const _sp3 = Page +// extra keys are allowed alongside the typed ones (UI-only query string state) +const _sp4 = Page +// search works on a route that declares no query search params at all +const _sp5 = Page + +// wrong value type for a declared search param is still caught +const _spe1 = + // @ts-expect-error -- string is not assignable to the Int search param + Bad + +// ── createMock search typing ────────────────────────────────────────────────── + +// typed search object on a route that declares search params +const _cm1 = createMock({ url: '/search_params', search: { offset: 2 }, data: {} as any }) +// extra UI-only keys are allowed alongside the typed ones +const _cm2 = createMock({ url: '/search_params', search: { offset: 2, tab: 'x' }, data: {} as any }) +// search on a route that declares no query search params +const _cm3 = createMock({ url: '/hello-world', search: { tab: 'x' }, data: {} as any }) + +// wrong value type for a declared search param is still caught +const _cme1 = createMock({ + url: '/search_params', + // @ts-expect-error -- offset must be a number + search: { offset: 'two' }, + data: {} as any, +}) + +// createMock params accept only the params the route declares +const _cmp1 = createMock({ url: '/route_params/[id]', params: { id: '1' }, data: {} as any }) +const _cmpe1 = createMock({ + url: '/route_params/[id]', + // @ts-expect-error -- userId is not a param of /route_params/[id] + params: { userId: '1' }, + data: {} as any, +}) +// @ts-expect-error -- params is required for a parameterized route +const _cmpe2 = createMock({ url: '/route_params/[id]', data: {} as any }) + +// ── goto: same typed targets as ──────────────────────────────────────── + +// a bare string is always allowed (escape hatch) +goto('/anything?with=querystring') +// typed targets: params required for parameterized routes, search typed + optional +goto({ to: '/route_params/[id]', params: { id: '1' } }) +goto({ to: '/search_params', search: { offset: 2 } }) +goto({ to: '/search_params', search: { offset: 2, tab: 'reviews' } }) + +// @ts-expect-error -- params required for a parameterized route +goto({ to: '/route_params/[id]' }) +// @ts-expect-error -- genre... offset is an Int, a string is not assignable +goto({ to: '/search_params', search: { offset: 'two' } }) +// @ts-expect-error -- not a known route +goto({ to: '/not-a-real-route' }) + diff --git a/e2e/react/src/auth.d.ts b/e2e/react/src/auth.d.ts new file mode 100644 index 0000000000..abbe48bb56 --- /dev/null +++ b/e2e/react/src/auth.d.ts @@ -0,0 +1,16 @@ +// the session shape for the progressively-enhanced auth e2e: a login mutation marked @session +// writes a whole { user } object here (Houdini signs the entire subtree), and useSession reads +// it back. +declare global { + namespace App { + interface Session { + user?: { id: string; username: string } + theme?: string + // established by the first-class OAuth flow (onSignIn in src/server/+config) + userId?: string + email?: string + } + } +} + +export {} diff --git a/e2e/react/src/no-secret-leak/test.ts b/e2e/react/src/no-secret-leak/test.ts new file mode 100644 index 0000000000..4ad4dcd694 --- /dev/null +++ b/e2e/react/src/no-secret-leak/test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test' +import { readdirSync, readFileSync, existsSync } from 'node:fs' +import path from 'node:path' + +// Guard against server-only config leaking into the client-served bundle. src/server/+config.ts +// holds the session signing keys and OAuth client secret; it is compiled into the server bundle +// (build/ssr) only. adapter-node serves build/assets to the browser, so nothing under it may +// contain those secrets. This is a structural invariant — the client and server builds go to +// separate directories and the adapter only exposes the client one — so this test reads the built +// output from disk rather than driving a browser. +// +// The sentinels mirror the values in e2e/react/src/server/+config.ts. +const SECRETS = ['supersecret', 'stub-secret'] + +const assetsDir = path.resolve('build/assets') + +function walk(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name) + return entry.isDirectory() ? walk(full) : [full] + }) +} + +test('server secrets never reach the client-served assets bundle', () => { + // the build must have run (playwright's `tests` script builds first) + expect(existsSync(assetsDir)).toBe(true) + + const offenders: string[] = [] + for (const file of walk(assetsDir)) { + const contents = readFileSync(file, 'utf8') + for (const secret of SECRETS) { + if (contents.includes(secret)) { + offenders.push(`${path.relative(assetsDir, file)} contains "${secret}"`) + } + } + } + + expect(offenders, `secret leaked into client bundle:\n${offenders.join('\n')}`).toEqual([]) + + // the server adapter entry imports +config; it must not be emitted into the client dir + expect(existsSync(path.join(assetsDir, 'entries', 'adapter.js'))).toBe(false) +}) diff --git a/e2e/react/src/routes/auth-form/+page.tsx b/e2e/react/src/routes/auth-form/+page.tsx new file mode 100644 index 0000000000..10f25ad7fe --- /dev/null +++ b/e2e/react/src/routes/auth-form/+page.tsx @@ -0,0 +1,41 @@ +import { graphql, useMutationForm, useSession } from '$houdini' + +// Progressively-enhanced login: @endpoint makes it a form (native POST before/without JS), +// @session makes the resolver's `login.session` become the session cookie. Both paths converge +// on the same session. +export default function AuthFormView() { + const [session] = useSession() + const { Form, state, pending } = useMutationForm( + graphql(` + mutation Login($username: String!) + @endpoint(redirect: "/auth-form/done") + @session(path: "login.session") { + login(username: $username) { + session { + user { + id + username + } + } + } + } + `) + ) + + return ( +
    +

    {session.user?.username ?? '(none)'}

    + + + + {state?.errors && ( +

    + {state.errors[0].message} +

    + )} + +
    + ) +} diff --git a/e2e/react/src/routes/auth-form/done/+page.tsx b/e2e/react/src/routes/auth-form/done/+page.tsx new file mode 100644 index 0000000000..278aad0674 --- /dev/null +++ b/e2e/react/src/routes/auth-form/done/+page.tsx @@ -0,0 +1,20 @@ +import { useLogoutForm, useSession } from '$houdini' + +// The post-login landing page. It reads the session from the cookie (so the test can prove +// login set it on both paths) and offers a progressively-enhanced logout. +export default function AuthDoneView() { + const [session] = useSession() + const { form, hidden } = useLogoutForm({ redirectTo: '/auth-form' }) + + return ( +
    +

    {session.user?.username ?? '(none)'}

    +
    + {hidden} + +
    +
    + ) +} diff --git a/e2e/react/src/routes/auth-form/test.ts b/e2e/react/src/routes/auth-form/test.ts new file mode 100644 index 0000000000..548b28473c --- /dev/null +++ b/e2e/react/src/routes/auth-form/test.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// Enhanced path: with JS, onSubmit runs the login mutation, relays the signed session token to +// the auth endpoint (which sets the cookie), then navigates to the redirect. The session payload +// is a whole user object — useSession() reads back login.session.user. +test('enhanced login establishes the session', async ({ page }) => { + await goto(page, routes.auth_form) + + // the form carries the signed CSRF token (proves the round-trip is wired) + await expect(page.locator('input[name="__houdini_csrf"]')).toHaveCount(1) + + await page.fill('[data-testid="username-input"]', 'alice') + await page.click('[data-testid="submit"]') + await page.waitForURL(/\/auth-form\/done/) + + // local session updates immediately (no reload) — useSession reflects the new session right + // after the enhanced submit, mirroring the cookie the token relay set + await expect(page.getByTestId('session-user')).toHaveText('alice') +}) + +// No-JS path: the same form submits natively, the server runs the mutation, sets the session +// cookie from login.session, and 303s to the redirect — no client JS involved. +test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }) + + test('no-JS login establishes the session and redirects', async ({ page }) => { + await page.goto(routes.auth_form) + await page.fill('[data-testid="username-input"]', 'bob') + await page.click('[data-testid="submit"]') + await page.waitForURL(/\/auth-form\/done/) + await expect(page.getByTestId('session-user')).toHaveText('bob') + }) + + test('no-JS logout clears the session', async ({ page }) => { + // establish a session first + await page.goto(routes.auth_form) + await page.fill('[data-testid="username-input"]', 'carol') + await page.click('[data-testid="submit"]') + await page.waitForURL(/\/auth-form\/done/) + await expect(page.getByTestId('session-user')).toHaveText('carol') + + // the logout form is a native POST that deletes the cookie and redirects back + await page.click('[data-testid="logout"]') + await page.waitForURL(/\/auth-form$/) + await expect(page.getByTestId('session-user')).toHaveText('(none)') + }) +}) + +// CSRF: a cross-origin native login POST is rejected fail-closed by the Origin check. +test('rejects a cross-origin login POST', async ({ request }) => { + const res = await request.post(routes.auth_form, { + headers: { + origin: 'http://evil.example', + 'content-type': 'application/x-www-form-urlencoded', + }, + data: '__houdini_form=Login&username=mallory', + maxRedirects: 0, + }) + expect(res.status()).toBe(403) +}) diff --git a/e2e/react/src/routes/layout_search/+layout.gql b/e2e/react/src/routes/layout_search/+layout.gql new file mode 100644 index 0000000000..72ae6dff64 --- /dev/null +++ b/e2e/react/src/routes/layout_search/+layout.gql @@ -0,0 +1,5 @@ +query LayoutSearch($limit: Int) { + usersList(limit: $limit, snapshot: "search_params") { + id + } +} diff --git a/e2e/react/src/routes/layout_search/+layout.tsx b/e2e/react/src/routes/layout_search/+layout.tsx new file mode 100644 index 0000000000..6d37cad800 --- /dev/null +++ b/e2e/react/src/routes/layout_search/+layout.tsx @@ -0,0 +1,15 @@ +import { useRoute } from '$houdini' + +import type { LayoutProps, LayoutRoute } from './$types' + +// the layout declares its own nullable query variable ($limit), which becomes a LayoutRoute +// search param readable here via useRoute() +export default function ({ children }: LayoutProps) { + const { search } = useRoute() + return ( +
    +
    {JSON.stringify(search.limit ?? null)}
    + {children} +
    + ) +} diff --git a/e2e/react/src/routes/layout_search/+page.gql b/e2e/react/src/routes/layout_search/+page.gql new file mode 100644 index 0000000000..34cf3f4698 --- /dev/null +++ b/e2e/react/src/routes/layout_search/+page.gql @@ -0,0 +1,3 @@ +query LayoutChild { + hello +} diff --git a/e2e/react/src/routes/layout_search/+page.tsx b/e2e/react/src/routes/layout_search/+page.tsx new file mode 100644 index 0000000000..6c7ebc2045 --- /dev/null +++ b/e2e/react/src/routes/layout_search/+page.tsx @@ -0,0 +1,5 @@ +import type { PageProps } from './$types' + +export default function ({ LayoutChild }: PageProps) { + return
    {LayoutChild.hello}
    +} diff --git a/e2e/react/src/routes/layout_search/test.ts b/e2e/react/src/routes/layout_search/test.ts new file mode 100644 index 0000000000..98c0cb04ee --- /dev/null +++ b/e2e/react/src/routes/layout_search/test.ts @@ -0,0 +1,10 @@ +import { test } from '@playwright/test' +import { expect_to_be, goto } from '~/utils/testsHelper' + +// a layout with its own query exposes that query's nullable variable as a LayoutRoute search +// param, read via useRoute() inside the layout component. Loaded directly, so it +// also covers a layout reading search on the server-rendered initial load. +test('layout reads its search param via useRoute()', async ({ page }) => { + await goto(page, '/layout_search?limit=3') + await expect_to_be(page, '3', '#layout-limit') +}) diff --git a/e2e/react/src/routes/loading-error-link/+page.tsx b/e2e/react/src/routes/loading-error-link/+page.tsx new file mode 100644 index 0000000000..df61d90d62 --- /dev/null +++ b/e2e/react/src/routes/loading-error-link/+page.tsx @@ -0,0 +1,11 @@ +import { Link } from '$houdini' + +// A query-less page whose only job is to link into the erroring @loading route so the e2e +// suite can exercise a client-side navigation into it (not just the initial SSR load). +export default function () { + return ( + + go to loading-error + + ) +} diff --git a/e2e/react/src/routes/loading-error/+error.tsx b/e2e/react/src/routes/loading-error/+error.tsx new file mode 100644 index 0000000000..1ed2993a7e --- /dev/null +++ b/e2e/react/src/routes/loading-error/+error.tsx @@ -0,0 +1,5 @@ +import type { ErrorProps } from './$types' + +export default function LoadingErrorError({ errors }: ErrorProps) { + return
    {errors[0]?.message}
    +} diff --git a/e2e/react/src/routes/loading-error/+page.gql b/e2e/react/src/routes/loading-error/+page.gql new file mode 100644 index 0000000000..029795b7e8 --- /dev/null +++ b/e2e/react/src/routes/loading-error/+page.gql @@ -0,0 +1,5 @@ +query LoadingErrorQuery @loading { + user(id: "999", snapshot: "loading-error", delay: 100) { + name + } +} diff --git a/e2e/react/src/routes/loading-error/+page.tsx b/e2e/react/src/routes/loading-error/+page.tsx new file mode 100644 index 0000000000..0ec35507f4 --- /dev/null +++ b/e2e/react/src/routes/loading-error/+page.tsx @@ -0,0 +1,12 @@ +import { isPending } from '$houdini' + +import type { PageProps } from './$types' + +// A page whose query is marked @loading streams its loading frame inside a Suspense boundary +// before the query resolves. When the query errors (user id 999 doesn't exist, so the resolver +// throws) the page must reach the +error.tsx boundary instead of hanging on the loading frame. +export default function ({ LoadingErrorQuery }: PageProps) { + const user = LoadingErrorQuery.user + + return
    {isPending(user.name) ? 'loading' : user.name}
    +} diff --git a/e2e/react/src/routes/loading-error/test.ts b/e2e/react/src/routes/loading-error/test.ts new file mode 100644 index 0000000000..afbdbef0c3 --- /dev/null +++ b/e2e/react/src/routes/loading-error/test.ts @@ -0,0 +1,26 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { expect_to_be, goto } from '~/utils/testsHelper.js' + +test.describe('@loading query that errors', () => { + // A query marked @loading streams its loading frame before the query resolves, so an error + // from the API has to be carried to the client; otherwise the page hangs on the loading + // state instead of reaching +error.tsx. We cover both entry points into the route. + + // the initial SSR load: the shell flushes with the loading frame, then the API errors and + // the error must stream down so the client surfaces it on the boundary after hydration. + test('SSR: an erroring @loading query reaches the error boundary', async ({ page }) => { + await goto(page, routes.loading_error) + await expect_to_be(page, 'User not found', '#error-message') + }) + + // a client-side navigation into the route: the query is sent from the browser and its error + // must unblock the @loading suspense and throw to the boundary. + test('client-side nav: an erroring @loading query reaches the error boundary', async ({ + page, + }) => { + await goto(page, routes.loading_error_link) + await page.click('#to-loading-error') + await expect_to_be(page, 'User not found', '#error-message') + }) +}) diff --git a/e2e/react/src/routes/loading-interactive/+page.gql b/e2e/react/src/routes/loading-interactive/+page.gql new file mode 100644 index 0000000000..77ea5a4d36 --- /dev/null +++ b/e2e/react/src/routes/loading-interactive/+page.gql @@ -0,0 +1,5 @@ +query LoadingInteractiveQuery @loading { + user(id: "1", snapshot: "loading-interactive", delay: 400) { + name + } +} diff --git a/e2e/react/src/routes/loading-interactive/+page.tsx b/e2e/react/src/routes/loading-interactive/+page.tsx new file mode 100644 index 0000000000..fbed0bc8ce --- /dev/null +++ b/e2e/react/src/routes/loading-interactive/+page.tsx @@ -0,0 +1,25 @@ +import { isPending } from '$houdini' +import * as React from 'react' + +import type { PageProps } from './$types' + +// The simplest possible reproduction of issue #1408's underlying cause: a page whose +// query is marked @loading renders its loading frame inside a streaming Suspense +// boundary. Once the data resolves the real markup is swapped in by the server stream, +// but the client must still hydrate it so the page is interactive. This button proves +// that: if hydration never commits, the counter stays at 0 no matter how many times +// it is clicked. +export default function ({ LoadingInteractiveQuery }: PageProps) { + const user = LoadingInteractiveQuery.user + const [count, setCount] = React.useState(0) + + return ( + <> +
    {isPending(user.name) ? 'loading' : user.name}
    +
    {count}
    + + + ) +} diff --git a/e2e/react/src/routes/loading-interactive/test.ts b/e2e/react/src/routes/loading-interactive/test.ts new file mode 100644 index 0000000000..a23f8c63ed --- /dev/null +++ b/e2e/react/src/routes/loading-interactive/test.ts @@ -0,0 +1,23 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_to_be, goto } from '~/utils/testsHelper.js' + +test.describe('@loading page hydration', () => { + // issue #1408: a page whose query is marked @loading streams its loading frame and then + // has the resolved markup swapped in by the server stream. The client must still hydrate + // the page so it is interactive. A button that increments a counter is the minimal proof: + // if hydration never commits, clicking it does nothing. + test('the page is interactive after the loading state resolves', async ({ page }) => { + await goto(page, routes.loading_interactive) + + // the real data lands once the @loading query resolves + await expect_to_be(page, 'Bruce Willis', '#name') + + // clicking the button must update the counter, which only happens if React hydrated + await page.click('button[id=increment]') + await expect_to_be(page, '1', '#count') + + await page.click('button[id=increment]') + await expect_to_be(page, '2', '#count') + }) +}) diff --git a/e2e/react/src/routes/loading-paginated-fragment/+page.gql b/e2e/react/src/routes/loading-paginated-fragment/+page.gql new file mode 100644 index 0000000000..a48efe06c2 --- /dev/null +++ b/e2e/react/src/routes/loading-paginated-fragment/+page.gql @@ -0,0 +1,6 @@ +query LoadingFragmentQuery @loading { + user(id: "1", snapshot: "loading-paginated-fragment", delay: 2000) { + name + ...LoadingFragmentList + } +} diff --git a/e2e/react/src/routes/loading-paginated-fragment/+page.tsx b/e2e/react/src/routes/loading-paginated-fragment/+page.tsx new file mode 100644 index 0000000000..cc72263159 --- /dev/null +++ b/e2e/react/src/routes/loading-paginated-fragment/+page.tsx @@ -0,0 +1,54 @@ +import { graphql, isPending, useFragmentHandle } from '$houdini' + +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment LoadingFragmentList on User { + usersConnectionSnapshot(snapshot: "loading-paginated-fragment", first: 2) @paginate { + edges { + node { + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`) + +// Rendered as a child so it mounts *during* the parent query's @loading frame +// (no if-guard). This is the exact scenario from issue #1408. +function FriendsList({ user }: { user: any }) { + const handle = useFragmentHandle(user, fragment) + + const edges = handle.data?.usersConnectionSnapshot?.edges + const names = Array.isArray(edges) + ? edges + .map(({ node }) => node?.name) + .filter((name) => typeof name === 'string') + .join(', ') + : '' + + return ( + <> +
    {names}
    +
    {JSON.stringify(handle.pageInfo)}
    + + + ) +} + +export default function ({ LoadingFragmentQuery }: PageProps) { + const user = LoadingFragmentQuery.user + + return ( + <> +
    {isPending(user.name) ? 'loading' : user.name}
    + + + ) +} diff --git a/e2e/react/src/routes/loading-paginated-fragment/test.ts b/e2e/react/src/routes/loading-paginated-fragment/test.ts new file mode 100644 index 0000000000..7a1a12bb1e --- /dev/null +++ b/e2e/react/src/routes/loading-paginated-fragment/test.ts @@ -0,0 +1,48 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { + clientSideNavigation, + expect_0_gql, + expect_1_gql, + expect_to_be, + goto, +} from '~/utils/testsHelper.js' + +test.describe('paginated fragment under an @loading query', () => { + // issue #1408: a paginated fragment spread on an @loading query mounts during the + // loading frame. once the parent query resolves the list should appear and paginate, + // without the user having to guard the component behind an `if (!loading)` check. + test('renders and paginates after loading resolves (no guard)', async ({ page }) => { + await goto(page, routes.loading_paginated_fragment) + + // first page arrives with the parent query once @loading resolves + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + + // and pagination keeps working from there + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') + }) + + // the pagination handlers must no-op while the parent entity is still a PendingValue + // placeholder, instead of firing a node(id: PendingValue) request. the loading frame is + // only observable via a client-side navigation (a direct load streams until resolved). + test('paginating during the loading frame is a no-op', async ({ page }) => { + await goto(page, '/') + await clientSideNavigation(page, routes.loading_paginated_fragment) + + // we're in the loading frame + await expect_to_be(page, 'loading', '#name') + + // clicking next while the parent is still loading must not fire a request + await expect_0_gql(page, 'button[id=next]') + + // once @loading resolves the friends render (the #result node is present but empty + // during loading, so wait for the data before asserting) and pagination works + await page.waitForFunction(() => + document.querySelector('#result')?.textContent?.includes('Bruce') + ) + await expect_to_be(page, 'Bruce Willis, Samuel Jackson', '#result') + await expect_1_gql(page, 'button[id=next]') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks', '#result') + }) +}) diff --git a/e2e/react/src/routes/mutation-form/+page.tsx b/e2e/react/src/routes/mutation-form/+page.tsx new file mode 100644 index 0000000000..79b49aa41e --- /dev/null +++ b/e2e/react/src/routes/mutation-form/+page.tsx @@ -0,0 +1,34 @@ +import { graphql, useMutationForm } from '$houdini' + +// A progressively-enhanced form over a mutation: it submits natively (a real POST) before +// or without JavaScript, and once hydrated the same form runs the mutation client-side. +// @endpoint(redirect:) bakes the same target into both paths. +export default function MutationFormView() { + const { Form, state, pending } = useMutationForm( + graphql(` + mutation MutationFormCreate($name: String!, $birthDate: DateTime!) + @endpoint(redirect: "/mutation-form/created?id={ addUser.id }", fields: ["name", "birthDate"]) { + addUser(snapshot: "MutationForm", name: $name, birthDate: $birthDate) { + id + name + } + } + `) + ) + + return ( +
    + + {/* DateTime is a custom scalar; the hidden timestamp exercises scalar coercion */} + + + {state?.errors && ( +

    + {state.errors[0].message} +

    + )} +
    + ) +} diff --git a/e2e/react/src/routes/mutation-form/created/+page.tsx b/e2e/react/src/routes/mutation-form/created/+page.tsx new file mode 100644 index 0000000000..e880fc4131 --- /dev/null +++ b/e2e/react/src/routes/mutation-form/created/+page.tsx @@ -0,0 +1,9 @@ +import { useRoute, type GenericRoute } from '$houdini' + +// The @endpoint redirect target. It reads the created id from the search param the +// redirect interpolated, so the test can prove the mutation ran and the redirect landed — +// on both the no-JS (303) and the enhanced (client goto) paths. +export default function MutationFormCreatedView() { + const { search } = useRoute>() + return

    Created: {search.id ?? '(none)'}

    +} diff --git a/e2e/react/src/routes/mutation-form/error/+page.tsx b/e2e/react/src/routes/mutation-form/error/+page.tsx new file mode 100644 index 0000000000..5cb38a32b3 --- /dev/null +++ b/e2e/react/src/routes/mutation-form/error/+page.tsx @@ -0,0 +1,37 @@ +import { graphql, useMutationForm } from '$houdini' +import { useState } from 'react' + +// The enhanced (client) error path: once hydrated, onSubmit runs the mutation, which the +// resolver forces to error. The form must surface state.errors, fire onError, and stay on +// the page — the @endpoint redirect is suppressed when the result carries errors. +export default function MutationFormErrorView() { + const [sawOnError, setSawOnError] = useState(false) + + const { Form, state } = useMutationForm( + graphql(` + mutation MutationFormError($name: String!, $birthDate: DateTime!) + @endpoint(redirect: "/mutation-form/created?id={ addUser.id }", fields: ["name", "birthDate"]) { + addUser(snapshot: "MutationFormError", name: $name, birthDate: $birthDate, force: ERROR) { + id + } + } + `), + { onError: () => setSawOnError(true) } + ) + + return ( +
    + + + + {state?.errors && ( +

    + {state.errors[0].message} +

    + )} + {sawOnError && onError fired} +
    + ) +} diff --git a/e2e/react/src/routes/mutation-form/error/test.ts b/e2e/react/src/routes/mutation-form/error/test.ts new file mode 100644 index 0000000000..64c52a207c --- /dev/null +++ b/e2e/react/src/routes/mutation-form/error/test.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// The enhanced (client) error path: after hydration onSubmit runs the mutation, the resolver +// forces a GraphQL error, and the form must surface state.errors, fire onError, and stay put — +// the @endpoint redirect is suppressed because the result carries errors. +test('enhanced submit surfaces the error, fires onError, and does not redirect', async ({ + page, +}) => { + await goto(page, routes.mutation_form_error) + + await page.fill('[data-testid="name-input"]', 'Errored Eve') + await page.click('[data-testid="submit"]') + + // the error renders from state.errors with the resolver's message + await expect(page.getByTestId('error')).toHaveText('force ERROR!') + // the onError callback fired on the enhanced path + await expect(page.getByTestId('on-error')).toBeVisible() + // and we stayed on the form — the redirect is suppressed when errors are present + await expect(page).toHaveURL(new RegExp(`${routes.mutation_form_error}$`)) +}) diff --git a/e2e/react/src/routes/mutation-form/status/+page.tsx b/e2e/react/src/routes/mutation-form/status/+page.tsx new file mode 100644 index 0000000000..6cdf0e7e36 --- /dev/null +++ b/e2e/react/src/routes/mutation-form/status/+page.tsx @@ -0,0 +1,39 @@ +import { graphql, useMutationForm, useMutationFormStatus } from '$houdini' + +// A child of
    reads the form's pending state via context — no prop drilling, the +// useFormStatus ergonomic for our forms. +function Submit() { + const { pending } = useMutationFormStatus() + return ( + + ) +} + +export default function FormStatusView() { + const { Form, state } = useMutationForm( + graphql(` + mutation MutationFormStatus($name: String!, $birthDate: DateTime!) + @endpoint(redirect: "/mutation-form/created?id={ addUser.id }") { + addUser( + snapshot: "MutationFormStatus" + name: $name + birthDate: $birthDate + delay: 600 + ) { + id + } + } + `) + ) + + return ( + + + + + {state?.errors &&

    {state.errors[0].message}

    } + + ) +} diff --git a/e2e/react/src/routes/mutation-form/status/test.ts b/e2e/react/src/routes/mutation-form/status/test.ts new file mode 100644 index 0000000000..602ca0c080 --- /dev/null +++ b/e2e/react/src/routes/mutation-form/status/test.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// useMutationFormStatus: a child of
    sees the pending state via context while the +// (delayed) mutation runs, then the form navigates on success. +test('a child reads pending via useMutationFormStatus, then the form redirects', async ({ + page, +}) => { + await goto(page, routes.mutation_form_status) + + await page.fill('[data-testid="name-input"]', 'Status Sam') + await page.click('[data-testid="submit"]') + + // the Submit child (which only knows pending via the context) reflects it + await expect(page.getByTestId('submit')).toHaveText('Saving…') + + await page.waitForURL(/\/mutation-form\/created\?id=/) + await expect(page.getByTestId('created-id')).toContainText('MutationFormStatus') +}) diff --git a/e2e/react/src/routes/mutation-form/test.ts b/e2e/react/src/routes/mutation-form/test.ts new file mode 100644 index 0000000000..d1b09087f3 --- /dev/null +++ b/e2e/react/src/routes/mutation-form/test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// The enhanced path: with JS, onSubmit intercepts, runs the mutation client-side, and +// navigates to the interpolated @endpoint redirect. +test('enhanced submit runs the mutation and navigates to the redirect', async ({ page }) => { + await goto(page, routes.mutation_form) + + // with router.formToken enabled, the form renders the signed CSRF token that the + // server verifies on submit (this whole test passing proves the round-trip works) + await expect(page.locator('input[name="__houdini_csrf"]')).toHaveCount(1) + + await page.fill('[data-testid="name-input"]', 'Enhanced Alice') + await page.click('[data-testid="submit"]') + + await page.waitForURL(/\/mutation-form\/created\?id=/) + await expect(page.getByTestId('created-id')).toContainText('MutationForm') +}) + +// The no-JS path: the same form submits natively, the server runs the mutation and 303s to +// the same target. This is the progressive-enhancement guarantee. +test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }) + + test('native POST runs the mutation and redirects', async ({ page }) => { + await page.goto(routes.mutation_form) + + await page.fill('[data-testid="name-input"]', 'NoJS Bob') + await page.click('[data-testid="submit"]') // a native form submit — no onSubmit + + await page.waitForURL(/\/mutation-form\/created\?id=/) + await expect(page.getByTestId('created-id')).toContainText('MutationForm') + }) +}) + +// CSRF: a cross-origin native form POST is rejected fail-closed by the Origin check. +test('rejects a cross-origin form POST', async ({ request }) => { + const res = await request.post(routes.mutation_form, { + headers: { + origin: 'http://evil.example', + 'content-type': 'application/x-www-form-urlencoded', + }, + data: '__houdini_form=MutationFormCreate&name=Mallory&birthDate=946684800000', + maxRedirects: 0, + }) + expect(res.status()).toBe(403) +}) diff --git a/e2e/react/src/routes/mutation-form/upload/+page.tsx b/e2e/react/src/routes/mutation-form/upload/+page.tsx new file mode 100644 index 0000000000..a8c853ff5c --- /dev/null +++ b/e2e/react/src/routes/mutation-form/upload/+page.tsx @@ -0,0 +1,31 @@ +import { graphql, useMutationForm } from '$houdini' + +// A file-upload form. The mutation takes a File scalar, so the compiler flags the form +// multipart and sets enctype="multipart/form-data". The enhanced path sends the File +// through the normal client multipart pipeline; the no-JS path posts it natively for the +// server form handler to assemble into a multipart GraphQL request. +export default function UploadFormView() { + const { Form, state, pending } = useMutationForm( + graphql(` + mutation MutationFormUpload($file: File!) @endpoint { + singleUpload(file: $file) + } + `) + ) + + return ( + + + + {/* the resolver echoes the uploaded file's contents back */} + {state?.data &&

    {state.data.singleUpload}

    } + {state?.errors && ( +

    + {state.errors[0].message} +

    + )} +
    + ) +} diff --git a/e2e/react/src/routes/mutation-form/upload/test.ts b/e2e/react/src/routes/mutation-form/upload/test.ts new file mode 100644 index 0000000000..b84ad7f1d7 --- /dev/null +++ b/e2e/react/src/routes/mutation-form/upload/test.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// The enhanced (client) upload path: the File from the input rides through coerceFormData +// and the normal client multipart pipeline to the resolver, which echoes its contents back. +test('uploads a file through the enhanced form path', async ({ page }) => { + await goto(page, routes.mutation_form_upload) + + await page.setInputFiles('[data-testid="file-input"]', { + name: 'note.txt', + mimeType: 'text/plain', + buffer: Buffer.from('hello from a form upload'), + }) + await page.click('[data-testid="submit"]') + + await expect(page.getByTestId('result')).toHaveText('hello from a form upload') +}) diff --git a/e2e/react/src/routes/oauth/+page.tsx b/e2e/react/src/routes/oauth/+page.tsx new file mode 100644 index 0000000000..4eeb318092 --- /dev/null +++ b/e2e/react/src/routes/oauth/+page.tsx @@ -0,0 +1,23 @@ +import { loginURL, useSession } from '$houdini' + +// First-class OAuth e2e: the loginURL link kicks off the flow against the stub provider. After the +// full round-trip (Houdini /login -> stub /authorize -> callback -> onSignIn) the session carries +// the user, which this page reads back via useSession. +export default function OAuthView() { + const [session] = useSession() + + if (session.userId) { + return ( +
    +

    {session.email}

    +

    {session.userId}

    +
    + ) + } + + return ( + + Log in + + ) +} diff --git a/e2e/react/src/routes/oauth/test.ts b/e2e/react/src/routes/oauth/test.ts new file mode 100644 index 0000000000..f174bbff81 --- /dev/null +++ b/e2e/react/src/routes/oauth/test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// The full first-class OAuth round-trip in a real browser: clicking loginURL walks +// /login -> stub /authorize -> Houdini callback (code exchange + profile + onSignIn) -> /oauth, +// which then carries the session established from the stub user. +test('first-class OAuth establishes the session through the provider round-trip', async ({ + page, +}) => { + await goto(page, routes.oauth) + await page.click('[data-testid="login"]') + await page.waitForURL(/\/oauth$/) + await expect(page.getByTestId('who')).toHaveText('stub@example.com') + await expect(page.getByTestId('user-id')).toHaveText('stub-user-1') +}) diff --git a/e2e/react/src/routes/plural-fragment-args/+page.gql b/e2e/react/src/routes/plural-fragment-args/+page.gql new file mode 100644 index 0000000000..6c0eceda01 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-args/+page.gql @@ -0,0 +1,6 @@ +query features__plural_fragment_args { + usersList(snapshot: "plural_args", limit: 4) { + id + ...PluralArgsRow @with(size: 100) + } +} diff --git a/e2e/react/src/routes/plural-fragment-args/+page.tsx b/e2e/react/src/routes/plural-fragment-args/+page.tsx new file mode 100644 index 0000000000..005e0ad748 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-args/+page.tsx @@ -0,0 +1,6 @@ +import { PageProps } from './$types' +import PluralArgsList from './PluralArgsList' + +export default ({ features__plural_fragment_args }: PageProps) => { + return +} diff --git a/e2e/react/src/routes/plural-fragment-args/PluralArgsList.tsx b/e2e/react/src/routes/plural-fragment-args/PluralArgsList.tsx new file mode 100644 index 0000000000..02dc0f9802 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-args/PluralArgsList.tsx @@ -0,0 +1,26 @@ +import { graphql, type PluralArgsRow, useFragment } from '$houdini' + +// a @plural fragment that also takes @arguments — the size variable is threaded per item. +export default function PluralArgsList({ users }: { users: PluralArgsRow }) { + const data = useFragment( + users, + graphql(` + fragment PluralArgsRow on User @plural @arguments(size: { type: "Int", default: 50 }) { + id + name + avatarURL(size: $size) + } + `) + ) + + return ( +
      + {data?.map((user) => ( +
    • + {user.name} + {user.name} +
    • + ))} +
    + ) +} diff --git a/e2e/react/src/routes/plural-fragment-args/test.ts b/e2e/react/src/routes/plural-fragment-args/test.ts new file mode 100644 index 0000000000..42c27f698c --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-args/test.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('@plural fragment with @arguments renders the list with the field argument applied', async ({ + page, +}) => { + await goto(page, routes.plural_fragment_args) + + // the whole list renders through one useFragment, and the per-item avatarURL(size:) field + // (driven by @arguments/@with) resolves for every row + await expect(page.locator('#plural-list li')).toHaveCount(4) + await expect(page.getByTestId('plural_args:1')).toContainText('Bruce Willis') + + const images = await page.locator('#plural-list img').all() + expect(images.length).toBe(4) + for (const image of images) { + expect(await image.getAttribute('src')).toBeTruthy() + } +}) diff --git a/e2e/react/src/routes/plural-fragment-empty/+page.gql b/e2e/react/src/routes/plural-fragment-empty/+page.gql new file mode 100644 index 0000000000..2816741aa3 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-empty/+page.gql @@ -0,0 +1,6 @@ +query features__plural_fragment_empty { + usersList(snapshot: "plural_empty", limit: 0) { + id + ...PluralUserRow + } +} diff --git a/e2e/react/src/routes/plural-fragment-empty/+page.tsx b/e2e/react/src/routes/plural-fragment-empty/+page.tsx new file mode 100644 index 0000000000..e99fcd3759 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-empty/+page.tsx @@ -0,0 +1,7 @@ +import PluralUserList from '../plural-fragment/PluralUserList' +import { PageProps } from './$types' + +export default ({ features__plural_fragment_empty }: PageProps) => { + // an empty list should render an empty plural fragment (a [], not null) + return +} diff --git a/e2e/react/src/routes/plural-fragment-empty/test.ts b/e2e/react/src/routes/plural-fragment-empty/test.ts new file mode 100644 index 0000000000..9e37320a56 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-empty/test.ts @@ -0,0 +1,12 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('@plural fragment renders an empty list as an empty array', async ({ page }) => { + await goto(page, routes.plural_fragment_empty) + + // the list (#plural-list) renders with no items — data is [] rather than null + // (an empty
      has no size, so assert it's attached rather than "visible") + await expect(page.locator('#plural-list')).toBeAttached() + await expect(page.locator('#plural-list li')).toHaveCount(0) +}) diff --git a/e2e/react/src/routes/plural-fragment-guard/+page.gql b/e2e/react/src/routes/plural-fragment-guard/+page.gql new file mode 100644 index 0000000000..268d8fe8e7 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-guard/+page.gql @@ -0,0 +1,6 @@ +query features__plural_fragment_guard { + usersList(snapshot: "plural_guard", limit: 4) { + id + ...GuardRow + } +} diff --git a/e2e/react/src/routes/plural-fragment-guard/+page.tsx b/e2e/react/src/routes/plural-fragment-guard/+page.tsx new file mode 100644 index 0000000000..a1f522b07d --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-guard/+page.tsx @@ -0,0 +1,7 @@ +import { PageProps } from './$types' +import GuardList from './GuardList' + +export default ({ features__plural_fragment_guard }: PageProps) => { + // pass the whole list to a non-plural fragment on purpose + return +} diff --git a/e2e/react/src/routes/plural-fragment-guard/GuardList.tsx b/e2e/react/src/routes/plural-fragment-guard/GuardList.tsx new file mode 100644 index 0000000000..966eb3c2b5 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-guard/GuardList.tsx @@ -0,0 +1,17 @@ +import { graphql, useFragment } from '$houdini' + +// GuardRow is NOT marked @plural, so handing useFragment the whole list of references should +// trip the runtime guard. +export default function GuardList({ users }: { users: any }) { + const data = useFragment( + users, + graphql(` + fragment GuardRow on User { + id + name + } + `) + ) + + return
      {JSON.stringify(data)}
      +} diff --git a/e2e/react/src/routes/plural-fragment-guard/test.ts b/e2e/react/src/routes/plural-fragment-guard/test.ts new file mode 100644 index 0000000000..c7c22311c0 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-guard/test.ts @@ -0,0 +1,12 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('useFragment throws when a non-plural fragment is handed a list of references', async ({ + page, +}) => { + await goto(page, routes.plural_fragment_guard) + + // the runtime guard should fire with a clear message + await expect(page.locator('body')).toContainText('not marked @plural') +}) diff --git a/e2e/react/src/routes/plural-fragment-rebind/+page.gql b/e2e/react/src/routes/plural-fragment-rebind/+page.gql new file mode 100644 index 0000000000..6cd7242e7d --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-rebind/+page.gql @@ -0,0 +1,6 @@ +query features__plural_fragment_rebind { + usersList(snapshot: "plural_rebind", limit: 4) { + id + ...PluralUserRow + } +} diff --git a/e2e/react/src/routes/plural-fragment-rebind/+page.tsx b/e2e/react/src/routes/plural-fragment-rebind/+page.tsx new file mode 100644 index 0000000000..5ef66c5741 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-rebind/+page.tsx @@ -0,0 +1,43 @@ +import { graphql, useMutation } from '$houdini' +import { useState } from 'react' + +import PluralUserList from '../plural-fragment/PluralUserList' +import { PageProps } from './$types' + +export default ({ features__plural_fragment_rebind }: PageProps) => { + const all = features__plural_fragment_rebind.usersList + const lastId = all[all.length - 1]?.id + + // re-bind the plural fragment to a smaller set of parents (the first two members). This + // changes which cache records the fragment is subscribed to. + const [firstTwo, setFirstTwo] = useState(false) + const shown = firstTwo ? all.slice(0, 2) : all + + // update a record that is NOT in the first-two subset. If the subscription to it was not + // torn down when we re-bound, this update would leak back into the rendered list. + const [updateLast] = useMutation( + graphql(` + mutation PluralRebindUpdate($id: ID!, $name: String!) { + updateUserByID(id: $id, snapshot: "plural_rebind", name: $name) { + id + name + } + } + `) + ) + + return ( + <> + + + + + ) +} diff --git a/e2e/react/src/routes/plural-fragment-rebind/test.ts b/e2e/react/src/routes/plural-fragment-rebind/test.ts new file mode 100644 index 0000000000..1ea5bfcabf --- /dev/null +++ b/e2e/react/src/routes/plural-fragment-rebind/test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('@plural fragment re-binds to a new set of parents and tears down old subscriptions', async ({ + page, +}) => { + await goto(page, routes.plural_fragment_rebind) + await expect(page.locator('#plural-list li')).toHaveCount(4) + + // re-bind the fragment to just the first two members + await page.click('[data-test-action="show-first-two"]') + await expect(page.locator('#plural-list li')).toHaveCount(2) + await expect(page.getByTestId('plural_rebind:1')).toHaveText('Bruce Willis') + + // update a record that is no longer bound. its subscription should have been torn down, + // so the rendered subset must not change (no extra row, no leaked value). + await page.click('[data-test-action="update-last"]') + await expect(page.locator('#plural-list li')).toHaveCount(2) + await expect(page.getByText('Off Screen Update')).toHaveCount(0) +}) diff --git a/e2e/react/src/routes/plural-fragment/+page.gql b/e2e/react/src/routes/plural-fragment/+page.gql new file mode 100644 index 0000000000..9d9b8496b5 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment/+page.gql @@ -0,0 +1,6 @@ +query features__plural_fragment { + usersList(snapshot: "plural_fragment", limit: 4) @list(name: "PluralUsers") { + id + ...PluralUserRow + } +} diff --git a/e2e/react/src/routes/plural-fragment/+page.tsx b/e2e/react/src/routes/plural-fragment/+page.tsx new file mode 100644 index 0000000000..448556ab76 --- /dev/null +++ b/e2e/react/src/routes/plural-fragment/+page.tsx @@ -0,0 +1,53 @@ +import { graphql, useMutation } from '$houdini' + +import { PageProps } from './$types' +import PluralUserList from './PluralUserList' + +export default ({ features__plural_fragment }: PageProps) => { + const firstId = features__plural_fragment.usersList[0]?.id + + // updating a single record in the cache should re-render just that row of the plural + // fragment, driven by the per-item cache subscription (not by a prop change). + const [update] = useMutation( + graphql(` + mutation PluralUpdateUser($id: ID!, $name: String!) { + updateUserByID(id: $id, snapshot: "plural_fragment", name: $name) { + id + name + } + } + `) + ) + + // inserting into the list grows the references array passed to the plural fragment, which + // should re-subscribe and render the new row. + const [addNew] = useMutation( + graphql(` + mutation PluralAddUser($name: String!, $birthDate: DateTime!) { + addUser(snapshot: "plural_fragment", name: $name, birthDate: $birthDate) { + ...PluralUsers_insert @prepend + } + } + `) + ) + + return ( + <> + + + + + ) +} diff --git a/e2e/react/src/routes/plural-fragment/PluralUserList.tsx b/e2e/react/src/routes/plural-fragment/PluralUserList.tsx new file mode 100644 index 0000000000..b3b6b45dad --- /dev/null +++ b/e2e/react/src/routes/plural-fragment/PluralUserList.tsx @@ -0,0 +1,30 @@ +import { graphql, type PluralUserRow, useFragment } from '$houdini' + +type Props = { + // the @plural fragment reference type is already an array + users: PluralUserRow +} + +// PluralUserList receives the whole list of users at once and reads them back through a +// single useFragment call thanks to @plural. +export default function PluralUserList({ users }: Props) { + const data = useFragment( + users, + graphql(` + fragment PluralUserRow on User @plural { + id + name + } + `) + ) + + return ( +
        + {data?.map((user) => ( +
      • + {user.name} +
      • + ))} +
      + ) +} diff --git a/e2e/react/src/routes/plural-fragment/test.ts b/e2e/react/src/routes/plural-fragment/test.ts new file mode 100644 index 0000000000..da4fa647ca --- /dev/null +++ b/e2e/react/src/routes/plural-fragment/test.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// one ordered flow so the cases don't depend on a shared, mutated snapshot across tests: +// initial render through a single useFragment, a single-member cache update reflecting in +// place, and an insert growing the list. +test('@plural fragment renders the list and reacts to updates and inserts', async ({ page }) => { + await goto(page, routes.plural_fragment) + + // initial render: the whole list comes through a single useFragment call + await expect(page.locator('#plural-list li')).toHaveCount(4) + await expect(page.getByTestId('plural_fragment:1')).toHaveText('Bruce Willis') + await expect(page.getByTestId('plural_fragment:2')).toHaveText('Samuel Jackson') + + // updating one record updates just that row, leaving the others untouched + await page.click('[data-test-action="update-first"]') + await expect(page.getByTestId('plural_fragment:1')).toHaveText('Updated Bruce') + await expect(page.getByTestId('plural_fragment:2')).toHaveText('Samuel Jackson') + await expect(page.locator('#plural-list li')).toHaveCount(4) + + // inserting a record grows the rendered list + await page.click('[data-test-action="add-new"]') + await expect(page.locator('#plural-list li')).toHaveCount(5) + await expect(page.locator('#plural-list li').first()).toHaveText('Brand New User') +}) diff --git a/e2e/react/src/routes/refetchable-fragment-custom/+page.gql b/e2e/react/src/routes/refetchable-fragment-custom/+page.gql new file mode 100644 index 0000000000..98ad33748c --- /dev/null +++ b/e2e/react/src/routes/refetchable-fragment-custom/+page.gql @@ -0,0 +1,5 @@ +query RefetchableCustomQuery { + refetchableEntity(id: "1") { + ...RefetchableEntityInfo @with(size: 50) + } +} diff --git a/e2e/react/src/routes/refetchable-fragment-custom/+page.tsx b/e2e/react/src/routes/refetchable-fragment-custom/+page.tsx new file mode 100644 index 0000000000..b6b28b384b --- /dev/null +++ b/e2e/react/src/routes/refetchable-fragment-custom/+page.tsx @@ -0,0 +1,32 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +// RefetchableEntity is NOT a Node — it is refetchable via the resolve config in +// houdini.config.ts (queryField: 'refetchableEntity'). This exercises the custom-resolve +// refetch path end to end. +const fragment = graphql(` + fragment RefetchableEntityInfo on RefetchableEntity @refetchable @arguments(size: { type: "Int", default: 50 }) { + avatarURL(size: $size) + } +`) + +export default function ({ RefetchableCustomQuery }: PageProps) { + const handle = useFragmentHandle(RefetchableCustomQuery.refetchableEntity, fragment) + + return ( + <> +
      {handle.data?.avatarURL}
      + {/* variables expose only the fragment args; the synthetic id (from resolve.arguments) must not leak */} +
      + size={handle.variables?.size};id={(handle.variables as any)?.id ?? 'none'} +
      + + + + + ) +} diff --git a/e2e/react/src/routes/refetchable-fragment-custom/test.ts b/e2e/react/src/routes/refetchable-fragment-custom/test.ts new file mode 100644 index 0000000000..23616bd127 --- /dev/null +++ b/e2e/react/src/routes/refetchable-fragment-custom/test.ts @@ -0,0 +1,25 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_1_gql, expectToContain, goto } from '~/utils/testsHelper.js' + +test.describe('refetchable fragment (custom resolve)', () => { + test('refetch works on a non-Node type resolved by a custom query', async ({ page }) => { + await goto(page, routes.refetchable_fragment_custom) + + // the fragment loads with the default size argument + await expectToContain(page, '?size=50', 'div[id=result]') + + // variables expose the fragment's args only — the resolve-derived id must not leak + await expectToContain(page, 'size=50;id=none', 'div[id=vars]') + + // refetching re-runs the embedded refetchableEntity(id:) query with new arguments + await expect_1_gql(page, 'button[id=refetch]') + await expectToContain(page, '?size=100', 'div[id=result]') + await expectToContain(page, 'size=100;id=none', 'div[id=vars]') + + // a second refetch with a different size also works + await expect_1_gql(page, 'button[id=refetch-large]') + await expectToContain(page, '?size=200', 'div[id=result]') + await expectToContain(page, 'size=200;id=none', 'div[id=vars]') + }) +}) diff --git a/e2e/react/src/routes/refetchable-fragment/+page.gql b/e2e/react/src/routes/refetchable-fragment/+page.gql new file mode 100644 index 0000000000..196b9e5be0 --- /dev/null +++ b/e2e/react/src/routes/refetchable-fragment/+page.gql @@ -0,0 +1,5 @@ +query RefetchableFragmentQuery { + user(id: "1", snapshot: "refetchable-fragment-react") { + ...RefetchableUserInfo @with(size: 50, param: true) + } +} diff --git a/e2e/react/src/routes/refetchable-fragment/+page.tsx b/e2e/react/src/routes/refetchable-fragment/+page.tsx new file mode 100644 index 0000000000..a666b58535 --- /dev/null +++ b/e2e/react/src/routes/refetchable-fragment/+page.tsx @@ -0,0 +1,33 @@ +import { graphql, useFragmentHandle } from '$houdini' +import type { PageProps } from './$types' + +const fragment = graphql(` + fragment RefetchableUserInfo on User @refetchable @arguments(size: { type: "Int", default: 50 }, param: { type: "Boolean", default: false }) { + name + avatarURL(size: $size) + testField(someParam: $param) + } +`) + +export default function ({ RefetchableFragmentQuery }: PageProps) { + const handle = useFragmentHandle(RefetchableFragmentQuery.user, fragment) + + return ( + <> +
      {handle.data?.avatarURL}
      + {/* testField reflects the `param` argument; we refetch only `size`, so this must survive */} +
      {handle.data?.testField}
      + {/* variables reflect the fragment's current args (no id key), updated after refetch */} +
      + size={handle.variables?.size};param={String(handle.variables?.param)} +
      + + + + + ) +} diff --git a/e2e/react/src/routes/refetchable-fragment/test.ts b/e2e/react/src/routes/refetchable-fragment/test.ts new file mode 100644 index 0000000000..9b05207c57 --- /dev/null +++ b/e2e/react/src/routes/refetchable-fragment/test.ts @@ -0,0 +1,36 @@ +import { test } from '@playwright/test' +import { routes } from '~/utils/routes.js' +import { expect_1_gql, expectToContain, goto } from '~/utils/testsHelper.js' + +test.describe('refetchable fragment', () => { + test('refetch re-runs the fragment with new arguments', async ({ page }) => { + await goto(page, routes.refetchable_fragment) + + // the fragment loads with the default size argument + await expectToContain(page, '?size=50', 'div[id=result]') + + // the `param` argument (passed via @with) drives testField + await expectToContain(page, 'Hello world', 'div[id=merge]') + + // the handle exposes the fragment's current args (no synthetic id key) + await expectToContain(page, 'size=50;param=true', 'div[id=vars]') + + // refetching with a new size hits the network and swaps in the result + await expect_1_gql(page, 'button[id=refetch]') + + await expectToContain(page, '?size=100', 'div[id=result]') + + // refetch only changed `size`; the previously-set `param` must be preserved (merge) + await expectToContain(page, 'Hello world', 'div[id=merge]') + + // variables update with the new size while the merged `param` persists + await expectToContain(page, 'size=100;param=true', 'div[id=vars]') + + // refetching again with a different size works and replaces the result + await expect_1_gql(page, 'button[id=refetch-large]') + + await expectToContain(page, '?size=200', 'div[id=result]') + + await expectToContain(page, 'size=200;param=true', 'div[id=vars]') + }) +}) diff --git a/e2e/react/src/routes/response-headers/+layout.tsx b/e2e/react/src/routes/response-headers/+layout.tsx new file mode 100644 index 0000000000..edf47f85fd --- /dev/null +++ b/e2e/react/src/routes/response-headers/+layout.tsx @@ -0,0 +1,12 @@ +import type { LayoutProps } from './$types' + +export function headers() { + return { + 'X-Houdini-Layout': 'layout-value', + 'X-Houdini-Shared': 'from-layout', + } +} + +export default function ResponseHeadersLayout({ children }: LayoutProps) { + return <>{children} +} diff --git a/e2e/react/src/routes/response-headers/+page.tsx b/e2e/react/src/routes/response-headers/+page.tsx new file mode 100644 index 0000000000..5eb65132d5 --- /dev/null +++ b/e2e/react/src/routes/response-headers/+page.tsx @@ -0,0 +1,10 @@ +export function headers() { + return { + 'X-Houdini-Page': 'page-value', + 'X-Houdini-Shared': 'from-page', + } +} + +export default function ResponseHeadersPage() { + return
      response headers
      +} diff --git a/e2e/react/src/routes/response-headers/test.ts b/e2e/react/src/routes/response-headers/test.ts new file mode 100644 index 0000000000..8f965d83f6 --- /dev/null +++ b/e2e/react/src/routes/response-headers/test.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +test('headers() exports are merged into the response', async ({ page }) => { + const response = await goto(page, routes.response_headers) + const headers = response?.headers() ?? {} + + // the layout-only header is present + expect(headers['x-houdini-layout']).toBe('layout-value') + // the page-only header is present + expect(headers['x-houdini-page']).toBe('page-value') + // on a conflict the page wins over the layout + expect(headers['x-houdini-shared']).toBe('from-page') + + // the page still rendered normally + await expect(page.textContent('#result')).resolves.toEqual('response headers') +}) diff --git a/e2e/react/src/routes/route_params/[id]/+page.tsx b/e2e/react/src/routes/route_params/[id]/+page.tsx index 16a0fff8b2..f298d9af9f 100644 --- a/e2e/react/src/routes/route_params/[id]/+page.tsx +++ b/e2e/react/src/routes/route_params/[id]/+page.tsx @@ -1,15 +1,15 @@ import { useRoute } from '$houdini' -import type { PageProps } from './$types' +import type { PageProps, PageRoute } from './$types' export default function ({ RouteParamsUserInfo }: PageProps) { - const route = useRoute() + const { params } = useRoute() const { user } = RouteParamsUserInfo return (
      - {route.params.id}:{user.name} + {params.id}:{user.name}
      ) diff --git a/e2e/react/src/routes/route_params_date/[day]/+page.gql b/e2e/react/src/routes/route_params_date/[day]/+page.gql new file mode 100644 index 0000000000..60bc7a53fb --- /dev/null +++ b/e2e/react/src/routes/route_params_date/[day]/+page.gql @@ -0,0 +1,6 @@ +query RouteParamDate($day: DateTime!) { + usersList(bornAfter: $day, snapshot: "search_params") { + id + name + } +} diff --git a/e2e/react/src/routes/route_params_date/[day]/+page.tsx b/e2e/react/src/routes/route_params_date/[day]/+page.tsx new file mode 100644 index 0000000000..9dc98a8ae1 --- /dev/null +++ b/e2e/react/src/routes/route_params_date/[day]/+page.tsx @@ -0,0 +1,17 @@ +import { useRoute } from '$houdini' + +import type { PageProps, PageRoute } from './$types' + +// a custom-scalar (DateTime) route param: the path segment carries the marshaled value +// and useRoute().params.day comes back as a Date. +export default function ({ RouteParamDate }: PageProps) { + const { usersList } = RouteParamDate + const { params } = useRoute() + return ( +
      +
      {usersList.map((user) => user.name).join(', ')}
      +
      {params.day instanceof Date ? 'Date' : typeof params.day}
      +
      {params.day instanceof Date ? params.day.toISOString() : ''}
      +
      + ) +} diff --git a/e2e/react/src/routes/route_params_date/[day]/test.ts b/e2e/react/src/routes/route_params_date/[day]/test.ts new file mode 100644 index 0000000000..4c65ffa775 --- /dev/null +++ b/e2e/react/src/routes/route_params_date/[day]/test.ts @@ -0,0 +1,15 @@ +import { test } from '@playwright/test' +import { expect_to_be, goto } from '~/utils/testsHelper' + +// A custom-scalar route param round-trips: the path segment holds the marshaled DateTime +// (getTime() ms), useRoute().params.day reads back a Date, and the query still +// runs (the rich value re-marshals through marshalInputs without crashing). Loaded +// directly (server render), so it also covers the SSR path for route params. +// new Date('2024-01-01T00:00:00.000Z').getTime() === 1704067200000 +test('custom-scalar route param unmarshals and round-trips on direct load', async ({ page }) => { + await goto(page, '/route_params_date/1704067200000') + + await expect_to_be(page, 'Date', '#day-type') + await expect_to_be(page, '2024-01-01T00:00:00.000Z', '#day-iso') + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') +}) diff --git a/e2e/react/src/routes/route_params_with_space/[title]/+page.tsx b/e2e/react/src/routes/route_params_with_space/[title]/+page.tsx index 00a33a4fa9..57f030b78d 100644 --- a/e2e/react/src/routes/route_params_with_space/[title]/+page.tsx +++ b/e2e/react/src/routes/route_params_with_space/[title]/+page.tsx @@ -1,15 +1,15 @@ import { useRoute } from '$houdini' -import type { PageProps } from './$types' +import type { PageProps, PageRoute } from './$types' export default function ({ RouteParamsWithSpace }: PageProps) { - const route = useRoute() + const { params } = useRoute() const { book } = RouteParamsWithSpace return (
      - {route.params.title}:{book?.title} + {params.title}:{book?.title}
      ) diff --git a/e2e/react/src/routes/search_params/+page.gql b/e2e/react/src/routes/search_params/+page.gql new file mode 100644 index 0000000000..7ec40d5a3d --- /dev/null +++ b/e2e/react/src/routes/search_params/+page.gql @@ -0,0 +1,6 @@ +query SearchParamsUsers($offset: Int, $limit: Int, $after: DateTime) { + usersList(offset: $offset, limit: $limit, bornAfter: $after, snapshot: "search_params") { + id + name + } +} diff --git a/e2e/react/src/routes/search_params/+page.tsx b/e2e/react/src/routes/search_params/+page.tsx new file mode 100644 index 0000000000..0f2215e029 --- /dev/null +++ b/e2e/react/src/routes/search_params/+page.tsx @@ -0,0 +1,46 @@ +import { Link, useRoute } from '$houdini' + +import type { PageProps, PageRoute } from './$types' + +// a fixed DateTime used by both the Link and goto cases so the test can assert an exact +// round-trip (marshal -> url -> unmarshal) +const AFTER = new Date('2024-01-01T00:00:00.000Z') + +export default function ({ SearchParamsUsers }: PageProps) { + const { usersList } = SearchParamsUsers + // the parsed query string: declared params (offset/limit) are coerced to numbers, a + // declared custom scalar (after: DateTime) is unmarshaled to a Date, and any other key + // (e.g. tab) passes through as a raw string + const { search, goto } = useRoute() + + + return ( +
      +
      + + default + + + offset 2 + + + limit 2 + + + offset + tab + + + after (Link) + + +
      +
      {usersList.map((user) => user.name).join(', ')}
      + +
      {typeof search.offset}
      +
      {search.after instanceof Date ? 'Date' : typeof search.after}
      +
      {search.after instanceof Date ? search.after.toISOString() : ''}
      +
      + ) +} diff --git a/e2e/react/src/routes/search_params/test.ts b/e2e/react/src/routes/search_params/test.ts new file mode 100644 index 0000000000..a621f3e1a0 --- /dev/null +++ b/e2e/react/src/routes/search_params/test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { sleep } from '~/utils/sleep' +import { expect_to_be, goto } from '~/utils/testsHelper' + +test('Search params drive query variables', async ({ page }) => { + await goto(page, routes.search_params) + + // with no search params the query uses the schema default limit of 4 + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') + + // an offset search param shifts the window + await page.click('#offset-link') + await sleep(100) + await expect_to_be(page, 'Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford') + + // a limit search param (without offset) shrinks the window from the start + await page.click('#limit-link') + await sleep(100) + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + + // navigating back to the bare route clears the search params + await page.click('#default-link') + await sleep(100) + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') +}) + +test('useRoute().search exposes the parsed query string', async ({ page }) => { + await goto(page, routes.search_params) + + // no query string -> empty object + await expect_to_be(page, '{}', '#search') + + // a declared param is coerced to its scalar type (number, not "2") + await page.click('#offset-link') + await sleep(100) + await expect_to_be(page, '{"offset":2}', '#search') + await expect_to_be(page, 'number', '#offset-type') + + // a UI-only key (not a query variable) passes through as a raw string alongside + // the coerced declared param + await page.click('#ui-link') + await sleep(100) + await expect_to_be(page, '{"offset":2,"tab":"reviews"}', '#search') +}) + +// the unmarshal happens in the Router body, which runs during SSR too — so a cold load +// (server render) with a custom-scalar search param in the url must produce the same Date +test('search params unmarshal on a direct (SSR) load', async ({ page }) => { + await goto(page, '/search_params?after=1704067200000') + + await expect_to_be(page, 'Date', '#after-type') + await expect_to_be(page, '2024-01-01T00:00:00.000Z', '#after-iso') + // the query ran on the server with the marshaled variable, without crashing + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') +}) + +// a custom scalar (DateTime) marshals into the url on write, is sent to the API in that +// same marshaled form, and unmarshals back to a Date when read via useRoute().search — +// verified through both and goto. The marshaled form is getTime() ms: +// new Date('2024-01-01T00:00:00.000Z').getTime() === 1704067200000 +test('custom-scalar search params round-trip through Link and goto', async ({ page }) => { + // captures the `after` variable as it was actually sent on the wire while `trigger` + // performs a navigation + async function sentAfter(trigger: () => Promise): Promise { + const [request] = await Promise.all([ + page.waitForRequest( + (req) => + req.url().includes('/_api') && + req.method() === 'POST' && + (req.postData() ?? '').includes('SearchParamsUsers') + ), + trigger(), + ]) + return request.postDataJSON()?.variables?.after + } + + // ── ───────────────────────────────────────────── + await goto(page, routes.search_params) + const linkAfter = await sentAfter(() => page.click('#date-link')) + await sleep(100) + + // 1. the Date was marshaled into the query string + expect(page.url()).toContain('?after=1704067200000') + // 2. and sent to the API in its marshaled form (the number, not the raw url string) + expect(linkAfter).toBe(1704067200000) + // 3. and unmarshaled back to a real Date when read + await expect_to_be(page, 'Date', '#after-type') + await expect_to_be(page, '2024-01-01T00:00:00.000Z', '#after-iso') + + // ── goto({ to, search: { after: Date } }) ─────────────────────────────────────── + await goto(page, routes.search_params) + const gotoAfter = await sentAfter(() => page.click('#goto-date')) + await sleep(100) + + expect(page.url()).toContain('?after=1704067200000') + expect(gotoAfter).toBe(1704067200000) + await expect_to_be(page, 'Date', '#after-type') + await expect_to_be(page, '2024-01-01T00:00:00.000Z', '#after-iso') +}) diff --git a/e2e/react/src/routes/search_params_list/+page.gql b/e2e/react/src/routes/search_params_list/+page.gql new file mode 100644 index 0000000000..eeb20921a3 --- /dev/null +++ b/e2e/react/src/routes/search_params_list/+page.gql @@ -0,0 +1,6 @@ +query SearchListUsers($tags: [String!], $dates: [DateTime!]) { + usersList(names: $tags, dates: $dates, snapshot: "search_params") { + id + name + } +} diff --git a/e2e/react/src/routes/search_params_list/+page.tsx b/e2e/react/src/routes/search_params_list/+page.tsx new file mode 100644 index 0000000000..eed3d2f653 --- /dev/null +++ b/e2e/react/src/routes/search_params_list/+page.tsx @@ -0,0 +1,37 @@ +import { Link, useRoute } from '$houdini' + +import type { PageProps, PageRoute } from './$types' + +// two fixed DateTimes for the custom-scalar List case +const D1 = new Date('2024-01-01T00:00:00.000Z') +const D2 = new Date('2024-06-01T00:00:00.000Z') + +export default function ({ SearchListUsers }: PageProps) { + const { usersList } = SearchListUsers + const { search } = useRoute() + return ( +
      +
      + + multi + + + single + + + dates + +
      +
      {usersList.map((user) => user.name).join(', ')}
      + {/* a String List search param: repeated keys -> array (single value stays an array) */} +
      {JSON.stringify(search.tags ?? null)}
      + {/* a DateTime List search param: each element unmarshaled to a Date */} +
      + {(search.dates ?? []).map((d) => (d instanceof Date ? 'Date' : typeof d)).join(',')} +
      +
      + {(search.dates ?? []).map((d) => (d instanceof Date ? d.toISOString() : '')).join(',')} +
      +
      + ) +} diff --git a/e2e/react/src/routes/search_params_list/test.ts b/e2e/react/src/routes/search_params_list/test.ts new file mode 100644 index 0000000000..08282f22c0 --- /dev/null +++ b/e2e/react/src/routes/search_params_list/test.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test' +import { sleep } from '~/utils/sleep' +import { expect_to_be, goto } from '~/utils/testsHelper' + +// List search params serialize as repeated keys and read back as arrays — including the +// single-value case (which must stay a one-element array, not collapse to a bare value) — +// and a custom-scalar List unmarshals element-wise. +test('List search params round-trip as arrays (incl. single element and custom scalars)', async ({ + page, +}) => { + await goto(page, '/search_params_list') + + // a multi-value List -> repeated query keys -> array + await page.click('#tags-multi') + await sleep(100) + expect(page.url()).toContain('?tags=a&tags=b') + await expect_to_be(page, '["a","b"]', '#tags') + + // a single value for a List param stays a one-element array, not a bare "solo" + await page.click('#tags-single') + await sleep(100) + expect(page.url()).toContain('?tags=solo') + await expect_to_be(page, '["solo"]', '#tags') + + // a custom-scalar (DateTime) List unmarshals each element back to a Date + await page.click('#dates-link') + await sleep(100) + await expect_to_be(page, 'Date,Date', '#dates-types') + await expect_to_be(page, '2024-01-01T00:00:00.000Z,2024-06-01T00:00:00.000Z', '#dates-isos') +}) diff --git a/e2e/react/src/routes/session-mutation/+page.tsx b/e2e/react/src/routes/session-mutation/+page.tsx new file mode 100644 index 0000000000..717770ae53 --- /dev/null +++ b/e2e/react/src/routes/session-mutation/+page.tsx @@ -0,0 +1,30 @@ +import { graphql, useMutation, useSession } from '$houdini' + +// @session via a plain useMutation — NO form, no useMutationForm. The sessionRelay client +// plugin relays the minted token to the auth endpoint after the mutation runs, so the cookie +// is written even though nothing here touches the session directly. Proves session-by-mutation +// isn't tied to forms. +export default function SessionMutationView() { + const [session] = useSession() + const [setTheme] = useMutation( + graphql(` + mutation SetThemeProgrammatic($theme: String!) + @session(path: "setTheme.session", merge: true) { + setTheme(theme: $theme) { + session { + theme + } + } + } + `) + ) + + return ( +
      +

      {session.theme ?? '(none)'}

      + +
      + ) +} diff --git a/e2e/react/src/routes/session-mutation/test.ts b/e2e/react/src/routes/session-mutation/test.ts new file mode 100644 index 0000000000..73f6afeb31 --- /dev/null +++ b/e2e/react/src/routes/session-mutation/test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// The generic relay: a plain useMutation (no form) tagged @session writes the session AND +// updates useSession() live — the sessionRelay client plugin relays the minted token to the +// auth endpoint (cookie) and mirrors the result into local state. No form, no refresh. +test('a plain useMutation updates the session live, without a refresh', async ({ page }) => { + await goto(page, routes.session_mutation) + await expect(page.getByTestId('session-theme')).toHaveText('(none)') + + await page.click('[data-testid="submit"]') + + // no reload — local state reflects the write immediately + await expect(page.getByTestId('session-theme')).toHaveText('dark') +}) diff --git a/e2e/react/src/routes/session-theme/+page.tsx b/e2e/react/src/routes/session-theme/+page.tsx new file mode 100644 index 0000000000..9a4ba7e182 --- /dev/null +++ b/e2e/react/src/routes/session-theme/+page.tsx @@ -0,0 +1,32 @@ +import { graphql, useMutationForm, useSession } from '$houdini' + +// A preference written by a mutation with @session(merge: true): setTheme returns a session +// subtree { theme } that is *merged* into the existing session, so a logged-in user keeps +// their user. Not auth — just "session by mutation". +export default function SessionThemeView() { + const [session] = useSession() + const { Form, pending } = useMutationForm( + graphql(` + mutation SetTheme($theme: String!) @session(path: "setTheme.session", merge: true) { + setTheme(theme: $theme) { + session { + theme + } + } + } + `) + ) + + return ( +
      +

      {session.user?.username ?? '(none)'}

      +

      {session.theme ?? '(none)'}

      +
      + + +
      +
      + ) +} diff --git a/e2e/react/src/routes/session-theme/test.ts b/e2e/react/src/routes/session-theme/test.ts new file mode 100644 index 0000000000..48ff52ed87 --- /dev/null +++ b/e2e/react/src/routes/session-theme/test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// @session(merge: true): a mutation upserts a preference into the session, keeping the rest. +// Logging in first, then setting a theme, must end with BOTH the logged-in user and the theme — +// proving the write merged rather than replaced. +test('merge upserts a preference, keeping the logged-in session', async ({ page }) => { + // establish a session that carries a user + await goto(page, routes.auth_form) + await page.fill('[data-testid="username-input"]', 'alice') + await page.click('[data-testid="submit"]') + await page.waitForURL(/\/auth-form\/done/) + + // navigate to the preference route — the session persists across the SPA navigation + await goto(page, routes.session_theme) + await expect(page.getByTestId('session-user')).toHaveText('alice') + await expect(page.getByTestId('session-theme')).toHaveText('(none)') + + // set the theme — merges { theme } in without clobbering the user + await page.click('[data-testid="submit"]') + await expect(page.getByTestId('session-theme')).toHaveText('dark') + await expect(page.getByTestId('session-user')).toHaveText('alice') +}) diff --git a/e2e/react/src/routes/session-to-api/+page.tsx b/e2e/react/src/routes/session-to-api/+page.tsx new file mode 100644 index 0000000000..1d8160037f --- /dev/null +++ b/e2e/react/src/routes/session-to-api/+page.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { graphql, useMutation, useSession } from '$houdini' + +// Closes the loop between the imperative session infrastructure (setSession / @session mutations) +// and the client plugin pipeline. fetchParams (see src/+client.ts) forwards the CURRENT +// session.theme to the api as a header; requestSession echoes that header back. So after we update +// the session — imperatively via updateSession OR via a @session mutation — asking the api proves +// the NEW value is what actually gets sent. +export default function SessionToApiView() { + const [session, updateSession] = useSession() + const [apiSaw, setApiSaw] = useState('(none)') + + const [askApi] = useMutation( + graphql(` + mutation RequestSession { + requestSession + } + `) + ) + + const [setThemeMutation] = useMutation( + graphql(` + mutation SetThemeForApi($theme: String!) @session(path: "setTheme.session", merge: true) { + setTheme(theme: $theme) { + session { + theme + } + } + } + `) + ) + + return ( +
      +

      {session.theme ?? '(none)'}

      +

      {apiSaw}

      + + + + +
      + ) +} diff --git a/e2e/react/src/routes/session-to-api/test.ts b/e2e/react/src/routes/session-to-api/test.ts new file mode 100644 index 0000000000..042081aa0b --- /dev/null +++ b/e2e/react/src/routes/session-to-api/test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test' +import { routes } from '~/utils/routes' +import { goto } from '~/utils/testsHelper.js' + +// The loop: a session updated client-side — imperatively (setSession) OR via a @session mutation — +// is the exact session the client plugin pipeline (fetchParams) sends to the api on the next +// request. requestSession echoes back what the api received, so we can assert the two match. +test('the managed session is the session the api receives (imperative + @session)', async ({ + page, +}) => { + await goto(page, routes.session_to_api) + await expect(page.getByTestId('local-theme')).toHaveText('(none)') + + // imperative update → the api sees it on the next request + await page.click('[data-testid="update-imperative"]') + await expect(page.getByTestId('local-theme')).toHaveText('set-imperatively') + await page.click('[data-testid="ask-api"]') + await expect(page.getByTestId('api-saw')).toHaveText('set-imperatively') + + // @session mutation update → the api sees the new value too + await page.click('[data-testid="update-via-mutation"]') + await expect(page.getByTestId('local-theme')).toHaveText('set-by-mutation') + await page.click('[data-testid="ask-api"]') + await expect(page.getByTestId('api-saw')).toHaveText('set-by-mutation') +}) diff --git a/e2e/react/src/routes/subscription-update/+page.tsx b/e2e/react/src/routes/subscription-update/+page.tsx new file mode 100644 index 0000000000..6b0f89adc0 --- /dev/null +++ b/e2e/react/src/routes/subscription-update/+page.tsx @@ -0,0 +1,35 @@ +import { graphql, useMutation, useSubscription } from '$houdini' + +const sub = graphql(` + subscription UserUpdateSub($id: ID!, $snapshot: String) { + userUpdate(id: $id, snapshot: $snapshot) { + name + } + } +`) + +// A mutation the test fires to advance the subscription: its mock handler flips the +// gate that releases the subscription's next payload, so the two operations interleave +// deterministically instead of racing. +const advance = graphql(` + mutation AdvanceSubscription($id: ID!) { + updateUserByID(id: $id, snapshot: "subscription-update", name: "advance") { + id + name + } + } +`) + +export default function SubscriptionUpdatePage() { + const data = useSubscription(sub, { id: '1', snapshot: 'test' }) + const [advanceSub] = useMutation(advance) + + return ( +
      +
      {data?.userUpdate?.name ?? 'waiting...'}
      + +
      + ) +} diff --git a/e2e/react/src/server/+config.ts b/e2e/react/src/server/+config.ts new file mode 100644 index 0000000000..2a0a270502 --- /dev/null +++ b/e2e/react/src/server/+config.ts @@ -0,0 +1,24 @@ +// Server-only Houdini config. src/server is compiled into the server bundle only, so secrets live +// here — never in houdini.config, which the client bundles for scalars. Typed as +// ServerConfigFile so it stays distinct from the public config. (TypeScript config supported.) +import type { ServerConfigFile } from 'houdini' +import { oidc } from 'houdini/oauth' + +// the e2e drives against a real third-party OIDC provider mock (oauth2-mock-server, started by +// Playwright as oauth-mock.mjs) using the stock `oidc` adapter — so the flow is exercised against +// an independent implementation of the spec, including id_token signature + nonce validation. +export default { + auth: { + // in a real app: [process.env.SESSION_SECRET, ...older keys for rotation] + sessionKeys: ['supersecret'], + providers: { + stub: oidc({ + issuer: 'http://localhost:8081', + clientId: 'stub-client', + clientSecret: 'stub-secret', + allowInsecureRequests: true, // local http mock only + }), + }, + onSignIn: ({ user }) => ({ userId: user.sub, email: user.email }), + }, +} satisfies ServerConfigFile diff --git a/e2e/react/src/api/+schema.js b/e2e/react/src/server/+schema.js similarity index 83% rename from e2e/react/src/api/+schema.js rename to e2e/react/src/server/+schema.js index 115a856fbd..6e24463df4 100644 --- a/e2e/react/src/api/+schema.js +++ b/e2e/react/src/server/+schema.js @@ -32,7 +32,38 @@ export const typeDefs = /* GraphQL */ ` ERROR } + type SessionUser { + id: ID! + username: String! + } + + type AuthSession { + user: SessionUser! + } + + type LoginResult { + session: AuthSession! + } + + type LogoutResult { + session: AuthSession + } + + type ThemeSession { + theme: String! + } + + type ThemeResult { + session: ThemeSession! + } + type Mutation { + login(username: String!): LoginResult! + logout: LogoutResult! + setTheme(theme: String!): ThemeResult! + # echoes back the session the client sent (via the fetchParams header) so the e2e can + # assert the managed session is what actually reaches the api on a mutation + requestSession: String addUser( """ The users birth date @@ -129,7 +160,7 @@ export const typeDefs = /* GraphQL */ ` last: Int snapshot: String! ): UserConnection! - usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! + usersList(limit: Int = 4, offset: Int, bornAfter: DateTime, names: [String!], dates: [DateTime!], snapshot: String!): [User!]! userNodes(limit: Int = 4, offset: Int, snapshot: String!): UserNodes! userSearch(filter: UserNameFilter!, snapshot: String!): [User!]! session: String @@ -144,12 +175,23 @@ export const typeDefs = /* GraphQL */ ` Get a monkey by its id """ monkey(id: ID!): Monkey + """ + A non-Node entity resolved by a custom query (exercises @refetchable on a + type that is refetchable via a resolve config rather than Node). + """ + refetchableEntity(id: ID!): RefetchableEntity } type Subscription { userUpdate(id: ID!, snapshot: String): User } + "A non-Node type that is refetchable via a custom resolve query." + type RefetchableEntity { + id: ID! + avatarURL(size: Int): String! + } + type User implements Node { birthDate: DateTime friendsConnection(after: String, before: String, first: Int, last: Int): UserConnection! diff --git a/e2e/react/src/tests/createMock.test.tsx b/e2e/react/src/tests/createMock.test.tsx new file mode 100644 index 0000000000..4a4ce2a056 --- /dev/null +++ b/e2e/react/src/tests/createMock.test.tsx @@ -0,0 +1,434 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMock } from '$houdini' + +afterEach(cleanup) + +// ─── Static query mock ─────────────────────────────────────────────────────── + +describe('static query mock', () => { + test('renders the mock response', async () => { + const App = createMock({ + url: '/hello-world', + data: { + HelloWorld: { hello: 'Mock World!' }, + }, + }) + + render() + await screen.findByText('Mock World!') + }) + + test('different static values render independently', async () => { + const App = createMock({ + url: '/hello-world', + data: { + HelloWorld: { hello: 'Custom greeting' }, + }, + }) + + render() + await screen.findByText('Custom greeting') + }) +}) + +// ─── Plain-JSON data ───────────────────────────────────────────────────────── + +describe('plain server JSON data', () => { + test('mock data is the fully-resolved server payload — nested fragment fields inline, no Houdini annotations', async () => { + // FragmentCursorForwardsQuery spreads a named fragment on User which selects a + // nested connection with edges and pageInfo. The server returns the fragment + // fields inlined — no " $fragments" mask annotations. This test verifies that + // the mock type accepts that shape and that the component renders from it. + const App = createMock({ + url: '/pagination/fragment/connection-forwards', + data: { + // Plain server JSON — id/__typename/cursor are normal GraphQL fields, + // not Houdini annotations. No " $fragments" keys are required here. + FragmentCursorForwardsQuery: { + user: { + id: '1', + __typename: 'User', + usersConnectionSnapshot: { + __typename: 'UserConnection', + edges: [ + { cursor: 'c1', __typename: 'UserEdge', node: { id: 'u1', __typename: 'User', name: 'Alice' } }, + { cursor: 'c2', __typename: 'UserEdge', node: { id: 'u2', __typename: 'User', name: 'Bob' } }, + ], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'c1', + endCursor: 'c2', + }, + }, + }, + }, + }, + }) + + render() + await screen.findByText('Alice, Bob') + }) +}) + +// ─── Route params → query variables ────────────────────────────────────────── + +describe('route params', () => { + test('params become query variables and are rendered by the page', async () => { + // /route_params/[id] renders "{id}:{user.name}" — both the route param and query + // result appear together, so both must be wired correctly. + const App = createMock({ + url: '/route_params/[id]', + params: { id: '99' }, + data: { + RouteParamsUserInfo: { user: { id: "99", __typename: "User", name: 'Alice' } }, + }, + }) + + render() + await screen.findByText('99:Alice') + }) + + test('different param values produce independent renders', async () => { + const App1 = createMock({ + url: '/route_params/[id]', + params: { id: '1' }, + data: { RouteParamsUserInfo: { user: { id: "1", __typename:"User", name: 'User One' } } }, + }) + const App2 = createMock({ + url: '/route_params/[id]', + params: { id: '2' }, + data: { RouteParamsUserInfo: { user: { id: "2", __typename:"User", name: 'User Two' } } }, + }) + + const { unmount } = render() + await screen.findByText('1:User One') + unmount() + + render() + await screen.findByText('2:User Two') + }) +}) + +// ─── Search params → query variables ───────────────────────────────────────── + +describe('search params', () => { + test('a typed search value becomes a (coerced) query variable', async () => { + const mockFn = vi.fn().mockReturnValue({ + usersList: [{ id: '1', __typename: 'User', name: 'Offset User' }], + }) + + const App = createMock({ + url: '/search_params', + search: { offset: 2 }, + data: { SearchParamsUsers: mockFn }, + }) + + render() + await screen.findByText('Offset User') + + // offset is declared Int, so the string from the URL is coerced back to a number + expect(mockFn).toHaveBeenCalledWith(expect.objectContaining({ offset: 2 })) + }) + + test('multiple search values are all threaded through', async () => { + const mockFn = vi.fn().mockReturnValue({ + usersList: [{ id: '1', __typename: 'User', name: 'Both' }], + }) + + const App = createMock({ + url: '/search_params', + search: { offset: 1, limit: 3 }, + data: { SearchParamsUsers: mockFn }, + }) + + render() + await screen.findByText('Both') + expect(mockFn).toHaveBeenCalledWith(expect.objectContaining({ offset: 1, limit: 3 })) + }) + + test('omitting search leaves the variables unset', async () => { + const mockFn = vi.fn().mockReturnValue({ + usersList: [{ id: '1', __typename: 'User', name: 'Default' }], + }) + + const App = createMock({ + url: '/search_params', + data: { SearchParamsUsers: mockFn }, + }) + + render() + await screen.findByText('Default') + + const vars = mockFn.mock.calls[0][0] + expect(vars.offset).toBeUndefined() + expect(vars.limit).toBeUndefined() + }) + + // ── negative cases ────────────────────────────────────────────────────────── + + test('an extra (non-query) search key is allowed but never becomes a query variable', async () => { + const mockFn = vi.fn().mockReturnValue({ + usersList: [{ id: '1', __typename: 'User', name: 'Ignored' }], + }) + + const App = createMock({ + url: '/search_params', + // `tab` isn't a query variable — it's UI-only state. The type allows it, and + // the router leaves it in the URL without ever passing it to the query. + search: { tab: 'reviews' }, + data: { SearchParamsUsers: mockFn }, + }) + + render() + await screen.findByText('Ignored') + + const vars = mockFn.mock.calls[0][0] + expect(vars.tab).toBeUndefined() + expect(vars.offset).toBeUndefined() + }) + + test('a value that cannot coerce to the scalar leaves the variable unset rather than NaN', async () => { + const mockFn = vi.fn().mockReturnValue({ + usersList: [{ id: '1', __typename: 'User', name: 'Unset' }], + }) + + const App = createMock({ + url: '/search_params', + // offset is an Int; a non-numeric value can't parse, so it must drop out + search: { offset: 'abc' } as any, + data: { SearchParamsUsers: mockFn }, + }) + + render() + await screen.findByText('Unset') + + const vars = mockFn.mock.calls[0][0] + expect(vars.offset).toBeUndefined() + }) +}) + +// ─── Function mock ──────────────────────────────────────────────────────────── + +describe('function mock', () => { + test('receives the variables that the router derives from the URL params', async () => { + const mockFn = vi.fn().mockReturnValue({ user: { name: 'Bob' } }) + + const App = createMock({ + url: '/route_params/[id]', + params: { id: '42' }, + data: { + RouteParamsUserInfo: mockFn, + }, + }) + + render() + await screen.findByText('42:Bob') + + // The router should have derived id:'42' from the URL and passed it as a variable + expect(mockFn).toHaveBeenCalledWith(expect.objectContaining({ id: '42' })) + }) + + test('can return different data based on variables', async () => { + const App = createMock({ + url: '/route_params/[id]', + params: { id: '7' }, + data: { + RouteParamsUserInfo: ({ id }) => ({ + user: { + id, + name: id === '7' ? 'Lucky Seven' : 'Unknown', + __typename: 'User', + }, + }), + }, + }) + + render() + await screen.findByText('7:Lucky Seven') + }) +}) + +// ─── Fresh cache isolation ──────────────────────────────────────────────────── + +describe('cache isolation', () => { + test('each createMock call gets its own cache — data does not bleed between instances', async () => { + const App1 = createMock({ + url: '/hello-world', + data: { HelloWorld: { hello: 'from instance 1' } }, + }) + const App2 = createMock({ + url: '/hello-world', + data: { HelloWorld: { hello: 'from instance 2' } }, + }) + + // Render both simultaneously — each should show its own data + const { container } = render( +
      +
      + +
      +
      + +
      +
      + ) + + await screen.findByText('from instance 1') + await screen.findByText('from instance 2') + + expect(container.querySelector('[data-testid="app1"]')!.textContent).toContain( + 'from instance 1' + ) + expect(container.querySelector('[data-testid="app2"]')!.textContent).toContain( + 'from instance 2' + ) + }) +}) + +// ─── Mutation handlers ──────────────────────────────────────────────────────── + +describe('mutation mock', () => { + test('mutation handler is called with the correct variables when a button fires it', async () => { + const updateHandler = vi.fn().mockReturnValue({ + updateUserByID: { id: '1', name: 'updated name' }, + }) + + const App = createMock({ + url: '/list-operations/update', + data: { + UpdateFragmentTest: { + usersList: [{ id: '1', name: 'Original Name', __typename: "User"}], + }, + // Mutations are optional — only provide handlers for the ones the test cares about + UpdateExisting: updateHandler, + }, + }) + + render() + await screen.findByText('Original Name') + + fireEvent.click(screen.getByText('Update Existing')) + + await waitFor(() => { + expect(updateHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: '1', name: 'updated name' }) + ) + }) + }) + + test('function mutation handler receives variables and can inspect them', async () => { + const capturedVariables: any[] = [] + + const App = createMock({ + url: '/list-operations/update', + data: { + UpdateFragmentTest: { + usersList: [{ id: 'user-abc', name: 'Test User', __typename: "User" }], + }, + UpdateExisting: (vars) => { + capturedVariables.push(vars) + return { updateUserByID: { id: vars.id, name: vars.name, __typename: "User" } } + }, + }, + }) + + render() + await screen.findByText('Test User') + fireEvent.click(screen.getByText('Update Existing')) + + await waitFor(() => { + expect(capturedVariables).toHaveLength(1) + expect(capturedVariables[0]).toMatchObject({ id: 'user-abc', name: 'updated name' }) + }) + }) +}) + +// ─── Subscription mock ─────────────────────────────────────────────────────── + +describe('subscription mock', () => { + test('yields successive values, coordinated by a mutation', async () => { + // A gate the subscription parks on after its first value. Asserting "First" while the + // generator is blocked here makes the ordering deterministic — without it both values + // resolve in back-to-back microtasks and the render can jump straight to "Second". + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + + async function* updates() { + yield { userUpdate: { id: '1', __typename: 'User' as const, name: 'First' } } + await gate + yield { userUpdate: { id: '1', __typename: 'User' as const, name: 'Second' } } + } + + const App = createMock({ + url: '/subscription-update', + data: { + UserUpdateSub: updates(), + // firing the mutation flips the gate, releasing the second subscription payload + AdvanceSubscription: () => { + release() + return { updateUserByID: { id: '1', name: 'advance', __typename: 'User' as const } } + }, + }, + }) + + render() + await screen.findByText('First') + + fireEvent.click(screen.getByTestId('advance')) + await screen.findByText('Second') + }) + + test('throws when a subscription fires with no handler', async () => { + const App = createMock({ + url: '/subscription-update', + data: {}, + }) + + let caughtError: Error | null = null + const rejectionHandler = (reason: Error) => { + if (reason?.message?.includes('UserUpdateSub')) { + caughtError = reason + } + } + process.on('unhandledRejection', rejectionHandler) + + render() + + await waitFor(() => { + expect(caughtError).not.toBeNull() + expect(caughtError!.message).toMatch(/UserUpdateSub.*fired but was not in data/) + }) + + process.off('unhandledRejection', rejectionHandler) + }) +}) + +// ─── Missing mock guard ─────────────────────────────────────────────────────── + +describe('missing mock', () => { + test('throws synchronously at createMock() when a required query is missing', () => { + expect(() => + createMock({ + url: '/hello-world', + // HelloWorld deliberately omitted + data: {} as any, + }) + ).toThrow(/missing mock data for "HelloWorld"/) + }) + + test('error message names every missing query', () => { + expect(() => + createMock({ + url: '/route_params/[id]', + params: { id: '1' }, + data: {} as any, + }) + ).toThrow(/missing mock data for "RouteParamsUserInfo"/) + }) +}) diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts index cc0446499a..be87a4b010 100644 --- a/e2e/react/src/utils/routes.ts +++ b/e2e/react/src/utils/routes.ts @@ -23,9 +23,16 @@ export const routes = { pagination_fragment_cursor_forwards_singlepage: '/pagination/fragment/connection-forwards-singlepage', pagination_fragment_cursor_backwards_singlepage: '/pagination/fragment/connection-backwards-singlepage', pagination_query_offset_variable: '/pagination/query/offset-variable/2', + loading_paginated_fragment: '/loading-paginated-fragment', + loading_interactive: '/loading-interactive', + loading_error: '/loading-error', + loading_error_link: '/loading-error-link', + refetchable_fragment: '/refetchable-fragment', + refetchable_fragment_custom: '/refetchable-fragment-custom', optimistic_keys: '/optimistic-keys', node_plugin: '/node-plugin', list_id: '/list-id', + oauth: '/oauth', list_operations_upsert: '/list-operations/upsert', list_operations_update: '/list-operations/update', routing_errors: '/routing-errors', @@ -34,4 +41,21 @@ export const routes = { routing_errors_redirect_target: '/routing-errors/redirect-target', routing_errors_static_404: '/routing-errors/does-not-exist', error_loop: '/error-loop/doesnt-exist', + response_headers: '/response-headers', + plural_fragment: '/plural-fragment', + plural_fragment_rebind: '/plural-fragment-rebind', + plural_fragment_guard: '/plural-fragment-guard', + plural_fragment_args: '/plural-fragment-args', + plural_fragment_empty: '/plural-fragment-empty', + search_params: '/search_params', + mutation_form: '/mutation-form', + mutation_form_created: '/mutation-form/created', + mutation_form_upload: '/mutation-form/upload', + mutation_form_status: '/mutation-form/status', + mutation_form_error: '/mutation-form/error', + auth_form: '/auth-form', + auth_form_done: '/auth-form/done', + session_theme: '/session-theme', + session_mutation: '/session-mutation', + session_to_api: '/session-to-api', } as const diff --git a/e2e/react/vite.config.ts b/e2e/react/vite.config.ts index 465a03ec58..6b9fd0ee2f 100644 --- a/e2e/react/vite.config.ts +++ b/e2e/react/vite.config.ts @@ -1,3 +1,4 @@ +/// import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import houdini from 'houdini/vite' @@ -10,4 +11,8 @@ export default defineConfig({ port: process.env.PORT ? parseInt(process.env.PORT) : 5173, }, plugins: [houdini({ adapter }), react(), tailwindcss()], + test: { + environment: 'happy-dom', + include: ['src/**/*.test.{ts,tsx}'], + }, }) diff --git a/e2e/svelte/.gitignore b/e2e/svelte/.gitignore deleted file mode 100644 index ca4b87f9e0..0000000000 --- a/e2e/svelte/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -$houdini -playwright-report -test-results diff --git a/e2e/svelte/.graphqlrc.yaml b/e2e/svelte/.graphqlrc.yaml deleted file mode 100644 index 4953088495..0000000000 --- a/e2e/svelte/.graphqlrc.yaml +++ /dev/null @@ -1,9 +0,0 @@ -projects: - default: - schema: - - ./schema.graphql - - ./$houdini/graphql/schema.graphql - documents: - - '**/*.gql' - - '**/*.svelte' - - ./$houdini/graphql/documents.gql diff --git a/e2e/svelte/README.md b/e2e/svelte/README.md deleted file mode 100644 index 7bce3e3d6c..0000000000 --- a/e2e/svelte/README.md +++ /dev/null @@ -1 +0,0 @@ -# Houdini Svelte End-to-End diff --git a/e2e/svelte/houdini.config.js b/e2e/svelte/houdini.config.js deleted file mode 100644 index d5e80cc456..0000000000 --- a/e2e/svelte/houdini.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/// - -/** @type {import('houdini').ConfigFile} */ -const config = { - watchSchema: { - url: 'http://localhost:4000/graphql', - }, - plugins: { - 'houdini-svelte': {}, - }, -} - -export default config diff --git a/e2e/svelte/index.html b/e2e/svelte/index.html deleted file mode 100644 index 02b45977e7..0000000000 --- a/e2e/svelte/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Houdini • e2e • Svelte - - -
      - - - diff --git a/e2e/svelte/package.json b/e2e/svelte/package.json deleted file mode 100644 index 704407a5d6..0000000000 --- a/e2e/svelte/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "e2e-svelte", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "web": "vite --port 3016", - "api": "cross-env TZ=utc e2e-api", - "dev": "concurrently \"pnpm run web\" \"pnpm run api\" -n \"web,api\" -c \"green,magenta\"", - "build:": "cd ../../ && ((run build && cd -) || (cd - && exit 1))", - "build:dev": "pnpm build: && pnpm dev", - "build:web": "pnpm build: && pnpm web", - "build:test": "pnpm build: && pnpm test", - "build:generate": "pnpm build: && pnpm houdini generate", - "build:build": "pnpm build: && pnpm build", - "build": "vite build", - "tests": "playwright test ", - "previewWeb": "vite preview --port 3006", - "preview": "concurrently \"pnpm run previewWeb\" \"pnpm run api\" -n \"web,api\" -c \"green,magenta\"", - "check": "svelte-check --tsconfig ./tsconfig.json" - }, - "devDependencies": { - "@playwright/test": "^1.60.0", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@tsconfig/svelte": "^5.0.0", - "concurrently": "^10.0.3", - "cross-env": "^10.1.0", - "e2e-api": "workspace:^", - "houdini": "workspace:^", - "houdini-svelte": "workspace:^", - "svelte": "^5.56.2", - "svelte-check": "^4.6.0", - "tslib": "^2.8.1", - "typescript": "^6.0.3", - "vite": "^8.0.16" - } -} diff --git a/e2e/svelte/playwright.config.ts b/e2e/svelte/playwright.config.ts deleted file mode 100644 index 66027baf23..0000000000 --- a/e2e/svelte/playwright.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from '@playwright/test' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const reporter = [['list']] -if (process.env.CI) { - reporter.push(['html', { open: 'never' }]) - reporter.push(['github']) -} - -const config = defineConfig({ - retries: process.env.CI ? 3 : 0, - testMatch: 'spec.ts', - workers: 5, - reporter, - screenshot: 'only-on-failure', - webServer: { - command: 'npm run build && npm run preview', - port: 3006, - timeout: 120 * 1000, - }, -}) - -export default config diff --git a/e2e/svelte/public/vite.svg b/e2e/svelte/public/vite.svg deleted file mode 100644 index e7b8dfb1b2..0000000000 --- a/e2e/svelte/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/e2e/svelte/schema.graphql b/e2e/svelte/schema.graphql deleted file mode 100644 index c4d65e378c..0000000000 --- a/e2e/svelte/schema.graphql +++ /dev/null @@ -1,167 +0,0 @@ -interface Animal implements Node { - id: ID! - name: String! -} - -interface AnimalConnection { - edges: [AnimalEdge!]! - pageInfo: PageInfo! -} - -interface AnimalEdge { - cursor: String - node: Animal -} - -type Book { - id: ID! - title: String! -} - -type City { - id: ID! - libraries: [Library]! - name: String! -} - -""" -Date custom scalar type -""" -scalar DateTime - -scalar File - -type Library { - books: [Book]! - id: ID! - name: String! -} - -type Message1 { - message: String! -} - -type Monkey implements Animal & Node { - hasBanana: Boolean! - id: ID! - name: String! -} - -type MonkeyConnection implements AnimalConnection { - edges: [MonkeyEdge!]! - pageInfo: PageInfo! -} - -type MonkeyEdge implements AnimalEdge { - cursor: String - node: Monkey -} - -type Mutation { - addBook(library: ID!, title: String!): Book! - addCity(name: String!): City! - addLibrary(city: ID!, name: String!): Library! - addUser( - birthDate: DateTime! - delay: Int - enumValue: MyEnum - name: String! - snapshot: String! - types: [TypeOfUser!] - ): User! - deleteBook(book: ID!): Book! - deleteCity(city: ID!): City! - deleteLibrary(library: ID!): Library! - multipleUpload(files: [File!]!): [String!]! - singleUpload(file: File!): String! - updateRentedBook(bookId: Int!, rate: Int!, userId: String!): RentedBook - updateUser(birthDate: DateTime, delay: Int, id: ID!, name: String, snapshot: String!): User! -} - -enum MyEnum { - Value1 - Value2 -} - -interface Node { - id: ID! -} - -type PageInfo { - endCursor: String - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String -} - -type Query { - animals: AnimalConnection! - avgYearsBirthDate: Float! - cities: [City]! - hello: String! - monkeys: MonkeyConnection! - node(id: ID!): Node - rentedBooks: [RentedBook!]! - session: String - user(delay: Int, forceNullDate: Boolean, id: ID!, snapshot: String!, tmp: Boolean): User! - userNodes(limit: Int = 4, offset: Int, snapshot: String!): UserNodes! - userNodesResult(forceMessage: Boolean!, snapshot: String!): UserNodesResult! - userResult(forceMessage: Boolean!, id: ID!, snapshot: String!): UserResult! - usersConnection( - after: String - before: String - first: Int - last: Int - snapshot: String! - ): UserConnection! - usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! -} - -type RentedBook { - bookId: Int! - rate: Int! - userId: String! -} - -type Subscription { - userUpdate(id: ID!, snapshot: String): User -} - -enum TypeOfUser { - COOL - NICE -} - -type User implements Node { - birthDate: DateTime - enumValue: MyEnum - friendsConnection(after: String, before: String, first: Int, last: Int): UserConnection! - friendsList(limit: Int, offset: Int): [User!]! - id: ID! - name: String! - types: [TypeOfUser!]! - - """ - This is the same list as what's used globally. its here to tests fragments - """ - usersConnection(after: String, before: String, first: Int, last: Int): UserConnection! -} - -type UserConnection { - edges: [UserEdge!]! - pageInfo: PageInfo! -} - -type UserEdge { - cursor: String - node: User -} - -type UserNodes { - nodes: [User!]! - totalCount: Int -} - -union UserNodesResult = Message1 | UserNodes - -union UserResult = Message1 | User diff --git a/e2e/svelte/src/App.svelte b/e2e/svelte/src/App.svelte deleted file mode 100644 index 525246ee90..0000000000 --- a/e2e/svelte/src/App.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
      - -

      {$store.data?.hello}

      - -
      - -
      - -

      Click on logos to learn more!

      -
      - - diff --git a/e2e/svelte/src/app.css b/e2e/svelte/src/app.css deleted file mode 100644 index 9e908b97fd..0000000000 --- a/e2e/svelte/src/app.css +++ /dev/null @@ -1,80 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -.card { - padding: 2em; -} - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/e2e/svelte/src/assets/logo_l.svg b/e2e/svelte/src/assets/logo_l.svg deleted file mode 100644 index 640e6655fd..0000000000 --- a/e2e/svelte/src/assets/logo_l.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/e2e/svelte/src/assets/svelte.svg b/e2e/svelte/src/assets/svelte.svg deleted file mode 100644 index c5e08481f8..0000000000 --- a/e2e/svelte/src/assets/svelte.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/e2e/svelte/src/client.ts b/e2e/svelte/src/client.ts deleted file mode 100644 index b26988156c..0000000000 --- a/e2e/svelte/src/client.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { HoudiniClient } from '$houdini' - -export default new HoudiniClient({ - url: 'http://localhost:4000/graphql', - - // uncomment this to configure the network call (for things like authentication) - // for more information, please visit here: https://www.houdinigraphql.com/guides/authentication - // fetchParams({ session }) { - // return { - // headers: { - // Authentication: `Bearer ${session.token}`, - // } - // } - // } -}) diff --git a/e2e/svelte/src/helpers.ts b/e2e/svelte/src/helpers.ts deleted file mode 100644 index 4957ff226c..0000000000 --- a/e2e/svelte/src/helpers.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Sleep function - promise-based wrapper over setTimeout - */ -export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/e2e/svelte/src/lib/Counter.svelte b/e2e/svelte/src/lib/Counter.svelte deleted file mode 100644 index dd82a1627b..0000000000 --- a/e2e/svelte/src/lib/Counter.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/e2e/svelte/src/main.ts b/e2e/svelte/src/main.ts deleted file mode 100644 index b50706e907..0000000000 --- a/e2e/svelte/src/main.ts +++ /dev/null @@ -1,8 +0,0 @@ -import App from './App.svelte' -import './app.css' - -const app = new App({ - target: document.getElementById('app'), -}) - -export default app diff --git a/e2e/svelte/src/spec.ts b/e2e/svelte/src/spec.ts deleted file mode 100644 index d2c09f4b1d..0000000000 --- a/e2e/svelte/src/spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sleep } from './helpers.js' -import { expect, test } from '@playwright/test' - -test('1 query on the home page', async ({ page }) => { - await page.goto('/') - await sleep(777) - - const allH2 = await page.getByRole('heading', { level: 2 }).allInnerTexts() - expect(allH2).toStrictEqual(['Hello World! // From Houdini!']) -}) diff --git a/e2e/svelte/src/vite-env.d.ts b/e2e/svelte/src/vite-env.d.ts deleted file mode 100644 index 4078e7476a..0000000000 --- a/e2e/svelte/src/vite-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/e2e/svelte/svelte.config.js b/e2e/svelte/svelte.config.js deleted file mode 100644 index 9c111bf191..0000000000 --- a/e2e/svelte/svelte.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' - -export default { - // Consult https://svelte.dev/docs#compile-time-svelte-preprocess - // for more information about preprocessors - preprocess: vitePreprocess(), -} diff --git a/e2e/svelte/tsconfig.json b/e2e/svelte/tsconfig.json deleted file mode 100644 index 7fba315011..0000000000 --- a/e2e/svelte/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": true, - "isolatedModules": true, - "rootDirs": [".", "./$houdini/types"], - "paths": { - "$houdini": ["./$houdini/"] - } - }, - "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], - "references": [ - { - "path": "./tsconfig.node.json" - } - ] -} diff --git a/e2e/svelte/tsconfig.node.json b/e2e/svelte/tsconfig.node.json deleted file mode 100644 index 73a2040d8a..0000000000 --- a/e2e/svelte/tsconfig.node.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "ESNext", - "moduleResolution": "Node" - }, - "include": ["vite.config.ts"] -} diff --git a/e2e/svelte/vite.config.ts b/e2e/svelte/vite.config.ts deleted file mode 100644 index 715a2e7ef3..0000000000 --- a/e2e/svelte/vite.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { svelte } from '@sveltejs/vite-plugin-svelte' -import houdini from 'houdini/vite' -import * as path from 'path' -import { defineConfig } from 'vite' - -export default defineConfig({ - plugins: [houdini(), svelte()], - - resolve: { - alias: { - $houdini: path.resolve('$houdini'), - }, - }, -}) diff --git a/package.json b/package.json index 20c6d1575a..d0362f1b27 100755 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "format:check": "biome format", "release": "pnpm run build && node packages/_scripts/release.js", "release:snapshot": "pnpm run build && node packages/_scripts/release.js --snapshot", - "version": "changeset version" + "version": "node packages/_scripts/version.js" }, "devDependencies": { "@babel/plugin-syntax-import-assertions": "^7.29.7", diff --git a/packages/_scripts/CHANGELOG.md b/packages/_scripts/CHANGELOG.md index 5d4779706d..af2df675e4 100644 --- a/packages/_scripts/CHANGELOG.md +++ b/packages/_scripts/CHANGELOG.md @@ -1,19 +1,21 @@ # scripts +## 2.0.0 + ## 2.0.0-go.2 ### Patch Changes -- [#1601](https://github.com/HoudiniGraphql/houdini/pull/1601) [`2c1979e4`](https://github.com/HoudiniGraphql/houdini/commit/2c1979e4428ba5ed17dd66b46109bc30c07b5638) Thanks [@siddarthvader](https://github.com/siddarthvader)! - fixed /runtime resolution for node esm +- [#1601](https://github.com/HoudiniGraphql/houdini/pull/1601) [`2c1979e4`](https://github.com/HoudiniGraphql/houdini/commit/2c1979e4428ba5ed17dd66b46109bc30c07b5638) Thanks [@siddarthvader](https://github.com/siddarthvader)! - fixed /runtime resolution for node esm ## 2.0.0-go.1 ### Patch Changes -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps +- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps ## 2.0.0-go.0 ### Major Changes -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture +- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture diff --git a/packages/_scripts/buildUtils.js b/packages/_scripts/buildUtils.js index 3457f1261c..f2506ee8a3 100644 --- a/packages/_scripts/buildUtils.js +++ b/packages/_scripts/buildUtils.js @@ -4,7 +4,9 @@ import fs from 'node:fs/promises' * Write a package.json file with consistent formatting */ export async function writePackageJson(filePath, packageJson) { - await fs.writeFile(filePath, JSON.stringify(packageJson, null, 4)) + // include a trailing newline so recompiling doesn't leave a spurious diff + // against the committed (newline-terminated) package.json files + await fs.writeFile(filePath, JSON.stringify(packageJson, null, 4) + '\n') } /** diff --git a/packages/_scripts/changeset-fetch-diagnostics.cjs b/packages/_scripts/changeset-fetch-diagnostics.cjs new file mode 100644 index 0000000000..988c889a14 --- /dev/null +++ b/packages/_scripts/changeset-fetch-diagnostics.cjs @@ -0,0 +1,59 @@ +// Diagnostics for the "Premature close" failures in `changeset version`. +// +// Preloaded (via `node --require`) before @changesets/get-github-info loads +// node-fetch, so we can wrap node-fetch and log the HTTP status + response +// headers GitHub returns. The failure surfaces as "Premature close" at the +// `.json()` body read, which hides whether GitHub actually sent an error +// (502 / 429 / auth) or severed a healthy 200 response mid-body. Logging the +// status + headers (which arrive before the body) tells us which. +// +// We only read status/headers and never touch the body, so this cannot change +// the timing or outcome of the real request. Response headers contain no +// secrets (the Authorization request header is never logged). Enabled in CI via +// HOUDINI_CHANGESET_DIAGNOSTICS; off by default so local runs stay quiet. + +const Module = require('module') + +const origLoad = Module._load +Module._load = function (request, parent, isMain) { + const loaded = origLoad.apply(this, arguments) + if (request !== 'node-fetch') { + return loaded + } + + // node-fetch v2's module.exports IS the fetch function (with `.default`, + // `Headers`, `Request`, etc. hung off it for interop / named imports). + const realFetch = loaded.default || loaded + if (realFetch.__houdiniWrapped) { + return loaded + } + + const wrapped = async function (url, opts) { + const method = (opts && opts.method) || 'GET' + const startedAt = Date.now() + let res + try { + res = await realFetch(url, opts) + } catch (e) { + console.error(`[changeset-fetch] ${method} ${url} threw before a response arrived: ${e.message}`) + throw e + } + + const headers = {} + res.headers.forEach((value, key) => { + headers[key] = value + }) + console.error( + `[changeset-fetch] ${method} ${url} -> ${res.status} ${res.statusText} (${Date.now() - startedAt}ms before body)` + ) + console.error(`[changeset-fetch] response headers: ${JSON.stringify(headers)}`) + return res + } + wrapped.__houdiniWrapped = true + + // Preserve the module's shape so both `fetch(...)` and `fetch.default(...)` + // call styles, plus named imports (Headers/Request/Response), keep working. + Object.assign(wrapped, realFetch) + wrapped.default = wrapped + return wrapped +} diff --git a/packages/_scripts/package.json b/packages/_scripts/package.json index a77faa9256..90ea5582e6 100644 --- a/packages/_scripts/package.json +++ b/packages/_scripts/package.json @@ -1,7 +1,7 @@ { "name": "scripts", "private": true, - "version": "2.0.0-next.2", + "version": "2.0.0", "description": "Build and test scripts for Houdini packages", "bin": "./main.js", "type": "module", diff --git a/packages/_scripts/version.js b/packages/_scripts/version.js new file mode 100644 index 0000000000..07ffa37409 --- /dev/null +++ b/packages/_scripts/version.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +// Runs `changeset version`, retrying when the only failure is a transient error +// talking to GitHub's GraphQL API. `@changesets/get-github-info` (used by the +// changelog-github generator) fetches PR/commit metadata over node-fetch, and on +// the CI runner that connection is sometimes dropped mid-response, surfacing as +// "Invalid response body ... Premature close" / "Failed to parse data from GitHub". +// changesets aborts before writing anything ("we have escaped applying the +// changesets, and no files should have been affected"), so re-running is safe. + +import { spawn } from 'child_process' +import { createRequire } from 'module' +import { fileURLToPath } from 'url' + +// Resolve the changeset CLI entry point directly so we don't depend on PATH or a shell. +const changesetBin = createRequire(import.meta.url).resolve('@changesets/cli/bin.js') + +// When HOUDINI_CHANGESET_DIAGNOSTICS is set (the release workflow does), preload a +// hook that logs the HTTP status + headers GitHub returns for each node-fetch call, +// so a "Premature close" failure tells us whether GitHub errored or dropped a 200. +const childArgs = [] +if (process.env.HOUDINI_CHANGESET_DIAGNOSTICS) { + const diagnostics = fileURLToPath(new URL('./changeset-fetch-diagnostics.cjs', import.meta.url)) + childArgs.push('--require', diagnostics) +} +childArgs.push(changesetBin, 'version') + +const MAX_ATTEMPTS = 5 +const BASE_DELAY_MS = 3000 + +// Markers that identify a transient GitHub-fetch failure (vs. a real changeset error). +const TRANSIENT_PATTERNS = [ + 'Premature close', + 'fetching data from GitHub', + 'parse data from GitHub', + 'fetch https://api.github.com/graphql', + 'ECONNRESET', + 'ETIMEDOUT', + 'socket hang up', +] + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +function runChangesetVersion() { + return new Promise(resolve => { + const child = spawn(process.execPath, childArgs) + + // Stream output through while capturing it so we can classify failures + // (changesets logs the GitHub error to either stream depending on version). + let output = '' + child.stdout.on('data', chunk => { + output += chunk.toString() + process.stdout.write(chunk) + }) + child.stderr.on('data', chunk => { + output += chunk.toString() + process.stderr.write(chunk) + }) + + child.on('close', code => resolve({ code, output })) + }) +} + +for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const { code, output } = await runChangesetVersion() + + if (code === 0) { + process.exit(0) + } + + const isTransient = TRANSIENT_PATTERNS.some(pattern => output.includes(pattern)) + if (!isTransient || attempt === MAX_ATTEMPTS) { + process.exit(code ?? 1) + } + + const delay = BASE_DELAY_MS * attempt + console.error( + `\n⚠️ changeset version failed talking to GitHub (attempt ${attempt}/${MAX_ATTEMPTS}). Retrying in ${delay / 1000}s…\n` + ) + await sleep(delay) +} diff --git a/packages/adapter-auto/CHANGELOG.md b/packages/adapter-auto/CHANGELOG.md index bae36511b6..039c8fb460 100644 --- a/packages/adapter-auto/CHANGELOG.md +++ b/packages/adapter-auto/CHANGELOG.md @@ -1,249 +1,17 @@ # houdini-adapter-auto -## 2.0.0-next.31 - -### Patch Changes - -- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: - - houdini@2.0.0-next.34 - -## 2.0.0-next.30 - -### Patch Changes - -- [#1637](https://github.com/HoudiniGraphql/houdini/pull/1637) [`8143ab7`](https://github.com/HoudiniGraphql/houdini/commit/8143ab76558aa6d1ac44fa80729d98cf10624bfd) Thanks [@github-actions](https://github.com/apps/github-actions)! - Move houdini from dependencies to peerDependencies to prevent duplicate installs when adapter and houdini versions differ - -- Updated dependencies [[`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2), [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2)]: - - houdini@2.0.0-next.31 - -## 2.0.0-next.29 - -### Patch Changes - -- [#1631](https://github.com/HoudiniGraphql/houdini/pull/1631) [`86cecd1`](https://github.com/HoudiniGraphql/houdini/commit/86cecd19a8f54662624913400a6d82192639901b) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Bump dependencies to latest: import-meta-resolve ^4 - -## 2.0.0-next.28 - -### Patch Changes - -- Updated dependencies [[`5668b992`](https://github.com/HoudiniGraphql/houdini/commit/5668b9927ace9b9574faf396d1a559b3b5ccf769)]: - - houdini@2.0.0-next.28 - -## 2.0.0-next.27 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.27 - -## 2.0.0-next.26 - -### Patch Changes - -- Updated dependencies [[`899054d5`](https://github.com/HoudiniGraphql/houdini/commit/899054d5d0ec1416dc0e4a3d8bd745093b951642)]: - - houdini@2.0.0-next.26 - -## 2.0.0-next.25 - -### Patch Changes - -- Updated dependencies [[`dd9d1cbf`](https://github.com/HoudiniGraphql/houdini/commit/dd9d1cbf499b8b6f327c8d457edc3b04176d55a4)]: - - houdini@2.0.0-next.25 - -## 2.0.0-next.24 - -### Patch Changes - -- Updated dependencies [[`a67c5fc6`](https://github.com/HoudiniGraphql/houdini/commit/a67c5fc671b0e53e77217fa9b43dfe53ec2bb0f6)]: - - houdini@2.0.0-next.24 - -## 2.0.0-next.23 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.23 - -## 2.0.0-next.22 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -### Patch Changes - -- Updated dependencies [[`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce)]: - - houdini@2.0.0-next.22 - -## 2.0.0-go.21 - -### Patch Changes - -- Updated dependencies [[`14fa602a`](https://github.com/HoudiniGraphql/houdini/commit/14fa602a4aaeee3f0863e7f0c93945f0eebac51e), [`cd3fa07a`](https://github.com/HoudiniGraphql/houdini/commit/cd3fa07a6405de85f08954faa84895296f032ef4)]: - - houdini@2.0.0-go.21 - -## 2.0.0-go.20 - -### Patch Changes - -- Updated dependencies [[`d1848162`](https://github.com/HoudiniGraphql/houdini/commit/d18481625a443cd41f72d605b0999a0ca75c9555)]: - - houdini@2.0.0-go.20 - -## 2.0.0-go.19 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version - -### Patch Changes - -- Updated dependencies [[`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f)]: - - houdini@2.0.0-go.19 - -## 2.0.0-go.18 - -### Patch Changes - -- Updated dependencies [[`86ed9d27`](https://github.com/HoudiniGraphql/houdini/commit/86ed9d279d11443df553e9d1d42ab930ba878393)]: - - houdini@2.0.0-go.18 - -## 2.0.0-go.17 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang - -### Patch Changes - -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix cloudflare adapter to detect CLOUDFLARE_PAGES env var and output worker.js - -- Updated dependencies [[`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: - - houdini@2.0.0-go.17 - -## 2.0.0-go.16 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.16 - -## 2.0.0-go.15 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.15 - -## 2.0.0-go.14 - -### Patch Changes - -- Updated dependencies [[`0610efa9`](https://github.com/HoudiniGraphql/houdini/commit/0610efa92e09344216bb1be1cf5610dbba3d570f), [`89252315`](https://github.com/HoudiniGraphql/houdini/commit/8925231525061d0fba35a6b78df5cfd2cde74920)]: - - houdini@2.0.0-go.14 - -## 2.0.0-go.13 - -### Patch Changes - -- Updated dependencies [[`62a0e62a`](https://github.com/HoudiniGraphql/houdini/commit/62a0e62a476d6183d50bda21ed939c8f267308f0)]: - - houdini@2.0.0-go.13 - -## 2.0.0-go.12 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.12 - -## 2.0.0-go.11 - -### Patch Changes - -- Updated dependencies [[`2bf6cd4f`](https://github.com/HoudiniGraphql/houdini/commit/2bf6cd4fdfddec1324ba702d65436c46d50e3fe5)]: - - houdini@2.0.0-go.11 - -## 2.0.0-go.10 - -### Patch Changes - -- Updated dependencies [[`53fc6baa`](https://github.com/HoudiniGraphql/houdini/commit/53fc6baaa58d4022ae3495c1e0940b07e85d971c)]: - - houdini@2.0.0-go.10 - -## 2.0.0-go.9 - -### Patch Changes - -- Updated dependencies [[`d656515b`](https://github.com/HoudiniGraphql/houdini/commit/d656515bda5835d6e8a19b0e6eb8ecf5627fe34e)]: - - houdini@2.0.0-go.9 - -## 2.0.0-go.8 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.8 - -## 2.0.0-go.7 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.7 - -## 2.0.0-go.6 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.6 - -## 2.0.0-go.5 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.5 - -## 2.0.0-go.4 - -### Patch Changes - -- Updated dependencies [[`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2)]: - - houdini@2.0.0-go.4 - -## 2.0.0-go.3 - -### Patch Changes - -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps - -- Updated dependencies [[`043c4e29`](https://github.com/HoudiniGraphql/houdini/commit/043c4e29ce2c2f41b4a6750b191983e5d53a3540), [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448)]: - - houdini@2.0.0-go.3 - -## 2.0.0-go.2 - -### Patch Changes - -- Updated dependencies [[`6fe29007`](https://github.com/HoudiniGraphql/houdini/commit/6fe290071bf356ef71567ebcbf025b1802f5cb42)]: - - houdini@2.0.0-go.2 - -## 2.0.0-go.1 - -### Patch Changes - -- Updated dependencies [[`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06)]: - - houdini@2.0.0-go.1 - -## 2.0.0-go.0 - -### Major Changes +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang ### Patch Changes -- Updated dependencies [[`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765)]: - - houdini@2.0.0-go.0 +- Updated dependencies [[`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f6e9636`](https://github.com/HoudiniGraphql/houdini/commit/f6e9636f223ff01737a4ca0a5e87aba3bbbeaf1a), [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f), [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f1ae542`](https://github.com/HoudiniGraphql/houdini/commit/f1ae542be6e094b4e39b1b181176c00d4eac1956), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: + - houdini@2.0.0 ## 1.3.1 diff --git a/packages/adapter-auto/package.json b/packages/adapter-auto/package.json index f03cca36c0..d56a730b20 100644 --- a/packages/adapter-auto/package.json +++ b/packages/adapter-auto/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-auto", - "version": "2.0.0-next.31", + "version": "2.0.0", "description": "An adapter for deploying your Houdini application according to the build environment ", "keywords": [ "houdini", diff --git a/packages/adapter-cloudflare/CHANGELOG.md b/packages/adapter-cloudflare/CHANGELOG.md index a973a51d73..89a19b968f 100644 --- a/packages/adapter-cloudflare/CHANGELOG.md +++ b/packages/adapter-cloudflare/CHANGELOG.md @@ -1,243 +1,17 @@ # houdini-adapter-cloudflare -## 2.0.0-next.30 - -### Patch Changes - -- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: - - houdini@2.0.0-next.34 - -## 2.0.0-next.29 - -### Patch Changes - -- [#1637](https://github.com/HoudiniGraphql/houdini/pull/1637) [`8143ab7`](https://github.com/HoudiniGraphql/houdini/commit/8143ab76558aa6d1ac44fa80729d98cf10624bfd) Thanks [@github-actions](https://github.com/apps/github-actions)! - Move houdini from dependencies to peerDependencies to prevent duplicate installs when adapter and houdini versions differ - -- Updated dependencies [[`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2), [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2)]: - - houdini@2.0.0-next.31 - -## 2.0.0-next.28 - -### Patch Changes - -- Updated dependencies [[`5668b992`](https://github.com/HoudiniGraphql/houdini/commit/5668b9927ace9b9574faf396d1a559b3b5ccf769)]: - - houdini@2.0.0-next.28 - -## 2.0.0-next.27 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.27 - -## 2.0.0-next.26 - -### Patch Changes - -- Updated dependencies [[`899054d5`](https://github.com/HoudiniGraphql/houdini/commit/899054d5d0ec1416dc0e4a3d8bd745093b951642)]: - - houdini@2.0.0-next.26 - -## 2.0.0-next.25 - -### Patch Changes - -- Updated dependencies [[`dd9d1cbf`](https://github.com/HoudiniGraphql/houdini/commit/dd9d1cbf499b8b6f327c8d457edc3b04176d55a4)]: - - houdini@2.0.0-next.25 - -## 2.0.0-next.24 - -### Patch Changes - -- Updated dependencies [[`a67c5fc6`](https://github.com/HoudiniGraphql/houdini/commit/a67c5fc671b0e53e77217fa9b43dfe53ec2bb0f6)]: - - houdini@2.0.0-next.24 - -## 2.0.0-next.23 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.23 - -## 2.0.0-next.22 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -### Patch Changes - -- Updated dependencies [[`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce)]: - - houdini@2.0.0-next.22 - -## 2.0.0-go.21 - -### Patch Changes - -- Updated dependencies [[`14fa602a`](https://github.com/HoudiniGraphql/houdini/commit/14fa602a4aaeee3f0863e7f0c93945f0eebac51e), [`cd3fa07a`](https://github.com/HoudiniGraphql/houdini/commit/cd3fa07a6405de85f08954faa84895296f032ef4)]: - - houdini@2.0.0-go.21 - -## 2.0.0-go.20 - -### Patch Changes - -- Updated dependencies [[`d1848162`](https://github.com/HoudiniGraphql/houdini/commit/d18481625a443cd41f72d605b0999a0ca75c9555)]: - - houdini@2.0.0-go.20 - -## 2.0.0-go.19 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version - -### Patch Changes - -- Updated dependencies [[`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f)]: - - houdini@2.0.0-go.19 - -## 2.0.0-go.18 - -### Patch Changes - -- Updated dependencies [[`86ed9d27`](https://github.com/HoudiniGraphql/houdini/commit/86ed9d279d11443df553e9d1d42ab930ba878393)]: - - houdini@2.0.0-go.18 - -## 2.0.0-go.17 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang - -### Patch Changes - -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix cloudflare adapter to detect CLOUDFLARE_PAGES env var and output worker.js - -- Updated dependencies [[`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: - - houdini@2.0.0-go.17 - -## 2.0.0-go.16 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.16 - -## 2.0.0-go.15 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.15 - -## 2.0.0-go.14 - -### Patch Changes - -- Updated dependencies [[`0610efa9`](https://github.com/HoudiniGraphql/houdini/commit/0610efa92e09344216bb1be1cf5610dbba3d570f), [`89252315`](https://github.com/HoudiniGraphql/houdini/commit/8925231525061d0fba35a6b78df5cfd2cde74920)]: - - houdini@2.0.0-go.14 - -## 2.0.0-go.13 - -### Patch Changes - -- Updated dependencies [[`62a0e62a`](https://github.com/HoudiniGraphql/houdini/commit/62a0e62a476d6183d50bda21ed939c8f267308f0)]: - - houdini@2.0.0-go.13 - -## 2.0.0-go.12 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.12 - -## 2.0.0-go.11 - -### Patch Changes - -- Updated dependencies [[`2bf6cd4f`](https://github.com/HoudiniGraphql/houdini/commit/2bf6cd4fdfddec1324ba702d65436c46d50e3fe5)]: - - houdini@2.0.0-go.11 - -## 2.0.0-go.10 - -### Patch Changes - -- Updated dependencies [[`53fc6baa`](https://github.com/HoudiniGraphql/houdini/commit/53fc6baaa58d4022ae3495c1e0940b07e85d971c)]: - - houdini@2.0.0-go.10 - -## 2.0.0-go.9 - -### Patch Changes - -- Updated dependencies [[`d656515b`](https://github.com/HoudiniGraphql/houdini/commit/d656515bda5835d6e8a19b0e6eb8ecf5627fe34e)]: - - houdini@2.0.0-go.9 - -## 2.0.0-go.8 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.8 - -## 2.0.0-go.7 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.7 - -## 2.0.0-go.6 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.6 - -## 2.0.0-go.5 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.5 - -## 2.0.0-go.4 - -### Patch Changes - -- Updated dependencies [[`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2)]: - - houdini@2.0.0-go.4 - -## 2.0.0-go.3 - -### Patch Changes - -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps - -- Updated dependencies [[`043c4e29`](https://github.com/HoudiniGraphql/houdini/commit/043c4e29ce2c2f41b4a6750b191983e5d53a3540), [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448)]: - - houdini@2.0.0-go.3 - -## 2.0.0-go.2 - -### Patch Changes - -- Updated dependencies [[`6fe29007`](https://github.com/HoudiniGraphql/houdini/commit/6fe290071bf356ef71567ebcbf025b1802f5cb42)]: - - houdini@2.0.0-go.2 - -## 2.0.0-go.1 - -### Patch Changes - -- Updated dependencies [[`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06)]: - - houdini@2.0.0-go.1 - -## 2.0.0-go.0 - -### Major Changes +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang ### Patch Changes -- Updated dependencies [[`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765)]: - - houdini@2.0.0-go.0 +- Updated dependencies [[`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f6e9636`](https://github.com/HoudiniGraphql/houdini/commit/f6e9636f223ff01737a4ca0a5e87aba3bbbeaf1a), [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f), [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f1ae542`](https://github.com/HoudiniGraphql/houdini/commit/f1ae542be6e094b4e39b1b181176c00d4eac1956), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: + - houdini@2.0.0 ## 1.3.1 diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index f48b8b96fd..e296f38896 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-cloudflare", - "version": "2.0.0-next.30", + "version": "2.0.0", "description": "The adapter for deploying your Houdini application to Cloudflare Pages", "keywords": [ "houdini", diff --git a/packages/adapter-node/CHANGELOG.md b/packages/adapter-node/CHANGELOG.md index 90f5e80668..4fdd75080b 100644 --- a/packages/adapter-node/CHANGELOG.md +++ b/packages/adapter-node/CHANGELOG.md @@ -1,241 +1,19 @@ # houdini-adapter-node -## 2.0.0-next.30 - -### Patch Changes - -- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: - - houdini@2.0.0-next.34 - -## 2.0.0-next.29 - -### Patch Changes - -- [#1637](https://github.com/HoudiniGraphql/houdini/pull/1637) [`8143ab7`](https://github.com/HoudiniGraphql/houdini/commit/8143ab76558aa6d1ac44fa80729d98cf10624bfd) Thanks [@github-actions](https://github.com/apps/github-actions)! - Move houdini from dependencies to peerDependencies to prevent duplicate installs when adapter and houdini versions differ - -- Updated dependencies [[`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2), [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2)]: - - houdini@2.0.0-next.31 - -## 2.0.0-next.28 - -### Patch Changes - -- Updated dependencies [[`5668b992`](https://github.com/HoudiniGraphql/houdini/commit/5668b9927ace9b9574faf396d1a559b3b5ccf769)]: - - houdini@2.0.0-next.28 - -## 2.0.0-next.27 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.27 - -## 2.0.0-next.26 - -### Patch Changes - -- Updated dependencies [[`899054d5`](https://github.com/HoudiniGraphql/houdini/commit/899054d5d0ec1416dc0e4a3d8bd745093b951642)]: - - houdini@2.0.0-next.26 - -## 2.0.0-next.25 - -### Patch Changes - -- Updated dependencies [[`dd9d1cbf`](https://github.com/HoudiniGraphql/houdini/commit/dd9d1cbf499b8b6f327c8d457edc3b04176d55a4)]: - - houdini@2.0.0-next.25 - -## 2.0.0-next.24 - -### Patch Changes - -- Updated dependencies [[`a67c5fc6`](https://github.com/HoudiniGraphql/houdini/commit/a67c5fc671b0e53e77217fa9b43dfe53ec2bb0f6)]: - - houdini@2.0.0-next.24 - -## 2.0.0-next.23 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.23 - -## 2.0.0-next.22 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -### Patch Changes - -- Updated dependencies [[`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce)]: - - houdini@2.0.0-next.22 - -## 2.0.0-go.21 - -### Patch Changes - -- Updated dependencies [[`14fa602a`](https://github.com/HoudiniGraphql/houdini/commit/14fa602a4aaeee3f0863e7f0c93945f0eebac51e), [`cd3fa07a`](https://github.com/HoudiniGraphql/houdini/commit/cd3fa07a6405de85f08954faa84895296f032ef4)]: - - houdini@2.0.0-go.21 - -## 2.0.0-go.20 - -### Patch Changes - -- Updated dependencies [[`d1848162`](https://github.com/HoudiniGraphql/houdini/commit/d18481625a443cd41f72d605b0999a0ca75c9555)]: - - houdini@2.0.0-go.20 - -## 2.0.0-go.19 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version - -### Patch Changes - -- Updated dependencies [[`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f)]: - - houdini@2.0.0-go.19 - -## 2.0.0-go.18 - -### Patch Changes - -- Updated dependencies [[`86ed9d27`](https://github.com/HoudiniGraphql/houdini/commit/86ed9d279d11443df553e9d1d42ab930ba878393)]: - - houdini@2.0.0-go.18 - -## 2.0.0-go.17 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang - -### Patch Changes - -- Updated dependencies [[`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: - - houdini@2.0.0-go.17 - -## 2.0.0-go.16 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.16 - -## 2.0.0-go.15 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.15 - -## 2.0.0-go.14 - -### Patch Changes - -- Updated dependencies [[`0610efa9`](https://github.com/HoudiniGraphql/houdini/commit/0610efa92e09344216bb1be1cf5610dbba3d570f), [`89252315`](https://github.com/HoudiniGraphql/houdini/commit/8925231525061d0fba35a6b78df5cfd2cde74920)]: - - houdini@2.0.0-go.14 - -## 2.0.0-go.13 - -### Patch Changes - -- Updated dependencies [[`62a0e62a`](https://github.com/HoudiniGraphql/houdini/commit/62a0e62a476d6183d50bda21ed939c8f267308f0)]: - - houdini@2.0.0-go.13 - -## 2.0.0-go.12 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.12 - -## 2.0.0-go.11 - -### Patch Changes - -- Updated dependencies [[`2bf6cd4f`](https://github.com/HoudiniGraphql/houdini/commit/2bf6cd4fdfddec1324ba702d65436c46d50e3fe5)]: - - houdini@2.0.0-go.11 - -## 2.0.0-go.10 - -### Patch Changes - -- Updated dependencies [[`53fc6baa`](https://github.com/HoudiniGraphql/houdini/commit/53fc6baaa58d4022ae3495c1e0940b07e85d971c)]: - - houdini@2.0.0-go.10 - -## 2.0.0-go.9 - -### Patch Changes - -- Updated dependencies [[`d656515b`](https://github.com/HoudiniGraphql/houdini/commit/d656515bda5835d6e8a19b0e6eb8ecf5627fe34e)]: - - houdini@2.0.0-go.9 - -## 2.0.0-go.8 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.8 +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -## 2.0.0-go.7 +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang ### Patch Changes -- Updated dependencies []: - - houdini@2.0.0-go.7 - -## 2.0.0-go.6 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.6 - -## 2.0.0-go.5 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.5 - -## 2.0.0-go.4 - -### Patch Changes - -- Updated dependencies [[`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2)]: - - houdini@2.0.0-go.4 - -## 2.0.0-go.3 - -### Patch Changes - -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps - -- Updated dependencies [[`043c4e29`](https://github.com/HoudiniGraphql/houdini/commit/043c4e29ce2c2f41b4a6750b191983e5d53a3540), [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448)]: - - houdini@2.0.0-go.3 - -## 2.0.0-go.2 - -### Patch Changes - -- Updated dependencies [[`6fe29007`](https://github.com/HoudiniGraphql/houdini/commit/6fe290071bf356ef71567ebcbf025b1802f5cb42)]: - - houdini@2.0.0-go.2 - -## 2.0.0-go.1 - -### Patch Changes - -- Updated dependencies [[`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06)]: - - houdini@2.0.0-go.1 - -## 2.0.0-go.0 - -### Major Changes - -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture - -### Patch Changes +- [#1700](https://github.com/HoudiniGraphql/houdini/pull/1700) [`caba000`](https://github.com/HoudiniGraphql/houdini/commit/caba000d1c52661f4562508137f40ce12df91e78) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix a path traversal vulnerability in the node adapter's static file server -- Updated dependencies [[`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765)]: - - houdini@2.0.0-go.0 +- Updated dependencies [[`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f6e9636`](https://github.com/HoudiniGraphql/houdini/commit/f6e9636f223ff01737a4ca0a5e87aba3bbbeaf1a), [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f), [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f1ae542`](https://github.com/HoudiniGraphql/houdini/commit/f1ae542be6e094b4e39b1b181176c00d4eac1956), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: + - houdini@2.0.0 ## 1.3.1 diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 92ad7216ee..e558be4c85 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-node", - "version": "2.0.0-next.30", + "version": "2.0.0", "description": "The adapter for deploying your Houdini application as a standalone node server", "keywords": [ "houdini", @@ -24,6 +24,7 @@ "build:build": "pnpm build: && pnpm build" }, "devDependencies": { + "@types/node": "^25.9.1", "houdini": "workspace:^", "scripts": "workspace:^", "tsup": "^8.5.1" diff --git a/packages/adapter-node/src/app.ts b/packages/adapter-node/src/app.ts index db15a4f42c..0fd5484142 100644 --- a/packages/adapter-node/src/app.ts +++ b/packages/adapter-node/src/app.ts @@ -4,6 +4,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import path, { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { resolveAssetPath } from './assets.js' + const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -36,7 +38,16 @@ function handleAssets( req: IncomingMessage } ) { - const filePath = path.join(__dirname, req.url === '/' ? 'index.html' : (req.url ?? '/')) + // confine the request to build/assets — a traversal like `/assets/../ssr/entries/adapter.js` + // (the server bundle, which holds the session signing keys) or `/assets/../../etc/passwd` must + // not be readable. resolveAssetPath returns null when the path would escape; fail closed. + const filePath = resolveAssetPath(req.url, __dirname) + if (filePath === null) { + res.writeHead(404, { 'Content-Type': 'text/html' }) + res.end('Not found', 'utf8') + return + } + const extname = path.extname(filePath) let contentType = 'text/html' diff --git a/packages/adapter-node/src/assets.test.ts b/packages/adapter-node/src/assets.test.ts new file mode 100644 index 0000000000..e48728fba2 --- /dev/null +++ b/packages/adapter-node/src/assets.test.ts @@ -0,0 +1,30 @@ +import path from 'node:path' +import { test, expect, describe } from 'vitest' + +import { resolveAssetPath } from './assets.js' + +const BUILD = path.join(path.sep, 'app', 'build') + +describe('resolveAssetPath (static asset path confinement)', () => { + test('serves a normal asset under build/assets', () => { + expect(resolveAssetPath('/assets/entries/app.js', BUILD)).toBe( + path.join(BUILD, 'assets', 'entries', 'app.js') + ) + }) + + test('refuses traversal into the server bundle (build/ssr holds the session keys)', () => { + expect(resolveAssetPath('/assets/../ssr/entries/adapter.js', BUILD)).toBe(null) + }) + + test('refuses traversal out of the build directory entirely', () => { + expect(resolveAssetPath('/assets/../../../../etc/passwd', BUILD)).toBe(null) + }) + + test('refuses a request that resolves to the build root itself', () => { + expect(resolveAssetPath('/assets/..', BUILD)).toBe(null) + }) + + test('allows the assets root', () => { + expect(resolveAssetPath('/assets', BUILD)).toBe(path.join(BUILD, 'assets')) + }) +}) diff --git a/packages/adapter-node/src/assets.ts b/packages/adapter-node/src/assets.ts new file mode 100644 index 0000000000..cf1081c279 --- /dev/null +++ b/packages/adapter-node/src/assets.ts @@ -0,0 +1,15 @@ +import path from 'node:path' + +// resolveAssetPath maps a request url to the file that should be served for it, or null if the +// request would escape the static assets directory (path traversal). Only files physically inside +// `/assets` may be web-served: a request like `/assets/../ssr/entries/adapter.js` (the +// SERVER bundle, which holds the session signing keys) or `/assets/../../etc/passwd` must be +// refused. path.join normalizes any `..`, so we confine the normalized result to the assets root. +export function resolveAssetPath(reqUrl: string | undefined, buildDir: string): string | null { + const filePath = path.join(buildDir, reqUrl === '/' ? 'index.html' : (reqUrl ?? '/')) + const assetsRoot = path.join(buildDir, 'assets') + if (filePath !== assetsRoot && !filePath.startsWith(assetsRoot + path.sep)) { + return null + } + return filePath +} diff --git a/packages/adapter-node/tsconfig.json b/packages/adapter-node/tsconfig.json new file mode 100644 index 0000000000..8df3ea1c17 --- /dev/null +++ b/packages/adapter-node/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/adapter-static/CHANGELOG.md b/packages/adapter-static/CHANGELOG.md index bca75ea25c..96e1cb9abe 100644 --- a/packages/adapter-static/CHANGELOG.md +++ b/packages/adapter-static/CHANGELOG.md @@ -1,247 +1,17 @@ # houdini-adapter-static -## 2.0.0-next.31 - -### Patch Changes - -- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: - - houdini@2.0.0-next.34 - -## 2.0.0-next.30 - -### Patch Changes - -- [#1637](https://github.com/HoudiniGraphql/houdini/pull/1637) [`8143ab7`](https://github.com/HoudiniGraphql/houdini/commit/8143ab76558aa6d1ac44fa80729d98cf10624bfd) Thanks [@github-actions](https://github.com/apps/github-actions)! - Move houdini from dependencies to peerDependencies to prevent duplicate installs when adapter and houdini versions differ - -- Updated dependencies [[`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2), [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2)]: - - houdini@2.0.0-next.31 - -## 2.0.0-next.29 - -### Patch Changes - -- [#1631](https://github.com/HoudiniGraphql/houdini/pull/1631) [`86cecd1`](https://github.com/HoudiniGraphql/houdini/commit/86cecd19a8f54662624913400a6d82192639901b) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Bump dependencies to latest: react ^19.2.7, vite peer dependency ^8 - -## 2.0.0-next.28 - -### Patch Changes - -- Updated dependencies [[`5668b992`](https://github.com/HoudiniGraphql/houdini/commit/5668b9927ace9b9574faf396d1a559b3b5ccf769)]: - - houdini@2.0.0-next.28 - -## 2.0.0-next.27 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.27 - -## 2.0.0-next.26 - -### Patch Changes - -- Updated dependencies [[`899054d5`](https://github.com/HoudiniGraphql/houdini/commit/899054d5d0ec1416dc0e4a3d8bd745093b951642)]: - - houdini@2.0.0-next.26 - -## 2.0.0-next.25 - -### Patch Changes - -- Updated dependencies [[`dd9d1cbf`](https://github.com/HoudiniGraphql/houdini/commit/dd9d1cbf499b8b6f327c8d457edc3b04176d55a4)]: - - houdini@2.0.0-next.25 - -## 2.0.0-next.24 - -### Patch Changes - -- Updated dependencies [[`a67c5fc6`](https://github.com/HoudiniGraphql/houdini/commit/a67c5fc671b0e53e77217fa9b43dfe53ec2bb0f6)]: - - houdini@2.0.0-next.24 - -## 2.0.0-next.23 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.23 - -## 2.0.0-next.22 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -### Patch Changes - -- Updated dependencies [[`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce)]: - - houdini@2.0.0-next.22 - -## 2.0.0-go.21 - -### Patch Changes - -- Updated dependencies [[`14fa602a`](https://github.com/HoudiniGraphql/houdini/commit/14fa602a4aaeee3f0863e7f0c93945f0eebac51e), [`cd3fa07a`](https://github.com/HoudiniGraphql/houdini/commit/cd3fa07a6405de85f08954faa84895296f032ef4)]: - - houdini@2.0.0-go.21 - -## 2.0.0-go.20 - -### Patch Changes - -- Updated dependencies [[`d1848162`](https://github.com/HoudiniGraphql/houdini/commit/d18481625a443cd41f72d605b0999a0ca75c9555)]: - - houdini@2.0.0-go.20 - -## 2.0.0-go.19 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version - -### Patch Changes - -- Updated dependencies [[`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f)]: - - houdini@2.0.0-go.19 - -## 2.0.0-go.18 - -### Patch Changes - -- Updated dependencies [[`86ed9d27`](https://github.com/HoudiniGraphql/houdini/commit/86ed9d279d11443df553e9d1d42ab930ba878393)]: - - houdini@2.0.0-go.18 - -## 2.0.0-go.17 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang - -### Patch Changes - -- Updated dependencies [[`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: - - houdini@2.0.0-go.17 - -## 2.0.0-go.16 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.16 - -## 2.0.0-go.15 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.15 - -## 2.0.0-go.14 - -### Patch Changes - -- Updated dependencies [[`0610efa9`](https://github.com/HoudiniGraphql/houdini/commit/0610efa92e09344216bb1be1cf5610dbba3d570f), [`89252315`](https://github.com/HoudiniGraphql/houdini/commit/8925231525061d0fba35a6b78df5cfd2cde74920)]: - - houdini@2.0.0-go.14 - -## 2.0.0-go.13 - -### Patch Changes - -- Updated dependencies [[`62a0e62a`](https://github.com/HoudiniGraphql/houdini/commit/62a0e62a476d6183d50bda21ed939c8f267308f0)]: - - houdini@2.0.0-go.13 - -## 2.0.0-go.12 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.12 - -## 2.0.0-go.11 - -### Patch Changes - -- Updated dependencies [[`2bf6cd4f`](https://github.com/HoudiniGraphql/houdini/commit/2bf6cd4fdfddec1324ba702d65436c46d50e3fe5)]: - - houdini@2.0.0-go.11 - -## 2.0.0-go.10 - -### Patch Changes - -- Updated dependencies [[`53fc6baa`](https://github.com/HoudiniGraphql/houdini/commit/53fc6baaa58d4022ae3495c1e0940b07e85d971c)]: - - houdini@2.0.0-go.10 - -## 2.0.0-go.9 - -### Patch Changes - -- Updated dependencies [[`d656515b`](https://github.com/HoudiniGraphql/houdini/commit/d656515bda5835d6e8a19b0e6eb8ecf5627fe34e)]: - - houdini@2.0.0-go.9 - -## 2.0.0-go.8 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.8 - -## 2.0.0-go.7 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.7 - -## 2.0.0-go.6 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.6 - -## 2.0.0-go.5 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.5 - -## 2.0.0-go.4 - -### Patch Changes - -- Updated dependencies [[`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2)]: - - houdini@2.0.0-go.4 - -## 2.0.0-go.3 - -### Patch Changes - -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps - -- Updated dependencies [[`043c4e29`](https://github.com/HoudiniGraphql/houdini/commit/043c4e29ce2c2f41b4a6750b191983e5d53a3540), [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448)]: - - houdini@2.0.0-go.3 - -## 2.0.0-go.2 - -### Patch Changes - -- Updated dependencies [[`6fe29007`](https://github.com/HoudiniGraphql/houdini/commit/6fe290071bf356ef71567ebcbf025b1802f5cb42)]: - - houdini@2.0.0-go.2 - -## 2.0.0-go.1 - -### Patch Changes - -- Updated dependencies [[`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06)]: - - houdini@2.0.0-go.1 - -## 2.0.0-go.0 - -### Major Changes +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang ### Patch Changes -- Updated dependencies [[`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765)]: - - houdini@2.0.0-go.0 +- Updated dependencies [[`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f6e9636`](https://github.com/HoudiniGraphql/houdini/commit/f6e9636f223ff01737a4ca0a5e87aba3bbbeaf1a), [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f), [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f1ae542`](https://github.com/HoudiniGraphql/houdini/commit/f1ae542be6e094b4e39b1b181176c00d4eac1956), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: + - houdini@2.0.0 ## 1.4.1 diff --git a/packages/adapter-static/package.json b/packages/adapter-static/package.json index 192be68bf7..77fcec27a5 100644 --- a/packages/adapter-static/package.json +++ b/packages/adapter-static/package.json @@ -1,6 +1,6 @@ { "name": "houdini-adapter-static", - "version": "2.0.0-next.31", + "version": "2.0.0", "description": "The adapter for deploying your Houdini application as a single-page application without a server component", "keywords": [ "houdini", diff --git a/packages/create-houdini/CHANGELOG.md b/packages/create-houdini/CHANGELOG.md index d889418d44..ba4d2e8c9d 100644 --- a/packages/create-houdini/CHANGELOG.md +++ b/packages/create-houdini/CHANGELOG.md @@ -1,94 +1,12 @@ # create-houdini -## 2.0.0-next.14 - -### Patch Changes - -- [`68d7de4`](https://github.com/HoudiniGraphql/houdini/commit/68d7de47bfd264a8eb33fe79443d90d95d5712d4) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Resolve each template package version independently to avoid stamping versions that haven't been published yet - -## 2.0.0-next.13 - -### Patch Changes - -- [#1631](https://github.com/HoudiniGraphql/houdini/pull/1631) [`86cecd1`](https://github.com/HoudiniGraphql/houdini/commit/86cecd19a8f54662624913400a6d82192639901b) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Bump dependencies to latest: commander ^15, graphql 16.14.1, @clack/prompts ^1.5.1 - -## 2.0.0-next.12 - -### Patch Changes - -- [`68f815be`](https://github.com/HoudiniGraphql/houdini/commit/68f815bebe8884898659a55528e2da5a5776dbd1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Update create script to reflect new init flow - -## 2.0.0-next.11 - -### Patch Changes - -- [`9058799a`](https://github.com/HoudiniGraphql/houdini/commit/9058799a7518f69d98f382f9be7eb7275fe86c74) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Improve init flow and fix dependency issues - -## 2.0.0-next.10 - -### Patch Changes - -- [`899054d5`](https://github.com/HoudiniGraphql/houdini/commit/899054d5d0ec1416dc0e4a3d8bd745093b951642) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix document count when generating ; fix scaffold config runtime" - -## 2.0.0-next.9 - -### Patch Changes - -- [`03e91242`](https://github.com/HoudiniGraphql/houdini/commit/03e912421b88610e9686b600f8e25d0c320ffa37) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - create is more flexible - -## 2.0.0-next.8 - -### Patch Changes - -- [`b23ed369`](https://github.com/HoudiniGraphql/houdini/commit/b23ed369c1a5810acc731364bc5667d085da88b3) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - bump create - -## 2.0.0-next.7 - -### Patch Changes - -- [`8f470769`](https://github.com/HoudiniGraphql/houdini/commit/8f4707693ea2a554b603f92996670e25fee719a1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - bump create - -## 2.0.0-next.6 - -### Patch Changes - -- [#1615](https://github.com/HoudiniGraphql/houdini/pull/1615) [`86124847`](https://github.com/HoudiniGraphql/houdini/commit/861248477429683de8f329bcb2a4da075b9d6122) Thanks [@github-actions](https://github.com/apps/github-actions)! - Fix package.json included in generated runtime - -## 2.0.0-next.5 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -## 2.0.0-go.4 - -### Patch Changes - -- [#1612](https://github.com/HoudiniGraphql/houdini/pull/1612) [`0150e386`](https://github.com/HoudiniGraphql/houdini/commit/0150e386b02b36fb3c98a2cf7f3036e147c93cc7) Thanks [@github-actions](https://github.com/apps/github-actions)! - bump create - -## 2.0.0-go.3 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version - -## 2.0.0-go.2 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang - -## 2.0.0-go.1 - -### Patch Changes - -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps - -## 2.0.0-go.0 - -### Major Changes +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang ## 1.2.65 diff --git a/packages/create-houdini/bin.js b/packages/create-houdini/bin.js index 6ab2eeb101..923986f48d 100755 --- a/packages/create-houdini/bin.js +++ b/packages/create-houdini/bin.js @@ -202,19 +202,15 @@ if (!localSchema) { } } -// the final client config depends on whether we have a local schema or not -const clientConfig = localSchema - ? `` - : `{ - url: '${apiUrl}', -}` +// the api url lives in houdini.config.js (`url`) now, never the client — passing it to +// HoudiniClient throws. So the client takes no config here. +const clientConfig = `` +// a remote api sets the top-level `url` (watchSchema defaults to it), env-switched per build. const configFile = localSchema ? '' : ` - watchSchema: { - url: '${apiUrl}', - }, + url: import.meta.env.VITE_API_URL ?? '${apiUrl}', ` copy( diff --git a/packages/create-houdini/fragments/localSchema/react-typescript/src/api/+schema.ts b/packages/create-houdini/fragments/localSchema/react-typescript/src/server/+schema.ts similarity index 100% rename from packages/create-houdini/fragments/localSchema/react-typescript/src/api/+schema.ts rename to packages/create-houdini/fragments/localSchema/react-typescript/src/server/+schema.ts diff --git a/packages/create-houdini/fragments/localSchema/react/src/api/+schema.js b/packages/create-houdini/fragments/localSchema/react/src/server/+schema.js similarity index 100% rename from packages/create-houdini/fragments/localSchema/react/src/api/+schema.js rename to packages/create-houdini/fragments/localSchema/react/src/server/+schema.js diff --git a/packages/create-houdini/package.json b/packages/create-houdini/package.json index 661fb37ad6..9aed0e879c 100644 --- a/packages/create-houdini/package.json +++ b/packages/create-houdini/package.json @@ -1,6 +1,6 @@ { "name": "create-houdini", - "version": "2.0.0-next.14", + "version": "2.0.0", "description": "A CLI for creating new Houdini projects", "repository": { "type": "git", diff --git a/packages/houdini-core/CHANGELOG.md b/packages/houdini-core/CHANGELOG.md index acd575fc29..04cbff0b36 100644 --- a/packages/houdini-core/CHANGELOG.md +++ b/packages/houdini-core/CHANGELOG.md @@ -1,157 +1,37 @@ # houdini-core -## 2.0.0-next.22 - -### Minor Changes - -- [#1646](https://github.com/HoudiniGraphql/houdini/pull/1646) [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add `record.refresh()` to refetch every document that contains a given cache record, including those that reference it only through a fragment spread. - -### Patch Changes - -- [#1649](https://github.com/HoudiniGraphql/houdini/pull/1649) [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix list filters and @when conditions that contain object values or variable references nested inside objects - -- [#1644](https://github.com/HoudiniGraphql/houdini/pull/1644) [`f40e510`](https://github.com/HoudiniGraphql/houdini/commit/f40e510e0e67cd4ecc444f01662e3163fe45e736) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add support for @includeListID directive - -- [#1648](https://github.com/HoudiniGraphql/houdini/pull/1648) [`5f3fd63`](https://github.com/HoudiniGraphql/houdini/commit/5f3fd635199681ef36ecb90a16df2e109a354c22) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rework argument type validation to follow the GraphQL spec, fixing coercions, `@with` checks, and unknown type/enum reporting ([#1645](https://github.com/HoudiniGraphql/houdini/issues/1645)). - -## 2.0.0-next.21 +## 2.0.2 ### Patch Changes -- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - strip sibling fields from generated pagination query documents so only the paginated field is included +- [#1712](https://github.com/HoudiniGraphql/houdini/pull/1712) [`f5cd43a`](https://github.com/HoudiniGraphql/houdini/commit/f5cd43ae15f897a28cc529d7cc8e6685b6fdb713) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Generate the enum imports in `inputs.ts` with `import type` so projects using TypeScript's `verbatimModuleSyntax` no longer fail to compile. -## 2.0.0-next.20 +## 2.0.1 ### Patch Changes -- [#1639](https://github.com/HoudiniGraphql/houdini/pull/1639) [`b3798cd`](https://github.com/HoudiniGraphql/houdini/commit/b3798cde406da0f4160ee64e6026817162e61959) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix cursor pagination: @paginate path now wins over @list, listPaginated and direction are correctly computed for bidirectional cursor fields - -- [#1639](https://github.com/HoudiniGraphql/houdini/pull/1639) [`b3798cd`](https://github.com/HoudiniGraphql/houdini/commit/b3798cde406da0f4160ee64e6026817162e61959) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - encode per-field pagination direction in pageInfo updates arrays; runtime now drives cache behavior from the artifact instead of hardcoded field names - -## 2.0.0-next.19 - -### Patch Changes - -- [#1638](https://github.com/HoudiniGraphql/houdini/pull/1638) [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix TS2554 in generated injectedPlugins.ts by omitting arguments when a client plugin's config is null - -- [#1638](https://github.com/HoudiniGraphql/houdini/pull/1638) [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix several bugs in paginated connection artifact generation: `@paginate` on a nested field no longer produces an empty refetch path; `hasNextPage`/`hasPreviousPage` updates now propagate correctly; `endCursor`/`startCursor` no longer receive wrong-direction updates; and cache updates no longer leak to grandchildren of paginated connections - -## 2.0.0-next.18 - -### Patch Changes - -- [`a095fcc`](https://github.com/HoudiniGraphql/houdini/commit/a095fcc4eb51d6863a9cabc04b145fa96a53f240) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - publish wasm packages - -## 2.0.0-next.17 - -### Patch Changes - -- [#1631](https://github.com/HoudiniGraphql/houdini/pull/1631) [`86cecd1`](https://github.com/HoudiniGraphql/houdini/commit/86cecd19a8f54662624913400a6d82192639901b) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Bump dependencies to latest: graphql-yoga ^5, @whatwg-node/server ^0.11, minimatch ^10 - -- [#1633](https://github.com/HoudiniGraphql/houdini/pull/1633) [`f84e3cc`](https://github.com/HoudiniGraphql/houdini/commit/f84e3cc00c1c4c70acd0bac2087f08b16af3a879) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix TypeScript types for fragment masking on abstract/interface fields. - -- [#1630](https://github.com/HoudiniGraphql/houdini/pull/1630) [`43d89e0`](https://github.com/HoudiniGraphql/houdini/commit/43d89e0a70b0daf8748ca9225a92b0b2b6bffa7a) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Added WebContainer compatible database layer - -## 2.0.0-next.16 - -### Patch Changes - -- [#1624](https://github.com/HoudiniGraphql/houdini/pull/1624) [`a8c43f7e`](https://github.com/HoudiniGraphql/houdini/commit/a8c43f7e830c0dfe55c808a76c34133f2e0f18cb) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix validation bug when field has default value defined in the schema - -## 2.0.0-next.15 - -### Patch Changes - -- [#1615](https://github.com/HoudiniGraphql/houdini/pull/1615) [`86124847`](https://github.com/HoudiniGraphql/houdini/commit/861248477429683de8f329bcb2a4da075b9d6122) Thanks [@github-actions](https://github.com/apps/github-actions)! - Fix package.json included in generated runtime - -## 2.0.0-next.14 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -## 2.0.0-go.13 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version +- [#1704](https://github.com/HoudiniGraphql/houdini/pull/1704) [`56a57d2`](https://github.com/HoudiniGraphql/houdini/commit/56a57d26837190503e5380ee1c3cd84c17cf613c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Improved ergonomics for `@loading`. -## 2.0.0-go.12 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -## 2.0.0-go.11 +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang -### Patch Changes - -- [#1597](https://github.com/HoudiniGraphql/houdini/pull/1597) [`7990ece2`](https://github.com/HoudiniGraphql/houdini/commit/7990ece2ed5d9a4b807ce2246b298f2777d0a6d9) Thanks [@siddarthvader](https://github.com/siddarthvader)! - generate artifacts pipeline deadlock condition resolve - -## 2.0.0-go.10 - -### Patch Changes - -- [#1595](https://github.com/HoudiniGraphql/houdini/pull/1595) [`3157a458`](https://github.com/HoudiniGraphql/houdini/commit/3157a458206bb15264b5fa124d7656c2257267de) Thanks [@siddarthvader](https://github.com/siddarthvader)! - Fix documents validation for schema that use custom operation types names for query/mutaiton/subscription - -## 2.0.0-go.9 - -### Patch Changes - -- [#1590](https://github.com/HoudiniGraphql/houdini/pull/1590) [`0610efa9`](https://github.com/HoudiniGraphql/houdini/commit/0610efa92e09344216bb1be1cf5610dbba3d570f) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Codegen pipeline now runs 5x faster - -- [#1589](https://github.com/HoudiniGraphql/houdini/pull/1589) [`89252315`](https://github.com/HoudiniGraphql/houdini/commit/8925231525061d0fba35a6b78df5cfd2cde74920) Thanks [@github-actions](https://github.com/apps/github-actions)! - treat argument value seeds as roots in recursive CTE traversal - -## 2.0.0-go.8 - -### Patch Changes - -- [`c90c92b1`](https://github.com/HoudiniGraphql/houdini/commit/c90c92b1e5966b9756676abafc314b6b8e6439fe) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix compatability issue with go binary shim sand pnpm - -## 2.0.0-go.7 - -### Patch Changes - -- [`ae4cdfe4`](https://github.com/HoudiniGraphql/houdini/commit/ae4cdfe445503611ab56330fdc750f79a067ab8d) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix shim replacement for execution - -## 2.0.0-go.6 - -### Patch Changes - -- [`2d60bc70`](https://github.com/HoudiniGraphql/houdini/commit/2d60bc70818bdcbefd3ba177bb56fc69b33f90ea) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - rework postinstall script - -## 2.0.0-go.5 - -### Patch Changes - -- [`d66db310`](https://github.com/HoudiniGraphql/houdini/commit/d66db31026f37c1e8b5f661b8fbc05173b618a0e) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Attempt to fix post install script - -## 2.0.0-go.4 - -### Patch Changes - -- [`7822a62e`](https://github.com/HoudiniGraphql/houdini/commit/7822a62e0421192000dbdf55a1c4379cdfe29358) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix files entry in published package - -## 2.0.0-go.3 +### Minor Changes -### Patch Changes +- [`ef91e5c`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports -- [`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix shim paths +- [`15c9453`](https://github.com/HoudiniGraphql/houdini/commit/15c945382821d5c4f7ddc94892a86d922fcf2c76) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add the `@plural` fragment directive for spreading a fragment on a list field. -## 2.0.0-go.2 +- [#1687](https://github.com/HoudiniGraphql/houdini/pull/1687) [`f1ae542`](https://github.com/HoudiniGraphql/houdini/commit/f1ae542be6e094b4e39b1b181176c00d4eac1956) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add the `@refetch` directive to mark a record in a mutation or subscription response so the cache refetches every document that depends on it once the response is written. -### Patch Changes +- [`15c9453`](https://github.com/HoudiniGraphql/houdini/commit/15c945382821d5c4f7ddc94892a86d922fcf2c76) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add the `@refetchable` directive to mark a fragment as refetchable on its own with new argument values. -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps - -## 2.0.0-go.1 +- [#1646](https://github.com/HoudiniGraphql/houdini/pull/1646) [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add `record.refresh()` to refetch every document that contains a given cache record, including those that reference it only through a fragment spread. ### Patch Changes -- [`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - bump houdini dep version - -## 2.0.0-go.0 - -### Major Changes - -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture +- [#1644](https://github.com/HoudiniGraphql/houdini/pull/1644) [`f40e510`](https://github.com/HoudiniGraphql/houdini/commit/f40e510e0e67cd4ecc444f01662e3163fe45e736) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add support for @includeListID directive diff --git a/packages/houdini-core/package.json b/packages/houdini-core/package.json index d98ea6b920..27da27d9ac 100644 --- a/packages/houdini-core/package.json +++ b/packages/houdini-core/package.json @@ -1,6 +1,6 @@ { "name": "houdini-core", - "version": "2.0.0-next.22", + "version": "2.0.2", "description": "The core GraphQL client for Houdini", "keywords": [ "graphql", diff --git a/packages/houdini-core/plugin/afterValidate.go b/packages/houdini-core/plugin/afterValidate.go index 937c744d9c..2073fb77c8 100644 --- a/packages/houdini-core/plugin/afterValidate.go +++ b/packages/houdini-core/plugin/afterValidate.go @@ -24,6 +24,12 @@ func (p *HoudiniCore) AfterValidate(ctx context.Context) error { return err } + // and the embedded queries for @refetchable fragments + err = lists.PrepareRefetchableDocuments(ctx, p.DB) + if err != nil { + return err + } + // finally, add the necessary fields to ALL documents (including newly created ones) err = documents.AddDocumentFields(ctx, p.DB) if err != nil { diff --git a/packages/houdini-core/plugin/documents/artifacts/artifacts.go b/packages/houdini-core/plugin/documents/artifacts/artifacts.go index 3a3d261073..27e864871a 100644 --- a/packages/houdini-core/plugin/documents/artifacts/artifacts.go +++ b/packages/houdini-core/plugin/documents/artifacts/artifacts.go @@ -105,9 +105,7 @@ func GenerateDocumentArtifacts( continue } - if fp != "" { - filepaths.Append(fp) - } + filepaths.Append(fp) } }() } diff --git a/packages/houdini-core/plugin/documents/artifacts/endpoint.go b/packages/houdini-core/plugin/documents/artifacts/endpoint.go new file mode 100644 index 0000000000..5d15b44ee5 --- /dev/null +++ b/packages/houdini-core/plugin/documents/artifacts/endpoint.go @@ -0,0 +1,134 @@ +package artifacts + +import ( + "fmt" + "strconv" + "strings" + + "code.houdinigraphql.com/packages/houdini-core/plugin/documents/collected" + "code.houdinigraphql.com/plugins/graphql" +) + +// uploadScalars are the conventional GraphQL scalar names for file uploads — "File" is +// Houdini's convention, "Upload" the graphql-multipart-request spec's. A mutation with a +// variable of either type gets a multipart form so the browser POSTs files natively. +var uploadScalars = map[string]bool{"File": true, "Upload": true} + +// buildEndpointArtifact returns the `"endpoint": { ... },` block for a document's +// compiled artifact, or "" when the document has no @endpoint directive. The presence +// of the block is the static-analysis marker that a mutation is form-submittable; the +// fields inside are what the runtime and server form handler consume: +// +// - "redirect" — the parsed template as a compact mixed array (literals are strings, +// interpolation paths are nested arrays), so both paths interpolate it identically. +// - "multipart" — true when any variable is Upload-typed (so the form sets enctype). +// - "id" — the explicit @endpoint(id:) form id, when given. +func buildEndpointArtifact(doc *collected.Document) string { + var directive *collected.Directive + for _, d := range doc.Directives { + if d.Name == graphql.EndpointDirective { + directive = d + break + } + } + if directive == nil { + return "" + } + + redirect := "" + hasRedirect := false + id := "" + hasID := false + var allowFields []string + hasFields := false + for _, arg := range directive.Arguments { + if arg.Value == nil { + continue + } + switch arg.Name { + case "redirect": + redirect = arg.Value.Raw + hasRedirect = true + case "id": + id = arg.Value.Raw + hasID = true + case "fields": + // a list of form-field names; collect each child's raw string + hasFields = true + for _, child := range arg.Value.Children { + if child.Value != nil { + allowFields = append(allowFields, child.Value.Raw) + } + } + } + } + + var fields strings.Builder + if hasRedirect { + fmt.Fprintf(&fields, ` + "redirect": %s,`, serializeRedirectTemplate(redirect)) + } + if documentHasUpload(doc) { + fields.WriteString(` + "multipart": true,`) + } + if hasID { + fmt.Fprintf(&fields, ` + "id": %s,`, strconv.Quote(id)) + } + if hasFields { + var list strings.Builder + for i, f := range allowFields { + if i > 0 { + list.WriteString(", ") + } + list.WriteString(strconv.Quote(f)) + } + fmt.Fprintf(&fields, ` + "fields": [%s],`, list.String()) + } + + return fmt.Sprintf(` + + "endpoint": {%s + },`, fields.String()) +} + +// documentHasUpload reports whether any of the document's variables is a file-upload +// scalar (regardless of list/non-null wrappers — variable.Type holds the base type name). +func documentHasUpload(doc *collected.Document) bool { + for _, variable := range doc.Variables { + if uploadScalars[variable.Type] { + return true + } + } + return false +} + +// serializeRedirectTemplate renders a parsed redirect template as the compact mixed +// array used in the artifact: literal segments are quoted strings, interpolation paths +// are nested arrays of quoted segments. e.g. "/users/{ createUser.id }" → +// ["/users/", ["createUser", "id"]]. +func serializeRedirectTemplate(template string) string { + var b strings.Builder + b.WriteString("[") + for i, part := range graphql.ParseRedirectTemplate(template) { + if i > 0 { + b.WriteString(", ") + } + if part.Path != nil { + b.WriteString("[") + for j, segment := range part.Path { + if j > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(segment)) + } + b.WriteString("]") + } else { + b.WriteString(strconv.Quote(part.Literal)) + } + } + b.WriteString("]") + return b.String() +} diff --git a/packages/houdini-core/plugin/documents/artifacts/print.go b/packages/houdini-core/plugin/documents/artifacts/print.go index 824f78c236..16c781bf7e 100644 --- a/packages/houdini-core/plugin/documents/artifacts/print.go +++ b/packages/houdini-core/plugin/documents/artifacts/print.go @@ -355,6 +355,28 @@ func printValue(value *collected.ArgumentValue, usedVariables map[string]bool) s switch value.Kind { case "Enum": return value.Raw + case "Object": + var resultBuilder strings.Builder + resultBuilder.WriteRune('{') + for i, v := range value.Children { + fmt.Fprintf(&resultBuilder, "%s: %s", v.Name, printValue(v.Value, usedVariables)) + if i != len(value.Children)-1 { + resultBuilder.WriteString(", ") + } + } + resultBuilder.WriteRune('}') + return resultBuilder.String() + case "List": + var resultBuilder strings.Builder + resultBuilder.WriteRune('[') + for i, v := range value.Children { + resultBuilder.WriteString(printValue(v.Value, usedVariables)) + if i != len(value.Children)-1 { + resultBuilder.WriteString(", ") + } + } + resultBuilder.WriteRune(']') + return resultBuilder.String() default: return stringifyValue(value, usedVariables) } diff --git a/packages/houdini-core/plugin/documents/artifacts/print_test.go b/packages/houdini-core/plugin/documents/artifacts/print_test.go index 9061a0499f..b5c8a463ea 100644 --- a/packages/houdini-core/plugin/documents/artifacts/print_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/print_test.go @@ -91,6 +91,7 @@ func TestDocumentCollectAndPrint(t *testing.T) { input ObjectInput { key: String block: String + site: Site } scalar ComplexType @@ -374,6 +375,28 @@ func TestDocumentCollectAndPrint(t *testing.T) { `), }, }, + { + Name: "Enum literal inside object argument is not quoted", + Pass: true, + Input: []string{ + ` + fragment EnumObjFrag on Friend @arguments(size: {type: "String"}, b: {type: "String"}) { + foo( + size: $size + bar: $b + obj: {key: "value", site: MOBILE} + ) + } + `, + }, + Extra: map[string]any{ + "EnumObjFrag": tests.Dedent(` + fragment EnumObjFrag on Friend { + foo(bar: $b, obj: {key: "value", site: MOBILE}, size: $size) + } + `), + }, + }, { Name: "Ignore internal directives", Pass: true, diff --git a/packages/houdini-core/plugin/documents/artifacts/selection.go b/packages/houdini-core/plugin/documents/artifacts/selection.go index e6be098e04..ca30ec7eb8 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/spf13/afero" - "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/packages/houdini-core/plugin/documents/artifacts/typescript" @@ -53,8 +52,9 @@ func writeSelectionDocument( artifactPath := projectConfig.ArtifactPath(name) // skip the write if the content hasn't changed (common on incremental runs) - if existing, err := afero.ReadFile(fs, artifactPath); err == nil && string(existing) == artifact { - return "", nil + if existing, err := afero.ReadFile(fs, artifactPath); err == nil && + string(existing) == artifact { + return artifactPath, nil } // write the file to disk @@ -141,11 +141,19 @@ func GenerateSelectionDocument( // dedupe config dedupe := "" + // @plural marks a fragment as list-shaped (consumed as an array of items) + pluralValue := "" + // we need to compute the cache policy for the document cachePolicy := projectConfig.DefaultCachePolicy partial := projectConfig.DefaultPartial for _, directive := range doc.Directives { switch directive.Name { + case graphql.PluralDirective: + pluralValue = ` + + "plural": true,` + case graphql.DedupeDirective: cancel := "last" match := "Variables" @@ -315,6 +323,13 @@ func GenerateSelectionDocument( "partial": %v`, partial) } + // @endpoint emits the form metadata (parsed redirect, multipart flag, form id) + // the runtime hook and server form handler consume + endpointValue := buildEndpointArtifact(doc) + + // @session emits the sessionPath — the result field whose value becomes the session + sessionValue := buildSessionArtifact(doc) + // we need to track the optimistic keys optimistic := "" if flags.OptimisticKeys { @@ -412,12 +427,50 @@ func GenerateSelectionDocument( "enableLoadingState": "%s",`, flags.HasLoading) } + // document-level operations (eg @refetch) collected during the walk + operations := "" + if len(flags.RootOperations) > 0 { + var opBuilder strings.Builder + for i, op := range flags.RootOperations { + if i > 0 { + opBuilder.WriteString(", ") + } + + var pathBuilder strings.Builder + pathBuilder.WriteByte('[') + for j, field := range op.Path { + if j > 0 { + pathBuilder.WriteByte(',') + } + pathBuilder.WriteByte('"') + pathBuilder.WriteString(field) + pathBuilder.WriteByte('"') + } + pathBuilder.WriteByte(']') + + opBuilder.WriteString(fmt.Sprintf(`{ + "action": "%s", + "type": "%s", + "path": %s + }`, op.Action, op.Type, pathBuilder.String())) + } + operations = fmt.Sprintf(` + + "operations": [%s],`, opBuilder.String()) + } + // compute the type definitions + unmaskedSelection, err := FlattenSelection(ctx, docs, name, false, sortKeys) + if err != nil { + return "", err + } typeDefs, imports, err := typescript.GenerateDocumentTypeDefs( projectConfig, rootTypes, docs, doc, + unmaskedSelection, + sortKeys, ) if err != nil { return "", err @@ -434,9 +487,9 @@ const artifact = { "rootType": "%s", "stripVariables": %s as Array, - "selection": %s, + "selection": %s,%s - "pluginData": %s,%s%s%s%s%s%s%s + "pluginData": %s,%s%s%s%s%s%s%s%s%s%s } as const export default artifact @@ -453,9 +506,13 @@ export default artifact doc.TypeCondition, string(stripVariables), selectionValues, + operations, string(marshaledData), componentFields, dedupe, + pluralValue, + endpointValue, + sessionValue, inputTypes, loadingValue, policyValue, @@ -561,6 +618,18 @@ func (pb *PathBuilder) Current() []string { return result } +// FieldPath returns the response path ending at the given field, making sure the +// field's own alias is the final segment. inline fragments invoke +// stringifyFieldSelection without pushing the field onto the builder, so we append +// it when it isn't already there. shared by @refetch and pagination (@list/@paginate). +func (pb *PathBuilder) FieldPath(selection *collected.Selection) []string { + current := pb.Current() + if len(current) == 0 || current[len(current)-1] != *selection.Alias { + current = append(current, *selection.Alias) + } + return current +} + // Len returns the current path depth func (pb *PathBuilder) Len() int { return len(pb.path) @@ -595,7 +664,12 @@ func stringifySelection( loadingTypes := []string{} for _, selection := range selections { - hasLoading := false + // inherit the cascading loading state (a document-level @loading, or a parent + // field's @loading(cascade: true)) so that fragment spreads participate in the + // loading state just like fields do. without this a spread under a global @loading + // is omitted from the loading-state selection entirely. mirrors the field path, + // which seeds hasLoading from forceLoading. + hasLoading := forceLoading for _, directive := range selection.Directives { switch directive.Name { case graphql.LoadingDirective: @@ -979,6 +1053,19 @@ func stringifyFieldSelection( indent5 := strings.Repeat(spacing, level+4) indent6 := strings.Repeat(spacing, level+5) + // @refetch is a document-level operation: record the path to this field's + // record so the runtime can refresh every dependent document after the write + for _, directive := range selection.Directives { + if directive.Name == graphql.RefetchDirective { + flags.RootOperations = append(flags.RootOperations, RootOperation{ + Action: "refetch", + Type: selection.FieldType, + Path: pathBuilder.FieldPath(selection), + }) + break + } + } + // figure out the pagination state var paginatedMode *string paginatedTargetType := "Query" @@ -1001,17 +1088,8 @@ func stringifyFieldSelection( // @paginate always wins over @list when both appear in the same document if flags.Refetch == nil || selection.List.Paginated { // use the computed path for list operations (both paginated and non-paginated) - currentPath := pathBuilder.Current() - - // For list fields (both @list and @paginate), ensure the field is included in the path - // This handles cases where fragments don't include the field in the path - fullPath := currentPath - if len(currentPath) == 0 || currentPath[len(currentPath)-1] != *selection.Alias { - fullPath = append(currentPath, *selection.Alias) - } - flags.Refetch = &RefetchSpec{ - Path: fullPath, + Path: pathBuilder.FieldPath(selection), Paginated: selection.List.Paginated, PageSize: selection.List.PageSize, Mode: RefetchMode(selection.List.Mode), @@ -1704,11 +1782,10 @@ func serializeFragmentArgument(arg *collected.ArgumentValue, level int) string { switch arg.Kind { case "Variable": attrs = fmt.Sprintf(` -%sname: { +%s"name": { %s"kind": "Name", %s"value": "%s", -%s}, -%s"value": "%s"`, indent1, indent2, indent2, arg.Raw, indent1, indent1, arg.Raw) +%s}`, indent1, indent2, indent2, arg.Raw, indent1) case "String", "Enum": attrs = fmt.Sprintf(` %s"value": "%s"`, indent1, arg.Raw) @@ -1735,17 +1812,27 @@ func serializeFragmentArgument(arg *collected.ArgumentValue, level int) string { %s"values": [%s]`, indent1, children, ) case "Object": + indent3 := strings.Repeat(spacing, level+3) fields := "" for _, child := range arg.Children { if len(fields) > 0 { fields += ", " } - fields += fmt.Sprintf( - `{"%s": %s}`, - child.Name, - serializeFragmentArgument(child.Value, level+1), - ) + fields += fmt.Sprintf(`{ +%s"kind": "ObjectField", +%s"name": { +%s"kind": "Name", +%s"value": "%s", +%s}, +%s"value": %s +%s}`, + indent2, + indent2, indent3, indent3, child.Name, indent2, + indent2, serializeFragmentArgument(child.Value, level+2), + indent1) } + attrs = fmt.Sprintf(` +%s"fields": [%s]`, indent1, fields) } return fmt.Sprintf(`{ @@ -1758,6 +1845,17 @@ type ArtifactFlags struct { Refetch *RefetchSpec ComponentFields bool HasLoading string + // document-level operations collected during the walk (eg @refetch) + RootOperations []RootOperation +} + +// RootOperation is a side effect applied after a document's response is written +// to the cache. @refetch records the path to a record that every dependent +// document should refetch. +type RootOperation struct { + Action string + Type string + Path []string } type SelectionFlags struct { diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go index 1ac4cc2684..db2cb98e06 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_conditional_test.go @@ -142,6 +142,14 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=c1028fc6e5213fd703e4e3f2138294735428be9437e68eab4d9854ba91f14c0c"`), @@ -220,11 +228,10 @@ fragment UserDetails on User { "arguments": { "if": { "kind": "Variable", - name: { + "name": { "kind": "Name", "value": "show", - }, - "value": "show" + } } } }], @@ -293,6 +300,14 @@ export type TestQuery$input = { show: boolean; }; +export type TestQuery$unmasked = { + readonly node: {} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + })) | null; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=2bc145fc0631e7f8e71fa91c1b77afc0b2aba93ca8fe5f39dc4dbfeceb158063"`), @@ -357,11 +372,10 @@ fragment UserDetails on User { "arguments": { "if": { "kind": "Variable", - name: { + "name": { "kind": "Name", "value": "show", - }, - "value": "show" + } } } }], @@ -425,6 +439,14 @@ export type TestQuery$input = { show: boolean; }; +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=4d664747270e4504bff250185fc0abd5a6778f75b203cbc5e8dca84aa8c36d93"`), diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go index 36313a7e2e..1074ac05e9 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_lists_test.go @@ -258,6 +258,14 @@ func TestListArtifacts(t *testing.T) { value: string; }; + export type TestQuery$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + })[]; + }; + export type TestQuery$artifact = typeof artifact "HoudiniHash=dc502dd533f31553a3c311a7aaa782d82f81d7f7a8816d5095f96584a7600004" @@ -475,6 +483,27 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly usersByCursor: { + readonly __typename: "UserConnection"; + readonly edges: ({ + readonly __typename: "UserEdge"; + readonly cursor: string; + readonly node: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + } | null; + })[]; + readonly pageInfo: { + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=601c84fb968090be239b4de6aabf1493ddd5723c0a3ce6f21a4cef0588c74d7a"`), @@ -741,6 +770,27 @@ export type TestQuery$input = { last?: number | null; }; +export type TestQuery$unmasked = { + readonly usersByCursor: { + readonly __typename: "UserConnection"; + readonly edges: ({ + readonly __typename: "UserEdge"; + readonly cursor: string; + readonly node: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + } | null; + })[]; + readonly pageInfo: { + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=2ab71008736af6ef21d3e5a414af57585acb8921743df58a4f258a88407fd212"`), @@ -1006,6 +1056,27 @@ export type TestQuery$input = { last?: number | null; }; +export type TestQuery$unmasked = { + readonly usersByCursor: { + readonly __typename: "UserConnection"; + readonly edges: ({ + readonly __typename: "UserEdge"; + readonly cursor: string; + readonly node: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + } | null; + })[]; + readonly pageInfo: { + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=2ab71008736af6ef21d3e5a414af57585acb8921743df58a4f258a88407fd212"`), @@ -1313,6 +1384,28 @@ export type AnimalQuery$result = { export type AnimalQuery$input = null | undefined; +export type AnimalQuery$unmasked = { + readonly animals: {} & (({ + readonly edges: ({ + readonly __typename: "MonkeyEdge"; + readonly node: { + readonly __typename: "Monkey"; + readonly hasBanana: boolean; + readonly id: string; + readonly name: string; + } | null; + })[]; + readonly pageInfo: { + readonly __typename: "PageInfo"; + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + readonly __typename: "MonkeyConnection"; + })) | null; +}; + export type AnimalQuery$artifact = typeof artifact "HoudiniHash=4f37a478d045b157f6b5a17228a3e63b13351e87346407203c9620b7f3ed5e40"`), @@ -1505,6 +1598,21 @@ export type AnimalsOverview$result = { export type AnimalsOverview$input = null | undefined; +export type AnimalsOverview$unmasked = { + readonly animals: { + readonly __typename: string; + readonly edges: ({ + readonly __typename: string; + readonly node: {} & (({ + readonly hasBanana: boolean; + readonly id: string; + readonly name: string; + readonly __typename: "Monkey"; + })) | null; + })[]; + } | null; +}; + export type AnimalsOverview$artifact = typeof artifact "HoudiniHash=dde990d4e5db6c676245f47bcc5403e1fcfcfda20e171402deb31e1ee0b50c35"`), @@ -1672,6 +1780,18 @@ export type Entities$result = { export type Entities$input = null | undefined; +export type Entities$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly id: string; + readonly name: string; + readonly __typename: "User"; + })))[]; +}; + export type Entities$artifact = typeof artifact "HoudiniHash=0780776e735ef956acb43484910401ac645b29582b83432084ee8717a48d01da"`), @@ -1903,6 +2023,27 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly usersByCursor: { + readonly __typename: "UserConnection"; + readonly edges: ({ + readonly __typename: "UserEdge"; + readonly cursor: string; + readonly node: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + } | null; + })[]; + readonly pageInfo: { + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=efc384927733daadaff58ef5818480ea7db4f0448a7fa733179fdbfad50b067b"`), diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go index 983814e0f2..235bfa4052 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_loading_test.go @@ -305,13 +305,34 @@ export type MonkeyListQuery$result = { readonly monkeys: { readonly pageInfo: LoadingType; readonly " $fragments": { - AnimalsList: {}; + AnimalsList: LoadingType; }; }; }; export type MonkeyListQuery$input = null | undefined; +export type MonkeyListQuery$unmasked = { + readonly monkeys: { + readonly __typename: "MonkeyConnection"; + readonly edges: ({ + readonly __typename: string; + readonly node: { + readonly __typename: string; + readonly id: string; + readonly name: string; + } | null; + })[]; + readonly pageInfo: { + readonly __typename: "PageInfo"; + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; +}; + export type MonkeyListQuery$artifact = typeof artifact "HoudiniHash=ece6ef3e8361e90d01206d34ba36afbeed2fb1903e3946aaa65790ffa7f1d0a2"`, @@ -538,15 +559,30 @@ export type Query$result = { readonly catOwners: { readonly cats: { readonly id: LoadingType; - }; + }[]; readonly User: { readonly firstName: LoadingType; }; - }; + }[]; }; export type Query$input = null | undefined; +export type Query$unmasked = { + readonly catOwners: ({} & (({ + readonly cats: ({ + readonly __typename: "Cat"; + readonly id: string; + })[]; + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type Query$artifact = typeof artifact "HoudiniHash=a7e16dc3a8fe4cc7a47a16444f1809cbc0865be2d997ae28e4e2e2539e890841"`, @@ -721,11 +757,26 @@ export type Query$result = { readonly Cat: { readonly name: LoadingType; }; - }; + }[]; }; export type Query$input = null | undefined; +export type Query$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type Query$artifact = typeof artifact "HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a"`, @@ -868,13 +919,24 @@ export type Query$result = { } | { readonly entity: { readonly " $fragments": { - Info: {}; + Info: LoadingType; }; }; }; export type Query$input = null | undefined; +export type Query$unmasked = { + readonly entity: {} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })); +}; + export type Query$artifact = typeof artifact "HoudiniHash=5ba953f37cfa2e0ce515c4c22ce9c6e3206fc05adc5c96fbc7a3184691d672f1"`, @@ -1042,11 +1104,26 @@ export type Query$result = { readonly User: { readonly firstName: LoadingType; }; - }; + }[]; }; export type Query$input = null | undefined; +export type Query$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type Query$artifact = typeof artifact "HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a"`, @@ -1165,6 +1242,8 @@ const artifact = { "typeMap": {}, }, + + "loadingTypes": ["Cat", "User"], }, "loading": { @@ -1223,6 +1302,21 @@ export type Query$result = { export type Query$input = null | undefined; +export type Query$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type Query$artifact = typeof artifact "HoudiniHash=75a077637efd548c3e2b73c0d6ba3a6b0adbf92d3661c3907253b00c250d594a"`, @@ -1406,6 +1500,8 @@ const artifact = { "typeMap": {}, }, + + "loadingTypes": ["Cat", "User"], }, "loading": { @@ -1472,12 +1568,231 @@ export type Query$result = { export type Query$input = null | undefined; +export type Query$unmasked = { + readonly b: ({} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type Query$artifact = typeof artifact "HoudiniHash=8a12a21168a8db7431b74a680bdce400f24aa9c577674893fddbf89c6d0a7877"`, ), }, }, + { + // a document-level @loading must cascade onto fragment spreads, not just + // fields. without this the spread is omitted from the loading-state + // selection (no "loading": true), so the runtime can't bind it during the + // loading frame. + Name: "global @loading cascades to fragment spreads", + Pass: true, + Input: []string{ + ` + query GlobalLoadingSpreadQuery @loading { + monkeys { + ...ConnectionInfo + } + } + `, + ` + fragment ConnectionInfo on AnimalConnection { + pageInfo { + hasNextPage + } + } + `, + }, + Extra: map[string]any{ + "GlobalLoadingSpreadQuery": tests.Dedent( + `import type { LoadingType } from "houdini/runtime"; +const artifact = { + "name": "GlobalLoadingSpreadQuery", + "kind": "HoudiniQuery", + "hash": "f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370", + "raw": ` + "`" + `fragment ConnectionInfo on AnimalConnection { + pageInfo { + hasNextPage + __typename + } + __typename +} + +query GlobalLoadingSpreadQuery { + monkeys { + ...ConnectionInfo + __typename + } +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "monkeys": { + "type": "MonkeyConnection", + "keyRaw": "monkeys", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + + "pageInfo": { + "type": "PageInfo", + "keyRaw": "pageInfo", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "loading": { + "kind": "value", + }, + }, + + "hasNextPage": { + "type": "Boolean", + "keyRaw": "hasNextPage", + "loading": { + "kind": "value", + }, + }, + }, + }, + + "loading": { + "kind": "continue", + }, + }, + }, + + "fragments": { + "ConnectionInfo": { + "arguments": {}, + "loading": true, + }, + }, + }, + + "loading": { + "kind": "continue", + }, + "visible": true, + }, + }, + }, + + "pluginData": {}, + "enableLoadingState": "global", + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type GlobalLoadingSpreadQuery = { + readonly "input"?: GlobalLoadingSpreadQuery$input; + readonly "result": GlobalLoadingSpreadQuery$result | undefined; +}; + +export type GlobalLoadingSpreadQuery$result = { + readonly monkeys: { + readonly " $fragments": { + ConnectionInfo: {}; + }; + }; +} | { + readonly monkeys: { + readonly " $fragments": { + ConnectionInfo: LoadingType; + }; + }; +}; + +export type GlobalLoadingSpreadQuery$input = null | undefined; + +export type GlobalLoadingSpreadQuery$unmasked = { + readonly monkeys: { + readonly __typename: "MonkeyConnection"; + readonly pageInfo: { + readonly __typename: "PageInfo"; + readonly hasNextPage: boolean; + }; + }; +}; + +export type GlobalLoadingSpreadQuery$artifact = typeof artifact + +"HoudiniHash=f68f32cc631419ea7b0fcbdf8849a91e66be8fbd77af9543cfda312e02438370"`, + ), + }, + }, + { + Name: "document-level @loading composes with field-level @loading(count) on a list", + Pass: true, + Input: []string{ + `query GlobalListConfig @loading { + monkeys { + pageInfo { + hasNextPage + } + edges @loading(count: 2) { + node { + id + } + } + } + }`, + }, + Extra: map[string]any{ + "GlobalListConfig": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"GlobalListConfig\",\n \"kind\": \"HoudiniQuery\",\n \"hash\": \"bf928586ecef8632f1df8ce14f2ec7d9012348852c086f026c174035128d92ba\",\n \"raw\": `query GlobalListConfig {\n monkeys {\n pageInfo {\n hasNextPage\n __typename\n }\n edges {\n node {\n id\n __typename\n }\n __typename\n }\n __typename\n }\n}\n`,\n\n \"rootType\": \"Query\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"monkeys\": {\n \"type\": \"MonkeyConnection\",\n \"keyRaw\": \"monkeys\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"edges\": {\n \"type\": \"MonkeyEdge\",\n \"keyRaw\": \"edges\",\n\n \"directives\": [{\n \"name\": \"loading\",\n \"arguments\": {\n \"count\": {\n \"kind\": \"IntValue\",\n \"value\": \"2\"\n }\n }\n }],\n\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"node\": {\n \"type\": \"Monkey\",\n \"keyRaw\": \"node\",\n \"nullable\": true,\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n \"list\": {\n \"depth\": 1,\n \"count\": 2,\n },\n },\n \"visible\": true,\n },\n\n \"pageInfo\": {\n \"type\": \"PageInfo\",\n \"keyRaw\": \"pageInfo\",\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n },\n\n \"hasNextPage\": {\n \"type\": \"Boolean\",\n \"keyRaw\": \"hasNextPage\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"loading\": {\n \"kind\": \"continue\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"local\",\n \"policy\": \"CacheOrNetwork\",\n \"partial\": false\n} as const\n\nexport default artifact\n\nexport type GlobalListConfig = {\n\treadonly \"input\"?: GlobalListConfig$input;\n\treadonly \"result\": GlobalListConfig$result | undefined;\n};\n\nexport type GlobalListConfig$result = {\n\treadonly monkeys: {\n\t\treadonly pageInfo: {\n\t\t\treadonly hasNextPage: boolean;\n\t\t};\n\t\treadonly edges: ({\n\t\t\treadonly node: {\n\t\t\t\treadonly id: string;\n\t\t\t} | null;\n\t\t})[];\n\t};\n} | {\n\treadonly monkeys: {\n\t\treadonly pageInfo: {\n\t\t\treadonly hasNextPage: LoadingType;\n\t\t};\n\t\treadonly edges: {\n\t\t\treadonly node: {\n\t\t\t\treadonly id: LoadingType;\n\t\t\t};\n\t\t}[];\n\t};\n};\n\nexport type GlobalListConfig$input = null | undefined;\n\nexport type GlobalListConfig$unmasked = {\n\treadonly monkeys: {\n\t\treadonly __typename: \"MonkeyConnection\";\n\t\treadonly edges: ({\n\t\t\treadonly __typename: \"MonkeyEdge\";\n\t\t\treadonly node: {\n\t\t\t\treadonly __typename: \"Monkey\";\n\t\t\t\treadonly id: string;\n\t\t\t} | null;\n\t\t})[];\n\t\treadonly pageInfo: {\n\t\t\treadonly __typename: \"PageInfo\";\n\t\t\treadonly hasNextPage: boolean;\n\t\t};\n\t};\n};\n\nexport type GlobalListConfig$artifact = typeof artifact\n\n\"HoudiniHash=bf928586ecef8632f1df8ce14f2ec7d9012348852c086f026c174035128d92ba\"", + }, + }, + { + Name: "definition-level @loading on a fragment generates a loading variant", + Pass: true, + Input: []string{ + `query CascadeFragmentLoadingQuery { + entity { + ... on Cat { + ...CascadeFragmentLoading + } + } + }`, + `fragment CascadeFragmentLoading on Cat @loading { + name + }`, + }, + Extra: map[string]any{ + "CascadeFragmentLoading": "import type { LoadingType } from \"houdini/runtime\";\nconst artifact = {\n \"name\": \"CascadeFragmentLoading\",\n \"kind\": \"HoudiniFragment\",\n \"hash\": \"ae47388a479222f23e79d8bac545d2111a5698c2fcae08e6c8028813d56ec112\",\n \"raw\": `fragment CascadeFragmentLoading on Cat {\n name\n __typename\n id\n}\n`,\n\n \"rootType\": \"Cat\",\n \"stripVariables\": [] as Array,\n\n \"selection\": {\n \"fields\": {\n \"__typename\": {\n \"type\": \"String\",\n \"keyRaw\": \"__typename\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n\n \"id\": {\n \"type\": \"ID\",\n \"keyRaw\": \"id\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n\n \"name\": {\n \"type\": \"String\",\n \"keyRaw\": \"name\",\n \"loading\": {\n \"kind\": \"value\",\n },\n \"visible\": true,\n },\n },\n },\n\n \"pluginData\": {},\n \"enableLoadingState\": \"global\",\n} as const\n\nexport default artifact\n\nexport type CascadeFragmentLoading$input = never;\n\nexport type CascadeFragmentLoading = {\n\treadonly \"shape\"?: CascadeFragmentLoading$data;\n\treadonly \" $fragments\": {\n\t\t\"CascadeFragmentLoading\": { readonly \"expected a CascadeFragmentLoading fragment spread\"?: never } | LoadingType;\n\t};\n};\n\nexport type CascadeFragmentLoading$data = {\n\treadonly name: string;\n} | {\n\treadonly name: LoadingType;\n};\n\nexport type CascadeFragmentLoading$artifact = typeof artifact\n\n\"HoudiniHash=ae47388a479222f23e79d8bac545d2111a5698c2fcae08e6c8028813d56ec112\"", + }, + }, }, }) } diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go index 59dc7d2149..a6bc65cfc7 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_operations_test.go @@ -21,14 +21,21 @@ func TestArtifactOperationsGeneration(t *testing.T) { users: [User!]! } - type User { + interface Node { + id: ID! + } + + type User implements Node { id: ID! firstName: String! field(filter: String): String + bestFriend: User } type AddFriendOutput { friend: User! + friends: [User!]! + node: Node } type DeleteUserOutput { @@ -144,6 +151,17 @@ export type B$optimistic = { }; }; +export type B$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type B$artifact = typeof artifact "HoudiniHash=9ce380e593f0ad23179092018fff6667f3249e9fc261be13c40a7291c1f151c6"`), @@ -280,6 +298,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=425691bbfea3900b92488e1ab1c9d6ee50242cadb1de2336342766d9577656f1"`), @@ -307,8 +336,8 @@ export type A$artifact = typeof artifact "A": tests.Dedent(`const artifact = { "name": "A", "kind": "HoudiniMutation", - "hash": "5c4e7db84da4cc870dab20430a5f4a1895573dbbbd3f7568caee055770ad0370", - "raw": ` + "`" + `mutation A() { + "hash": "478267e6079162675775c31eaffa1e1108c883b24f7b3ff81f1caed9ad415cd6", + "raw": ` + "`" + `mutation A { addFriend { friend { ...All_Users_insert_kVR6H @@ -430,9 +459,21 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly field: string | null; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact -"HoudiniHash=5c4e7db84da4cc870dab20430a5f4a1895573dbbbd3f7568caee055770ad0370"`), +"HoudiniHash=478267e6079162675775c31eaffa1e1108c883b24f7b3ff81f1caed9ad415cd6"`), }, }, { @@ -590,6 +631,17 @@ export type B$optimistic = { }; }; +export type B$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type B$artifact = typeof artifact "HoudiniHash=680082591789d4a74f7136909b1e6349e6561f44b777de23138d5dda947e4150"`), @@ -735,6 +787,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=425691bbfea3900b92488e1ab1c9d6ee50242cadb1de2336342766d9577656f1"`), @@ -880,6 +943,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=425691bbfea3900b92488e1ab1c9d6ee50242cadb1de2336342766d9577656f1"`), @@ -1031,6 +1105,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=425691bbfea3900b92488e1ab1c9d6ee50242cadb1de2336342766d9577656f1"`), @@ -1058,8 +1143,8 @@ export type A$artifact = typeof artifact "A": tests.Dedent(`const artifact = { "name": "A", "kind": "HoudiniMutation", - "hash": "5c4e7db84da4cc870dab20430a5f4a1895573dbbbd3f7568caee055770ad0370", - "raw": ` + "`" + `mutation A() { + "hash": "478267e6079162675775c31eaffa1e1108c883b24f7b3ff81f1caed9ad415cd6", + "raw": ` + "`" + `mutation A { addFriend { friend { ...All_Users_insert_kVR6H @@ -1182,9 +1267,21 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly field: string | null; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact -"HoudiniHash=5c4e7db84da4cc870dab20430a5f4a1895573dbbbd3f7568caee055770ad0370"`), +"HoudiniHash=478267e6079162675775c31eaffa1e1108c883b24f7b3ff81f1caed9ad415cd6"`), }, }, { @@ -1294,6 +1391,16 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=8a080e59ca9f1fbf5e83ed5f778594c5fb2271fc6f48291ca27c18d0b1583c32"`), @@ -1431,6 +1538,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=425691bbfea3900b92488e1ab1c9d6ee50242cadb1de2336342766d9577656f1"`), @@ -1559,6 +1677,16 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=5b4c90b131ad3fa0c82375c8a3ead0b8f6a2f62c87e60af202ea0989beb3e71e"`), @@ -1696,6 +1824,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=716a789bd735c599d781df5adeb1fd159af7b32d1dc72f4ad425ed5354c126b8"`), @@ -1723,8 +1862,8 @@ export type A$artifact = typeof artifact "A": tests.Dedent(`const artifact = { "name": "A", "kind": "HoudiniMutation", - "hash": "14c8b84f85cf39c1e786506a09bbeaa617139aa22c6723b595eaf0b7b29ca441", - "raw": ` + "`" + `mutation A() { + "hash": "2a2d7cbe16d4430cd3c817bc3f5ea605fadb3a84bf2574a15413322cc513da88", + "raw": ` + "`" + `mutation A { addFriend { friend { ...All_Users_toggle_kVR6H @@ -1847,9 +1986,21 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly field: string | null; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact -"HoudiniHash=14c8b84f85cf39c1e786506a09bbeaa617139aa22c6723b595eaf0b7b29ca441"`), +"HoudiniHash=2a2d7cbe16d4430cd3c817bc3f5ea605fadb3a84bf2574a15413322cc513da88"`), }, }, { @@ -1987,6 +2138,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=716a789bd735c599d781df5adeb1fd159af7b32d1dc72f4ad425ed5354c126b8"`), @@ -2126,6 +2288,17 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=716a789bd735c599d781df5adeb1fd159af7b32d1dc72f4ad425ed5354c126b8"`), @@ -2253,6 +2426,16 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=5b4c90b131ad3fa0c82375c8a3ead0b8f6a2f62c87e60af202ea0989beb3e71e"`), @@ -2351,6 +2534,13 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly deleteUser: { + readonly __typename: "DeleteUserOutput"; + readonly userID: string | null; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=74a70a5832df8760e9a80f1b32360a58e5c6ecd48551606448ce2cd6bbae28c2"`), @@ -2466,6 +2656,13 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly deleteUser: { + readonly __typename: "DeleteUserOutput"; + readonly userID: string | null; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=74a70a5832df8760e9a80f1b32360a58e5c6ecd48551606448ce2cd6bbae28c2"`), @@ -2581,6 +2778,13 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly deleteUser: { + readonly __typename: "DeleteUserOutput"; + readonly userID: string | null; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=74a70a5832df8760e9a80f1b32360a58e5c6ecd48551606448ce2cd6bbae28c2"`), @@ -2722,11 +2926,792 @@ export type A$optimistic = { }; }; +export type A$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type A$artifact = typeof artifact "HoudiniHash=425691bbfea3900b92488e1ab1c9d6ee50242cadb1de2336342766d9577656f1"`), }, }, + { + Name: "Refetch operation", + Pass: true, + Input: []string{ + `mutation RefetchFriend { + addFriend { + friend @refetch { + firstName + } + } + }`, + }, + Extra: map[string]any{ + "RefetchFriend": tests.Dedent(`const artifact = { + "name": "RefetchFriend", + "kind": "HoudiniMutation", + "hash": "f21fe997186bd64751f7dd8e9ef3d9329716175a963e5004be685010e2e4c9c0", + "raw": ` + "`" + `mutation RefetchFriend { + addFriend { + friend { + firstName + __typename + id + } + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "addFriend": { + "type": "AddFriendOutput", + "keyRaw": "addFriend", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "friend": { + "type": "User", + "keyRaw": "friend", + + "directives": [{ + "name": "refetch", + "arguments": {} + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "firstName": { + "type": "String", + "keyRaw": "firstName", + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "operations": [{ + "action": "refetch", + "type": "User", + "path": ["addFriend","friend"] + }], + + "pluginData": {}, +} as const + +export default artifact + +export type RefetchFriend = { + readonly "input"?: RefetchFriend$input; + readonly "result": RefetchFriend$result; +}; + +export type RefetchFriend$result = { + readonly addFriend: { + readonly friend: { + readonly firstName: string; + }; + }; +}; + +export type RefetchFriend$input = null | undefined; + +export type RefetchFriend$optimistic = { + readonly addFriend?: { + readonly friend?: { + readonly firstName?: string; + }; + }; +}; + +export type RefetchFriend$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + +export type RefetchFriend$artifact = typeof artifact + +"HoudiniHash=f21fe997186bd64751f7dd8e9ef3d9329716175a963e5004be685010e2e4c9c0"`), + }, + }, + { + Name: "Multiple refetch operations", + Pass: true, + Input: []string{ + `mutation MultiRefetch { + addFriend { + friend @refetch { + id + } + node @refetch { + id + } + } + }`, + }, + Extra: map[string]any{ + "MultiRefetch": tests.Dedent(`const artifact = { + "name": "MultiRefetch", + "kind": "HoudiniMutation", + "hash": "3ed4e4fd600d09b8b921ab5e0b7fea29f00a0204d5fed08832d14a9aef665433", + "raw": ` + "`" + `mutation MultiRefetch { + addFriend { + friend { + id + __typename + } + node { + id + __typename + } + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "addFriend": { + "type": "AddFriendOutput", + "keyRaw": "addFriend", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "friend": { + "type": "User", + "keyRaw": "friend", + + "directives": [{ + "name": "refetch", + "arguments": {} + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + + "node": { + "type": "Node", + "keyRaw": "node", + "nullable": true, + + "directives": [{ + "name": "refetch", + "arguments": {} + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "abstract": true, + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "operations": [{ + "action": "refetch", + "type": "User", + "path": ["addFriend","friend"] + }, { + "action": "refetch", + "type": "Node", + "path": ["addFriend","node"] + }], + + "pluginData": {}, +} as const + +export default artifact + +export type MultiRefetch = { + readonly "input"?: MultiRefetch$input; + readonly "result": MultiRefetch$result; +}; + +export type MultiRefetch$result = { + readonly addFriend: { + readonly friend: { + readonly id: string; + }; + readonly node: { + readonly id: string; + } | null; + }; +}; + +export type MultiRefetch$input = null | undefined; + +export type MultiRefetch$optimistic = { + readonly addFriend?: { + readonly friend?: { + readonly id?: string; + }; + readonly node?: { + readonly id?: string; + } | null; + }; +}; + +export type MultiRefetch$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friend: { + readonly __typename: "User"; + readonly id: string; + }; + readonly node: { + readonly __typename: string; + readonly id: string; + } | null; + }; +}; + +export type MultiRefetch$artifact = typeof artifact + +"HoudiniHash=3ed4e4fd600d09b8b921ab5e0b7fea29f00a0204d5fed08832d14a9aef665433"`), + }, + }, + { + Name: "Refetch operation on a list field", + Pass: true, + Input: []string{ + `mutation RefetchFriends { + addFriend { + friends @refetch { + firstName + } + } + }`, + }, + Extra: map[string]any{ + "RefetchFriends": tests.Dedent(`const artifact = { + "name": "RefetchFriends", + "kind": "HoudiniMutation", + "hash": "7f70b1e558ec001cd81ed64bb326ac7dd4d2aacc7da7406ee18b44d85279915e", + "raw": ` + "`" + `mutation RefetchFriends { + addFriend { + friends { + firstName + __typename + id + } + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "addFriend": { + "type": "AddFriendOutput", + "keyRaw": "addFriend", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "friends": { + "type": "User", + "keyRaw": "friends", + + "directives": [{ + "name": "refetch", + "arguments": {} + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "firstName": { + "type": "String", + "keyRaw": "firstName", + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "operations": [{ + "action": "refetch", + "type": "User", + "path": ["addFriend","friends"] + }], + + "pluginData": {}, +} as const + +export default artifact + +export type RefetchFriends = { + readonly "input"?: RefetchFriends$input; + readonly "result": RefetchFriends$result; +}; + +export type RefetchFriends$result = { + readonly addFriend: { + readonly friends: ({ + readonly firstName: string; + })[]; + }; +}; + +export type RefetchFriends$input = null | undefined; + +export type RefetchFriends$optimistic = { + readonly addFriend?: { + readonly friends?: { + readonly firstName?: string; + }; + }; +}; + +export type RefetchFriends$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly friends: ({ + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + })[]; + }; +}; + +export type RefetchFriends$artifact = typeof artifact + +"HoudiniHash=7f70b1e558ec001cd81ed64bb326ac7dd4d2aacc7da7406ee18b44d85279915e"`), + }, + }, + { + Name: "Refetch operation inside an inline fragment", + Pass: true, + Input: []string{ + `mutation RefetchInline { + addFriend { + node { + ... on User { + bestFriend @refetch { + id + } + } + } + } + }`, + }, + Extra: map[string]any{ + "RefetchInline": tests.Dedent(`const artifact = { + "name": "RefetchInline", + "kind": "HoudiniMutation", + "hash": "a5616a6e70fbdd95a904e71965656212df5aac6f06d25e3a0451d82b1776964c", + "raw": ` + "`" + `mutation RefetchInline { + addFriend { + node { + ... on User { + bestFriend { + id + __typename + } + __typename + id + } + __typename + id + } + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "addFriend": { + "type": "AddFriendOutput", + "keyRaw": "addFriend", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "node": { + "type": "Node", + "keyRaw": "node", + "nullable": true, + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + "abstractFields": { + "fields": { + "User": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + "bestFriend": { + "type": "User", + "keyRaw": "bestFriend", + "nullable": true, + + "directives": [{ + "name": "refetch", + "arguments": {} + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "typeMap": {}, + }, + }, + + "abstract": true, + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "operations": [{ + "action": "refetch", + "type": "User", + "path": ["addFriend","node","bestFriend"] + }], + + "pluginData": {}, +} as const + +export default artifact + +export type RefetchInline = { + readonly "input"?: RefetchInline$input; + readonly "result": RefetchInline$result; +}; + +export type RefetchInline$result = { + readonly addFriend: { + readonly node: {} & (({ + readonly bestFriend: { + readonly id: string; + } | null; + readonly id: string; + readonly __typename: "User"; + })) | null; + }; +}; + +export type RefetchInline$input = null | undefined; + +export type RefetchInline$optimistic = { + readonly addFriend?: { + readonly node?: {} & (({ + readonly bestFriend: { + readonly id: string; + } | null; + readonly id: string; + readonly __typename: "User"; + })) | null; + }; +}; + +export type RefetchInline$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly node: {} & (({ + readonly bestFriend: { + readonly __typename: "User"; + readonly id: string; + } | null; + readonly id: string; + readonly __typename: "User"; + })) | null; + }; +}; + +export type RefetchInline$artifact = typeof artifact + +"HoudiniHash=a5616a6e70fbdd95a904e71965656212df5aac6f06d25e3a0451d82b1776964c"`), + }, + }, + { + Name: "Refetch operation on an abstract field", + Pass: true, + Input: []string{ + `mutation RefetchNode { + addFriend { + node @refetch { + id + } + } + }`, + }, + Extra: map[string]any{ + "RefetchNode": tests.Dedent(`const artifact = { + "name": "RefetchNode", + "kind": "HoudiniMutation", + "hash": "a14605e70bb3c9355b59c44980d48021846cd58ccb53506de70b73ceb85571ed", + "raw": ` + "`" + `mutation RefetchNode { + addFriend { + node { + id + __typename + } + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "addFriend": { + "type": "AddFriendOutput", + "keyRaw": "addFriend", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "node": { + "type": "Node", + "keyRaw": "node", + "nullable": true, + + "directives": [{ + "name": "refetch", + "arguments": {} + }], + + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "abstract": true, + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "operations": [{ + "action": "refetch", + "type": "Node", + "path": ["addFriend","node"] + }], + + "pluginData": {}, +} as const + +export default artifact + +export type RefetchNode = { + readonly "input"?: RefetchNode$input; + readonly "result": RefetchNode$result; +}; + +export type RefetchNode$result = { + readonly addFriend: { + readonly node: { + readonly id: string; + } | null; + }; +}; + +export type RefetchNode$input = null | undefined; + +export type RefetchNode$optimistic = { + readonly addFriend?: { + readonly node?: { + readonly id?: string; + } | null; + }; +}; + +export type RefetchNode$unmasked = { + readonly addFriend: { + readonly __typename: "AddFriendOutput"; + readonly node: { + readonly __typename: string; + readonly id: string; + } | null; + }; +}; + +export type RefetchNode$artifact = typeof artifact + +"HoudiniHash=a14605e70bb3c9355b59c44980d48021846cd58ccb53506de70b73ceb85571ed"`), + }, + }, }, }) } diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go index bac95a2757..c2d76207d7 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_pagination_test.go @@ -303,7 +303,7 @@ export type PaginatedFragment$input = never; export type PaginatedFragment = { readonly "shape"?: PaginatedFragment$data; readonly " $fragments": { - "PaginatedFragment": any; + "PaginatedFragment": { readonly "expected a PaginatedFragment fragment spread"?: never }; }; }; @@ -524,7 +524,7 @@ export type PaginatedFragment$input = never; export type PaginatedFragment = { readonly "shape"?: PaginatedFragment$data; readonly " $fragments": { - "PaginatedFragment": any; + "PaginatedFragment": { readonly "expected a PaginatedFragment fragment spread"?: never }; }; }; @@ -647,7 +647,7 @@ export type PaginatedFragment$input = never; export type PaginatedFragment = { readonly "shape"?: PaginatedFragment$data; readonly " $fragments": { - "PaginatedFragment": any; + "PaginatedFragment": { readonly "expected a PaginatedFragment fragment spread"?: never }; }; }; @@ -997,6 +997,40 @@ export type ScalarPagination$input = { last?: number | null; }; +export type ScalarPagination$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friendsByCursorScalar: { + readonly __typename: "UserConnection"; + readonly edges: ({ + readonly __typename: "UserEdge"; + readonly cursor: string; + readonly node: { + readonly __typename: "User"; + readonly friendsByCursor: { + readonly __typename: "UserConnection"; + readonly edges: ({ + readonly __typename: "UserEdge"; + readonly node: { + readonly __typename: "User"; + readonly id: string; + } | null; + })[]; + } | null; + readonly id: string; + } | null; + })[]; + readonly pageInfo: { + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; + readonly id: string; + }; +}; + export type ScalarPagination$artifact = typeof artifact "HoudiniHash=7f2262dcaf136ea17500364d6ca7be04eca17f9950a9177287240aba89d8f8e7"`), @@ -1412,7 +1446,7 @@ export type PaginatedFragment$input = never; export type PaginatedFragment = { readonly "shape"?: PaginatedFragment$data; readonly " $fragments": { - "PaginatedFragment": any; + "PaginatedFragment": { readonly "expected a PaginatedFragment fragment spread"?: never }; }; }; @@ -1716,13 +1750,13 @@ export type TestQuery$result = { readonly entitiesByCursor: { readonly edges: ({ readonly node: {} & (({ - readonly firstName: string; - readonly id: string; - readonly __typename: "User"; - }) | ({ - readonly " $fragments"?: {}; - readonly __typename: "non-exhaustive; don't match this"; - })) | null; + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; readonly cursor: string; })[]; readonly pageInfo: { @@ -1741,6 +1775,30 @@ export type TestQuery$input = { last?: number | null; }; +export type TestQuery$unmasked = { + readonly entitiesByCursor: { + readonly __typename: "EntityConnection"; + readonly edges: ({ + readonly __typename: "EntityEdge"; + readonly cursor: string; + readonly node: {} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; + })[]; + readonly pageInfo: { + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=0d0fa55060035d4eb6ae7de938bdcfe8703aecff0becc0a479b6e29ffa999e4b"`), @@ -2015,6 +2073,31 @@ export type Info$input = { id?: number | null; }; +export type Info$unmasked = { + readonly species: { + readonly __typename: "Species"; + readonly id: number; + readonly moves: { + readonly __typename: "SpeciesMoveConnection"; + readonly edges: ({ + readonly __typename: "SpeciesMoveEdge"; + readonly cursor: string; + readonly node: { + readonly __typename: "SpeciesMove"; + readonly id: number; + } | null; + })[]; + readonly pageInfo: { + readonly __typename: "PageInfo"; + readonly endCursor: string | null; + readonly hasNextPage: boolean; + readonly hasPreviousPage: boolean; + readonly startCursor: string | null; + }; + }; + } | null; +}; + export type Info$artifact = typeof artifact "HoudiniHash=8c477355428da3ad46b8438259e16cda72a336392b61e06b7130fa99bf103631"`), diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_refetchable_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_refetchable_test.go new file mode 100644 index 0000000000..de106a6b0b --- /dev/null +++ b/packages/houdini-core/plugin/documents/artifacts/selection_refetchable_test.go @@ -0,0 +1,181 @@ +package artifacts_test + +import ( + "testing" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-core/plugin" + "code.houdinigraphql.com/plugins/tests" +) + +func TestRefetchableArtifacts(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: ` + type Query { + node(id: ID!): Node + user: User + } + + type User implements Node { + id: ID! + firstName: String! + } + + interface Node { + id: ID! + } + `, + PerformTest: performArtifactTest, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "refetchable fragment generates a query artifact with a non-paginated refetch block", + Input: []string{ + ` + fragment UserInfo on User @refetchable { + firstName + } + `, + }, + Pass: true, + Extra: map[string]any{ + "UserInfo_Refetch_Query": `const artifact = { + "name": "UserInfo_Refetch_Query", + "kind": "HoudiniQuery", + "hash": "835ab9f8d3c95cebdcee39010d1e0256ed73c05c84b03f9422b22da5806943a7", + + "refetch": { + "path": [], + "method": "offset", + "pageSize": 0, + "embedded": false, + "targetType": "Node", + "paginated": false, + "direction": "forward", + "mode": "Infinite" + }, + + "raw": ` + "`" + `fragment UserInfo on User { + firstName + __typename + id +} + +query UserInfo_Refetch_Query($id: ID!) { + node(id: $id) { + ...UserInfo + __typename + id + } +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "node": { + "type": "Node", + "keyRaw": "node(id: $id)", + "nullable": true, + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + "abstractFields": { + "fields": { + "User": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + "firstName": { + "type": "String", + "keyRaw": "firstName", + "visible": true, + }, + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "typeMap": {}, + }, + + "fragments": { + "UserInfo": { + "arguments": {} + }, + }, + }, + + "abstract": true, + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "input": { + "fields": { + "id": "ID", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type UserInfo_Refetch_Query = { + readonly "input": UserInfo_Refetch_Query$input; + readonly "result": UserInfo_Refetch_Query$result | undefined; +}; + +export type UserInfo_Refetch_Query$result = { + readonly node: { + readonly " $fragments": { + UserInfo: {}; + }; + } | null; +}; + +export type UserInfo_Refetch_Query$input = { + id: string; +}; + +export type UserInfo_Refetch_Query$unmasked = { + readonly node: {} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + })) | null; +}; + +export type UserInfo_Refetch_Query$artifact = typeof artifact + +"HoudiniHash=835ab9f8d3c95cebdcee39010d1e0256ed73c05c84b03f9422b22da5806943a7"`, + }, + }, + }, + }) +} diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go index 9264f11085..e9d3ab822e 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_requiredDirective_test.go @@ -255,6 +255,22 @@ export type TestQuery$input = { id: string; }; +export type TestQuery$unmasked = { + readonly node: {} & (({ + readonly id: string; + readonly legends: ({ + readonly __typename: string; + readonly id: string; + readonly name: string | null; + } | null)[] | null; + readonly name: string | null; + readonly __typename: "Ghost"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=49d37523ee0a68c5e0ab528c947fb122c6a50e5efc79555d84155747aad3b518"`), diff --git a/packages/houdini-core/plugin/documents/artifacts/selection_test.go b/packages/houdini-core/plugin/documents/artifacts/selection_test.go index 758df818a9..69492a3d1f 100644 --- a/packages/houdini-core/plugin/documents/artifacts/selection_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/selection_test.go @@ -64,6 +64,7 @@ func TestArtifactGeneration(t *testing.T) { friends: [User!]! pets(name: String!, filter: PetFilter ): [Pet!]! friendsByOffset(offset: Int, filter: String): [User!]! + friendsByNames(names: [String!]!): [User!]! field(filter: String): String } @@ -121,6 +122,13 @@ func TestArtifactGeneration(t *testing.T) { type Subscription { newUser: NewUserResult! } + + scalar Upload + + type Mutation { + createUser(name: String!): User! + uploadAvatar(file: Upload!): User! + } `, PerformTest: performArtifactTest, Tests: []tests.Test[config.PluginConfig]{ @@ -181,6 +189,10 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly version: number; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=399380b224f926ada58db369b887cfdce8b0f08f263f27a48eec3d5e832d1777"`), @@ -230,7 +242,7 @@ export type TestFragment$input = never; export type TestFragment = { readonly "shape"?: TestFragment$data; readonly " $fragments": { - "TestFragment": any; + "TestFragment": { readonly "expected a TestFragment fragment spread"?: never }; }; }; @@ -342,6 +354,14 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=2c9c28f8cb271806d458dfe004805956234eba3596c9ab6f5fded8a16de61275"`), @@ -449,6 +469,14 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=a4461c0ad54e630a8bcefb242ada528478dedb87e1c48a72e5efae7fe66065ee"`), @@ -582,6 +610,22 @@ export type MyQuery$input = { id: string; }; +export type MyQuery$unmasked = { + readonly node: {} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly id: string; + readonly name: string; + readonly __typename: "Dog"; + }) | ({ + readonly id: string; + readonly name: string; + readonly __typename: "User"; + })) | null; +}; + export type MyQuery$artifact = typeof artifact "HoudiniHash=42a4210cd0fa0394e1256a751a9c7a8acbbeafb6efc4578260c4c0aa482cc0ae"`), @@ -719,6 +763,14 @@ export type TestQuery$input = { id?: string | null; }; +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly id: string; + readonly name: string; + }; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=fd7aa425b2f63c25bb733385c5337c0f128be116a423c65722b23a616b02d1f7"`, @@ -865,6 +917,22 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly friends: ({} & (({ + readonly firstName: string; + readonly friends: ({ + readonly __typename: "User"; + readonly id: string; + readonly lastName: string; + })[]; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=8671a0ece7987aa1e7d26f011d737b70ff059b7df8ac62179b4f28f022bbb733"`), @@ -1031,6 +1099,25 @@ export type Friends$result = { export type Friends$input = null | undefined; +export type Friends$unmasked = { + readonly friends: ({} & (({ + readonly id: string; + readonly owner: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + readonly __typename: "Cat"; + }) | ({ + readonly id: string; + readonly name: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; +}; + export type Friends$artifact = typeof artifact "HoudiniHash=5e7d291b1068492b9416a5460896b9b5064eacd175ec6ce4312be09c666cc121"`), @@ -1186,6 +1273,18 @@ export type Friends$result = { export type Friends$input = null | undefined; +export type Friends$unmasked = { + readonly pets: ({} & (({ + readonly id: string; + readonly owner: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + readonly __typename: "Cat"; + })))[]; +}; + export type Friends$artifact = typeof artifact "HoudiniHash=19f6f1c998ae37bb4d4c15b384755c6a08f301fb4b5ccbed724961b186aa338d"`), @@ -1308,6 +1407,13 @@ export type Friends$result = { export type Friends$input = null | undefined; +export type Friends$unmasked = { + readonly pets: ({} & (({ + readonly id: string; + readonly __typename: "Cat"; + })))[]; +}; + export type Friends$artifact = typeof artifact "HoudiniHash=84e7a2f0697fdc8af410c52a3c7a15e7f807035c884f302b4572462551052b1d"`), @@ -1467,6 +1573,14 @@ export type TestQuery$input = { value: string; }; +export type TestQuery$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + })[]; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=cf9a1b37522817318bc0893e797a289dc9ff66bee13544171839bd1b685ad514"`), @@ -1540,6 +1654,13 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly allItems: ({ + readonly __typename: "TodoItem"; + readonly createdAt: DateTime; + })[]; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=6ab84d7e483ecf5559a0ab69cc5983b1c74c8abc61170ff82ed304dca7a6b178"`), @@ -1644,6 +1765,17 @@ export type B$result = { export type B$input = null | undefined; +export type B$unmasked = { + readonly newUser: { + readonly __typename: "NewUserResult"; + readonly user: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; +}; + export type B$artifact = typeof artifact "HoudiniHash=296a21f0071cbe117a6db1565e144775e4c1461a843fd019b4e840c80bfb17be"`), @@ -1808,6 +1940,17 @@ export type NestedQuery$result = { export type NestedQuery$input = null | undefined; +export type NestedQuery$unmasked = { + readonly node: {} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; +}; + export type NestedQuery$artifact = typeof artifact "HoudiniHash=4c1b9d55fd57147ec99ed63b461874d1ec4010858f7e1e07710f672f6e02a5f2"`), @@ -1956,6 +2099,16 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly node: {} & (({ + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; +}; + export type TestQuery$artifact = typeof artifact "HoudiniHash=4c2c62f573fb898602ef340af5870e73274c2d1b9b5c7bde3d90ad96a3cdb1eb"`), @@ -1986,7 +2139,7 @@ export type TestQuery$artifact = typeof artifact "TestQuery": tests.Dedent(`const artifact = { "name": "TestQuery", "kind": "HoudiniQuery", - "hash": "8b69b464e99b7e8d99710bef4974593aff92444ba2a7ce789100877e0969caa8", + "hash": "8425a7a24172dd6c9329e2025bcccbec73e2a0b7adcc8b52228a36497cc48600", "raw": ` + "`" + `fragment NodeDetails_33ZDpt on Node { ... on User { __typename @@ -1997,7 +2150,7 @@ export type TestQuery$artifact = typeof artifact id } -query TestQuery() { +query TestQuery { node(id: "some_id") { id ...NodeDetails_33ZDpt @@ -2093,132 +2246,233 @@ export type TestQuery$result = { export type TestQuery$input = null | undefined; +export type TestQuery$unmasked = { + readonly node: {} & (({ + readonly field: string | null; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; +}; + export type TestQuery$artifact = typeof artifact -"HoudiniHash=8b69b464e99b7e8d99710bef4974593aff92444ba2a7ce789100877e0969caa8"`), +"HoudiniHash=8425a7a24172dd6c9329e2025bcccbec73e2a0b7adcc8b52228a36497cc48600"`), }, }, { - Name: "fragment variables are embedded in artifact", + Name: "object argument to @with is serialized correctly", Pass: true, Input: []string{ ` - fragment UserBase on User { - id - firstName - ...UserMore + query TestQuery { + user { + ...UserPets @with(petFilter: { age_gt: 5 }) + } } `, ` - fragment UserMore on User { - id - firstName + fragment UserPets on User @arguments(petFilter: { type: "PetFilter" }) { + pets(name: "test", filter: $petFilter) { + ... on Cat { + name + } + } } `, }, Extra: map[string]any{ - "UserBase": tests.Dedent(`const artifact = { - "name": "UserBase", - "kind": "HoudiniFragment", - "hash": "04c007b29948cfcf9498fd214b2665243e26b27f8012c26dedceda29ba361c81", - "raw": ` + "`" + `fragment UserBase on User { - id - firstName - ...UserMore - __typename + "TestQuery": tests.Dedent(`const artifact = { + "name": "TestQuery", + "kind": "HoudiniQuery", + "hash": "2c800aa339ee6e032fa3556fa5dfcc560d01248a07b93986477de07f63f176e8", + "raw": ` + "`" + `query TestQuery { + user { + ...UserPets_qkFx4 + __typename + id + } } -fragment UserMore on User { - id - firstName +fragment UserPets_qkFx4 on User { __typename + id + pets(filter: {age_gt: 5}, name: "test") { + ... on Cat { + name + __typename + id + } + __typename + } } ` + "`" + `, - "rootType": "User", + "rootType": "Query", "stripVariables": [] as Array, "selection": { "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - "visible": true, - }, + "user": { + "type": "User", + "keyRaw": "user", - "firstName": { - "type": "String", - "keyRaw": "firstName", - "visible": true, - }, + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, - "id": { - "type": "ID", - "keyRaw": "id", - "visible": true, - }, - }, + "id": { + "type": "ID", + "keyRaw": "id", + }, - "fragments": { - "UserMore": { - "arguments": {} + "pets": { + "type": "Pet", + "keyRaw": "pets(filter: {age_gt: 5}, name: \"test\")", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + }, + "abstractFields": { + "fields": { + "Cat": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + "id": { + "type": "ID", + "keyRaw": "id", + }, + "name": { + "type": "String", + "keyRaw": "name", + }, + }, + }, + + "typeMap": {}, + }, + }, + + "abstract": true, + }, + }, + + "fragments": { + "UserPets": { + "arguments": { + "petFilter": { + "kind": "ObjectValue", + "fields": [{ + "kind": "ObjectField", + "name": { + "kind": "Name", + "value": "age_gt", + }, + "value": { + "kind": "IntValue", + "value": "5" + } + }] + }, + } + }, + }, + }, + + "visible": true, }, }, }, "pluginData": {}, + "policy": "CacheOrNetwork", + "partial": false } as const export default artifact -export type UserBase$input = never; +export type TestQuery = { + readonly "input"?: TestQuery$input; + readonly "result": TestQuery$result | undefined; +}; -export type UserBase = { - readonly "shape"?: UserBase$data; - readonly " $fragments": { - "UserBase": any; +export type TestQuery$result = { + readonly user: { + readonly " $fragments": { + UserPets: {}; + }; }; }; -export type UserBase$data = { - readonly id: string; - readonly firstName: string; - readonly " $fragments": { - UserMore: {}; +export type TestQuery$input = null | undefined; + +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly id: string; + readonly pets: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + })))[]; }; }; -export type UserBase$artifact = typeof artifact +export type TestQuery$artifact = typeof artifact -"HoudiniHash=04c007b29948cfcf9498fd214b2665243e26b27f8012c26dedceda29ba361c81"`), +"HoudiniHash=2c800aa339ee6e032fa3556fa5dfcc560d01248a07b93986477de07f63f176e8"`), }, }, { - Name: "runtime scalars", + Name: "list argument to @with is serialized correctly", Pass: true, - ProjectConfig: func(config *plugins.ProjectConfig) { - config.RuntimeScalars = map[string]string{ - "ViewerIDFromSession": "ID", - } - }, Input: []string{ ` - query AnimalsOverview($id: ViewerIDFromSession!) { - node(id: $id) { - id + query TestQuery { + user { + ...UserFriends @with(names: ["Foo", "Bar"]) + } + } + `, + ` + fragment UserFriends on User @arguments(names: { type: "[String!]!" }) { + friendsByNames(names: $names) { + name } } `, }, Extra: map[string]any{ - "AnimalsOverview": tests.Dedent(`const artifact = { - "name": "AnimalsOverview", + "TestQuery": tests.Dedent(`const artifact = { + "name": "TestQuery", "kind": "HoudiniQuery", - "hash": "6275a980f0b68321c29f28177a4a67a3a78efa4be59f28ad052345ac4b4bc757", - "raw": ` + "`" + `query AnimalsOverview($id: ID!) { - node(id: $id) { + "hash": "432ce39d04ee755d6c26c3c4e097f377dc0d7dfc604c85ebd037d4850a51940c", + "raw": ` + "`" + `query TestQuery { + user { + ...UserFriends_TXXm0 + __typename id + } +} + +fragment UserFriends_TXXm0 on User { + __typename + id + friendsByNames(names: ["Foo", "Bar"]) { + name __typename + id } } ` + "`" + `, @@ -2228,10 +2482,9 @@ export type UserBase$artifact = typeof artifact "selection": { "fields": { - "node": { - "type": "Node", - "keyRaw": "node(id: $id)", - "nullable": true, + "user": { + "type": "User", + "keyRaw": "user", "selection": { "fields": { @@ -2240,90 +2493,142 @@ export type UserBase$artifact = typeof artifact "keyRaw": "__typename", }, + "friendsByNames": { + "type": "User", + "keyRaw": "friendsByNames(names: [\"Foo\", \"Bar\"])", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + + "name": { + "type": "String", + "keyRaw": "name", + }, + }, + }, + + }, + "id": { "type": "ID", "keyRaw": "id", - "visible": true, + }, + }, + + "fragments": { + "UserFriends": { + "arguments": { + "names": { + "kind": "ListValue", + "values": [{ + "kind": "StringValue", + "value": "Foo" + }, { + "kind": "StringValue", + "value": "Bar" + }] + }, + } }, }, }, - "abstract": true, "visible": true, }, }, }, "pluginData": {}, - - "input": { - "fields": { - "id": "ID", - }, - - "types": {}, - - "defaults": {}, - - "runtimeScalars": { - "id": "ViewerIDFromSession", - }, - }, - "policy": "CacheOrNetwork", "partial": false } as const export default artifact -export type AnimalsOverview = { - readonly "input": AnimalsOverview$input; - readonly "result": AnimalsOverview$result | undefined; +export type TestQuery = { + readonly "input"?: TestQuery$input; + readonly "result": TestQuery$result | undefined; }; -export type AnimalsOverview$result = { - readonly node: { - readonly id: string; - } | null; +export type TestQuery$result = { + readonly user: { + readonly " $fragments": { + UserFriends: {}; + }; + }; }; -export type AnimalsOverview$input = { - id: string; +export type TestQuery$input = null | undefined; + +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friendsByNames: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; + readonly id: string; + }; }; -export type AnimalsOverview$artifact = typeof artifact +export type TestQuery$artifact = typeof artifact -"HoudiniHash=6275a980f0b68321c29f28177a4a67a3a78efa4be59f28ad052345ac4b4bc757"`), +"HoudiniHash=432ce39d04ee755d6c26c3c4e097f377dc0d7dfc604c85ebd037d4850a51940c"`), }, }, { - Name: "default argument", + Name: "document variable in @with object argument is serialized correctly", Pass: true, Input: []string{ ` - query UserFriends($count: Int = 10, $search: String = "bob") { + query TestQuery($minAge: Int) { user { - friendsByOffset(offset: $count, filter: $search) { + ...UserPetsByAge @with(petFilter: { age_gt: $minAge }) + } + } + `, + ` + fragment UserPetsByAge on User @arguments(petFilter: { type: "PetFilter" }) { + pets(name: "test", filter: $petFilter) { + ... on Cat { name } } } `, }, - Extra: map[string]any{ - "UserFriends": tests.Dedent(`const artifact = { - "name": "UserFriends", + Extra: map[string]any{"TestQuery": `const artifact = { + "name": "TestQuery", "kind": "HoudiniQuery", - "hash": "1be5e9c7dbda921f62f3e53a7ce7cca4649d50adaa16a7bfcabc5d2e711f7f73", - "raw": ` + "`" + `query UserFriends($count: Int = 10, $search: String = "bob") { + "hash": "ce82d4bfec4f59a3ff03dfa23351f52b6486e062dc440a29d97d8cfe0f3cc98f", + "raw": ` + "`" + `query TestQuery($minAge: Int) { user { - friendsByOffset(filter: $search, offset: $count) { + ...UserPetsByAge_qkFx4 + __typename + id + } +} + +fragment UserPetsByAge_qkFx4 on User { + __typename + id + pets(filter: {age_gt: $minAge}, name: "test") { + ... on Cat { name __typename id } __typename - id } } ` + "`" + `, @@ -2344,9 +2649,14 @@ export type AnimalsOverview$artifact = typeof artifact "keyRaw": "__typename", }, - "friendsByOffset": { - "type": "User", - "keyRaw": "friendsByOffset(filter: $search, offset: $count)", + "id": { + "type": "ID", + "keyRaw": "id", + }, + + "pets": { + "type": "Pet", + "keyRaw": "pets(filter: {age_gt: $minAge}, name: \"test\")", "selection": { "fields": { @@ -2354,26 +2664,54 @@ export type AnimalsOverview$artifact = typeof artifact "type": "String", "keyRaw": "__typename", }, - - "id": { - "type": "ID", - "keyRaw": "id", + }, + "abstractFields": { + "fields": { + "Cat": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + "id": { + "type": "ID", + "keyRaw": "id", + }, + "name": { + "type": "String", + "keyRaw": "name", + }, + }, }, - "name": { - "type": "String", - "keyRaw": "name", - "visible": true, - }, + "typeMap": {}, }, }, - "visible": true, + "abstract": true, }, + }, - "id": { - "type": "ID", - "keyRaw": "id", + "fragments": { + "UserPetsByAge": { + "arguments": { + "petFilter": { + "kind": "ObjectValue", + "fields": [{ + "kind": "ObjectField", + "name": { + "kind": "Name", + "value": "age_gt", + }, + "value": { + "kind": "Variable", + "name": { + "kind": "Name", + "value": "minAge", + } + } + }] + }, + } }, }, }, @@ -2387,16 +2725,12 @@ export type AnimalsOverview$artifact = typeof artifact "input": { "fields": { - "count": "Int", - "search": "String", + "minAge": "Int", }, "types": {}, - "defaults": { - "count": 10, - "search": "bob", - }, + "defaults": {}, "runtimeScalars": {}, }, @@ -2407,164 +2741,160 @@ export type AnimalsOverview$artifact = typeof artifact export default artifact -export type UserFriends = { - readonly "input": UserFriends$input; - readonly "result": UserFriends$result | undefined; +export type TestQuery = { + readonly "input": TestQuery$input; + readonly "result": TestQuery$result | undefined; }; -export type UserFriends$result = { +export type TestQuery$result = { readonly user: { - readonly friendsByOffset: ({ - readonly name: string; - })[]; + readonly " $fragments": { + UserPetsByAge: {}; + }; }; }; -export type UserFriends$input = { - count?: number | null; - search?: string | null; +export type TestQuery$input = { + minAge?: number | null; }; -export type UserFriends$artifact = typeof artifact +export type TestQuery$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly id: string; + readonly pets: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + })))[]; + }; +}; -"HoudiniHash=1be5e9c7dbda921f62f3e53a7ce7cca4649d50adaa16a7bfcabc5d2e711f7f73"`), - }, +export type TestQuery$artifact = typeof artifact + +"HoudiniHash=ce82d4bfec4f59a3ff03dfa23351f52b6486e062dc440a29d97d8cfe0f3cc98f"`}, }, { - Name: "default argument handles base scalars correctly", + Name: "fragment variables are embedded in artifact", Pass: true, Input: []string{ ` - query ListUsers($bool: Boolean = true, $int: Int = 5, $float: Float = 3.14, $string: String = "hello world") { - users(boolValue: $bool, intValue: $int, floatValue: $float, stringValue: $string) { - name - } + fragment UserBase on User { + id + firstName + ...UserMore + } + `, + ` + fragment UserMore on User { + id + firstName } `, }, Extra: map[string]any{ - "ListUsers": tests.Dedent(`const artifact = { - "name": "ListUsers", - "kind": "HoudiniQuery", - "hash": "459f81b6ef22858bdc88408194bf27be86886c2819e4498d57f211dca4e6499b", - "raw": ` + "`" + `query ListUsers($bool: Boolean = true, $float: Float = 3.14, $int: Int = 5, $string: String = "hello world") { - users(boolValue: $bool, floatValue: $float, intValue: $int, stringValue: $string) { - name - __typename - id - } + "UserBase": tests.Dedent(`const artifact = { + "name": "UserBase", + "kind": "HoudiniFragment", + "hash": "04c007b29948cfcf9498fd214b2665243e26b27f8012c26dedceda29ba361c81", + "raw": ` + "`" + `fragment UserBase on User { + id + firstName + ...UserMore + __typename +} + +fragment UserMore on User { + id + firstName + __typename } ` + "`" + `, - "rootType": "Query", + "rootType": "User", "stripVariables": [] as Array, "selection": { "fields": { - "users": { - "type": "User", - "keyRaw": "users(boolValue: $bool, floatValue: $float, intValue: $int, stringValue: $string)", - - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, - - "id": { - "type": "ID", - "keyRaw": "id", - }, - - "name": { - "type": "String", - "keyRaw": "name", - "visible": true, - }, - }, - }, - + "__typename": { + "type": "String", + "keyRaw": "__typename", "visible": true, }, - }, - }, - "pluginData": {}, + "firstName": { + "type": "String", + "keyRaw": "firstName", + "visible": true, + }, - "input": { - "fields": { - "bool": "Boolean", - "float": "Float", - "int": "Int", - "string": "String", + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, }, - "types": {}, - - "defaults": { - "bool": true, - "float": 3.14, - "int": 5, - "string": "hello world", + "fragments": { + "UserMore": { + "arguments": {} + }, }, - - "runtimeScalars": {}, }, - "policy": "CacheOrNetwork", - "partial": false + "pluginData": {}, } as const export default artifact -export type ListUsers = { - readonly "input": ListUsers$input; - readonly "result": ListUsers$result | undefined; -}; +export type UserBase$input = never; -export type ListUsers$result = { - readonly users: ({ - readonly name: string; - })[]; +export type UserBase = { + readonly "shape"?: UserBase$data; + readonly " $fragments": { + "UserBase": { readonly "expected a UserBase fragment spread"?: never }; + }; }; -export type ListUsers$input = { - bool?: boolean | null; - float?: number | null; - int?: number | null; - string?: string | null; +export type UserBase$data = { + readonly id: string; + readonly firstName: string; + readonly " $fragments": { + UserMore: {}; + }; }; -export type ListUsers$artifact = typeof artifact +export type UserBase$artifact = typeof artifact -"HoudiniHash=459f81b6ef22858bdc88408194bf27be86886c2819e4498d57f211dca4e6499b"`), +"HoudiniHash=04c007b29948cfcf9498fd214b2665243e26b27f8012c26dedceda29ba361c81"`), }, }, { - Name: "default argument handles complex default arguments", + Name: "runtime scalars", Pass: true, + ProjectConfig: func(config *plugins.ProjectConfig) { + config.RuntimeScalars = map[string]string{ + "ViewerIDFromSession": "ID", + } + }, Input: []string{ ` - query FindUser($filter: UserFilter = { name: "bob" }) { - users(offset: 5, filter: $filter) { - name + query AnimalsOverview($id: ViewerIDFromSession!) { + node(id: $id) { + id } } `, }, Extra: map[string]any{ - "FindUser": tests.Dedent( - `import type { UserFilter } from "$houdini/graphql/inputs"; -const artifact = { - "name": "FindUser", + "AnimalsOverview": tests.Dedent(`const artifact = { + "name": "AnimalsOverview", "kind": "HoudiniQuery", - "hash": "f960d0440b469f47aa1a2471c9f82a1709fec5959ed1d40aae1f0ecf537da4f7", - "raw": ` + "`" + `query FindUser($filter: UserFilter = {name: "bob"}) { - users(filter: $filter, offset: 5) { - name - __typename + "hash": "6275a980f0b68321c29f28177a4a67a3a78efa4be59f28ad052345ac4b4bc757", + "raw": ` + "`" + `query AnimalsOverview($id: ID!) { + node(id: $id) { id + __typename } } ` + "`" + `, @@ -2574,9 +2904,10 @@ const artifact = { "selection": { "fields": { - "users": { - "type": "User", - "keyRaw": "users(filter: $filter, offset: 5)", + "node": { + "type": "Node", + "keyRaw": "node(id: $id)", + "nullable": true, "selection": { "fields": { @@ -2588,16 +2919,12 @@ const artifact = { "id": { "type": "ID", "keyRaw": "id", - }, - - "name": { - "type": "String", - "keyRaw": "name", "visible": true, }, }, }, + "abstract": true, "visible": true, }, }, @@ -2607,32 +2934,16 @@ const artifact = { "input": { "fields": { - "filter": "UserFilter", + "id": "ID", }, - "types": { - "NestedUserFilter": { - "admin": "Boolean", - "age": "Int", - "firstName": "String", - "id": "ID", - "weight": "Float", - }, - "UserFilter": { - "enum": "MyEnum", - "listRequired": "String", - "middle": "NestedUserFilter", - "name": "String", - "nullList": "String", - "recursive": "UserFilter", - }, - }, + "types": {}, - "defaults": { - "filter": {name: "bob"}, - }, + "defaults": {}, - "runtimeScalars": {}, + "runtimeScalars": { + "id": "ViewerIDFromSession", + }, }, "policy": "CacheOrNetwork", @@ -2641,47 +2952,59 @@ const artifact = { export default artifact -export type FindUser = { - readonly "input": FindUser$input; - readonly "result": FindUser$result | undefined; +export type AnimalsOverview = { + readonly "input": AnimalsOverview$input; + readonly "result": AnimalsOverview$result | undefined; }; -export type FindUser$result = { - readonly users: ({ - readonly name: string; - })[]; +export type AnimalsOverview$result = { + readonly node: { + readonly id: string; + } | null; }; -export type FindUser$input = { - filter?: UserFilter | null; +export type AnimalsOverview$input = { + id: string; }; -export type FindUser$artifact = typeof artifact +export type AnimalsOverview$unmasked = { + readonly node: { + readonly __typename: string; + readonly id: string; + } | null; +}; -"HoudiniHash=f960d0440b469f47aa1a2471c9f82a1709fec5959ed1d40aae1f0ecf537da4f7"`, - ), +export type AnimalsOverview$artifact = typeof artifact + +"HoudiniHash=6275a980f0b68321c29f28177a4a67a3a78efa4be59f28ad052345ac4b4bc757"`), }, }, { - Name: "default dedupe arguments", + Name: "default argument", Pass: true, Input: []string{ ` - query FindUser @dedupe { - users { + query UserFriends($count: Int = 10, $search: String = "bob") { + user { + friendsByOffset(offset: $count, filter: $search) { name } } + } `, }, Extra: map[string]any{ - "FindUser": tests.Dedent(`const artifact = { - "name": "FindUser", + "UserFriends": tests.Dedent(`const artifact = { + "name": "UserFriends", "kind": "HoudiniQuery", - "hash": "1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7", - "raw": ` + "`" + `query FindUser { - users { - name + "hash": "1be5e9c7dbda921f62f3e53a7ce7cca4649d50adaa16a7bfcabc5d2e711f7f73", + "raw": ` + "`" + `query UserFriends($count: Int = 10, $search: String = "bob") { + user { + friendsByOffset(filter: $search, offset: $count) { + name + __typename + id + } __typename id } @@ -2693,9 +3016,9 @@ export type FindUser$artifact = typeof artifact "selection": { "fields": { - "users": { + "user": { "type": "User", - "keyRaw": "users", + "keyRaw": "user", "selection": { "fields": { @@ -2704,18 +3027,39 @@ export type FindUser$artifact = typeof artifact "keyRaw": "__typename", }, - "id": { - "type": "ID", - "keyRaw": "id", - }, + "friendsByOffset": { + "type": "User", + "keyRaw": "friendsByOffset(filter: $search, offset: $count)", - "name": { - "type": "String", - "keyRaw": "name", - "visible": true, - }, - }, - }, + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + + "name": { + "type": "String", + "keyRaw": "name", + "visible": true, + }, + }, + }, + + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, "visible": true, }, @@ -2724,53 +3068,82 @@ export type FindUser$artifact = typeof artifact "pluginData": {}, - "dedupe": { - "cancel": "last", - "match": "Variables" + "input": { + "fields": { + "count": "Int", + "search": "String", + }, + + "types": {}, + + "defaults": { + "count": 10, + "search": "bob", + }, + + "runtimeScalars": {}, }, + "policy": "CacheOrNetwork", "partial": false } as const export default artifact -export type FindUser = { - readonly "input"?: FindUser$input; - readonly "result": FindUser$result | undefined; +export type UserFriends = { + readonly "input": UserFriends$input; + readonly "result": UserFriends$result | undefined; }; -export type FindUser$result = { - readonly users: ({ - readonly name: string; - })[]; +export type UserFriends$result = { + readonly user: { + readonly friendsByOffset: ({ + readonly name: string; + })[]; + }; }; -export type FindUser$input = null | undefined; +export type UserFriends$input = { + count?: number | null; + search?: string | null; +}; -export type FindUser$artifact = typeof artifact +export type UserFriends$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friendsByOffset: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; + readonly id: string; + }; +}; -"HoudiniHash=1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7"`), +export type UserFriends$artifact = typeof artifact + +"HoudiniHash=1be5e9c7dbda921f62f3e53a7ce7cca4649d50adaa16a7bfcabc5d2e711f7f73"`), }, }, { - Name: "persists dedupe which", + Name: "default argument handles base scalars correctly", Pass: true, Input: []string{ ` - query FindUser @dedupe(match: Operation) { - users { - name - } + query ListUsers($bool: Boolean = true, $int: Int = 5, $float: Float = 3.14, $string: String = "hello world") { + users(boolValue: $bool, intValue: $int, floatValue: $float, stringValue: $string) { + name } + } `, }, Extra: map[string]any{ - "FindUser": tests.Dedent(`const artifact = { - "name": "FindUser", + "ListUsers": tests.Dedent(`const artifact = { + "name": "ListUsers", "kind": "HoudiniQuery", - "hash": "1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7", - "raw": ` + "`" + `query FindUser { - users { + "hash": "459f81b6ef22858bdc88408194bf27be86886c2819e4498d57f211dca4e6499b", + "raw": ` + "`" + `query ListUsers($bool: Boolean = true, $float: Float = 3.14, $int: Int = 5, $string: String = "hello world") { + users(boolValue: $bool, floatValue: $float, intValue: $int, stringValue: $string) { name __typename id @@ -2785,7 +3158,7 @@ export type FindUser$artifact = typeof artifact "fields": { "users": { "type": "User", - "keyRaw": "users", + "keyRaw": "users(boolValue: $bool, floatValue: $float, intValue: $int, stringValue: $string)", "selection": { "fields": { @@ -2814,53 +3187,84 @@ export type FindUser$artifact = typeof artifact "pluginData": {}, - "dedupe": { - "cancel": "last", - "match": "Operation" + "input": { + "fields": { + "bool": "Boolean", + "float": "Float", + "int": "Int", + "string": "String", + }, + + "types": {}, + + "defaults": { + "bool": true, + "float": 3.14, + "int": 5, + "string": "hello world", + }, + + "runtimeScalars": {}, }, + "policy": "CacheOrNetwork", "partial": false } as const export default artifact -export type FindUser = { - readonly "input"?: FindUser$input; - readonly "result": FindUser$result | undefined; +export type ListUsers = { + readonly "input": ListUsers$input; + readonly "result": ListUsers$result | undefined; }; -export type FindUser$result = { +export type ListUsers$result = { readonly users: ({ readonly name: string; })[]; }; -export type FindUser$input = null | undefined; +export type ListUsers$input = { + bool?: boolean | null; + float?: number | null; + int?: number | null; + string?: string | null; +}; -export type FindUser$artifact = typeof artifact +export type ListUsers$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; +}; -"HoudiniHash=1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7"`), +export type ListUsers$artifact = typeof artifact + +"HoudiniHash=459f81b6ef22858bdc88408194bf27be86886c2819e4498d57f211dca4e6499b"`), }, }, { - Name: "persists dedupe first", + Name: "default argument handles complex default arguments", Pass: true, Input: []string{ ` - query FindUser @dedupe(cancelFirst: true) { - users { - name - } + query FindUser($filter: UserFilter = { name: "bob" }) { + users(offset: 5, filter: $filter) { + name } + } `, }, Extra: map[string]any{ - "FindUser": tests.Dedent(`const artifact = { + "FindUser": tests.Dedent( + `import type { UserFilter } from "$houdini/graphql/inputs"; +const artifact = { "name": "FindUser", "kind": "HoudiniQuery", - "hash": "1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7", - "raw": ` + "`" + `query FindUser { - users { + "hash": "f960d0440b469f47aa1a2471c9f82a1709fec5959ed1d40aae1f0ecf537da4f7", + "raw": ` + "`" + `query FindUser($filter: UserFilter = {name: "bob"}) { + users(filter: $filter, offset: 5) { name __typename id @@ -2875,7 +3279,7 @@ export type FindUser$artifact = typeof artifact "fields": { "users": { "type": "User", - "keyRaw": "users", + "keyRaw": "users(filter: $filter, offset: 5)", "selection": { "fields": { @@ -2904,10 +3308,36 @@ export type FindUser$artifact = typeof artifact "pluginData": {}, - "dedupe": { - "cancel": "first", - "match": "Variables" + "input": { + "fields": { + "filter": "UserFilter", + }, + + "types": { + "NestedUserFilter": { + "admin": "Boolean", + "age": "Int", + "firstName": "String", + "id": "ID", + "weight": "Float", + }, + "UserFilter": { + "enum": "MyEnum", + "listRequired": "String", + "middle": "NestedUserFilter", + "name": "String", + "nullList": "String", + "recursive": "UserFilter", + }, + }, + + "defaults": { + "filter": {name: "bob"}, + }, + + "runtimeScalars": {}, }, + "policy": "CacheOrNetwork", "partial": false } as const @@ -2915,7 +3345,7 @@ export type FindUser$artifact = typeof artifact export default artifact export type FindUser = { - readonly "input"?: FindUser$input; + readonly "input": FindUser$input; readonly "result": FindUser$result | undefined; }; @@ -2925,242 +3355,324 @@ export type FindUser$result = { })[]; }; -export type FindUser$input = null | undefined; +export type FindUser$input = { + filter?: UserFilter | null; +}; + +export type FindUser$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; +}; export type FindUser$artifact = typeof artifact -"HoudiniHash=1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7"`), +"HoudiniHash=f960d0440b469f47aa1a2471c9f82a1709fec5959ed1d40aae1f0ecf537da4f7"`, + ), }, }, { - Name: "cache policy is persisted in artifact", + Name: "default dedupe arguments", Pass: true, Input: []string{ ` - query CachedFriends @cache(policy: CacheAndNetwork) { - user { - friends { - id - } + query FindUser @dedupe { + users { + name } - } + } `, }, Extra: map[string]any{ - "CachedFriends": tests.Dedent(` - const artifact = { - "name": "CachedFriends", - "kind": "HoudiniQuery", - "hash": "0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a", - "raw": ` + "`" + `query CachedFriends { - user { - friends { - id - __typename - } - __typename - id - } - } - ` + "`" + `, + "FindUser": tests.Dedent(`const artifact = { + "name": "FindUser", + "kind": "HoudiniQuery", + "hash": "1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7", + "raw": ` + "`" + `query FindUser { + users { + name + __typename + id + } +} +` + "`" + `, - "rootType": "Query", - "stripVariables": [] as Array, - - "selection": { - "fields": { - "user": { - "type": "User", - "keyRaw": "user", + "rootType": "Query", + "stripVariables": [] as Array, - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, + "selection": { + "fields": { + "users": { + "type": "User", + "keyRaw": "users", - "friends": { - "type": "User", - "keyRaw": "friends", + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, + "id": { + "type": "ID", + "keyRaw": "id", + }, - "id": { - "type": "ID", - "keyRaw": "id", - "visible": true, - }, - }, - }, + "name": { + "type": "String", + "keyRaw": "name", + "visible": true, + }, + }, + }, - "visible": true, - }, + "visible": true, + }, + }, + }, - "id": { - "type": "ID", - "keyRaw": "id", - }, - }, - }, + "pluginData": {}, - "visible": true, - }, - }, - }, + "dedupe": { + "cancel": "last", + "match": "Variables" + }, + "policy": "CacheOrNetwork", + "partial": false +} as const - "pluginData": {}, - "policy": "CacheAndNetwork", - "partial": false - } as const +export default artifact - export default artifact +export type FindUser = { + readonly "input"?: FindUser$input; + readonly "result": FindUser$result | undefined; +}; - export type CachedFriends = { - readonly "input"?: CachedFriends$input; - readonly "result": CachedFriends$result | undefined; - }; +export type FindUser$result = { + readonly users: ({ + readonly name: string; + })[]; +}; - export type CachedFriends$result = { - readonly user: { - readonly friends: ({ - readonly id: string; - })[]; - }; - }; +export type FindUser$input = null | undefined; - export type CachedFriends$input = null | undefined; +export type FindUser$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; +}; - export type CachedFriends$artifact = typeof artifact +export type FindUser$artifact = typeof artifact - "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" - `), +"HoudiniHash=1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7"`), }, }, { - Name: "can change default cache policy", + Name: "persists dedupe which", Pass: true, - ProjectConfig: func(config *plugins.ProjectConfig) { - config.DefaultCachePolicy = "NetworkOnly" - }, Input: []string{ ` - query CachedFriends { - user { - friends { - id - } + query FindUser @dedupe(match: Operation) { + users { + name } - } + } `, }, Extra: map[string]any{ - "CachedFriends": tests.Dedent(` - const artifact = { - "name": "CachedFriends", - "kind": "HoudiniQuery", - "hash": "0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a", - "raw": ` + "`" + `query CachedFriends { - user { - friends { - id - __typename - } - __typename - id - } - } - ` + "`" + `, + "FindUser": tests.Dedent(`const artifact = { + "name": "FindUser", + "kind": "HoudiniQuery", + "hash": "1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7", + "raw": ` + "`" + `query FindUser { + users { + name + __typename + id + } +} +` + "`" + `, - "rootType": "Query", - "stripVariables": [] as Array, + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "users": { + "type": "User", + "keyRaw": "users", "selection": { "fields": { - "user": { - "type": "User", - "keyRaw": "user", - - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, - - "friends": { - "type": "User", - "keyRaw": "friends", - - "selection": { - "fields": { - "__typename": { - "type": "String", - "keyRaw": "__typename", - }, - - "id": { - "type": "ID", - "keyRaw": "id", - "visible": true, - }, - }, - }, - - "visible": true, - }, + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, - "id": { - "type": "ID", - "keyRaw": "id", - }, - }, - }, + "id": { + "type": "ID", + "keyRaw": "id", + }, + "name": { + "type": "String", + "keyRaw": "name", "visible": true, }, }, }, - "pluginData": {}, - "policy": "NetworkOnly", - "partial": false - } as const - - export default artifact + "visible": true, + }, + }, + }, - export type CachedFriends = { - readonly "input"?: CachedFriends$input; - readonly "result": CachedFriends$result | undefined; - }; + "pluginData": {}, - export type CachedFriends$result = { - readonly user: { - readonly friends: ({ - readonly id: string; - })[]; - }; - }; + "dedupe": { + "cancel": "last", + "match": "Operation" + }, + "policy": "CacheOrNetwork", + "partial": false +} as const - export type CachedFriends$input = null | undefined; +export default artifact - export type CachedFriends$artifact = typeof artifact +export type FindUser = { + readonly "input"?: FindUser$input; + readonly "result": FindUser$result | undefined; +}; - "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" - `), +export type FindUser$result = { + readonly users: ({ + readonly name: string; + })[]; +}; + +export type FindUser$input = null | undefined; + +export type FindUser$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; +}; + +export type FindUser$artifact = typeof artifact + +"HoudiniHash=1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7"`), }, }, { - Name: "partial opt-in is persisted", + Name: "persists dedupe first", Pass: true, Input: []string{ ` - query CachedFriends @cache(policy: CacheAndNetwork, partial: true) { + query FindUser @dedupe(cancelFirst: true) { + users { + name + } + } + `, + }, + Extra: map[string]any{ + "FindUser": tests.Dedent(`const artifact = { + "name": "FindUser", + "kind": "HoudiniQuery", + "hash": "1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7", + "raw": ` + "`" + `query FindUser { + users { + name + __typename + id + } +} +` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "users": { + "type": "User", + "keyRaw": "users", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + + "name": { + "type": "String", + "keyRaw": "name", + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "dedupe": { + "cancel": "first", + "match": "Variables" + }, + "policy": "CacheOrNetwork", + "partial": false +} as const + +export default artifact + +export type FindUser = { + readonly "input"?: FindUser$input; + readonly "result": FindUser$result | undefined; +}; + +export type FindUser$result = { + readonly users: ({ + readonly name: string; + })[]; +}; + +export type FindUser$input = null | undefined; + +export type FindUser$unmasked = { + readonly users: ({ + readonly __typename: "User"; + readonly id: string; + readonly name: string; + })[]; +}; + +export type FindUser$artifact = typeof artifact + +"HoudiniHash=1420307316411f9ff8413670ae0fbe99ececa60b9a06071013ab6e152c6f9ea7"`), + }, + }, + { + Name: "cache policy is persisted in artifact", + Pass: true, + Input: []string{ + ` + query CachedFriends @cache(policy: CacheAndNetwork) { user { friends { id @@ -3239,7 +3751,7 @@ export type FindUser$artifact = typeof artifact "pluginData": {}, "policy": "CacheAndNetwork", - "partial": true + "partial": false } as const export default artifact @@ -3259,6 +3771,17 @@ export type FindUser$artifact = typeof artifact export type CachedFriends$input = null | undefined; + export type CachedFriends$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friends: ({ + readonly __typename: "User"; + readonly id: string; + })[]; + readonly id: string; + }; + }; + export type CachedFriends$artifact = typeof artifact "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" @@ -3266,14 +3789,135 @@ export type FindUser$artifact = typeof artifact }, }, { - Name: "can set default partial opt-in", + Name: "can change default cache policy", Pass: true, ProjectConfig: func(config *plugins.ProjectConfig) { - config.DefaultPartial = true + config.DefaultCachePolicy = "NetworkOnly" }, Input: []string{ ` - query CachedFriends @cache(policy: CacheAndNetwork) { + query CachedFriends { + user { + friends { + id + } + } + } + `, + }, + Extra: map[string]any{ + "CachedFriends": tests.Dedent(` + const artifact = { + "name": "CachedFriends", + "kind": "HoudiniQuery", + "hash": "0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a", + "raw": ` + "`" + `query CachedFriends { + user { + friends { + id + __typename + } + __typename + id + } + } + ` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "user": { + "type": "User", + "keyRaw": "user", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "friends": { + "type": "User", + "keyRaw": "friends", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + "policy": "NetworkOnly", + "partial": false + } as const + + export default artifact + + export type CachedFriends = { + readonly "input"?: CachedFriends$input; + readonly "result": CachedFriends$result | undefined; + }; + + export type CachedFriends$result = { + readonly user: { + readonly friends: ({ + readonly id: string; + })[]; + }; + }; + + export type CachedFriends$input = null | undefined; + + export type CachedFriends$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friends: ({ + readonly __typename: "User"; + readonly id: string; + })[]; + readonly id: string; + }; + }; + + export type CachedFriends$artifact = typeof artifact + + "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" + `), + }, + }, + { + Name: "partial opt-in is persisted", + Pass: true, + Input: []string{ + ` + query CachedFriends @cache(policy: CacheAndNetwork, partial: true) { user { friends { id @@ -3372,11 +4016,146 @@ export type FindUser$artifact = typeof artifact export type CachedFriends$input = null | undefined; - export type CachedFriends$artifact = typeof artifact - - "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" - `), - }, + export type CachedFriends$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friends: ({ + readonly __typename: "User"; + readonly id: string; + })[]; + readonly id: string; + }; + }; + + export type CachedFriends$artifact = typeof artifact + + "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" + `), + }, + }, + { + Name: "can set default partial opt-in", + Pass: true, + ProjectConfig: func(config *plugins.ProjectConfig) { + config.DefaultPartial = true + }, + Input: []string{ + ` + query CachedFriends @cache(policy: CacheAndNetwork) { + user { + friends { + id + } + } + } + `, + }, + Extra: map[string]any{ + "CachedFriends": tests.Dedent(` + const artifact = { + "name": "CachedFriends", + "kind": "HoudiniQuery", + "hash": "0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a", + "raw": ` + "`" + `query CachedFriends { + user { + friends { + id + __typename + } + __typename + id + } + } + ` + "`" + `, + + "rootType": "Query", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "user": { + "type": "User", + "keyRaw": "user", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "friends": { + "type": "User", + "keyRaw": "friends", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + "policy": "CacheAndNetwork", + "partial": true + } as const + + export default artifact + + export type CachedFriends = { + readonly "input"?: CachedFriends$input; + readonly "result": CachedFriends$result | undefined; + }; + + export type CachedFriends$result = { + readonly user: { + readonly friends: ({ + readonly id: string; + })[]; + }; + }; + + export type CachedFriends$input = null | undefined; + + export type CachedFriends$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly friends: ({ + readonly __typename: "User"; + readonly id: string; + })[]; + readonly id: string; + }; + }; + + export type CachedFriends$artifact = typeof artifact + + "HoudiniHash=0c6098a719ba87b3bdc37ae86f125da4f8abcf54cc285000f8317ae8060daa8a" + `), + }, }, { Name: "fragments of unions inject correctly", @@ -3521,6 +4300,18 @@ query EntityList { export type EntityList$input = null | undefined; + export type EntityList$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly name: string; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + })))[]; + }; + export type EntityList$artifact = typeof artifact "HoudiniHash=41abe068027a3e99325fd911b69effe90a0a3aabbb2e1cdfed73dd37dd73677e" @@ -3663,6 +4454,14 @@ query UserWithAvatar { export type UserWithAvatar$input = null | undefined; + export type UserWithAvatar$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly firstName: string; + readonly id: string; + }; + }; + export type UserWithAvatar$artifact = typeof artifact "HoudiniHash=51262f47df33c40c18a8f4b081242dedd62c8ffb0fd94595ee122afb0e83ad71" @@ -3796,12 +4595,515 @@ query UserWithAvatar { export type UserRequiredFragments$input = null | undefined; + export type UserRequiredFragments$unmasked = { + readonly user: { + readonly __typename: "User"; + readonly field: string | null; + readonly id: string; + readonly name: string; + }; + }; + export type UserRequiredFragments$artifact = typeof artifact "HoudiniHash=67cc15d853c8c680b147a01db88491fc092dac7acb22354144b6107c52d86963" `), }, }, + { + Name: "plural fragment records the plural flag", + Pass: true, + Input: []string{ + `fragment PluralRow on User @plural { + firstName + }`, + `query PluralQuery { + users { + ...PluralRow + } + }`, + }, + Extra: map[string]any{ + "PluralRow": tests.Dedent(`const artifact = { + "name": "PluralRow", + "kind": "HoudiniFragment", + "hash": "9f281c7c04e9908f4490520ed23a594a20fea6332dfe90b69ae7eef227673dea", + "raw": ` + "`" + `fragment PluralRow on User { + firstName + __typename + id +} +` + "`" + `, + + "rootType": "User", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + "visible": true, + }, + + "firstName": { + "type": "String", + "keyRaw": "firstName", + "visible": true, + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "plural": true, +} as const + +export default artifact + +export type PluralRow$input = never; + +export type PluralRow = ReadonlyArray<{ + readonly "shape"?: PluralRow$data; + readonly " $fragments": { + "PluralRow": { readonly "expected a PluralRow fragment spread"?: never }; + }; +}>; + +export type PluralRow$data = { + readonly firstName: string; +}; + +export type PluralRow$artifact = typeof artifact + +"HoudiniHash=9f281c7c04e9908f4490520ed23a594a20fea6332dfe90b69ae7eef227673dea"`), + }, + }, + { + Name: "@endpoint emits parsed redirect and form id", + Pass: true, + Input: []string{ + `mutation CreateUserForm($name: String!) @endpoint(id: "invite", redirect: "/users/{ createUser.id }") { + createUser(name: $name) { id } + }`, + }, + Extra: map[string]any{ + "CreateUserForm": tests.Dedent(`const artifact = { + "name": "CreateUserForm", + "kind": "HoudiniMutation", + "hash": "c210bbfff6766c1fd2848f2b89997111a93ca4eb2bb4c2442ce41fbbf9a0635c", + "raw": ` + "`" + `mutation CreateUserForm($name: String!) { + createUser(name: $name) { + id + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "createUser": { + "type": "User", + "keyRaw": "createUser(name: $name)", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "endpoint": { + "redirect": ["/users/", ["createUser", "id"]], + "id": "invite", + }, + + "input": { + "fields": { + "name": "String", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + +} as const + +export default artifact + +export type CreateUserForm = { + readonly "input": CreateUserForm$input; + readonly "result": CreateUserForm$result; +}; + +export type CreateUserForm$result = { + readonly createUser: { + readonly id: string; + }; +}; + +export type CreateUserForm$input = { + name: string; +}; + +export type CreateUserForm$optimistic = { + readonly createUser?: { + readonly id?: string; + }; +}; + +export type CreateUserForm$unmasked = { + readonly createUser: { + readonly __typename: "User"; + readonly id: string; + }; +}; + +export type CreateUserForm$artifact = typeof artifact + +"HoudiniHash=c210bbfff6766c1fd2848f2b89997111a93ca4eb2bb4c2442ce41fbbf9a0635c"`), + }, + }, + { + Name: "@endpoint emits multipart for Upload variables", + Pass: true, + Input: []string{ + `mutation UploadAvatarForm($file: Upload!) @endpoint { + uploadAvatar(file: $file) { id } + }`, + }, + Extra: map[string]any{ + "UploadAvatarForm": tests.Dedent(`const artifact = { + "name": "UploadAvatarForm", + "kind": "HoudiniMutation", + "hash": "5498a8444058d726f6fcff5bca136c8e1c79e516c51a4d4815039536423f1398", + "raw": ` + "`" + `mutation UploadAvatarForm($file: Upload!) { + uploadAvatar(file: $file) { + id + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "uploadAvatar": { + "type": "User", + "keyRaw": "uploadAvatar(file: $file)", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "endpoint": { + "multipart": true, + }, + + "input": { + "fields": { + "file": "Upload", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + +} as const + +export default artifact + +export type UploadAvatarForm = { + readonly "input": UploadAvatarForm$input; + readonly "result": UploadAvatarForm$result; +}; + +export type UploadAvatarForm$result = { + readonly uploadAvatar: { + readonly id: string; + }; +}; + +export type UploadAvatarForm$input = { + file: Upload; +}; + +export type UploadAvatarForm$optimistic = { + readonly uploadAvatar?: { + readonly id?: string; + }; +}; + +export type UploadAvatarForm$unmasked = { + readonly uploadAvatar: { + readonly __typename: "User"; + readonly id: string; + }; +}; + +export type UploadAvatarForm$artifact = typeof artifact + +"HoudiniHash=5498a8444058d726f6fcff5bca136c8e1c79e516c51a4d4815039536423f1398"`), + }, + }, + { + Name: "@endpoint emits the fields allowlist", + Pass: true, + Input: []string{ + `mutation CreateUserFieldsForm($name: String!) @endpoint(fields: ["name"]) { + createUser(name: $name) { id } + }`, + }, + Extra: map[string]any{ + "CreateUserFieldsForm": tests.Dedent(`const artifact = { + "name": "CreateUserFieldsForm", + "kind": "HoudiniMutation", + "hash": "6321c3d3f53f1dd60ad57d5796e97c44f6eabb2d4df2b0ea23789d7f936aba38", + "raw": ` + "`" + `mutation CreateUserFieldsForm($name: String!) { + createUser(name: $name) { + id + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "createUser": { + "type": "User", + "keyRaw": "createUser(name: $name)", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "endpoint": { + "fields": ["name"], + }, + + "input": { + "fields": { + "name": "String", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + +} as const + +export default artifact + +export type CreateUserFieldsForm = { + readonly "input": CreateUserFieldsForm$input; + readonly "result": CreateUserFieldsForm$result; +}; + +export type CreateUserFieldsForm$result = { + readonly createUser: { + readonly id: string; + }; +}; + +export type CreateUserFieldsForm$input = { + name: string; +}; + +export type CreateUserFieldsForm$optimistic = { + readonly createUser?: { + readonly id?: string; + }; +}; + +export type CreateUserFieldsForm$unmasked = { + readonly createUser: { + readonly __typename: "User"; + readonly id: string; + }; +}; + +export type CreateUserFieldsForm$artifact = typeof artifact + +"HoudiniHash=6321c3d3f53f1dd60ad57d5796e97c44f6eabb2d4df2b0ea23789d7f936aba38"`), + }, + }, + { + Name: "@session emits a top-level sessionPath", + Pass: true, + Input: []string{ + `mutation LoginForm($name: String!) @session(path: "createUser") { + createUser(name: $name) { id } + }`, + }, + Extra: map[string]any{ + "LoginForm": tests.Dedent(`const artifact = { + "name": "LoginForm", + "kind": "HoudiniMutation", + "hash": "7781e7aef13f15dc275ab44769bc456f5af795fc04864518008fbbb1e05fe4e7", + "raw": ` + "`" + `mutation LoginForm($name: String!) { + createUser(name: $name) { + id + __typename + } +} +` + "`" + `, + + "rootType": "Mutation", + "stripVariables": [] as Array, + + "selection": { + "fields": { + "createUser": { + "type": "User", + "keyRaw": "createUser(name: $name)", + + "selection": { + "fields": { + "__typename": { + "type": "String", + "keyRaw": "__typename", + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true, + }, + }, + }, + + "visible": true, + }, + }, + }, + + "pluginData": {}, + + "sessionPath": "createUser", + + "input": { + "fields": { + "name": "String", + }, + + "types": {}, + + "defaults": {}, + + "runtimeScalars": {}, + }, + +} as const + +export default artifact + +export type LoginForm = { + readonly "input": LoginForm$input; + readonly "result": LoginForm$result; +}; + +export type LoginForm$result = { + readonly createUser: { + readonly id: string; + }; +}; + +export type LoginForm$input = { + name: string; +}; + +export type LoginForm$optimistic = { + readonly createUser?: { + readonly id?: string; + }; +}; + +export type LoginForm$unmasked = { + readonly createUser: { + readonly __typename: "User"; + readonly id: string; + }; +}; + +export type LoginForm$artifact = typeof artifact + +"HoudiniHash=7781e7aef13f15dc275ab44769bc456f5af795fc04864518008fbbb1e05fe4e7"`), + }, + }, }, }) } diff --git a/packages/houdini-core/plugin/documents/artifacts/session.go b/packages/houdini-core/plugin/documents/artifacts/session.go new file mode 100644 index 0000000000..1e87636526 --- /dev/null +++ b/packages/houdini-core/plugin/documents/artifacts/session.go @@ -0,0 +1,53 @@ +package artifacts + +import ( + "fmt" + "strconv" + + "code.houdinigraphql.com/packages/houdini-core/plugin/documents/collected" + "code.houdinigraphql.com/plugins/graphql" +) + +// buildSessionArtifact returns the top-level `"sessionPath"` (and `"sessionMerge"` when set) +// entry for a document's compiled artifact when the mutation carries @session, or "" otherwise. +// Independent of @endpoint: the path is the static marker the runtime hook and the server's +// session-mint plugin / form handler use to find the subtree of the result that writes the +// session; merge distinguishes a replace from an upsert. +func buildSessionArtifact(doc *collected.Document) string { + var directive *collected.Directive + for _, d := range doc.Directives { + if d.Name == graphql.SessionDirective { + directive = d + break + } + } + if directive == nil { + return "" + } + + path := "" + merge := false + for _, arg := range directive.Arguments { + if arg.Value == nil { + continue + } + switch arg.Name { + case "path": + path = arg.Value.Raw + case "merge": + merge = arg.Value.Raw == "true" + } + } + if path == "" { + return "" + } + + out := fmt.Sprintf(` + + "sessionPath": %s,`, strconv.Quote(path)) + if merge { + out += ` + "sessionMerge": true,` + } + return out +} diff --git a/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go b/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go index 6efcaaaaab..d4551e15ed 100644 --- a/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go +++ b/packages/houdini-core/plugin/documents/artifacts/typescript/documents.go @@ -11,13 +11,13 @@ import ( "code.houdinigraphql.com/packages/houdini-core/plugin/documents/collected" "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/graphql" - ) // DocumentContext holds document-specific state that was previously stored in global variables // and embeds context.Context to serve as both context and document state type DocumentContext struct { HasLoading bool + SortKeys bool ProjectConfig plugins.ProjectConfig EnumTypes map[string]bool InputTypes map[string]bool @@ -147,6 +147,8 @@ func GenerateDocumentTypeDefs( rootTypes *RootTypeNames, collectedDefinitions *collected.Documents, doc *collected.Document, + unmaskedSelection []*collected.Selection, + sortKeys bool, ) (string, []string, error) { // Calculate root type name once per document rootTypeName := getRootTypeName(doc, rootTypes) @@ -157,6 +159,8 @@ func GenerateDocumentTypeDefs( rootTypeName, doc, collectedDefinitions, + unmaskedSelection, + sortKeys, ) if err != nil { return "", nil, err @@ -170,10 +174,13 @@ func generateDocumentTypeDef( rootTypeName string, doc *collected.Document, collectedDocs *collected.Documents, + unmaskedSelection []*collected.Selection, + sortKeys bool, ) (string, []string, error) { // Create document context to pass state instead of using global variables docCtx := DocumentContext{ ProjectConfig: projectConfig, + SortKeys: sortKeys, EnumTypes: make(map[string]bool), InputTypes: make(map[string]bool), ScalarImports: make(map[string]bool), @@ -202,6 +209,7 @@ func generateDocumentTypeDef( rootTypeName, doc, collectedDocs, + unmaskedSelection, )...) } @@ -271,6 +279,20 @@ func generateFragmentTypes( ) []string { var types []string + // gather the document-level directive flags in a single pass. @plural marks the + // fragment as list-shaped (spread on a list field, consumed as an array); a + // document-level @loading shapes the value while it loads. + pluralFragment := false + documentLoading := false + for _, directive := range doc.Directives { + switch directive.Name { + case graphql.PluralDirective: + pluralFragment = true + case graphql.LoadingDirective: + documentLoading = true + } + } + // Generate fragment input type inputTypeName := fmt.Sprintf("%s$input", doc.Name) if len(doc.Variables) > 0 { @@ -301,18 +323,46 @@ func generateFragmentTypes( types = append(types, fmt.Sprintf("export type %s = never;", inputTypeName)) } - // Generate main fragment type + // Generate main fragment type. @plural fragments are spread on a list field and + // consumed as an array, so the reference type is wrapped in ReadonlyArray (the + // $data type below stays the single-item shape). dataTypeName := fmt.Sprintf("%s$data", doc.Name) - mainType := fmt.Sprintf(`export type %s = { + // The " $fragments" marker is a phantom brand that a pending (LoadingType) reference is + // NOT assignable to, so a loading reference can't be passed to a fragment that can't + // render it. The brand's single property is a human-readable message keyed by the + // fragment name, so it shows up in the type error both when a loading reference is + // passed (no properties in common with the brand) and when the spread is missing + // (... is missing ... required in { Fragment: }). A fragment that can render + // during a loading frame — i.e. it carries @loading anywhere, on its definition OR on a + // field — also accepts a pending reference; only a fragment with no @loading at all + // rejects one. + fragmentMarker := fmt.Sprintf( + `{ readonly "expected a %s fragment spread"?: never }`, + doc.Name, + ) + if documentLoading || hasAnyLoadingDirectives(doc.Selections) { + fragmentMarker = fmt.Sprintf( + `{ readonly "expected a %s fragment spread"?: never } | LoadingType`, + doc.Name, + ) + } + referenceShape := fmt.Sprintf(`{ readonly "shape"?: %s; readonly " $fragments": { - "%s": any; + "%s": %s; }; -};`, doc.Name, dataTypeName, doc.Name) +}`, dataTypeName, doc.Name, fragmentMarker) + if pluralFragment { + referenceShape = fmt.Sprintf("ReadonlyArray<%s>", referenceShape) + } + mainType := fmt.Sprintf("export type %s = %s;", doc.Name, referenceShape) types = append(types, mainType) - // Generate fragment data type (with single indentation for fragments) - if hasAnyLoadingDirectives(doc.Selections) { + // Generate fragment data type (with single indentation for fragments). The loading + // variant is generated when the fragment has field-level @loading OR a definition-level + // @loading (`fragment X on Y @loading`); the latter cascades into every field, and + // composes with any field-level marks rather than being cancelled by them. + if hasAnyLoadingDirectives(doc.Selections) || documentLoading { // Generate union type with normal and loading states normalType, _ := generateSelectionType( ctx, @@ -321,8 +371,9 @@ func generateFragmentTypes( 0, rootTypeName, collectedDocs, + false, ) - hasGlobalLoading := hasDocumentLevelLoading(doc) && !hasAnyLoadingDirectives(doc.Selections) + hasGlobalLoading := documentLoading loadingType, _ := generateLoadingStateType( ctx, doc.Selections, @@ -335,7 +386,7 @@ func generateFragmentTypes( types = append(types, fmt.Sprintf("export type %s = %s;", dataTypeName, dataType)) } else { // Generate normal single type - dataType, _ := generateSelectionType(ctx, doc.Selections, true, 0, rootTypeName, collectedDocs) + dataType, _ := generateSelectionType(ctx, doc.Selections, true, 0, rootTypeName, collectedDocs, false) types = append(types, fmt.Sprintf("export type %s = %s;", dataTypeName, dataType)) } @@ -347,6 +398,7 @@ func generateOperationTypes( rootTypeName string, doc *collected.Document, collectedDocs *collected.Documents, + unmaskedSelection []*collected.Selection, ) []string { var types []string @@ -384,8 +436,13 @@ func generateOperationTypes( 0, rootTypeName, collectedDocs, + false, ) - hasGlobalLoading := hasDocumentLevelLoading(doc) && !hasAnyLoadingDirectives(doc.Selections) + // Document-level @loading turns on the global loading shape (every field + // present as a placeholder). Field-level @loading directives layer on top to + // configure individual fields (e.g. @loading(count:) on a list); they must not + // switch the document out of global mode and drop the unmarked fields. + hasGlobalLoading := hasDocumentLevelLoading(doc) loadingType, _ := generateLoadingStateType( ctx, doc.Selections, @@ -398,7 +455,7 @@ func generateOperationTypes( types = append(types, fmt.Sprintf("export type %s = %s;", resultTypeName, resultType)) } else { // Generate normal single type - resultType, _ := generateSelectionType(ctx, doc.Selections, true, 0, rootTypeName, collectedDocs) + resultType, _ := generateSelectionType(ctx, doc.Selections, true, 0, rootTypeName, collectedDocs, false) types = append(types, fmt.Sprintf("export type %s = %s;", resultTypeName, resultType)) } @@ -449,6 +506,15 @@ func generateOperationTypes( ) } + // Generate $unmasked type: the fully-resolved server payload with all fragment + // fields inlined and no " $fragments" mask annotations. Used by createMock so + // test data can be written as plain JSON matching what the network would return. + // unmaskedSelection comes from FlattenSelection(defaultMask=false) so all fragment + // fields are already merged in deterministic order — no manual sort needed. + unmaskedTypeName := fmt.Sprintf("%s$unmasked", doc.Name) + unmaskedType, _ := generateSelectionType(ctx, unmaskedSelection, true, 0, rootTypeName, collectedDocs, true) + types = append(types, fmt.Sprintf("export type %s = %s;", unmaskedTypeName, unmaskedType)) + return types } @@ -459,6 +525,7 @@ func generateSelectionType( indentLevel int, parentType string, collectedDocs *collected.Documents, + unmasked bool, ) (string, error) { if len(selections) == 0 { return "{}", nil @@ -490,12 +557,17 @@ func generateSelectionType( // Count explicit fields that would be visible (excluding internal/auto-added fields) for _, sel := range selections { if sel.Kind == "fragment" { - visibleSelections = append(visibleSelections, sel) + // In unmasked mode, fragment spread markers are skipped entirely — the + // fields they contributed are already inlined in the flattened selection. + if !unmasked { + visibleSelections = append(visibleSelections, sel) + } continue } - // Skip internal fields (automatically added fields like __typename) - if sel.Internal { + // Skip internal fields (automatically added fields like __typename) unless + // generating the $unmasked type, where all server-visible fields are included. + if sel.Internal && !unmasked { continue } @@ -561,6 +633,8 @@ func generateSelectionType( selection, readonly, collectedDocs, + unmasked, + indentLevel+1, ) // Apply type modifiers (lists, nullability) to the union type @@ -571,7 +645,7 @@ func generateSelectionType( fieldType = ApplyTypeModifiers(unionType, modifiers, false) // Output type } else { // Regular nested object type - childType, childErr := generateSelectionType(ctx, selection.Children, readonly, indentLevel+1, selection.FieldType, collectedDocs) + childType, childErr := generateSelectionType(ctx, selection.Children, readonly, indentLevel+1, selection.FieldType, collectedDocs, unmasked) if childErr != nil { return "", childErr } @@ -584,8 +658,13 @@ func generateSelectionType( fieldType = ApplyTypeModifiers(childType, modifiers, false) // Output type } } else { - // Scalar field - use simplified type conversion - fieldType = convertLeafType(ctx, selection.FieldType, selection.TypeModifiers, collectedDocs) + // Scalar field - use simplified type conversion. + // Special-case __typename on a concrete parent type: we know the exact string literal. + if selection.FieldName == "__typename" && parentType != "" && len(collectedDocs.PossibleTypes[parentType]) == 0 { + fieldType = fmt.Sprintf(`"%s"`, parentType) + } else { + fieldType = convertLeafType(ctx, selection.FieldType, selection.TypeModifiers, collectedDocs) + } } // @includeListID attaches an opaque __id to the runtime value; reflect that in the type @@ -659,8 +738,10 @@ func generateInterfaceUnionType( selection *collected.Selection, readonly bool, collectedDocs *collected.Documents, + unmasked bool, + indentLevel int, ) string { - return generateInterfaceUnionTypeWithLoading(ctx, selection, readonly, false, collectedDocs) + return generateInterfaceUnionTypeWithLoading(ctx, selection, readonly, false, collectedDocs, unmasked, indentLevel) } func generateInterfaceUnionTypeWithLoading( @@ -669,11 +750,16 @@ func generateInterfaceUnionTypeWithLoading( readonly bool, isLoadingState bool, collectedDocs *collected.Documents, + unmasked bool, + indentLevel int, ) string { readonlyPrefix := "" if readonly { readonlyPrefix = "readonly " } + fieldIndent := strings.Repeat("\t", indentLevel+1) + memberIndent := strings.Repeat("\t", indentLevel) + fragmentSubIndent := strings.Repeat("\t", indentLevel+2) // Collect all fragments and determine which concrete types need union members fragmentsByType := make(map[string][]*collected.Selection) @@ -802,6 +888,8 @@ func generateInterfaceUnionTypeWithLoading( fragmentChild, readonly, collectedDocs, + unmasked, + indentLevel+1, ) // Apply type modifiers (lists, nullability) to the union type @@ -816,7 +904,7 @@ func generateInterfaceUnionTypeWithLoading( ) // Output type } else { // Regular nested object type - childType, childErr := generateSelectionType(ctx, fragmentChild.Children, readonly, 2, fragmentChild.FieldType, collectedDocs) + childType, childErr := generateSelectionType(ctx, fragmentChild.Children, readonly, indentLevel+1, fragmentChild.FieldType, collectedDocs, unmasked) if childErr != nil { // Fallback to simple type conversion on error fieldType = convertLeafType( @@ -854,7 +942,8 @@ func generateInterfaceUnionTypeWithLoading( fields = append( fields, fmt.Sprintf( - "\t\t%s%s%s: %s;", + "%s%s%s%s: %s;", + fieldIndent, readonlyPrefix, fragmentChild.FieldName, optional, @@ -865,33 +954,46 @@ func generateInterfaceUnionTypeWithLoading( } } + // union/interface arms merge fields across several inline fragments, so the + // per-node sort FlattenSelection applies doesn't survive the merge. only the + // $unmasked type wants a stable global order and only tests need it sorted, so + // gate on sortKeys to keep production (sortKeys=false) free of the cost. + if ctx.SortKeys && unmasked { + sort.Strings(fields) + } + // Add " $fragments" marker for named fragment spreads on this concrete type - if fragNames := namedFragmentsByType[typeName]; len(fragNames) > 0 { - seen := make(map[string]bool) - var uniqueFragNames []string - for _, n := range fragNames { - if !seen[n] { - seen[n] = true - uniqueFragNames = append(uniqueFragNames, n) + // (omitted in unmasked mode — the fields are already inlined) + if !unmasked { + if fragNames := namedFragmentsByType[typeName]; len(fragNames) > 0 { + seen := make(map[string]bool) + var uniqueFragNames []string + for _, n := range fragNames { + if !seen[n] { + seen[n] = true + uniqueFragNames = append(uniqueFragNames, n) + } } + sort.Strings(uniqueFragNames) + fragEntries := make([]string, len(uniqueFragNames)) + for i, n := range uniqueFragNames { + fragEntries[i] = fmt.Sprintf("%s%s: {};", fragmentSubIndent, n) + } + fields = append(fields, fmt.Sprintf( + "%s%s\" $fragments\": {\n%s\n%s};", + fieldIndent, + readonlyPrefix, + strings.Join(fragEntries, "\n"), + fieldIndent, + )) } - sort.Strings(uniqueFragNames) - fragEntries := make([]string, len(uniqueFragNames)) - for i, n := range uniqueFragNames { - fragEntries[i] = fmt.Sprintf("\t\t\t%s: {};", n) - } - fields = append(fields, fmt.Sprintf( - "\t\t%s\" $fragments\": {\n%s\n\t\t};", - readonlyPrefix, - strings.Join(fragEntries, "\n"), - )) } // Always add __typename field for discrimination with literal type - fields = append(fields, fmt.Sprintf("\t\t%s__typename: \"%s\";", readonlyPrefix, typeName)) + fields = append(fields, fmt.Sprintf("%s%s__typename: \"%s\";", fieldIndent, readonlyPrefix, typeName)) // Create the type literal - typeLiteral := fmt.Sprintf("({\n%s\n\t})", strings.Join(fields, "\n")) + typeLiteral := fmt.Sprintf("({\n%s\n%s})", strings.Join(fields, "\n"), memberIndent) unionParts = append(unionParts, typeLiteral) } @@ -918,9 +1020,12 @@ func generateInterfaceUnionTypeWithLoading( if !hasFragmentForAllTypes { nonExhaustive := fmt.Sprintf( - "({\n\t\t%s\" $fragments\"?: {};\n\t\t%s__typename: \"non-exhaustive; don't match this\";\n\t})", + "({\n%s%s\" $fragments\"?: {};\n%s%s__typename: \"non-exhaustive; don't match this\";\n%s})", + fieldIndent, readonlyPrefix, + fieldIndent, readonlyPrefix, + memberIndent, ) unionParts = append(unionParts, nonExhaustive) } @@ -1080,6 +1185,8 @@ func generateOptimisticType( selection, readonly, collectedDocs, + false, + indentLevel+1, ) } else if len(selection.Children) > 0 { // Regular nested object type @@ -1089,8 +1196,13 @@ func generateOptimisticType( } fieldType = childType } else { - // Leaf field - convert the GraphQL type to TypeScript - fieldType = convertLeafType(ctx, selection.FieldType, selection.TypeModifiers, collectedDocs) + // Leaf field - convert the GraphQL type to TypeScript. + // Special-case __typename on a concrete parent type: we know the exact string literal. + if selection.FieldName == "__typename" && parentType != "" && len(collectedDocs.PossibleTypes[parentType]) == 0 { + fieldType = fmt.Sprintf(`"%s"`, parentType) + } else { + fieldType = convertLeafType(ctx, selection.FieldType, selection.TypeModifiers, collectedDocs) + } } // Add JSDoc comment if this field has a description @@ -1186,7 +1298,11 @@ func generateLoadingStateType( } if hasFragmentLoading { - // Fragment spread with @loading (or global loading) - preserve fragment structure in loading state + // Fragment spread with @loading (or global loading): the spread's data is + // pending while it loads, so mark the reference as LoadingType in the loading + // variant. This keeps the variant a genuine loading variant (it carries a + // PendingValue) so isPending can detect it, matching the runtime where the + // fragment's pending fields are co-located on the reference. fragmentName := selection.FieldName if selection.FragmentRef != nil { fragmentName = *selection.FragmentRef @@ -1194,7 +1310,7 @@ func generateLoadingStateType( fragmentIndent := strings.Repeat("\t", indentLevel+2) fragmentFields = append( fragmentFields, - fmt.Sprintf("%s%s: {};", fragmentIndent, fragmentName), + fmt.Sprintf("%s%s: LoadingType;", fragmentIndent, fragmentName), ) } continue @@ -1296,6 +1412,8 @@ func generateLoadingStateType( true, // readonly true, // isLoadingState collectedDocs, + false, // unmasked + indentLevel+1, ) // Apply array syntax if this is a list type @@ -1318,6 +1436,12 @@ func generateLoadingStateType( return "", childErr } fieldType = childType + + // Apply array syntax if this is a list type + if selection.TypeModifiers != nil && + strings.Contains(*selection.TypeModifiers, "]") { + fieldType = fmt.Sprintf("%s[]", fieldType) + } } } else { // Field with @loading directive (leaf or no loading children) - becomes LoadingType @@ -1353,6 +1477,12 @@ func generateLoadingStateType( return "", childErr } fieldType = childType + + // Apply array syntax if this is a list type + if selection.TypeModifiers != nil && + strings.Contains(*selection.TypeModifiers, "]") { + fieldType = fmt.Sprintf("%s[]", fieldType) + } } else { // Field without loading - skip it in loading state continue diff --git a/packages/houdini-core/plugin/documents/artifacts/typescript/documents_test.go b/packages/houdini-core/plugin/documents/artifacts/typescript/documents_test.go index ca91fe65e2..8285cbed4f 100644 --- a/packages/houdini-core/plugin/documents/artifacts/typescript/documents_test.go +++ b/packages/houdini-core/plugin/documents/artifacts/typescript/documents_test.go @@ -8,11 +8,33 @@ import ( "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/packages/houdini-core/plugin" + "code.houdinigraphql.com/packages/houdini-core/plugin/documents" "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/tests" "github.com/spf13/afero" ) +func performTypescriptTest( + verifyFn func(*testing.T, *plugin.HoudiniCore, tests.Test[config.PluginConfig]), +) func(*testing.T, *plugin.HoudiniCore, tests.Test[config.PluginConfig]) { + return func(t *testing.T, p *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + if err := p.Validate(context.Background()); err != nil { + require.False(t, test.Pass, err.Error()) + return + } + if err := p.AfterValidate(context.Background()); err != nil { + require.False(t, test.Pass, err) + return + } + if _, err := documents.Generate(context.Background(), p.DB, p.Fs, true); err != nil { + require.False(t, test.Pass, err.Error()) + return + } + require.True(t, test.Pass) + verifyFn(t, p, test) + } +} + func TestTypescriptGeneration(t *testing.T) { tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ Schema: ` @@ -119,20 +141,16 @@ func TestTypescriptGeneration(t *testing.T) { weight: Float } `, - VerifyTest: func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + PerformTest: performTypescriptTest(func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { config, err := plugin.DB.ProjectConfig(context.Background()) require.NoError(t, err) - // for every document that we want to generate types for for docName, expected := range test.Extra { - // open the file with the appropriate type definitions typeDefs, err := afero.ReadFile(plugin.Fs, config.ArtifactTypePath(docName)) - - // make sure values match expectations require.NoError(t, err) require.Contains(t, string(typeDefs), expected) } - }, + }), Tests: []tests.Test[config.PluginConfig]{ { Name: "generates document types", @@ -182,6 +200,30 @@ func TestTypescriptGeneration(t *testing.T) { export type TestQuery$input = null | undefined; + export type TestQuery$unmasked = { + /** + * Get a user. + */ + readonly user: { + readonly __typename: "User"; + readonly admin: boolean | null; + readonly age: number | null; + /** + * An enum value + */ + readonly enumValue: MyEnum$options | null; + /** + * The user's first name + */ + readonly firstName: string; + /** + * The user's first name + */ + readonly firstname: string; + readonly id: string; + } | null; + }; + export type TestQuery$artifact = typeof artifact `), "otherInfo": tests.Dedent(` @@ -190,7 +232,7 @@ func TestTypescriptGeneration(t *testing.T) { export type otherInfo = { readonly "shape"?: otherInfo$data; readonly " $fragments": { - "otherInfo": any; + "otherInfo": { readonly "expected a otherInfo fragment spread"?: never }; }; }; @@ -223,7 +265,7 @@ func TestTypescriptGeneration(t *testing.T) { export type TestFragment = { readonly "shape"?: TestFragment$data; readonly " $fragments": { - "TestFragment": any; + "TestFragment": { readonly "expected a TestFragment fragment spread"?: never }; }; }; @@ -243,6 +285,83 @@ func TestTypescriptGeneration(t *testing.T) { `), }, }, + { + Name: "plural fragment wraps the reference type in ReadonlyArray", + Input: []string{ + `fragment PluralRow on User @plural { firstName }`, + }, + Pass: true, + Extra: map[string]any{ + // the reference type is an array, but $data stays the single-item shape + "PluralRow": tests.Dedent(` + export type PluralRow = ReadonlyArray<{ + readonly "shape"?: PluralRow$data; + readonly " $fragments": { + "PluralRow": { readonly "expected a PluralRow fragment spread"?: never }; + }; + }>; + + export type PluralRow$data = { + /** + * The user's first name + */ + readonly firstName: string; + }; + `), + }, + }, + { + Name: "plural fragment with @arguments keeps the array reference and typed input", + Input: []string{ + `fragment PluralArgs on User @plural @arguments(pattern: { type: "String" }) { firstName(pattern: $pattern) }`, + }, + Pass: true, + Extra: map[string]any{ + // the @arguments input is typed and the reference stays an array + "PluralArgs": tests.Dedent(` + export type PluralArgs$input = { + pattern?: string | null; + }; + + export type PluralArgs = ReadonlyArray<{ + readonly "shape"?: PluralArgs$data; + readonly " $fragments": { + "PluralArgs": { readonly "expected a PluralArgs fragment spread"?: never }; + }; + }>; + `), + }, + }, + { + Name: "plural fragment with @loading", + Input: []string{ + `fragment PluralLoading on User @plural { firstName @loading }`, + }, + Pass: true, + Extra: map[string]any{ + // the reference stays an array; $data carries the per-item loading union + "PluralLoading": tests.Dedent(` + export type PluralLoading = ReadonlyArray<{ + readonly "shape"?: PluralLoading$data; + readonly " $fragments": { + "PluralLoading": { readonly "expected a PluralLoading fragment spread"?: never } | LoadingType; + }; + }>; + + export type PluralLoading$data = { + /** + * The user's first name + */ + readonly firstName: string; + } | { + /** + * The user's first name + */ + readonly firstName: LoadingType; + }; + `), + }, + }, { Name: "fragment types with variables", Input: []string{ @@ -258,7 +377,7 @@ func TestTypescriptGeneration(t *testing.T) { export type TestFragment = { readonly "shape"?: TestFragment$data; readonly " $fragments": { - "TestFragment": any; + "TestFragment": { readonly "expected a TestFragment fragment spread"?: never }; }; }; @@ -290,7 +409,7 @@ func TestTypescriptGeneration(t *testing.T) { export type TestFragment = { readonly "shape"?: TestFragment$data; readonly " $fragments": { - "TestFragment": any; + "TestFragment": { readonly "expected a TestFragment fragment spread"?: never }; }; }; @@ -320,7 +439,7 @@ func TestTypescriptGeneration(t *testing.T) { export type TestFragment = { readonly "shape"?: TestFragment$data; readonly " $fragments": { - "TestFragment": any; + "TestFragment": { readonly "expected a TestFragment fragment spread"?: never }; }; }; @@ -368,6 +487,17 @@ func TestTypescriptGeneration(t *testing.T) { list: (UserFilter)[]; }; + export type MyQuery$unmasked = { + readonly users: ({ + readonly __typename: "User"; + /** + * The user's first name + */ + readonly firstName: string; + readonly id: string; + } | null)[] | null; + }; + export type MyQuery$artifact = typeof artifact `), }, @@ -398,8 +528,22 @@ func TestTypescriptGeneration(t *testing.T) { }; export type MyQuery$input = { - id: string; enum?: MyEnum$options | null; + id: string; + }; + + export type MyQuery$unmasked = { + /** + * Get a user. + */ + readonly user: { + readonly __typename: "User"; + /** + * The user's first name + */ + readonly firstName: string; + readonly id: string; + } | null; }; export type MyQuery$artifact = typeof artifact @@ -431,6 +575,16 @@ func TestTypescriptGeneration(t *testing.T) { export type MyTestQuery$input = null | undefined; + export type MyTestQuery$unmasked = { + readonly entity: {} & (({ + readonly id: string; + readonly __typename: "Cat"; + }) | ({ + readonly id: string; + readonly __typename: "User"; + })); + }; + export type MyTestQuery$artifact = typeof artifact `), }, @@ -464,6 +618,20 @@ func TestTypescriptGeneration(t *testing.T) { filter: UserFilter; }; + export type MyQuery$unmasked = { + /** + * Get a user. + */ + readonly user: { + readonly __typename: "User"; + /** + * The user's first name + */ + readonly firstName: string; + readonly id: string; + } | null; + }; + export type MyQuery$artifact = typeof artifact `), }, @@ -496,6 +664,19 @@ func TestTypescriptGeneration(t *testing.T) { export type MyQuery$input = null | undefined; + export type MyQuery$unmasked = { + readonly nodes: ({} & (({ + readonly id: string; + readonly __typename: "Cat"; + }) | ({ + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; + }; + export type MyQuery$artifact = typeof artifact `), }, @@ -525,6 +706,16 @@ func TestTypescriptGeneration(t *testing.T) { export type MyQuery$input = null | undefined; + export type MyQuery$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly __typename: "Cat"; + }) | ({ + readonly id: string; + readonly __typename: "User"; + })) | null)[] | null; + }; + export type MyQuery$artifact = typeof artifact `), }, @@ -575,6 +766,24 @@ func TestTypescriptGeneration(t *testing.T) { export type ComplexQuery$input = null | undefined; + export type ComplexQuery$unmasked = { + readonly nodes: ({} & (({ + readonly id: string; + readonly kitty: boolean; + readonly names: (string | null)[]; + readonly __typename: "Cat"; + }) | ({ + readonly admin: boolean | null; + readonly age: number | null; + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; + }; + export type ComplexQuery$artifact = typeof artifact `), }, @@ -621,6 +830,20 @@ func TestTypescriptGeneration(t *testing.T) { export type UnionQuery$input = null | undefined; + export type UnionQuery$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly isAnimal: boolean; + readonly kitty: boolean; + readonly __typename: "Cat"; + }) | ({ + readonly admin: boolean | null; + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + })) | null)[] | null; + }; + export type UnionQuery$artifact = typeof artifact `), }, @@ -667,6 +890,21 @@ func TestTypescriptGeneration(t *testing.T) { export type MixedQuery$input = null | undefined; + export type MixedQuery$unmasked = { + readonly nodes: ({} & (({ + readonly id: string; + readonly kitty: boolean; + readonly __typename: "Cat"; + }) | ({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })))[]; + }; + export type MixedQuery$artifact = typeof artifact `), }, @@ -714,6 +952,20 @@ func TestTypescriptGeneration(t *testing.T) { export type AbstractConcreteQuery$input = null | undefined; + export type AbstractConcreteQuery$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly isAnimal: boolean; + readonly kitty: boolean; + readonly __typename: "Cat"; + }) | ({ + readonly admin: boolean | null; + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + })) | null)[] | null; + }; + export type AbstractConcreteQuery$artifact = typeof artifact `), }, @@ -768,6 +1020,22 @@ func TestTypescriptGeneration(t *testing.T) { export type UnionAbstractQuery$input = null | undefined; + export type UnionAbstractQuery$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly isAnimal: boolean; + readonly kitty: boolean; + readonly names: (string | null)[]; + readonly __typename: "Cat"; + }) | ({ + readonly admin: boolean | null; + readonly age: number | null; + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + })) | null)[] | null; + }; + export type UnionAbstractQuery$artifact = typeof artifact `), }, @@ -808,6 +1076,18 @@ func TestTypescriptGeneration(t *testing.T) { export type AnimalCatQuery$input = null | undefined; + export type AnimalCatQuery$unmasked = { + readonly entities: ({} & (({ + readonly id: string; + readonly isAnimal: boolean; + readonly kitty: boolean; + readonly __typename: "Cat"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null)[] | null; + }; + export type AnimalCatQuery$artifact = typeof artifact `), }, @@ -854,12 +1134,12 @@ func TestTypescriptGeneration(t *testing.T) { }; export type MyMutation$input = { + admin?: boolean | null; + age?: number | null; filter?: UserFilter | null; filterList: (UserFilter)[]; - id: string; firstName: string; - admin?: boolean | null; - age?: number | null; + id: string; weight?: number | null; }; @@ -872,6 +1152,17 @@ func TestTypescriptGeneration(t *testing.T) { } | null; }; + export type MyMutation$unmasked = { + readonly doThing: { + readonly __typename: "User"; + /** + * The user's first name + */ + readonly firstName: string; + readonly id: string; + } | null; + }; + export type MyMutation$artifact = typeof artifact `), }, @@ -903,6 +1194,20 @@ func TestTypescriptGeneration(t *testing.T) { export type MyQuery$input = null | undefined; + export type MyQuery$unmasked = { + /** + * Get a user. + */ + readonly user: { + readonly __typename: "User"; + /** + * The user's first name + */ + readonly firstName: string; + readonly id: string; + } | null; + }; + export type MyQuery$artifact = typeof artifact `), "Foo": tests.Dedent(` @@ -911,7 +1216,7 @@ func TestTypescriptGeneration(t *testing.T) { export type Foo = { readonly "shape"?: Foo$data; readonly " $fragments": { - "Foo": any; + "Foo": { readonly "expected a Foo fragment spread"?: never }; }; }; @@ -958,6 +1263,17 @@ func TestTypescriptGeneration(t *testing.T) { id: string; }; + export type NodeQuery$unmasked = { + readonly node: {} & (({ + readonly firstName: string; + readonly id: string; + readonly __typename: "User"; + }) | ({ + readonly " $fragments"?: {}; + readonly __typename: "non-exhaustive; don't match this"; + })) | null; + }; + export type NodeQuery$artifact = typeof artifact `), }, @@ -988,7 +1304,7 @@ func TestTypescriptGeneration(t *testing.T) { * Get a user. */ readonly user: { - readonly __typename: string; + readonly __typename: "User"; /** * The user's first name */ @@ -998,6 +1314,20 @@ func TestTypescriptGeneration(t *testing.T) { export type UserQuery$input = null | undefined; + export type UserQuery$unmasked = { + /** + * Get a user. + */ + readonly user: { + readonly __typename: "User"; + /** + * The user's first name + */ + readonly firstName: string; + readonly id: string; + } | null; + }; + export type UserQuery$artifact = typeof artifact `), }, @@ -1051,7 +1381,7 @@ func TestScalarImports(t *testing.T) { metadata: JSON } `, - VerifyTest: func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + PerformTest: performTypescriptTest(func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { cfg, err := plugin.DB.ProjectConfig(context.Background()) require.NoError(t, err) @@ -1060,7 +1390,7 @@ func TestScalarImports(t *testing.T) { require.NoError(t, err) require.Contains(t, string(typeDefs), expected) } - }, + }), Tests: []tests.Test[config.PluginConfig]{ { Name: "named scalar import generates import statement in artifact types", @@ -1188,14 +1518,14 @@ func TestScalarImports(t *testing.T) { createdAt: DateTime } `, - VerifyTest: func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + PerformTest: performTypescriptTest(func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { cfg, err := plugin.DB.ProjectConfig(context.Background()) require.NoError(t, err) typeDefs, err := afero.ReadFile(plugin.Fs, cfg.ArtifactTypePath("UserQuery")) require.NoError(t, err) require.NotContains(t, string(typeDefs), `import type { Date } from 'date-fns'`) - }, + }), Tests: []tests.Test[config.PluginConfig]{ { Name: "no scalar import when field not selected", diff --git a/packages/houdini-core/plugin/documents/collected/collect.go b/packages/houdini-core/plugin/documents/collected/collect.go index 04741c1515..0729decb55 100644 --- a/packages/houdini-core/plugin/documents/collected/collect.go +++ b/packages/houdini-core/plugin/documents/collected/collect.go @@ -230,6 +230,7 @@ func collectDoc( statements.Search, statements.DocumentVariables, statements.DocumentDirectives, + statements.RefetchMeta, statements.PossibleTypes, statements.InputTypes, } { @@ -788,6 +789,44 @@ func collectDoc( return } + // attach refetch metadata for list-less refetchable documents. this drives the + // artifact "refetch" block the same way a discovered_lists row does for lists, + // but without pretending the document contains a list. + err = db.StepStatement(ctx, statements.RefetchMeta, func() { + documentName := statements.RefetchMeta.GetText("document_name") + doc, ok := documents[documentName] + if !ok { + return + } + + mode := statements.RefetchMeta.GetText("mode") + if mode == "" { + mode = "Infinite" + } + method := statements.RefetchMeta.GetText("method") + if method == "" { + method = "offset" + } + + doc.Refetch = &DocumentRefetch{ + Path: []string{}, + Method: method, + PageSize: int(statements.RefetchMeta.GetInt64("page_size")), + Mode: mode, + TargetType: statements.RefetchMeta.GetText("target_type"), + Embedded: statements.RefetchMeta.GetBool("embedded"), + Paginated: false, + Direction: "forward", + } + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + if errs.Len() > 0 { + return + } + // if we've gotten this far then we have recreated the full selection apart from // the nested argument structure valueIDs := []int64{} @@ -909,6 +948,7 @@ type CollectStatements struct { Search plugins.Stmt DocumentVariables plugins.Stmt DocumentDirectives plugins.Stmt + RefetchMeta plugins.Stmt PossibleTypes plugins.Stmt InputTypes plugins.Stmt } @@ -1203,6 +1243,25 @@ func prepareCollectStatements(conn plugins.Conn, count int) (*CollectStatements, return nil, err } + // refetch_meta carries the artifact "refetch" block for list-less refetchable + // documents (e.g. @refetchable fragments' generated queries). it's the analog of + // the discovered_lists row that drives the refetch block for paginated lists. + refetchMeta, err := conn.Prepare(fmt.Sprintf(` + SELECT + d.name AS document_name, + refetch_meta.target_type, + refetch_meta.method, + refetch_meta.mode, + refetch_meta.page_size, + refetch_meta.embedded + FROM refetch_meta + JOIN documents d ON d.id = refetch_meta.document + WHERE d.id in %s + `, whereIn)) + if err != nil { + return nil, err + } + // we need a query that looks up every abstract type that's used in // the set of documents. Split into UNION branches (no OR in JOIN) so // SQLite can use indexes on each branch independently. @@ -1345,6 +1404,7 @@ func prepareCollectStatements(conn plugins.Conn, count int) (*CollectStatements, Search: search, DocumentVariables: documentVariables, DocumentDirectives: documentDirectives, + RefetchMeta: refetchMeta, PossibleTypes: possibleTypes, InputTypes: inputTypes, }, nil @@ -1355,6 +1415,7 @@ func (s *CollectStatements) Finalize() { s.Search.Finalize() s.DocumentVariables.Finalize() s.DocumentDirectives.Finalize() + s.RefetchMeta.Finalize() s.PossibleTypes.Finalize() s.InputTypes.Finalize() } diff --git a/packages/houdini-core/plugin/documents/collected/types.go b/packages/houdini-core/plugin/documents/collected/types.go index e393802d9b..f4f64ba347 100644 --- a/packages/houdini-core/plugin/documents/collected/types.go +++ b/packages/houdini-core/plugin/documents/collected/types.go @@ -136,6 +136,7 @@ func (s *Selection) Clone(includeChildren bool) *Selection { Visible: s.Visible, Internal: s.Internal, ComponentField: s.ComponentField, + Description: s.Description, } // clone pointer fields diff --git a/packages/houdini-core/plugin/documents/endpoint.go b/packages/houdini-core/plugin/documents/endpoint.go new file mode 100644 index 0000000000..d34a8c3fea --- /dev/null +++ b/packages/houdini-core/plugin/documents/endpoint.go @@ -0,0 +1,348 @@ +package documents + +import ( + "context" + "fmt" + "strings" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/graphql" +) + +// endpointUsage collects everything we need to validate a single @endpoint usage. +type endpointUsage struct { + documentID int + directiveID int + docKind string + docName string + filepath string + row int + column int + redirect string + hasRedirect bool +} + +// ValidateEndpointDirective enforces the build-time guarantees for @endpoint: +// - it only sits on mutation documents +// - its redirect (when present) is a relative path, which is what closes +// open-redirect at build time +// - every redirect interpolation path resolves to a leaf scalar in the +// mutation's selection set, so we can never emit /users/undefined +func ValidateEndpointDirective( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + // gather every @endpoint usage along with its document and redirect value. + // the redirect arg is optional (no redirect → PRG back to the page), so we LEFT + // JOIN it; av.kind is empty when the argument is absent. + query := ` + SELECT + d.id, + d.kind, + d.name, + rd.filepath, + dd.row, + dd.column, + av.kind, + av.raw, + dd.id + FROM document_directives dd + JOIN documents d ON d.id = dd.document + JOIN raw_documents rd ON rd.id = d.raw_document + LEFT JOIN document_directive_arguments dda ON dda.parent = dd.id AND dda.name = 'redirect' + LEFT JOIN argument_values av ON av.id = dda.value + WHERE dd.directive = $endpoint_directive + AND (rd.current_task = $task_id OR $task_id IS NULL) + ` + + usages := []endpointUsage{} + err := db.StepQuery(ctx, query, map[string]any{ + "endpoint_directive": graphql.EndpointDirective, + }, func(row plugins.Row) { + usage := endpointUsage{ + documentID: int(row.ColumnInt(0)), + docKind: row.ColumnText(1), + docName: row.ColumnText(2), + filepath: row.ColumnText(3), + row: int(row.ColumnInt(4)), + column: int(row.ColumnInt(5)), + directiveID: int(row.ColumnInt(8)), + } + // only treat the redirect as present when it was supplied as a string literal. + // a non-string value is a type error that core's argument validation reports. + if row.ColumnText(6) == "String" { + usage.hasRedirect = true + usage.redirect = row.ColumnText(7) + } + usages = append(usages, usage) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + + // bulk-load everything the per-usage checks need, so we touch the database a fixed + // number of times instead of once per @endpoint usage: the `fields` list entries (keyed + // by directive id), the declared variables (keyed by document id), and the field + // selections used to resolve redirect interpolation paths (keyed by document id). + fieldsByDirective, variablesByDocument, selectionsByDocument, err := loadEndpointData(ctx, db) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + + for _, usage := range usages { + location := []*plugins.ErrorLocation{{ + Filepath: usage.filepath, + Line: usage.row, + Column: usage.column, + }} + + // @endpoint describes a form that runs a mutation; it is meaningless on a + // query or fragment. flag it and move on — the redirect checks below assume a + // mutation selection set. + if usage.docKind != "mutation" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s can only be used on a mutation, but %q is a %s", + graphql.EndpointDirective, usage.docName, usage.docKind, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + continue + } + + // validate the optional fields allowlist (independent of redirect): each entry must + // name a real input path, starting from a declared variable of the mutation. + checkEndpointFields( + usage, + fieldsByDirective[usage.directiveID], + variablesByDocument[usage.documentID], + location, + errs, + ) + + if !usage.hasRedirect { + continue + } + + // the redirect has to be a relative path. rejecting anything with a scheme or a + // protocol-relative prefix is what closes open-redirect at build time. + if !isRelativePath(usage.redirect) { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s redirect %q must be a relative path (start with a single '/', no scheme or '//')", + graphql.EndpointDirective, usage.redirect, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + // keep going — the interpolation paths are still worth validating + } + + // every { path } in the redirect must resolve to a leaf scalar in the + // mutation's selection set so the runtime/server can interpolate it. + paths := graphql.RedirectInterpolationPaths(usage.redirect) + if len(paths) == 0 { + continue + } + validateRedirectPaths(usage, paths, selectionsByDocument[usage.documentID], location, errs) + } +} + +// loadEndpointData bulk-loads, in three queries, the data the per-usage checks need across +// every @endpoint usage: the `fields` list entries (keyed by directive id, in source +// order), the declared variable names (keyed by document id), and the field selections used +// to resolve redirect interpolation paths (keyed by document id, then by parent selection +// id with 0 = root). +func loadEndpointData( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], +) (map[int][]string, map[int]map[string]bool, map[int]map[int]map[string]endpointSelectionNode, error) { + // every `fields` list entry for every @endpoint, in one pass + fieldsByDirective := map[int][]string{} + err := db.StepQuery(ctx, ` + SELECT dda.parent, child.raw + FROM document_directives dd + JOIN document_directive_arguments dda ON dda.parent = dd.id AND dda.name = 'fields' + JOIN argument_values list ON list.id = dda.value + JOIN argument_value_children avc ON avc.parent = list.id + JOIN argument_values child ON child.id = avc.value + WHERE dd.directive = $endpoint_directive + ORDER BY dda.parent, avc.row + `, map[string]any{"endpoint_directive": graphql.EndpointDirective}, func(row plugins.Row) { + id := int(row.ColumnInt(0)) + fieldsByDirective[id] = append(fieldsByDirective[id], row.ColumnText(1)) + }) + if err != nil { + return nil, nil, nil, err + } + + // the declared variables of every document that carries an @endpoint, in one pass + variablesByDocument := map[int]map[string]bool{} + err = db.StepQuery(ctx, ` + SELECT DISTINCT dv.document, dv.name + FROM document_variables dv + JOIN document_directives dd ON dd.document = dv.document + WHERE dd.directive = $endpoint_directive + `, map[string]any{"endpoint_directive": graphql.EndpointDirective}, func(row plugins.Row) { + id := int(row.ColumnInt(0)) + if variablesByDocument[id] == nil { + variablesByDocument[id] = map[string]bool{} + } + variablesByDocument[id][row.ColumnText(1)] = true + }) + if err != nil { + return nil, nil, nil, err + } + + // the field selections of every document that carries an @endpoint, in one pass, indexed + // by document and then by parent selection id (0 = root). only fields can be referenced + // by a redirect path; fragment spreads are out of scope for v1 forms. + selectionsByDocument := map[int]map[int]map[string]endpointSelectionNode{} + err = db.StepQuery(ctx, ` + SELECT + sr.document, + COALESCE(sr.parent_id, 0) AS parent, + s.id, + COALESCE(s.alias, s.field_name) AS name, + t.kind AS type_kind + FROM selection_refs sr + JOIN selections s ON s.id = sr.child_id + JOIN document_directives dd ON dd.document = sr.document AND dd.directive = $endpoint_directive + LEFT JOIN type_fields tf ON s.type = tf.id + LEFT JOIN types t ON tf.type = t.name + WHERE s.kind = 'field' + `, map[string]any{"endpoint_directive": graphql.EndpointDirective}, func(row plugins.Row) { + document := int(row.ColumnInt(0)) + parent := int(row.ColumnInt(1)) + name := row.ColumnText(3) + if selectionsByDocument[document] == nil { + selectionsByDocument[document] = map[int]map[string]endpointSelectionNode{} + } + if selectionsByDocument[document][parent] == nil { + selectionsByDocument[document][parent] = map[string]endpointSelectionNode{} + } + selectionsByDocument[document][parent][name] = endpointSelectionNode{ + id: int(row.ColumnInt(2)), + typeKind: row.ColumnText(4), + } + }) + if err != nil { + return nil, nil, nil, err + } + + return fieldsByDirective, variablesByDocument, selectionsByDocument, nil +} + +// checkEndpointFields validates the optional `@endpoint(fields: [...])` allowlist against +// pre-loaded data (no database access). Entries use the form-field-name vocabulary +// ("name", "input.email", "tags[]"); the top-level segment must be a declared variable of +// the mutation, so a typo'd entry (which would silently allow nothing at runtime) is caught +// at build time instead. +func checkEndpointFields( + usage endpointUsage, + entries []string, + variables map[string]bool, + location []*plugins.ErrorLocation, + errs *plugins.ErrorList, +) { + for _, entry := range entries { + top := topLevelFieldSegment(entry) + if top == "" || !variables[top] { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s fields entry %q does not match any variable of %q (entries use form-field names like \"name\" or \"input.email\")", + graphql.EndpointDirective, entry, usage.docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + } + } +} + +// topLevelFieldSegment returns the variable-name segment of a form-field path: +// "input.email" → "input", "tags[]" → "tags", "name" → "name". +func topLevelFieldSegment(path string) string { + seg := path + if i := strings.Index(seg, "."); i >= 0 { + seg = seg[:i] + } + return strings.TrimSuffix(seg, "[]") +} + +// isRelativePath reports whether a redirect value is a safe relative path: a single +// leading slash, no protocol-relative "//" prefix, and no "scheme://". +func isRelativePath(value string) bool { + if !strings.HasPrefix(value, "/") { + return false + } + if strings.HasPrefix(value, "//") { + return false + } + if strings.Contains(value, "://") { + return false + } + return true +} + +// endpointSelectionNode is a field in a document's selection set, indexed by the name it +// appears under in the response (its alias, or field name when unaliased). +type endpointSelectionNode struct { + id int + typeKind string // OBJECT / INTERFACE / UNION / SCALAR / ENUM ... +} + +// validateRedirectPaths walks each interpolation path through the mutation's selection tree +// (pre-loaded by loadEndpointData, indexed by parent selection id with 0 = root) and reports +// any segment that doesn't exist or doesn't resolve to a leaf scalar. No database access. +func validateRedirectPaths( + usage endpointUsage, + paths [][]string, + children map[int]map[string]endpointSelectionNode, + location []*plugins.ErrorLocation, + errs *plugins.ErrorList, +) { + for _, segments := range paths { + parent := 0 + ok := true + var node endpointSelectionNode + for _, segment := range segments { + next, found := children[parent][segment] + if !found { + ok = false + break + } + node = next + parent = next.id + } + + display := strings.Join(segments, ".") + if !ok { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s redirect path { %s } does not exist in the selection set of %q", + graphql.EndpointDirective, display, usage.docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + continue + } + + if node.typeKind != "SCALAR" && node.typeKind != "ENUM" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s redirect path { %s } in %q must resolve to a leaf scalar, but it is a %s", + graphql.EndpointDirective, display, usage.docName, node.typeKind, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + } + } +} diff --git a/packages/houdini-core/plugin/documents/session.go b/packages/houdini-core/plugin/documents/session.go new file mode 100644 index 0000000000..50e8384e34 --- /dev/null +++ b/packages/houdini-core/plugin/documents/session.go @@ -0,0 +1,197 @@ +package documents + +import ( + "context" + "fmt" + "strings" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/graphql" +) + +// sessionUsage collects what we need to validate a single @session usage. +type sessionUsage struct { + documentID int + docKind string + docName string + filepath string + row int + column int + sessionPath string + hasPath bool +} + +// ValidateSessionDirective enforces the build-time guarantees for @session, the directive +// that writes the session from a mutation result (orthogonal to @endpoint's form story): +// - it only sits on mutation documents +// - it carries a `path`, which must resolve to an object in the mutation's selection set — +// that object's fields become App.Session +func ValidateSessionDirective( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + query := ` + SELECT + d.id, + d.kind, + d.name, + rd.filepath, + dd.row, + dd.column, + av.kind, + av.raw + FROM document_directives dd + JOIN documents d ON d.id = dd.document + JOIN raw_documents rd ON rd.id = d.raw_document + LEFT JOIN document_directive_arguments dda ON dda.parent = dd.id AND dda.name = 'path' + LEFT JOIN argument_values av ON av.id = dda.value + WHERE dd.directive = $session_directive + AND (rd.current_task = $task_id OR $task_id IS NULL) + ` + + usages := []sessionUsage{} + err := db.StepQuery(ctx, query, map[string]any{ + "session_directive": graphql.SessionDirective, + }, func(row plugins.Row) { + usage := sessionUsage{ + documentID: int(row.ColumnInt(0)), + docKind: row.ColumnText(1), + docName: row.ColumnText(2), + filepath: row.ColumnText(3), + row: int(row.ColumnInt(4)), + column: int(row.ColumnInt(5)), + } + if row.ColumnText(6) == "String" { + usage.hasPath = true + usage.sessionPath = row.ColumnText(7) + } + usages = append(usages, usage) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + + // bulk-load the field selections of every @session document in one pass, indexed by + // document then parent selection id (0 = root) — the same shape the endpoint validator uses. + selectionsByDocument := map[int]map[int]map[string]endpointSelectionNode{} + err = db.StepQuery(ctx, ` + SELECT + sr.document, + COALESCE(sr.parent_id, 0) AS parent, + s.id, + COALESCE(s.alias, s.field_name) AS name, + t.kind AS type_kind + FROM selection_refs sr + JOIN selections s ON s.id = sr.child_id + JOIN document_directives dd ON dd.document = sr.document AND dd.directive = $session_directive + LEFT JOIN type_fields tf ON s.type = tf.id + LEFT JOIN types t ON tf.type = t.name + WHERE s.kind = 'field' + `, map[string]any{"session_directive": graphql.SessionDirective}, func(row plugins.Row) { + document := int(row.ColumnInt(0)) + parent := int(row.ColumnInt(1)) + name := row.ColumnText(3) + if selectionsByDocument[document] == nil { + selectionsByDocument[document] = map[int]map[string]endpointSelectionNode{} + } + if selectionsByDocument[document][parent] == nil { + selectionsByDocument[document][parent] = map[string]endpointSelectionNode{} + } + selectionsByDocument[document][parent][name] = endpointSelectionNode{ + id: int(row.ColumnInt(2)), + typeKind: row.ColumnText(4), + } + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + + for _, usage := range usages { + location := []*plugins.ErrorLocation{{ + Filepath: usage.filepath, + Line: usage.row, + Column: usage.column, + }} + + // @session writes the session from a mutation result; it is meaningless elsewhere. + if usage.docKind != "mutation" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s can only be used on a mutation, but %q is a %s", + graphql.SessionDirective, usage.docName, usage.docKind, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + continue + } + + // path is what makes @session meaningful — without it nothing becomes the session. + if !usage.hasPath { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s on %q requires a path naming the result field to write to the session", + graphql.SessionDirective, usage.docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + continue + } + + validateSessionPath(usage, selectionsByDocument[usage.documentID], location, errs) + } +} + +// validateSessionPath walks the dotted @session path through the mutation's selection tree +// and reports if it doesn't exist or doesn't resolve to an object — its fields become the +// session, so (unlike a redirect path) it must NOT be a leaf scalar. +func validateSessionPath( + usage sessionUsage, + children map[int]map[string]endpointSelectionNode, + location []*plugins.ErrorLocation, + errs *plugins.ErrorList, +) { + segments := strings.Split(usage.sessionPath, ".") + parent := 0 + ok := true + var node endpointSelectionNode + for _, segment := range segments { + segment = strings.TrimSpace(segment) + next, found := children[parent][segment] + if !found { + ok = false + break + } + node = next + parent = next.id + } + + display := strings.Join(segments, ".") + if !ok { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s path %q does not exist in the selection set of %q", + graphql.SessionDirective, display, usage.docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + return + } + + if node.typeKind != "OBJECT" && node.typeKind != "INTERFACE" && node.typeKind != "UNION" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s path %q in %q must resolve to an object whose fields become the session, but it is a %s", + graphql.SessionDirective, display, usage.docName, node.typeKind, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + } +} diff --git a/packages/houdini-core/plugin/documents/validate.go b/packages/houdini-core/plugin/documents/validate.go index 08855fb4a6..a9cc7aace0 100644 --- a/packages/houdini-core/plugin/documents/validate.go +++ b/packages/houdini-core/plugin/documents/validate.go @@ -10,8 +10,6 @@ import ( "sort" "strings" - - "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/packages/houdini-core/plugin/schema" "code.houdinigraphql.com/plugins" @@ -1761,6 +1759,119 @@ func ValidateOptimisticKeyOnScalar( } } +func ValidateRefetchDirective( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + // @refetch marks the record(s) returned by a field so the cache refetches every + // document that depends on them. the field has to return a keyable object/abstract + // type (a list of them is fine — we refresh each), so we reject scalars, keyless + // types, and fields that already carry the list/pagination machinery. + query := ` + SELECT + s.field_name, + tf.type_modifiers, + t.kind AS fieldTypeKind, + COALESCE(tc.keys, c.default_keys) AS keys, + rd.filepath, + sr.row, + sr.column, + d.name AS documentName, + d.kind AS documentKind, + EXISTS( + SELECT 1 FROM selection_directives sd2 + WHERE sd2.selection_id = s.id AND sd2.directive IN ($list_directive, $paginate_directive) + ) AS hasListDirective + FROM selections s + JOIN selection_directives sd ON s.id = sd.selection_id + JOIN type_fields tf ON s.type = tf.id + JOIN types t ON tf.type = t.name + LEFT JOIN type_configs tc ON tc.name = tf.type + CROSS JOIN config c + JOIN selection_refs sr ON sr.child_id = s.id + JOIN documents d ON d.id = sr.document + JOIN raw_documents rd ON rd.id = d.raw_document + WHERE sd.directive = $refetch_directive + AND (rd.current_task = $task_id OR $task_id IS NULL) + ` + + bindings := map[string]any{ + "refetch_directive": graphql.RefetchDirective, + "list_directive": graphql.ListDirective, + "paginate_directive": graphql.PaginationDirective, + } + + err := db.StepQuery(ctx, query, bindings, func(stmt plugins.Row) { + fieldName := stmt.ColumnText(0) + fieldTypeKind := stmt.ColumnText(2) + keys := strings.TrimSpace(stmt.ColumnText(3)) + filepath := stmt.ColumnText(4) + row := int(stmt.ColumnInt(5)) + column := int(stmt.ColumnInt(6)) + docName := stmt.ColumnText(7) + docKind := stmt.ColumnText(8) + hasListDirective := stmt.ColumnInt(9) != 0 + + location := []*plugins.ErrorLocation{{Filepath: filepath, Line: row, Column: column}} + + // @refetch is a side effect of writing a response; it belongs on a mutation + // or subscription. on a query it would refetch the document itself. + if docKind != "mutation" && docKind != "subscription" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s can only be used in a mutation or subscription, but field %q appears in %s %q", + graphql.RefetchDirective, fieldName, docKind, docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + return + } + + // the field has to return an object/abstract type so we can identify a record + if fieldTypeKind != "OBJECT" && fieldTypeKind != "INTERFACE" && fieldTypeKind != "UNION" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s can only be used on fields that return an object type, but field %q in document %q returns a %s", + graphql.RefetchDirective, fieldName, docName, fieldTypeKind, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + return + } + + // @list / @paginate fields have their own machinery and aren't single records + if hasListDirective { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s cannot be combined with @%s or @%s (field %q in document %q)", + graphql.RefetchDirective, graphql.ListDirective, graphql.PaginationDirective, fieldName, docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + return + } + + // we need keys to identify the record to refetch + if keys == "" || keys == "[]" { + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "@%s can only be used on types with keys, but field %q in document %q returns a keyless type", + graphql.RefetchDirective, fieldName, docName, + ), + Kind: plugins.ErrorKindValidation, + Locations: location, + }) + } + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } +} + func ValidateOptimisticKeyFullSelection( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], @@ -1944,3 +2055,109 @@ func ValidateOptimisticKeyFullSelection( } } } + +// ValidatePluralDirective enforces the constraints around the @plural directive (mirrors +// Relay's @relay(plural: true) rules): +// - a @plural fragment may only be spread on a list field (a field backed by a GraphQL list), +// since the consumer receives the whole list as an array. +// - @plural may not be combined with @paginate, whose refetch machinery assumes a single record. +func ValidatePluralDirective( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + // Rule 1: a @plural fragment must be spread on a list field. We look at every fragment + // spread whose referenced fragment definition carries @plural, resolve the enclosing + // parent field's type_modifiers, and flag any whose modifiers do not encode a list + // (list types contain "]" in the inner→outer modifier encoding). Spreads with no + // enclosing field (parent_id IS NULL, e.g. at the document root) are also invalid. + spreadQuery := ` + SELECT + spread.field_name AS fragmentName, + rd.filepath, + sr.row, + sr.column + FROM selections spread + JOIN selection_refs sr ON sr.child_id = spread.id + JOIN documents d ON d.id = sr.document + JOIN raw_documents rd ON rd.id = d.raw_document + JOIN documents frag ON frag.name = spread.field_name AND frag.kind = 'fragment' + JOIN document_directives dd ON dd.document = frag.id AND dd.directive = $plural_directive + LEFT JOIN selections pf ON pf.id = sr.parent_id + LEFT JOIN type_fields tf ON tf.id = pf.type + WHERE spread.kind = 'fragment' + AND (sr.parent_id IS NULL OR tf.type_modifiers IS NULL OR tf.type_modifiers NOT LIKE '%]%') + AND (rd.current_task = $task_id OR $task_id IS NULL) + ` + + err := db.StepQuery(ctx, spreadQuery, map[string]any{ + "plural_directive": graphql.PluralDirective, + }, func(stmt plugins.Row) { + fragmentName := stmt.ColumnText(0) + filepath := stmt.ColumnText(1) + row := int(stmt.ColumnInt(2)) + column := int(stmt.ColumnInt(3)) + + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "fragment %q is marked @%s and can only be spread on a list field", + fragmentName, + graphql.PluralDirective, + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{ + {Filepath: filepath, Line: row, Column: column}, + }, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } + + // Rule 2: @plural and @paginate cannot coexist on the same fragment. + paginateQuery := ` + SELECT + d.name AS fragmentName, + rd.filepath, + dd.row, + dd.column + FROM documents d + JOIN document_directives dd ON dd.document = d.id AND dd.directive = $plural_directive + JOIN raw_documents rd ON rd.id = d.raw_document + WHERE d.kind = 'fragment' + AND EXISTS ( + SELECT 1 + FROM selections s + JOIN selection_refs sr ON sr.child_id = s.id AND sr.document = d.id + JOIN selection_directives sd ON sd.selection_id = s.id + WHERE sd.directive = $paginate_directive + ) + AND (rd.current_task = $task_id OR $task_id IS NULL) + ` + + err = db.StepQuery(ctx, paginateQuery, map[string]any{ + "plural_directive": graphql.PluralDirective, + "paginate_directive": graphql.PaginationDirective, + }, func(stmt plugins.Row) { + fragmentName := stmt.ColumnText(0) + filepath := stmt.ColumnText(1) + row := int(stmt.ColumnInt(2)) + column := int(stmt.ColumnInt(3)) + + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "fragment %q cannot use both @%s and @%s", + fragmentName, + graphql.PluralDirective, + graphql.PaginationDirective, + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{ + {Filepath: filepath, Line: row, Column: column}, + }, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } +} diff --git a/packages/houdini-core/plugin/fragmentArguments/transform.go b/packages/houdini-core/plugin/fragmentArguments/transform.go index 16775ef84e..fade67c7cf 100644 --- a/packages/houdini-core/plugin/fragmentArguments/transform.go +++ b/packages/houdini-core/plugin/fragmentArguments/transform.go @@ -8,8 +8,6 @@ import ( "sync" "golang.org/x/sync/syncmap" - - "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/graphql" @@ -57,6 +55,7 @@ func Transform[PluginConfig any](ctx context.Context, db plugins.DatabasePool[Pl AND selection_directives.directive = 'with' AND (raw_documents.current_task = $task_id OR $task_id IS NULL) AND (documents.processed = false OR documents.processed IS NULL) + AND (documents.internal = false OR documents.internal IS NULL) GROUP BY documents.id `) if err != nil { @@ -253,7 +252,8 @@ func processDocument[PluginConfig any]( parent_doc.name as document, selection_refs.id as selection_ref, selections.id as selection_id, - selections.field_name as fragment, + selections.field_name as current_name, + COALESCE(selections.fragment_ref, selections.field_name) as fragment, fragment_doc.id as fragment_doc_id, json_group_array( json_object( @@ -278,15 +278,14 @@ func processDocument[PluginConfig any]( ) END as doc_variables, fragment_doc.type_condition as type_condition, - fragment_doc.raw_document as raw_document, - selections.fragment_ref as fragment_ref + fragment_doc.raw_document as raw_document FROM selection_directives JOIN selections ON selection_directives.selection_id = selections.id JOIN selection_refs ON selection_refs.child_id = selections.id JOIN selection_directive_arguments ON selection_directives.id = selection_directive_arguments.parent AND selection_directive_arguments.document = $document JOIN argument_values as selection_arg_values ON selection_directive_arguments."value" = selection_arg_values.id AND selection_arg_values.document = $document JOIN documents as parent_doc ON selection_refs."document" = parent_doc.id - JOIN documents as fragment_doc on selections.field_name = fragment_doc.name + JOIN documents as fragment_doc on COALESCE(selections.fragment_ref, selections.field_name) = fragment_doc.name LEFT JOIN document_variables on fragment_doc.id = document_variables."document" LEFT JOIN argument_values as document_variable_default_values on document_variable_default_values.id = document_variables.default_value WHERE selection_directives.directive = $with_directive @@ -307,11 +306,11 @@ func processDocument[PluginConfig any]( selectionID := withSearch.GetInt64("selection_id") fragmentDocID := withSearch.GetInt64("fragment_doc_id") fragmentName := withSearch.GetText("fragment") + currentFieldName := withSearch.GetText("current_name") withArgsStr := withSearch.GetText("with_args") docVariablesStr := withSearch.GetText("doc_variables") typeCondition := withSearch.GetText("type_condition") rawDocument := withSearch.GetInt64("raw_document") - fragmentRef := withSearch.GetText("fragment_ref") withArgs := []struct { Name string `json:"name"` @@ -341,40 +340,44 @@ func processDocument[PluginConfig any]( // the first thing we have to do is compute the set of values being passed to the processedFragments fragmentHashArgs := map[string]string{} - documentScope := map[string]DocArg{} fragmentScopeVariables := []DocArg{} - for _, arg := range docArgs { - if arg.DefaultValue != 0 { - documentScope[arg.Name] = arg - } - } fragmentScope := map[string]int64{} + coveredArgs := map[string]bool{} for _, arg := range withArgs { + coveredArgs[arg.Name] = true // if the argument kind is a variable then we have 2 options, we either use the // parent scope or we have a default value if arg.Kind == "Variable" { + // Hash by the caller's variable name so @with(name: $x) and @with(name: $y) + // produce distinct clones. + fragmentHashArgs[arg.Name] = arg.Raw + + callerVal, inScope := scope[arg.Raw] + if !inScope { + // ReplaceVariables already ran on this document, so the variable may have + // been renamed (e.g. $outerName → $userId). arg.Value is the current + // argument_value ID in this document, which already holds the right value. + callerVal = arg.Value + } + fragmentScope[arg.Name] = callerVal for _, docArg := range docArgs { - if arg.Name == docArg.Name { + if docArg.Name == arg.Name { fragmentScopeVariables = append(fragmentScopeVariables, docArg) + break } } - - // we have a local value - if docArg, ok := scope[arg.Name]; ok { - fragmentScope[arg.Name] = docArg - fragmentHashArgs[arg.Name] = arg.Name - - // there is a document variable - } else if fragmentArg, ok := documentScope[arg.Name]; ok { - fragmentScope[arg.Name] = fragmentArg.DefaultValue - fragmentHashArgs[arg.Name] = fragmentArg.Raw - fragmentScopeVariables = append(fragmentScopeVariables, fragmentArg) - } } else { fragmentScope[arg.Name] = arg.Value fragmentHashArgs[arg.Name] = arg.Raw } } + // Inline defaults for any declared fragment args that @with didn't cover. + for _, docArg := range docArgs { + if !coveredArgs[docArg.Name] && docArg.DefaultValue != 0 { + fragmentScope[docArg.Name] = docArg.DefaultValue + fragmentHashArgs[docArg.Name] = docArg.Raw + } + } // the new fragment name gets suffixed with a hashed version of the arguments applied to the args, err := json.Marshal(fragmentHashArgs) @@ -385,9 +388,8 @@ func processDocument[PluginConfig any]( hash := murmurHash(string(args)) newFragmentName := fragmentName + "_" + hash - // if the selection has already been transformed, don't transfor it again - expectedName := fragmentRef + "_" + hash - if fragmentName == expectedName { + // if the selection's field_name already matches the expected clone for this scope, skip + if currentFieldName == newFragmentName { return } @@ -414,10 +416,14 @@ func processDocument[PluginConfig any]( return } - // compute the used variables + // Direct Variable @with arguments (@with(x: $x)) immediately give us the raw + // variable name from withArgs. Descendant Variables nested inside Object/List + // arguments are returned by CopyScope (inside cloneDocument) and appended below. usedVars := []string{} - for key := range fragmentScope { - usedVars = append(usedVars, key) + for _, arg := range withArgs { + if arg.Kind == "Variable" { + usedVars = append(usedVars, arg.Raw) + } } if hasExisting { @@ -443,8 +449,10 @@ func processDocument[PluginConfig any]( processedFragments.Store(newFragmentName, true) fragmentMutex.Unlock() - // clone the fragment document with the new name - fragmentID, fragmentScope, err := cloneDocument( + // clone the fragment document with the new name; CopyScope (called inside) also + // returns the raw names of any Variable nodes nested inside Object/List scope + // values (@with(f: {a: $x}) → "x"), which we append to usedVars. + fragmentID, fragmentScope, descendantVarNames, err := cloneDocument( ctx, db, conn, @@ -460,6 +468,7 @@ func processDocument[PluginConfig any]( errs.Append(plugins.WrapError(err)) return } + usedVars = append(usedVars, descendantVarNames...) // fragment is already marked as processed above @@ -532,7 +541,7 @@ func cloneDocument[PluginConfig any]( statements *transformStatements[PluginConfig], fragmentScope map[string]int64, fragmentScopeVariables []DocArg, -) (int64, map[string]int64, error) { +) (int64, map[string]int64, []string, error) { // the first thing we have to do is create a new document with the correct name err := db.ExecStatement(statements.InsertFragment, map[string]any{ "name": name, @@ -540,7 +549,7 @@ func cloneDocument[PluginConfig any]( "raw_document": sourceRawDocument, }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } documentID := conn.LastInsertRowID() @@ -559,14 +568,14 @@ func cloneDocument[PluginConfig any]( "document": documentID, }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } variable["default_value"] = conn.LastInsertRowID() } err = db.ExecStatement(statements.InsertDocumentVariable, variable) if err != nil { - return 0, nil, err + return 0, nil, nil, err } } @@ -578,7 +587,7 @@ func cloneDocument[PluginConfig any]( "to": documentID, }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } // now we need to copy the argument values for this document which requires recreating the nested @@ -594,7 +603,7 @@ func cloneDocument[PluginConfig any]( "document": sourceDocument, }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } err = db.StepStatement(ctx, statements.DocumentArgumentValueSearch, func() { id := statements.DocumentArgumentValueSearch.GetInt64("id") @@ -640,10 +649,10 @@ func cloneDocument[PluginConfig any]( valueMap[id] = newValue }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } if errs.Len() > 0 { - return 0, nil, errors.New(errs.Error()) + return 0, nil, nil, errors.New(errs.Error()) } // we now have a mapping from old argument value to their copy for the new document @@ -659,7 +668,7 @@ func cloneDocument[PluginConfig any]( }) } - return 0, nil, plugins.Error{ + return 0, nil, nil, plugins.Error{ Message: fmt.Sprintf( "could not find value when copying document argument values: %v", oldValue, @@ -672,7 +681,7 @@ func cloneDocument[PluginConfig any]( for _, child := range children { newChild, ok := valueMap[child.Value] if !ok { - return 0, nil, plugins.Error{ + return 0, nil, nil, plugins.Error{ Message: fmt.Sprintf( "could not find child value when copying document argument values: %v", child.Value, @@ -692,7 +701,7 @@ func cloneDocument[PluginConfig any]( "column": 0, }) if err != nil { - return 0, nil, plugins.Error{ + return 0, nil, nil, plugins.Error{ Message: fmt.Sprintf( "encountered error inserting argument value children: %v", err, @@ -711,7 +720,7 @@ func cloneDocument[PluginConfig any]( map[string]any{"from": sourceDocument}, ) if err != nil { - return 0, nil, plugins.WrapError(err) + return 0, nil, nil, plugins.WrapError(err) } err = db.StepStatement(ctx, statements.NoSelectionArgsDirectiveArgsSearch, @@ -720,12 +729,22 @@ func cloneDocument[PluginConfig any]( name := statements.NoSelectionArgsDirectiveArgsSearch.GetText("name") value := statements.NoSelectionArgsDirectiveArgsSearch.GetInt64("value") - // insert a directive document for the new document with the mapped value + // create a fresh copy of the argument value exclusively for this directive arg so + // it doesn't share a row with field/nested arg uses of the same variable + err = db.ExecStatement(statements.CopyArgumentValue, map[string]any{ + "id": valueMap[value], + "document": documentID, + }) + if err != nil { + return + } + freshCopy := conn.LastInsertRowID() + err = db.ExecStatement(statements.InsertSelectionDirectiveArgument, map[string]any{ "name": name, "parent": parent, - "value": valueMap[value], + "value": freshCopy, "document": documentID, }) }, @@ -745,7 +764,7 @@ func cloneDocument[PluginConfig any]( }, ) if err != nil { - return 0, nil, err + return 0, nil, nil, err } // there are a few instances where we need to copy argument values (scope and directive arguments) @@ -863,13 +882,24 @@ func cloneDocument[PluginConfig any]( directiveID := conn.LastInsertRowID() for _, arg := range directive.Arguments { - // add the corresponding directive argument + // create a fresh copy exclusively for this directive arg so it doesn't + // share an argument_values row with field/nested arg uses of the same variable + err = db.ExecStatement(statements.CopyArgumentValue, map[string]any{ + "id": valueMap[arg.Value], + "document": documentID, + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + return + } + freshCopy := conn.LastInsertRowID() + err = db.ExecStatement( statements.InsertSelectionDirectiveArgument, map[string]any{ "parent": directiveID, "name": arg.Name, - "value": valueMap[arg.Value], + "value": freshCopy, "document": documentID, }, ) @@ -881,10 +911,10 @@ func cloneDocument[PluginConfig any]( } }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } if errs.Len() > 0 { - return 0, nil, errors.New(errs.Error()) + return 0, nil, nil, errors.New(errs.Error()) } // the only thing left to do is patch the selection refs whose parents have args @@ -895,7 +925,7 @@ func cloneDocument[PluginConfig any]( "document": documentID, }) if err != nil { - return 0, nil, err + return 0, nil, nil, err } } @@ -910,16 +940,22 @@ func cloneDocument[PluginConfig any]( selectionMap, ) if err != nil { - return 0, nil, err + return 0, nil, nil, err } // before we finish lets figure out the new scope - newScope, err := statements.CopyScope(ctx, db, conn, fragmentScope, documentID) + newScope, descendantVarNames, err := statements.CopyScope( + ctx, + db, + conn, + fragmentScope, + documentID, + ) if err != nil { - return 0, nil, err + return 0, nil, nil, err } - return documentID, newScope, nil + return documentID, newScope, descendantVarNames, nil } // copyDiscoveredListsForClonedFragment copies discovered_lists entries that reference selections @@ -1095,17 +1131,21 @@ type transformStatements[PluginConfig any] struct { nullValue int64 } +// CopyScope copies the caller's argument value trees into documentID and returns the new scope. +// It also returns the raw names of any Variable nodes that were descendants of an Object/List +// scope value (e.g. $minAge inside @with(f: {a: $minAge})). These names must be added to +// usedVars so PrintCollectedDocument keeps the corresponding operation variables in the header. func (s *transformStatements[PluginConfig]) CopyScope( ctx context.Context, db plugins.DatabasePool[PluginConfig], conn plugins.Conn, fragmentScope map[string]int64, documentID int64, -) (map[string]int64, error) { +) (map[string]int64, []string, error) { // copying the fragment scope for a document is 2 steps. one grabs the // argument values and another recreates the nested structure if len(fragmentScope) == 0 { - return map[string]int64{}, nil + return map[string]int64{}, nil, nil } whereIn := "(" for _, id := range fragmentScope { @@ -1117,21 +1157,21 @@ func (s *transformStatements[PluginConfig]) CopyScope( WITH RECURSIVE args as ( SELECT * from argument_values WHERE id in %s - - UNION - - SELECT argument_values.* + + UNION + + SELECT argument_values.* FROM argument_value_children as parent_refs JOIN args ON args.id = parent_refs.parent JOIN argument_values on parent_refs."value" = argument_values.id LEFT JOIN argument_value_children ON argument_value_children.parent = argument_values.id ) - SELECT + SELECT args.* , - CASE WHEN argument_value_children."value" IS NULL + CASE WHEN argument_value_children."value" IS NULL THEN null - ELSE + ELSE json_group_array( json_object ( 'name', argument_value_children."name", @@ -1139,13 +1179,12 @@ func (s *transformStatements[PluginConfig]) CopyScope( ) ) END as children - FROM args + FROM args LEFT JOIN argument_value_children on argument_value_children.parent = args.id - GROUP BY args.id - ORDER BY args.id DESC + GROUP BY args.id `, whereIn)) if err != nil { - return nil, err + return nil, nil, err } defer search.Finalize() @@ -1155,6 +1194,19 @@ func (s *transformStatements[PluginConfig]) CopyScope( valueMap := map[int64]int64{} errs := &plugins.ErrorList{} + // Root scope values are the top-level IDs in fragmentScope (one per @with argument). + // - Root Variable values (@with(x: $x)) must be copied so each specialization owns + // its own row; shared rows get deleted by nested specializations (e.g. pagination). + // - Descendant Variable nodes inside Object/List values (@with(f: {a: $x})) must NOT + // be copied. ArgumentValueVariableSearch filters by document, so leaving them in the + // caller's document prevents them from being found and wrongly nullified. We record + // their raw names and return them so callers can mark the operation variables as used. + rootScopeValues := map[int64]bool{} + for _, id := range fragmentScope { + rootScopeValues[id] = true + } + var descendantVarNames []string + err = db.StepStatement(ctx, search, func() { id := search.GetInt64("id") kind := search.GetText("kind") @@ -1176,6 +1228,12 @@ func (s *transformStatements[PluginConfig]) CopyScope( } } + if kind == "Variable" && !rootScopeValues[id] { + valueMap[id] = id + descendantVarNames = append(descendantVarNames, raw) + return + } + // insert the new argument value err := db.ExecStatement(s.InsertArgumentValue, map[string]any{ "kind": kind, @@ -1199,10 +1257,10 @@ func (s *transformStatements[PluginConfig]) CopyScope( valueMap[id] = newValue }) if err != nil { - return nil, err + return nil, nil, err } if errs.Len() > 0 { - return nil, errors.New(errs.Error()) + return nil, nil, errors.New(errs.Error()) } // we now have a mapping from old argument value to their copy for the new document @@ -1218,7 +1276,7 @@ func (s *transformStatements[PluginConfig]) CopyScope( }) } - return nil, plugins.Error{ + return nil, nil, plugins.Error{ Message: fmt.Sprintf( "could not find value when copying document argument values: %v", oldValue, @@ -1231,7 +1289,7 @@ func (s *transformStatements[PluginConfig]) CopyScope( for _, child := range children { newChild, ok := valueMap[child.Value] if !ok { - return nil, plugins.Error{ + return nil, nil, plugins.Error{ Message: fmt.Sprintf( "could not find child value when copying document argument values: %v", child.Value, @@ -1251,7 +1309,7 @@ func (s *transformStatements[PluginConfig]) CopyScope( "column": 0, }) if err != nil { - return nil, plugins.Error{ + return nil, nil, plugins.Error{ Message: fmt.Sprintf( "encountered error inserting argument value children: %v", err, @@ -1269,9 +1327,14 @@ func (s *transformStatements[PluginConfig]) CopyScope( for key, value := range fragmentScope { newScope[key] = valueMap[value] } - return newScope, nil + return newScope, descendantVarNames, nil } +// FindVariablesInScope returns the raw names of any Variable argument value nodes that +// are reachable (as direct values or nested descendants) from the given scope values. +// These are query-level variables passed inside Object or List @with arguments, and +// must be included in the selection's FragmentArgs so that PrintCollectedDocument marks +// them as used and does not strip them from the operation header. func prepareTransformStatements[PluginConfig any]( conn plugins.Conn, ) (*transformStatements[PluginConfig], error) { @@ -1670,12 +1733,21 @@ func (s *transformStatements[PluginConfig]) ReplaceVariables( ) error { errs := &plugins.ErrorList{} + // track values we've already written as replacements so SQLite cursor re-scans don't + // pick them up and incorrectly nullify them + alreadyReplaced := map[int64]bool{} + db.BindStatement(search, map[string]any{"document": documentID}) err := db.StepStatement(ctx, search, func() { parentValue := search.GetInt64("parent") variableName := search.GetText("variable") oldValue := search.GetInt64("value") + // skip values we already placed as replacements in this pass + if alreadyReplaced[oldValue] { + return + } + // if the variable name is not defined in the scope then we need to delete the original // value and replace it with null otherwise we'll replace it with the scope value scopeValue, ok := scope[variableName] @@ -1710,6 +1782,9 @@ func (s *transformStatements[PluginConfig]) ReplaceVariables( return } + // mark the replacement so cursor re-scans don't nullify it + alreadyReplaced[scopeValue] = true + // by now, the value passed to the scope will replace the old value so we need to delete it err = db.ExecStatement(s.DeleteValue, map[string]any{"id": oldValue}) if err != nil { diff --git a/packages/houdini-core/plugin/fragmentArguments/transform_test.go b/packages/houdini-core/plugin/fragmentArguments/transform_test.go index 031041a226..fa5eebf559 100644 --- a/packages/houdini-core/plugin/fragmentArguments/transform_test.go +++ b/packages/houdini-core/plugin/fragmentArguments/transform_test.go @@ -22,9 +22,14 @@ func TestFragmentArgumentTransform(t *testing.T) { type User { firstName: String! - friends(name: String): [User!]! + friends(name: String, limit: Int, offset: Int, filter: FriendFilter): [User!]! + friendsByNames(names: [String!]): [User!]! id: ID! } + + input FriendFilter { + name: String! + } `, Tests: []tests.Test[config.PluginConfig]{ { @@ -368,6 +373,292 @@ func TestFragmentArgumentTransform(t *testing.T) { ), }, }, + { + Name: "Default value is inlined when arg omitted from @with", + Pass: true, + Input: []string{ + `query Q($limit: Int!) { user { ...F @with(limit: $limit) } }`, + `fragment F on User @arguments(limit: {type: "Int!"}, offset: {type: "Int", default: 5}) { friends(limit: $limit, offset: $offset) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($limit: Int!) { user { ...F_2quo11 @with(limit: $limit) __typename id } }`, + ), + tests.ExpectedDoc( + `fragment F_2quo11 on User { friends(limit: $limit, offset: 5) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "limit", Type: "Int", TypeModifiers: "!"}), + }, + }, + { + Name: "Omitting a defaulted arg and passing the same value explicitly produce the same clone", + Pass: true, + Input: []string{ + `query Implicit($limit: Int!) { user { ...F @with(limit: $limit) } }`, + `query Explicit($limit: Int!) { user { ...F @with(limit: $limit, offset: 5) } }`, + `fragment F on User @arguments(limit: {type: "Int!"}, offset: {type: "Int", default: 5}) { friends(limit: $limit, offset: $offset) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Implicit($limit: Int!) { user { ...F_2quo11 @with(limit: $limit) __typename id } }`, + ), + tests.ExpectedDoc( + `query Explicit($limit: Int!) { user { ...F_2quo11 @with(limit: $limit, offset: 5) __typename id } }`, + ), + tests.ExpectedDoc( + `fragment F_2quo11 on User { friends(limit: $limit, offset: 5) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "limit", Type: "Int", TypeModifiers: "!"}), + }, + }, + { + Name: "Mixed literal and variable args in the same @with", + Pass: true, + Input: []string{ + `query Q($userName: String!) { user { ...F @with(name: $userName, limit: 10) } }`, + `fragment F on User @arguments(name: {type: "String!"}, limit: {type: "Int!"}) { friends(name: $name, limit: $limit) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($userName: String!) { user { ...F_1CaZGl @with(name: $userName, limit: 10) __typename id } }`, + ), + tests.ExpectedDoc( + `fragment F_1CaZGl on User { friends(name: $userName, limit: 10) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Same fragment spread twice with different literal values produces distinct clones", + Pass: true, + Input: []string{ + `query Q { user { friendsA: friends { ...F @with(name: "alice") } friendsB: friends { ...F @with(name: "bob") } } }`, + `fragment F on User @arguments(name: {type: "String!"}) { friends(name: $name) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q { user { friendsA: friends { ...F_4p6st7 @with(name: "alice") __typename id } friendsB: friends { ...F_16H5UA @with(name: "bob") __typename id } __typename id } }`, + ), + tests.ExpectedDoc( + `fragment F_4p6st7 on User { friends(name: "alice") { firstName id __typename } id __typename }`, + ), + tests.ExpectedDoc( + `fragment F_16H5UA on User { friends(name: "bob") { firstName id __typename } id __typename }`, + ), + }, + }, + { + Name: "List literal argument is inlined into the clone", + Pass: true, + Input: []string{ + `query Q { user { ...F @with(names: ["alice", "bob"]) } }`, + `fragment F on User @arguments(names: {type: "[String!]!"}) { friendsByNames(names: $names) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q { user { ...F_TXXm0 @with(names: ["alice", "bob"]) __typename id } }`, + ), + tests.ExpectedDoc( + `fragment F_TXXm0 on User { friendsByNames(names: ["alice", "bob"]) { firstName id __typename } id __typename }`, + ), + }, + }, + { + Name: "String default value is inlined when arg omitted from @with", + Pass: true, + Input: []string{ + `query Q($limit: Int!) { user { ...F @with(limit: $limit) } }`, + `fragment F on User @arguments(limit: {type: "Int!"}, name: {type: "String", default: "all"}) { friends(name: $name, limit: $limit) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($limit: Int!) { user { ...F_3qSBKq @with(limit: $limit) __typename id } }`, + ), + tests.ExpectedDoc( + `fragment F_3qSBKq on User { friends(name: "all", limit: $limit) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "limit", Type: "Int", TypeModifiers: "!"}), + }, + }, + { + Name: "Argument variable can have arbitrary name", + Pass: true, + Input: []string{ + `query Info($userName: String!) { user { ...UserInfo @with(name: $userName) } }`, + `fragment UserInfo on User @arguments(name: {type: "String!"}) { friends(name: $name) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Info($userName: String!) { user { ...UserInfo_qDNpv @with(name: $userName) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment UserInfo_qDNpv on User { friends(name: $userName) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{ + Name: "name", + Type: "String", + TypeModifiers: "!", + }), + }, + }, + { + Name: "Mixed same-name and renamed variable arguments", + Pass: true, + Input: []string{ + `query Q($shared: String!, $renamed: String!) { user { ...F @with(a: $shared, b: $renamed) } }`, + `fragment F on User @arguments(a: {type: "String!"}, b: {type: "String!"}) { friendsA: friends(name: $a) { firstName } friendsB: friends(name: $b) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($shared: String!, $renamed: String!) { user { ...F_3fkJCt @with(a: $shared, b: $renamed) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment F_3fkJCt on User { friendsA: friends(name: $shared) { firstName id __typename } friendsB: friends(name: $renamed) { firstName id __typename } id __typename }`, + ). + WithVariables( + tests.ExpectedOperationVariable{ + Name: "a", + Type: "String", + TypeModifiers: "!", + }, + tests.ExpectedOperationVariable{ + Name: "b", + Type: "String", + TypeModifiers: "!", + }, + ), + }, + }, + { + Name: "Same fragment spread with two different variable names produces distinct clones", + Pass: true, + Input: []string{ + `query Q($x: String!, $y: String!) { user { ...F @with(name: $x) friends { ...F @with(name: $y) } } }`, + `fragment F on User @arguments(name: {type: "String!"}) { friends(name: $name) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($x: String!, $y: String!) { user { ...F_bdaPf @with(name: $x) friends { ...F_2bkMuv @with(name: $y) id __typename } id __typename } }`, + ), + tests.ExpectedDoc( + `fragment F_bdaPf on User { friends(name: $x) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + tests.ExpectedDoc( + `fragment F_2bkMuv on User { friends(name: $y) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Fragment spreads fragment with variable argument", + Pass: true, + Input: []string{ + `query Q($name: String!) { user { ...Outer @with(name: $name) } }`, + `fragment Outer on User @arguments(name: {type: "String!"}) { ...Inner @with(name: $name) }`, + `fragment Inner on User @arguments(name: {type: "String!"}) { friends(name: $name) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($name: String!) { user { ...Outer_4E9dx0 @with(name: $name) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment Outer_4E9dx0 on User { ...Inner_4E9dx0 @with(name: $name) id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + tests.ExpectedDoc( + `fragment Inner_4E9dx0 on User { friends(name: $name) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Argument used in object", + Pass: true, + Input: []string{ + `query Q($name: String!) { user { ...F @with(name: $name) } }`, + `fragment F on User @arguments(name: {type: "String!"}) { friends(filter: {name: $name}) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($name: String!) { user { ...F_4E9dx0 @with(name: $name) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment F_4E9dx0 on User { id __typename friends(filter: {name: $name}) {firstName id __typename }}`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Fragment spreads fragment with renamed variable argument", + Pass: true, + Input: []string{ + `query Q($userId: String!) { user { ...Outer @with(outerName: $userId) } }`, + `fragment Outer on User @arguments(outerName: {type: "String!"}) { ...Inner @with(innerName: $outerName) }`, + `fragment Inner on User @arguments(innerName: {type: "String!"}) { friends(name: $innerName) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($userId: String!) { user { ...Outer_2KfY5k @with(outerName: $userId) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment Outer_2KfY5k on User { ...Inner_1YmyDS @with(innerName: $userId) id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "outerName", Type: "String", TypeModifiers: "!"}), + tests.ExpectedDoc( + `fragment Inner_1YmyDS on User { friends(name: $userId) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "innerName", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Renamed variable used in both field arg and nested @with", + Pass: true, + Input: []string{ + `query Q($studentId: String!) { user { ...Outer @with(name: $studentId) } }`, + `fragment Outer on User @arguments(name: {type: "String!"}) { friends(name: $name) { firstName } ...Inner @with(innerName: $name) }`, + `fragment Inner on User @arguments(innerName: {type: "String!"}) { friends(name: $innerName) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($studentId: String!) { user { ...Outer_3TkJtv @with(name: $studentId) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment Outer_3TkJtv on User { id __typename friends(name: $studentId) { firstName id __typename } ...Inner_2Lx8Sp @with(innerName: $studentId) }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + tests.ExpectedDoc( + `fragment Inner_2Lx8Sp on User { friends(name: $studentId) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "innerName", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Renamed variable used in nested field object arg and nested @with", + Pass: true, + Input: []string{ + `query Q($studentId: String!) { user { ...Outer @with(name: $studentId) } }`, + `fragment Outer on User @arguments(name: {type: "String!"}) { friends(filter: {name: $name}) { firstName } ...Inner @with(innerName: $name) }`, + `fragment Inner on User @arguments(innerName: {type: "String!"}) { friends(name: $innerName) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($studentId: String!) { user { ...Outer_3TkJtv @with(name: $studentId) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment Outer_3TkJtv on User { id __typename friends(filter: {name: $studentId}) { firstName id __typename } ...Inner_2Lx8Sp @with(innerName: $studentId) }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + tests.ExpectedDoc( + `fragment Inner_2Lx8Sp on User { friends(name: $studentId) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "innerName", Type: "String", TypeModifiers: "!"}), + }, + }, + { + Name: "Renamed variable used only in nested @with (no field args)", + Pass: true, + Input: []string{ + `query Q($studentId: String!) { user { ...Outer @with(name: $studentId) } }`, + `fragment Outer on User @arguments(name: {type: "String!"}) { ...Inner @with(innerName: $name) }`, + `fragment Inner on User @arguments(innerName: {type: "String!"}) { friends(name: $innerName) { firstName } }`, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + `query Q($studentId: String!) { user { ...Outer_3TkJtv @with(name: $studentId) id __typename } }`, + ), + tests.ExpectedDoc( + `fragment Outer_3TkJtv on User { id __typename ...Inner_2Lx8Sp @with(innerName: $studentId) }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "name", Type: "String", TypeModifiers: "!"}), + tests.ExpectedDoc( + `fragment Inner_2Lx8Sp on User { friends(name: $studentId) { firstName id __typename } id __typename }`, + ).WithVariables(tests.ExpectedOperationVariable{Name: "innerName", Type: "String", TypeModifiers: "!"}), + }, + }, }, }) } diff --git a/packages/houdini-core/plugin/lists/paginationDocuments.go b/packages/houdini-core/plugin/lists/paginationDocuments.go index afa94b59fc..9d0eddc510 100644 --- a/packages/houdini-core/plugin/lists/paginationDocuments.go +++ b/packages/houdini-core/plugin/lists/paginationDocuments.go @@ -5,9 +5,6 @@ import ( "encoding/json" "fmt" - - - "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/graphql" @@ -175,7 +172,11 @@ func PreparePaginationDocuments( AND tf.name = je.value LEFT JOIN documents existing_operations ON existing_operations.name = documents.name || $pagination_suffix WHERE (raw_documents.current_task = $task_id OR $task_id IS NULL) - AND documents.name NOT LIKE '%_paginated%' + -- exclude the internally-generated "_paginated" fragments. GLOB (not LIKE) + -- so the underscore is matched literally and case-sensitively; LIKE's '_' is a + -- single-char wildcard, which wrongly excluded any user document whose name merely + -- contained "paginated" (e.g. "MyPaginatedList"). + AND documents.name NOT GLOB '*_paginated*' AND existing_operations.id IS NULL AND (documents.processed = false OR documents.processed IS NULL) GROUP BY discovered_lists.id @@ -207,8 +208,14 @@ func PreparePaginationDocuments( return commit(plugins.WrapError(err)) } defer insertFragment.Finalize() + // resolve-key variables are inserted before the fragment's own @arguments. if a fragment + // @argument shares a name with a key (e.g. a custom-resolve type keyed by a field the + // fragment also takes as an argument), reuse the existing non-null key declaration instead + // of inserting a duplicate, which would violate UNIQUE(document, name). references resolve + // by name, so the @with argument still points at the same variable. insertDocumentVariable, err := conn.Prepare(` INSERT INTO document_variables (document, "name", type, type_modifiers, default_value, row, column) VALUES ($document, $name, $type, $type_modifiers, $default_value, 0, 0) + ON CONFLICT (document, "name") DO NOTHING `) if err != nil { return commit(plugins.WrapError(err)) diff --git a/packages/houdini-core/plugin/lists/paginationDocuments_test.go b/packages/houdini-core/plugin/lists/paginationDocuments_test.go index a883e146d1..3bc659a039 100644 --- a/packages/houdini-core/plugin/lists/paginationDocuments_test.go +++ b/packages/houdini-core/plugin/lists/paginationDocuments_test.go @@ -27,7 +27,7 @@ func TestPaginationDocumentGeneration(t *testing.T) { type Legend { title: String! - believers(limit: Int, offset: Int): [User!]! + believers(title: String, limit: Int, offset: Int): [User!]! } type User implements Node { @@ -559,6 +559,166 @@ func TestPaginationDocumentGeneration(t *testing.T) { )), }, }, + { + // regression: a fragment whose name contains "paginated" must still get its + // pagination documents generated. discovery used `NOT LIKE '%_paginated%'` to + // skip the internal "_paginated" docs, but LIKE's '_' is a single-char + // wildcard, so "MyPaginatedFriends" was wrongly skipped (issue surfaced via #1408). + Name: "fragment whose name contains 'paginated'", + Pass: true, + Input: []string{ + ` + fragment MyPaginatedFriends on User { + friends(first: 10) @paginate { + edges { + node { + firstName + } + } + } + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc(` + fragment MyPaginatedFriends_paginated_c9Zhk on User { + __typename + friends(first: $first, after: $after, last: $last, before: $before) @paginate { + edges { + node { + firstName + __typename + id + } + cursor + __typename + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + __typename + } + id + } + `).WithVariables( + tests.ExpectedOperationVariable{ + Name: "first", + Type: "Int", + DefaultValue: &tests.ExpectedArgumentValue{ + Kind: "Int", + Raw: "10", + }, + }, + tests.ExpectedOperationVariable{ + Name: "after", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "last", + Type: "Int", + }, + tests.ExpectedOperationVariable{ + Name: "before", + Type: "String", + }, + ), + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) { + node(id: $id) { + ...MyPaginatedFriends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last) + __typename + id + } + } + `, + graphql.FragmentPaginationQueryName("MyPaginatedFriends"), + )), + }, + }, + { + // companion to the case above: "paginated" at the very START of the name has no + // preceding character, so the buggy `%_paginated%` LIKE never matched it. testing + // it guards against an over-eager fix (e.g. `GLOB '*paginated*'` dropping the + // underscore) that would wrongly exclude it. + Name: "fragment whose name starts with 'paginated'", + Pass: true, + Input: []string{ + ` + fragment PaginatedFriends on User { + friends(first: 10) @paginate { + edges { + node { + firstName + } + } + } + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc(` + fragment PaginatedFriends_paginated_c9Zhk on User { + __typename + friends(first: $first, after: $after, last: $last, before: $before) @paginate { + edges { + node { + firstName + __typename + id + } + cursor + __typename + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + __typename + } + id + } + `).WithVariables( + tests.ExpectedOperationVariable{ + Name: "first", + Type: "Int", + DefaultValue: &tests.ExpectedArgumentValue{ + Kind: "Int", + Raw: "10", + }, + }, + tests.ExpectedOperationVariable{ + Name: "after", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "last", + Type: "Int", + }, + tests.ExpectedOperationVariable{ + Name: "before", + Type: "String", + }, + ), + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($first: Int = 10, $after: String, $before: String, $last: Int, $id: ID!) @dedupe(match: Variables) { + node(id: $id) { + ...PaginatedFriends_paginated_c9Zhk @mask_disable @with(first: $first, after: $after, before: $before, last: $last) + __typename + id + } + } + `, + graphql.FragmentPaginationQueryName("PaginatedFriends"), + )), + }, + }, { Name: "fragment on custom resolve query", Pass: true, @@ -616,6 +776,69 @@ func TestPaginationDocumentGeneration(t *testing.T) { )), }, }, + { + Name: "fragment @argument reuses the resolve key variable when names collide", + Pass: true, + Input: []string{ + ` +fragment BelieverPages on Legend @arguments(title: { type: "String" }) { +believers(title: $title, limit: 10) @paginate { +firstName +} +} +`, + }, + ProjectConfig: func(config *plugins.ProjectConfig) { + config.TypeConfig["Legend"] = plugins.TypeConfig{ + Keys: []string{"title"}, + ResolveQuery: "legend", + } + }, + // the resolve key `title` and the fragment @argument `title` collapse to a single + // non-null variable on the pagination query; @with still references it by name. + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc(` +fragment BelieverPages_paginated_3ovGOt on Legend { +title +__typename +believers(title: $title, limit: $limit, offset: $offset) @paginate { +firstName +__typename +id +} +} +`).WithVariables( + tests.ExpectedOperationVariable{ + Name: "title", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "limit", + Type: "Int", + DefaultValue: &tests.ExpectedArgumentValue{ + Kind: "Int", + Raw: "10", + }, + }, + tests.ExpectedOperationVariable{ + Name: "offset", + Type: "Int", + }, + ), + tests.ExpectedDoc( + fmt.Sprintf(` +query %s($limit: Int = 10, $offset: Int, $title: String!) @dedupe(match: Variables) { +legend(title: $title) { +...BelieverPages_paginated_3ovGOt @mask_disable @with(limit: $limit, offset: $offset, title: $title) +__typename +title +} +} +`, + graphql.FragmentPaginationQueryName("BelieverPages"), + )), + }, + }, { Name: "fragment suppress dedupe", Pass: true, @@ -889,7 +1112,7 @@ func TestPaginationDocumentGeneration_multipleInvocations(t *testing.T) { type Legend { title: String! - believers(limit: Int, offset: Int): [User!]! + believers(title: String, limit: Int, offset: Int): [User!]! } type User implements Node { diff --git a/packages/houdini-core/plugin/lists/refetchableDocuments.go b/packages/houdini-core/plugin/lists/refetchableDocuments.go new file mode 100644 index 0000000000..c38561b375 --- /dev/null +++ b/packages/houdini-core/plugin/lists/refetchableDocuments.go @@ -0,0 +1,473 @@ +package lists + +import ( + "context" + "encoding/json" + "fmt" + + "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/graphql" +) + +// refetchableFragment captures everything we need to generate the embedded query +// for a fragment tagged with @refetchable. +type refetchableFragment struct { + ID int64 + Name string + RawDocument int + TypeCondition string + ResolveQuery string + Keys []fieldArgumentSpec +} + +// refetchableArg is an @arguments declaration on the fragment. These need to be +// forwarded to the generated query (as document variables) and passed back into +// the fragment via @with so they can be supplied at refetch time. +type refetchableArg struct { + Name string + Type string + TypeModifiers string + DefaultValue int64 // argument_values id, 0 if there is no default +} + +// PrepareRefetchableDocuments looks for every fragment tagged with @refetchable and +// generates an embedded query (named _Refetch_Query) that re-fetches the +// fragment by id. This is the same wrapper @paginate generates for paginated +// fragments — node(id:) { ...Fragment @with(...) } — but without any list/pagination +// semantics. The generated query gets a refetch_meta row so the artifact picks up a +// "refetch" block with paginated: false. +func PrepareRefetchableDocuments( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], +) error { + projectConfig, err := db.ProjectConfig(ctx) + if err != nil { + return plugins.WrapError(err) + } + + conn, err := db.Take(ctx) + if err != nil { + return plugins.WrapError(err) + } + defer db.Put(conn) + + close := db.Transaction(conn) + commit := func(err error) error { + close(&err) + return err + } + + // find every fragment marked @refetchable that hasn't already been processed, + // along with the query used to resolve its type by id (node by default) and the + // key fields (with their types) needed to look it up. + query, err := conn.Prepare(` + SELECT + documents.id, + documents.name, + documents.raw_document, + documents.type_condition, + COALESCE(type_configs.resolve_query, 'node') as resolve_query, + CASE + WHEN COUNT(tf.type) = 0 THEN NULL + ELSE json_group_array( + DISTINCT json_object('name', je.value, 'kind', tf.type) + ) + END as resolve_keys + FROM documents + JOIN raw_documents on documents.raw_document = raw_documents.id + JOIN document_directives on document_directives.document = documents.id + AND document_directives.directive = $refetchable_directive + LEFT JOIN type_configs on documents.type_condition = type_configs."name" + JOIN config + CROSS JOIN json_each(COALESCE(type_configs.keys, config.default_keys)) AS je + LEFT JOIN type_fields tf + ON tf.parent = documents.type_condition + AND tf.name = je.value + LEFT JOIN documents existing_operations ON existing_operations.name = documents.name || $refetch_suffix + WHERE (raw_documents.current_task = $task_id OR $task_id IS NULL) + AND documents.kind = 'fragment' + AND existing_operations.id IS NULL + AND (documents.processed = false OR documents.processed IS NULL) + GROUP BY documents.id + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer query.Finalize() + err = db.BindStatement(query, map[string]any{ + "refetchable_directive": graphql.RefetchableDirective, + "refetch_suffix": graphql.RefetchQuerySuffix, + }) + if err != nil { + return commit(plugins.WrapError(err)) + } + + // the @arguments declared on a fragment are stored as document_variables. + getArguments, err := conn.Prepare(` + SELECT "name", "type", type_modifiers, default_value + FROM document_variables + WHERE document = $document + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer getArguments.Finalize() + + // statements for building the generated query (mirrors paginationDocuments.go) + insertDocument, err := conn.Prepare(` + INSERT INTO documents (name, kind, raw_document, internal, visible) VALUES ($name, 'query', $raw_document, false, false) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertDocument.Finalize() + insertSelection, err := conn.Prepare(` + INSERT INTO selections (field_name, kind, alias, type, fragment_args) VALUES ($field_name, $kind, $field_name, $type, $fragment_args) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertSelection.Finalize() + insertSelectionRef, err := conn.Prepare(` + INSERT INTO selection_refs (document, child_id, parent_id, row, column, path_index, internal) VALUES ($document, $child_id, $parent_id, 0, 0, 0, $internal) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertSelectionRef.Finalize() + insertSelectionArgument, err := conn.Prepare(` + INSERT INTO selection_arguments (selection_id, "name", "value", row, column, field_argument, document) VALUES ($selection_id, $name, $value, 0, 0, $field_argument, $document) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertSelectionArgument.Finalize() + insertArgumentValue, err := conn.Prepare(` + INSERT INTO argument_values (kind, raw, expected_type, document, row, column) VALUES ($kind, $raw, $expected_type, $document, 0, 0) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertArgumentValue.Finalize() + // the lookup key variables are inserted first (non-null, e.g. $id: ID!). if a fragment + // @argument happens to share a name with a key, reuse the existing declaration instead of + // inserting a duplicate (which would violate UNIQUE(document, name)). the key's non-null + // type is what the resolve field requires, so letting it win is correct; the @with + // reference resolves to it by name either way. + insertDocumentVariable, err := conn.Prepare(` + INSERT INTO document_variables (document, "name", type, type_modifiers, default_value, row, column) VALUES ($document, $name, $type, $type_modifiers, $default_value, 0, 0) + ON CONFLICT (document, "name") DO NOTHING + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertDocumentVariable.Finalize() + insertSelectionDirective, err := conn.Prepare(` + INSERT INTO selection_directives (selection_id, directive, row, column) VALUES ($selection, $directive, 0, 0) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertSelectionDirective.Finalize() + insertSelectionDirectiveArgument, err := conn.Prepare(` + INSERT INTO selection_directive_arguments (parent, name, value, document) VALUES ($parent, $name, $value, $document) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertSelectionDirectiveArgument.Finalize() + copyArgumentValue, err := conn.Prepare(` + INSERT INTO argument_values (kind, raw, row, column, expected_type, document) + SELECT kind, raw, row, column, expected_type, $document + FROM argument_values where id = $id + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer copyArgumentValue.Finalize() + insertRefetchMeta, err := conn.Prepare(` + INSERT INTO refetch_meta + (document, selection, target_type) + VALUES + ($document, $selection, $target_type) + `) + if err != nil { + return commit(plugins.WrapError(err)) + } + defer insertRefetchMeta.Finalize() + + // collect the matching fragments first so we don't generate while iterating. + fragments := []refetchableFragment{} + errs := &plugins.ErrorList{} + err = db.StepStatement(ctx, query, func() { + frag := refetchableFragment{ + ID: query.ColumnInt64(0), + Name: query.ColumnText(1), + RawDocument: query.ColumnInt(2), + TypeCondition: query.ColumnText(3), + ResolveQuery: query.ColumnText(4), + } + + if !query.IsNull("resolve_keys") { + if err := json.Unmarshal([]byte(query.GetText("resolve_keys")), &frag.Keys); err != nil { + errs.Append(plugins.WrapError(fmt.Errorf("failed to unmarshal refetchable keys: %v", err))) + return + } + } + + fragments = append(fragments, frag) + }) + if err != nil { + return commit(plugins.WrapError(err)) + } + if errs.Len() > 0 { + return commit(errs) + } + + for _, frag := range fragments { + // gather the fragment's @arguments (stored as document variables) + args := []refetchableArg{} + err = db.BindStatement(getArguments, map[string]any{"document": frag.ID}) + if err != nil { + return commit(plugins.WrapError(err)) + } + err = db.StepStatement(ctx, getArguments, func() { + arg := refetchableArg{ + Name: getArguments.ColumnText(0), + Type: getArguments.ColumnText(1), + TypeModifiers: getArguments.ColumnText(2), + } + if !getArguments.IsNull("default_value") { + arg.DefaultValue = getArguments.ColumnInt64(3) + } + args = append(args, arg) + }) + if err != nil { + return commit(plugins.WrapError(err)) + } + + err = generateRefetchableQuery(ctx, db, conn, projectConfig, statementsForRefetch{ + insertDocument: insertDocument, + insertSelection: insertSelection, + insertSelectionRef: insertSelectionRef, + insertSelectionArgument: insertSelectionArgument, + insertArgumentValue: insertArgumentValue, + insertDocumentVariable: insertDocumentVariable, + insertSelectionDirective: insertSelectionDirective, + insertSelectionDirectiveArgument: insertSelectionDirectiveArgument, + copyArgumentValue: copyArgumentValue, + insertRefetchMeta: insertRefetchMeta, + }, frag, args) + if err != nil { + errs.Append(plugins.WrapError(err)) + continue + } + } + + if errs.Len() > 0 { + return commit(errs) + } + + return commit(nil) +} + +type statementsForRefetch struct { + insertDocument plugins.Stmt + insertSelection plugins.Stmt + insertSelectionRef plugins.Stmt + insertSelectionArgument plugins.Stmt + insertArgumentValue plugins.Stmt + insertDocumentVariable plugins.Stmt + insertSelectionDirective plugins.Stmt + insertSelectionDirectiveArgument plugins.Stmt + copyArgumentValue plugins.Stmt + insertRefetchMeta plugins.Stmt +} + +func generateRefetchableQuery( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + conn plugins.Conn, + projectConfig plugins.ProjectConfig, + stmts statementsForRefetch, + frag refetchableFragment, + args []refetchableArg, +) error { + // create the query that embeds the fragment + err := db.ExecStatement(stmts.insertDocument, map[string]any{ + "name": graphql.FragmentRefetchQueryName(frag.Name), + "raw_document": frag.RawDocument, + }) + if err != nil { + return err + } + queryDocumentID := conn.LastInsertRowID() + + // the resolve field that looks the entity up by id (node by default) + err = db.ExecStatement(stmts.insertSelection, map[string]any{ + "field_name": frag.ResolveQuery, + "kind": "field", + "type": fmt.Sprintf("Query.%s", frag.ResolveQuery), + "fragment_args": nil, + }) + if err != nil { + return err + } + resolveSelectionID := conn.LastInsertRowID() + + err = db.ExecStatement(stmts.insertSelectionRef, map[string]any{ + "document": queryDocumentID, + "child_id": resolveSelectionID, + "internal": false, + }) + if err != nil { + return err + } + + // spread the original fragment underneath the resolve field + err = db.ExecStatement(stmts.insertSelection, map[string]any{ + "field_name": frag.Name, + "kind": "fragment", + "type": "", + "fragment_args": nil, + }) + if err != nil { + return err + } + fragmentSpreadID := conn.LastInsertRowID() + + err = db.ExecStatement(stmts.insertSelectionRef, map[string]any{ + "document": queryDocumentID, + "child_id": fragmentSpreadID, + "parent_id": resolveSelectionID, + "internal": true, + }) + if err != nil { + return err + } + + // node() returns an interface and external fragment spreads are masked by + // default; expose the fragment's fields through the wrapper. + err = db.ExecStatement(stmts.insertSelectionDirective, map[string]any{ + "selection": fragmentSpreadID, + "directive": graphql.DisableMaskDirective, + }) + if err != nil { + return err + } + + // the resolve field's key arguments (id) become query variables + for _, key := range frag.Keys { + err = db.ExecStatement(stmts.insertArgumentValue, map[string]any{ + "kind": "Variable", + "raw": key.Name, + "expected_type": key.Kind, + "document": queryDocumentID, + }) + if err != nil { + return err + } + err = db.ExecStatement(stmts.insertSelectionArgument, map[string]any{ + "selection_id": resolveSelectionID, + "name": key.Name, + "value": conn.LastInsertRowID(), + "field_argument": fmt.Sprintf("Query.%s.%s", frag.ResolveQuery, key.Name), + "document": queryDocumentID, + }) + if err != nil { + return err + } + err = db.ExecStatement(stmts.insertDocumentVariable, map[string]any{ + "document": queryDocumentID, + "name": key.Name, + "type": key.Kind, + "type_modifiers": "!", + "default_value": nil, + }) + if err != nil { + return err + } + } + + // forward the fragment's @arguments via @with so they can be supplied at refetch time + if len(args) > 0 { + err = db.ExecStatement(stmts.insertSelectionDirective, map[string]any{ + "selection": fragmentSpreadID, + "directive": graphql.WithDirective, + }) + if err != nil { + return err + } + withDirectiveID := conn.LastInsertRowID() + + for _, arg := range args { + // the @with argument references a query variable of the same name + err = db.ExecStatement(stmts.insertArgumentValue, map[string]any{ + "kind": "Variable", + "raw": arg.Name, + "expected_type": arg.Type, + "document": queryDocumentID, + }) + if err != nil { + return err + } + err = db.ExecStatement(stmts.insertSelectionDirectiveArgument, map[string]any{ + "parent": withDirectiveID, + "name": arg.Name, + "value": conn.LastInsertRowID(), + "document": queryDocumentID, + }) + if err != nil { + return err + } + + // copy the @arguments default (if any) onto the query variable + var defaultValue any + if arg.DefaultValue != 0 { + err = db.ExecStatement(stmts.copyArgumentValue, map[string]any{ + "id": arg.DefaultValue, + "document": queryDocumentID, + }) + if err != nil { + return err + } + defaultValue = conn.LastInsertRowID() + } + + err = db.ExecStatement(stmts.insertDocumentVariable, map[string]any{ + "document": queryDocumentID, + "name": arg.Name, + "type": arg.Type, + "type_modifiers": arg.TypeModifiers, + "default_value": defaultValue, + }) + if err != nil { + return err + } + } + } + + // the target type used to resolve the entity: "Node" via node(id:) by default, + // or the fragment's type when it has a custom resolve query configured. + targetType := "Node" + if typeConfig, exists := projectConfig.TypeConfig[frag.TypeCondition]; exists && + typeConfig.ResolveQuery != "" { + targetType = frag.TypeCondition + } + + // record a refetch_meta row for the generated query so the artifact emits a + // "refetch" block (paginated: false). this is the list-less analog of a + // discovered_lists row — it carries refetch metadata only, keyed by the + // node(id:)/resolve selection the block attaches to. + err = db.ExecStatement(stmts.insertRefetchMeta, map[string]any{ + "document": queryDocumentID, + "selection": resolveSelectionID, + "target_type": targetType, + }) + if err != nil { + return err + } + + return nil +} diff --git a/packages/houdini-core/plugin/lists/refetchableDocuments_test.go b/packages/houdini-core/plugin/lists/refetchableDocuments_test.go new file mode 100644 index 0000000000..524c6912d3 --- /dev/null +++ b/packages/houdini-core/plugin/lists/refetchableDocuments_test.go @@ -0,0 +1,184 @@ +package lists_test + +import ( + "fmt" + "testing" + + "code.houdinigraphql.com/packages/houdini-core/config" + core "code.houdinigraphql.com/packages/houdini-core/plugin" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/graphql" + "code.houdinigraphql.com/plugins/tests" +) + +func TestRefetchableDocumentGeneration(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *core.HoudiniCore]{ + Schema: ` + type Query { + node(id: ID!): Node + user: User + legend(title: String!): Legend + } + + type User implements Node { + id: ID! + firstName: String! + field(filter: String): String + } + + type Legend { + title: String! + name: String! + nickname(title: String): String + } + + interface Node { + id: ID! + } + `, + Tests: []tests.Test[config.PluginConfig]{ + { + Name: "embeds a refetchable fragment in a query keyed by id", + Pass: true, + Input: []string{ + ` + fragment UserInfo on User @refetchable { + firstName + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($id: ID!) { + node(id: $id) { + ...UserInfo @mask_disable + __typename + id + } + } + `, + graphql.FragmentRefetchQueryName("UserInfo"), + )).WithVariables( + tests.ExpectedOperationVariable{ + Name: "id", + Type: "ID", + TypeModifiers: "!", + }, + ), + }, + }, + { + Name: "forwards @arguments through @with on the embedded query", + Pass: true, + Input: []string{ + ` + fragment UserInfo on User @refetchable @arguments(filter: { type: "String" }) { + field(filter: $filter) + } + `, + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($filter: String, $id: ID!) { + node(id: $id) { + ...UserInfo_3Wbh3 @mask_disable @with(filter: $filter) + __typename + id + } + } + `, + graphql.FragmentRefetchQueryName("UserInfo"), + )).WithVariables( + tests.ExpectedOperationVariable{ + Name: "filter", + Type: "String", + }, + tests.ExpectedOperationVariable{ + Name: "id", + Type: "ID", + TypeModifiers: "!", + }, + ), + }, + }, + { + Name: "embeds via a custom resolve query keyed by the type's keys", + Pass: true, + Input: []string{ + ` + fragment LegendInfo on Legend @refetchable { + name + } + `, + }, + ProjectConfig: func(config *plugins.ProjectConfig) { + config.TypeConfig["Legend"] = plugins.TypeConfig{ + Keys: []string{"title"}, + ResolveQuery: "legend", + } + }, + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($title: String!) { + legend(title: $title) { + ...LegendInfo @mask_disable + __typename + title + } + } + `, + graphql.FragmentRefetchQueryName("LegendInfo"), + )).WithVariables( + tests.ExpectedOperationVariable{ + Name: "title", + Type: "String", + TypeModifiers: "!", + }, + ), + }, + }, + { + Name: "reuses the key variable when a fragment @argument shares its name", + Pass: true, + Input: []string{ + ` + fragment LegendCollision on Legend @refetchable @arguments(title: { type: "String" }) { + nickname(title: $title) + } + `, + }, + ProjectConfig: func(config *plugins.ProjectConfig) { + config.TypeConfig["Legend"] = plugins.TypeConfig{ + Keys: []string{"title"}, + ResolveQuery: "legend", + } + }, + // the resolve key `title` and the fragment @argument `title` collapse to a + // single non-null variable; the @with reference points at that same variable. + Expected: []tests.ExpectedDocument{ + tests.ExpectedDoc( + fmt.Sprintf(` + query %s($title: String!) { + legend(title: $title) { + ...LegendCollision_OqqDb @mask_disable @with(title: $title) + __typename + title + } + } + `, + graphql.FragmentRefetchQueryName("LegendCollision"), + )).WithVariables( + tests.ExpectedOperationVariable{ + Name: "title", + Type: "String", + TypeModifiers: "!", + }, + ), + }, + }, + }, + }) +} diff --git a/packages/houdini-core/plugin/lists/validate.go b/packages/houdini-core/plugin/lists/validate.go index c372c73cba..ed1ba7e2b0 100644 --- a/packages/houdini-core/plugin/lists/validate.go +++ b/packages/houdini-core/plugin/lists/validate.go @@ -6,8 +6,6 @@ import ( "strings" "sync" - - "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/graphql" @@ -267,6 +265,129 @@ func ValidatePaginateTypeCondition( } } +// ValidateRefetchableTypeCondition makes sure that every fragment tagged with +// @refetchable lives on a type that can be looked up on its own — it either +// implements Node or has a type_configs entry with a custom resolve_query. +// Without that, the embedded refetch query we generate (node(id:) { ...Frag }) +// would be invalid, so we surface a clear error instead. +func ValidateRefetchableTypeCondition( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + // @refetchable is a document-level directive, so we look it up via + // document_directives (unlike @paginate which lives on a selection). The + // Node / resolve_query checks are the same as ValidatePaginateTypeCondition: + // the type must implement Node or have a custom resolve_query so the generated + // query can look it up by id. + // + // Unlike @paginate we do NOT exclude operation types — a fragment on Query is + // rejected here because queries are already refetchable on their own, so + // @refetchable is redundant (and we can't look one up by id). + query := ` + SELECT DISTINCT + d.name AS documentName, + d.type_condition, + rd.filepath, + rd.offset_line, + rd.offset_column, + dd.row as line, + dd.column + FROM documents d + JOIN raw_documents rd ON rd.id = d.raw_document + JOIN document_directives dd ON dd.document = d.id + LEFT JOIN possible_types pt ON pt.type = 'Node' AND d.type_condition = pt.member + LEFT JOIN type_configs tc ON tc.resolve_query IS NOT NULL AND d.type_condition = tc.name + WHERE d.kind = 'fragment' + AND dd.directive = $refetchable_directive + AND pt.member IS NULL + AND tc.name IS NULL + AND (rd.current_task = $task_id OR $task_id IS NULL) + ` + bindings := map[string]any{ + "refetchable_directive": graphql.RefetchableDirective, + } + err := db.StepQuery(ctx, query, bindings, func(stmt plugins.Row) { + docName := stmt.ColumnText(0) + typeCondition := stmt.ColumnText(1) + filepath := stmt.ColumnText(2) + line := int(stmt.ColumnInt(3)) + int(stmt.ColumnInt(5)) + column := int(stmt.ColumnInt(4)) + int(stmt.ColumnInt(6)) + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "Document %q uses @%s but its type condition %q is invalid. It must either implement Node or have a type_configs entry with a valid resolve_query", + docName, + graphql.RefetchableDirective, + typeCondition, + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{ + {Filepath: filepath, Line: line, Column: column}, + }, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } +} + +// ValidateRefetchablePaginateConflict rejects documents that carry both @refetchable +// (a document-level directive) and @paginate (a selection-level directive). A paginated +// fragment is already independently refetchable via its generated _Pagination_Query, so +// @refetchable is redundant — and both want to own the embedded refetch query, so allowing +// the combination would silently drop one. We surface a clear error instead. +func ValidateRefetchablePaginateConflict( + ctx context.Context, + db plugins.DatabasePool[config.PluginConfig], + errs *plugins.ErrorList, +) { + // find documents that have a @refetchable document directive AND a @paginate selection + // directive. we report at the @refetchable location since that's the redundant one. + query := ` + SELECT DISTINCT + d.name AS documentName, + rd.filepath, + rd.offset_line, + rd.offset_column, + dd.row as line, + dd.column + FROM documents d + JOIN raw_documents rd ON rd.id = d.raw_document + JOIN document_directives dd ON dd.document = d.id + AND dd.directive = $refetchable_directive + JOIN selection_refs sr ON sr.document = d.id + JOIN selection_directives sd ON sd.selection_id = sr.child_id + AND sd.directive = $paginate_directive + WHERE (rd.current_task = $task_id OR $task_id IS NULL) + ` + bindings := map[string]any{ + "refetchable_directive": graphql.RefetchableDirective, + "paginate_directive": graphql.PaginationDirective, + } + err := db.StepQuery(ctx, query, bindings, func(stmt plugins.Row) { + docName := stmt.ColumnText(0) + filepath := stmt.ColumnText(1) + line := int(stmt.ColumnInt(2)) + int(stmt.ColumnInt(4)) + column := int(stmt.ColumnInt(3)) + int(stmt.ColumnInt(5)) + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "Document %q cannot use both @%s and @%s. A paginated fragment is already refetchable on its own, so @%s is redundant", + docName, + graphql.RefetchableDirective, + graphql.PaginationDirective, + graphql.RefetchableDirective, + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{ + {Filepath: filepath, Line: line, Column: column}, + }, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } +} + func ValidateSinglePaginateDirective( ctx context.Context, db plugins.DatabasePool[config.PluginConfig], @@ -771,7 +892,11 @@ func DiscoverListsThenValidate( errs.Append(plugins.WrapError(err)) return } - defer insertDiscoveredLists.Finalize() + // NOTE: this statement is finalized explicitly before db.Put(conn) below rather + // than via defer. The connection is returned to the pool partway through this + // function so the remaining work can run concurrently; finalizing in a deferred + // call (which runs after Put) would mutate the connection's statement cache while + // another goroutine has checked it back out, which is a data race. // loop over every name we found and insert the discovered list into the database for _, list := range lists { @@ -818,11 +943,16 @@ func DiscoverListsThenValidate( }) if err != nil { errs.Append(plugins.WrapError(err)) + insertDiscoveredLists.Finalize() + db.Put(conn) return } } - // there's still more to do but we'll parallelize the next steps so we're done with the connectionf + // there's still more to do but we'll parallelize the next steps so we're done + // with the connection. finalize the statement before returning the connection to + // the pool so another goroutine can't check it out while Finalize mutates it. + insertDiscoveredLists.Finalize() db.Put(conn) // now that we have recorded the discovered lists we can build up the full set of directives and fragments diff --git a/packages/houdini-core/plugin/runtime/runtimeIndex.go b/packages/houdini-core/plugin/runtime/runtimeIndex.go index 1a8f96765b..872c917418 100644 --- a/packages/houdini-core/plugin/runtime/runtimeIndex.go +++ b/packages/houdini-core/plugin/runtime/runtimeIndex.go @@ -54,13 +54,17 @@ func GenerateRuntimeIndexFile( } defer documentSearch.Finalize() - // generate an export for every document + // generate a TYPE-ONLY export for every document. The artifact's runtime value is its default + // export (which `export *` never re-exports anyway — only the named $result/$input/$artifact + // types), so a value-level `export *` would statically pull every artifact into the entry + // chunk and defeat the manifest's per-route dynamic import (INEFFECTIVE_DYNAMIC_IMPORT). A + // type-only re-export keeps the same types available from $houdini while erasing at runtime. indexDocs := []string{} err = db.StepStatement(ctx, documentSearch, func() { name := documentSearch.GetText("name") indexDocs = append( indexDocs, - fmt.Sprintf("export * from './artifacts/%s'", name), + fmt.Sprintf("export type * from './artifacts/%s'", name), ) }) if err != nil { diff --git a/packages/houdini-core/plugin/runtime/runtimeIndex_test.go b/packages/houdini-core/plugin/runtime/runtimeIndex_test.go index 27bff88e8e..8d17eb1255 100644 --- a/packages/houdini-core/plugin/runtime/runtimeIndex_test.go +++ b/packages/houdini-core/plugin/runtime/runtimeIndex_test.go @@ -40,8 +40,8 @@ func TestRuntimeIndexGeneration(t *testing.T) { export * from './runtime' export * from './graphql' -export * from './artifacts/TestFragment' -export * from './artifacts/TestQuery' +export type * from './artifacts/TestFragment' +export type * from './artifacts/TestQuery' `, string(indexContent)) }, }) diff --git a/packages/houdini-core/plugin/runtime/transformRuntime.go b/packages/houdini-core/plugin/runtime/transformRuntime.go index 367589eeb5..e9a0ae66f1 100644 --- a/packages/houdini-core/plugin/runtime/transformRuntime.go +++ b/packages/houdini-core/plugin/runtime/transformRuntime.go @@ -39,6 +39,36 @@ func TransformRuntime( } configPath = fp.ToSlash(configPath) + // bake the server's GraphQL endpoint (src/server/+config `endpoint`) into the client config + // as `apiURL`, so the client knows where to send queries when houdini.config has no public + // `url` (the local-API case). It's a path resolved at codegen from router_config — never + // injected at render. Empty when unset, in which case the client falls back to the default. + endpoint := "" + conn, err := db.Take(ctx) + if err != nil { + return "", err + } + defer db.Put(conn) + endpointStmt, err := conn.Prepare( + `SELECT api_endpoint FROM router_config WHERE api_endpoint IS NOT NULL LIMIT 1`, + ) + if err != nil { + return "", err + } + defer endpointStmt.Finalize() + err = db.StepStatement(ctx, endpointStmt, func() { + endpoint = endpointStmt.GetText("api_endpoint") + }) + if err != nil { + return "", err + } + + if endpoint != "" { + return fmt.Sprintf(`import projectConfig from "%s"; +export default { ...projectConfig, apiURL: %q }; +`, configPath, endpoint), nil + } + return fmt.Sprintf(`import projectConfig from "%s"; export default projectConfig; `, configPath), nil diff --git a/packages/houdini-core/plugin/runtime/transformRuntime_test.go b/packages/houdini-core/plugin/runtime/transformRuntime_test.go index 35d83954d2..fa01897a2f 100644 --- a/packages/houdini-core/plugin/runtime/transformRuntime_test.go +++ b/packages/houdini-core/plugin/runtime/transformRuntime_test.go @@ -2,15 +2,57 @@ package runtime_test import ( "context" + "path/filepath" "testing" "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/packages/houdini-core/plugin" "code.houdinigraphql.com/packages/houdini-core/plugin/runtime" + "code.houdinigraphql.com/plugins" "code.houdinigraphql.com/plugins/tests" "github.com/stretchr/testify/require" ) +// the GraphQL endpoint lives in src/server/+config (router_config.api_endpoint). Codegen bakes it +// into the client config as `apiURL` so the client knows where to send when houdini.config has no +// public `url` — the local-API case. No render-time injection. +func TestRuntimeTransform_configEndpoint(t *testing.T) { + tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ + Schema: `type Query { hello: String }`, + Tests: []tests.Test[config.PluginConfig]{ + {Name: "bakes router_config.api_endpoint into the client config as apiURL"}, + }, + PerformTest: func(t *testing.T, plugin *plugin.HoudiniCore, test tests.Test[config.PluginConfig]) { + conn, err := plugin.DB.Take(context.Background()) + require.Nil(t, err) + defer plugin.DB.Put(conn) + + insert, err := conn.Prepare( + `INSERT INTO router_config (api_endpoint, session_keys) VALUES ('/graphql', '')`, + ) + require.Nil(t, err) + defer insert.Finalize() + require.Nil(t, plugin.DB.ExecStatement(insert, map[string]any{})) + + projectConfig := plugins.ProjectConfig{ + ProjectRoot: "/proj", + RuntimeDir: ".houdini", + Filepath: "/proj/houdini.config.js", + } + result, err := runtime.TransformRuntime( + context.Background(), + plugin.DB, + projectConfig, + filepath.Join("imports", "config.ts"), + "", + ) + require.Nil(t, err) + require.Contains(t, result, `apiURL: "/graphql"`) + require.Contains(t, result, "...projectConfig") + }, + }) +} + func TestRuntimeTransform_extraConfig(t *testing.T) { tests.RunTable(t, tests.Table[config.PluginConfig, *plugin.HoudiniCore]{ Schema: `type Query { hello: String }`, diff --git a/packages/houdini-core/plugin/schema/generateDefinitions.go b/packages/houdini-core/plugin/schema/generateDefinitions.go index 501fabda04..0dcc2ae49c 100644 --- a/packages/houdini-core/plugin/schema/generateDefinitions.go +++ b/packages/houdini-core/plugin/schema/generateDefinitions.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/afero" "golang.org/x/sync/errgroup" - "code.houdinigraphql.com/packages/houdini-core/config" "code.houdinigraphql.com/plugins" @@ -156,7 +155,7 @@ func generateSchemaFile( schemaString.WriteString(", ") } schemaString.WriteString( - fmt.Sprintf("%s: %s%s", arg.Name, arg.Type, arg.TypeModifiers), + fmt.Sprintf("%s: %s", arg.Name, renderGraphQLType(arg.Type, arg.TypeModifiers)), ) } schemaString.WriteString(")") @@ -415,6 +414,13 @@ func generateEnumFiles( return nil } +// renderGraphQLType reconstructs the GraphQL type string from a base type and its +// inner→outer type-modifier encoding, e.g. ("String", "]") → "[String]". It decodes the +// modifiers with the canonical ParseTypeRef so the encoding is interpreted in one place. +func renderGraphQLType(base, modifier string) string { + return ParseTypeRef(modifier).Render(base) +} + // helper func isBuiltInScalar(typeName string) bool { builtInScalars := map[string]bool{ diff --git a/packages/houdini-core/plugin/schema/generateDefinitions_test.go b/packages/houdini-core/plugin/schema/generateDefinitions_test.go index 39690b4f2a..f2fff415ca 100644 --- a/packages/houdini-core/plugin/schema/generateDefinitions_test.go +++ b/packages/houdini-core/plugin/schema/generateDefinitions_test.go @@ -102,6 +102,9 @@ directive @when_not on FRAGMENT_SPREAD """@arguments is used to define the arguments of a fragment.""" directive @arguments on FRAGMENT_DEFINITION +"""@plural marks a fragment as list-shaped so it can be spread on a list field and consumed as an array of items.""" +directive @plural on FRAGMENT_DEFINITION + """@with is used to provide arguments to fragments that have been marked with @arguments""" directive @with on FRAGMENT_SPREAD @@ -120,6 +123,18 @@ directive @loading(cascade: Boolean, count: Int) on FIELD | FRAGMENT_DEFINITION """@required makes a nullable field always non-null by making the parent null when the field is""" directive @required on FIELD +"""@refetch marks a record in a mutation response so the cache refetches every document that depends on it""" +directive @refetch on FIELD + +"""@refetchable marks a fragment so it can be refetched on its own with new argument values""" +directive @refetchable on FRAGMENT_DEFINITION + +"""@endpoint generates a server endpoint for a mutation that accepts a native form POST and redirects, enabling progressively-enhanced forms.""" +directive @endpoint(fields: [String!], id: String, redirect: String) on MUTATION + +"""@session writes the session from a mutation result: the field named by path becomes (or, with merge, is merged into) the user's session.""" +directive @session(merge: Boolean, path: String!) on MUTATION + """@componentField is used to mark a field as a component field""" directive @componentField(field: String, prop: String) on FIELD_DEFINITION | FRAGMENT_DEFINITION | INLINE_FRAGMENT diff --git a/packages/houdini-core/plugin/schema/inputTypes.go b/packages/houdini-core/plugin/schema/inputTypes.go index 18d3207037..70297610c9 100644 --- a/packages/houdini-core/plugin/schema/inputTypes.go +++ b/packages/houdini-core/plugin/schema/inputTypes.go @@ -122,8 +122,10 @@ func generateInputTypeDefinitions( } sort.Strings(enumNames) - // generate import statement - finalContent.WriteString("import { ") + // generate import statement - the enum option types are only ever + // referenced in type positions, so use `import type` to stay compatible + // with TypeScript's verbatimModuleSyntax + finalContent.WriteString("import type { ") for i, enumName := range enumNames { if i > 0 { finalContent.WriteString(", ") diff --git a/packages/houdini-core/plugin/schema/inputTypes_test.go b/packages/houdini-core/plugin/schema/inputTypes_test.go index 95c0fca6d0..b7c15ac70a 100644 --- a/packages/houdini-core/plugin/schema/inputTypes_test.go +++ b/packages/houdini-core/plugin/schema/inputTypes_test.go @@ -70,7 +70,7 @@ func TestInputTypeDefinitions(t *testing.T) { targetPath := filepath.Join(config.DefinitionsDirectory(), "inputs.ts") expected := tests.Dedent(` - import { MyEnum$options, Priority$options, Status$options } from './enums.js'; + import type { MyEnum$options, Priority$options, Status$options } from './enums.js'; type ValueOf = T[keyof T]; diff --git a/packages/houdini-core/plugin/schema/renderType_test.go b/packages/houdini-core/plugin/schema/renderType_test.go new file mode 100644 index 0000000000..d39856a8fd --- /dev/null +++ b/packages/houdini-core/plugin/schema/renderType_test.go @@ -0,0 +1,28 @@ +package schema + +import "testing" + +func TestRenderGraphQLType(t *testing.T) { + // type_modifiers are inner->outer: "]" wraps a list, "!" adds non-null. The renderer + // must handle arbitrary nesting (list of lists of lists, with non-null at any depth). + cases := []struct { + base string + modifier string + want string + }{ + {"String", "", "String"}, + {"String", "!", "String!"}, + {"String", "]", "[String]"}, + {"String", "]!", "[String]!"}, + {"String", "!]!", "[String!]!"}, + {"String", "]]", "[[String]]"}, + {"String", "]]]", "[[[String]]]"}, + {"String", "!]!]!", "[[String!]!]!"}, + {"String", "!]]]!", "[[[String!]]]!"}, + } + for _, tc := range cases { + if got := renderGraphQLType(tc.base, tc.modifier); got != tc.want { + t.Errorf("renderGraphQLType(%q, %q) = %q, want %q", tc.base, tc.modifier, got, tc.want) + } + } +} diff --git a/packages/houdini-core/plugin/schema/typeRef.go b/packages/houdini-core/plugin/schema/typeRef.go index e893861f20..03c96aa296 100644 --- a/packages/houdini-core/plugin/schema/typeRef.go +++ b/packages/houdini-core/plugin/schema/typeRef.go @@ -43,6 +43,23 @@ func ParseTypeRef(modifiers string) *TypeRef { return ref } +// Render reconstructs the GraphQL type string for a decoded TypeRef given the base type's +// name, walking from the outermost wrapper inward. It is the inverse of ParseTypeRef, so +// the modifier encoding is interpreted in exactly one place: ("ID", `!]!`) → "[ID!]!". +func (r *TypeRef) Render(base string) string { + if r.Inner == nil { + if r.NonNull { + return base + "!" + } + return base + } + out := "[" + r.Inner.Render(base) + "]" + if r.NonNull { + out += "!" + } + return out +} + // TypeCompatible reports whether a value of the variable's shape can flow into // the location's shape, assuming the base types already match. It implements the // spec's AreTypesCompatible: at every level a non-null variable satisfies a diff --git a/packages/houdini-core/plugin/schema/write.go b/packages/houdini-core/plugin/schema/write.go index a840b8166f..07eb91de57 100644 --- a/packages/houdini-core/plugin/schema/write.go +++ b/packages/houdini-core/plugin/schema/write.go @@ -841,6 +841,23 @@ then the request will never be deduplicated.`, return err } + // @plural on FRAGMENT_DEFINITION + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.PluralDirective, + "description": "@plural marks a fragment as list-shaped so it can be spread on a list field and consumed as an array of items.", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.PluralDirective, + "location": "FRAGMENT_DEFINITION", + }) + if err != nil { + return err + } + // @with on FRAGMENT_SPREAD err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ "name": graphql.WithDirective, @@ -1018,6 +1035,118 @@ then the request will never be deduplicated.`, return err } + // @refetch on FIELD + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.RefetchDirective, + "description": "@refetch marks a record in a mutation response so the cache refetches every document that depends on it", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.RefetchDirective, + "location": "FIELD", + }) + if err != nil { + return err + } + + // @refetchable on FRAGMENT_DEFINITION + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.RefetchableDirective, + "description": "@refetchable marks a fragment so it can be refetched on its own with new argument values", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.RefetchableDirective, + "location": "FRAGMENT_DEFINITION", + }) + if err != nil { + return err + } + + // @endpoint(redirect: String, id: String) on MUTATION + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.EndpointDirective, + "description": "@endpoint generates a server endpoint for a mutation that accepts a native form POST and redirects, enabling progressively-enhanced forms.", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.EndpointDirective, + "location": "MUTATION", + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveArgument, map[string]any{ + "directive": graphql.EndpointDirective, + "name": "redirect", + "type": "String", + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveArgument, map[string]any{ + "directive": graphql.EndpointDirective, + "name": "id", + "type": "String", + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveArgument, map[string]any{ + "directive": graphql.EndpointDirective, + "name": "fields", + "type": "String", + "type_modifiers": "!]", + }) + if err != nil { + return err + } + // @session(path: String, merge: Boolean) on MUTATION — the mutation-driven counterpart to + // setSession(): the result field named by `path` is written to the session. Orthogonal to + // @endpoint (a session mutation need not be a form). By default it replaces the session; + // `merge: true` upserts it into the existing session (e.g. a preference). + err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ + "name": graphql.SessionDirective, + "description": "@session writes the session from a mutation result: the field named by path becomes (or, with merge, is merged into) the user's session.", + "visible": true, + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveLocation, map[string]any{ + "directive": graphql.SessionDirective, + "location": "MUTATION", + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveArgument, map[string]any{ + "directive": graphql.SessionDirective, + "name": "path", + "type": "String", + "type_modifiers": "!", + }) + if err != nil { + return err + } + err = db.ExecStatement(statements.InsertDirectiveArgument, map[string]any{ + "directive": graphql.SessionDirective, + "name": "merge", + "type": "Boolean", + }) + if err != nil { + return err + } + // @componentField(prop: String, field: String) on FRAGMENT_DEFINITION | INLINE_FRAGMENT | FIELD_DEFINITION err = db.ExecStatement(statements.InsertInternalDirective, map[string]any{ "name": graphql.ComponentFieldDirective, diff --git a/packages/houdini-core/plugin/validate.go b/packages/houdini-core/plugin/validate.go index fe9c37f992..aae11ea815 100644 --- a/packages/houdini-core/plugin/validate.go +++ b/packages/houdini-core/plugin/validate.go @@ -44,13 +44,19 @@ func (p *HoudiniCore) Validate(ctx context.Context) error { documents.ValidateMaskDirectives, documents.ValidateLoadingDirective, documents.ValidateRequiredDirective, + documents.ValidatePluralDirective, documents.ValidateOptimisticKeyFullSelection, documents.ValidateOptimisticKeyOnScalar, + documents.ValidateRefetchDirective, + documents.ValidateEndpointDirective, + documents.ValidateSessionDirective, lists.DiscoverListsThenValidate, lists.ValidateConflictingParentIDAllLists, lists.ValidateConflictingPrependAppend, lists.ValidateIncludeListID, lists.ValidatePaginateTypeCondition, + lists.ValidateRefetchableTypeCondition, + lists.ValidateRefetchablePaginateConflict, lists.ValidateSinglePaginateDirective, lists.ValidateParentID, fragmentArguments.ValidateFragmentArgumentValues, diff --git a/packages/houdini-core/plugin/validate_test.go b/packages/houdini-core/plugin/validate_test.go index 4f144332a0..1924f81686 100644 --- a/packages/houdini-core/plugin/validate_test.go +++ b/packages/houdini-core/plugin/validate_test.go @@ -20,6 +20,10 @@ func TestValidate_Houdini(t *testing.T) { "Ghost": { Keys: []string{"aka", "name"}, }, + "Cat": { + Keys: []string{"name"}, + ResolveQuery: "cat", + }, }, RuntimeScalars: map[string]string{ "ViewerIDFromSession": "ID", @@ -37,6 +41,7 @@ func TestValidate_Houdini(t *testing.T) { type Subscription { newMessage: String anotherMessage: String + userUpdate: User } type Cat { @@ -76,6 +81,7 @@ func TestValidate_Houdini(t *testing.T) { entitiesByCursor(first: Int, after: String, last: Int, before: String): EntityConnection! node(id: ID!): Node ghost: Ghost! + cat(name: String!): Cat } input UserFilter { @@ -92,6 +98,7 @@ func TestValidate_Houdini(t *testing.T) { addFriend: AddFriendOutput! deleteUser(id: ID!): DeleteUserOutput! updateGhost: Ghost! + updateNode: Node } union Human = User @@ -1006,6 +1013,123 @@ func TestValidate_Houdini(t *testing.T) { }`, }, }, + { + Name: "@with providing all required arguments passes validation", + Pass: true, + Input: []string{ + `fragment Fragment on User @arguments( + limit: { type: "Int!" } + ) { + friends(limit: $limit) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(limit: 10) + } + }`, + }, + }, + { + Name: "@with omitting a required argument fails validation", + Pass: false, + Input: []string{ + `fragment Fragment on User @arguments( + limit: { type: "Int!" }, + offset: { type: "Int" } + ) { + friends(limit: $limit, offset: $offset) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(offset: 5) + } + }`, + }, + }, + { + Name: "@with omitting a required String! argument fails validation", + Pass: false, + Input: []string{ + `fragment Fragment on User @arguments( + name: { type: "String!" }, + limit: { type: "Int" } + ) { + friends(name: $name, limit: $limit) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(limit: 10) + } + }`, + }, + }, + { + Name: "optional fragment argument with default can be passed explicitly via @with", + Pass: true, + Input: []string{ + `fragment Fragment on User @arguments( + limit: { type: "Int", default: 10 } + ) { + friends(limit: $limit) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(limit: 5) + } + }`, + }, + }, + { + Name: "spreading without @with when all args have defaults passes validation", + Pass: true, + Input: []string{ + `fragment Fragment on User @arguments( + limit: { type: "Int", default: 10 }, + offset: { type: "Int", default: 0 } + ) { + friends(limit: $limit, offset: $offset) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment + } + }`, + }, + }, + { + Name: "required argument alongside optional default — required must be passed", + Pass: false, + Input: []string{ + `fragment Fragment on User @arguments( + limit: { type: "Int!" }, + offset: { type: "Int", default: 0 } + ) { + friends(limit: $limit, offset: $offset) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(offset: 5) + } + }`, + }, + }, + { + Name: "required argument alongside optional default — passing only required is valid", + Pass: true, + Input: []string{ + `fragment Fragment on User @arguments( + limit: { type: "Int!" }, + offset: { type: "Int", default: 0 } + ) { + friends(limit: $limit, offset: $offset) { id } + }`, + `query Test { + user(name: "foo") { + ...Fragment @with(limit: 10) + } + }`, + }, + }, { Name: "@with rejects mistyped fields nested in input objects", Pass: false, @@ -1916,6 +2040,67 @@ func TestValidate_Houdini(t *testing.T) { `, }, }, + { + Name: "@refetchable on a Node type (happy path)", + Pass: true, + Input: []string{ + ` + fragment RefetchableOnNode on User @refetchable { + firstName + } + `, + }, + }, + { + Name: "@refetchable on a non-Node type that has a resolve query (happy path)", + Pass: true, + Input: []string{ + ` + fragment RefetchableOnCat on Cat @refetchable { + name + } + `, + }, + }, + { + Name: "@refetchable on a type that is not a Node and has no resolve query", + Pass: false, + Input: []string{ + ` + fragment RefetchableOnGhost on Ghost @refetchable { + name + } + `, + }, + }, + { + Name: "@refetchable on Query is not allowed (queries are refetchable by default)", + Pass: false, + Input: []string{ + ` + fragment RefetchableOnQuery on Query @refetchable { + rootScalar + } + `, + }, + }, + { + Name: "@refetchable and @paginate on the same document is not allowed", + Pass: false, + Input: []string{ + ` + fragment RefetchableAndPaginated on User @refetchable { + friendsConnection(first: 10) @paginate { + edges { + node { + id + } + } + } + } + `, + }, + }, { Name: "limit pagination requires first", Pass: false, @@ -2386,6 +2571,288 @@ func TestValidate_Houdini(t *testing.T) { }`, }, }, + { + Name: "@refetch on an object field (positive)", + Pass: true, + Input: []string{ + `mutation RefetchFriend { + addFriend { + friend @refetch { + firstName + } + } + }`, + }, + }, + { + Name: "@refetch in a subscription (positive)", + Pass: true, + Input: []string{ + `subscription RefetchSub { + userUpdate @refetch { + id + } + }`, + }, + }, + { + Name: "@refetch outside a mutation (negative)", + Pass: false, + Input: []string{ + `query RefetchQuery { + user(name: "foo") { + bestFriend @refetch { + id + } + } + }`, + }, + }, + { + Name: "@refetch on a scalar field (negative)", + Pass: false, + Input: []string{ + `mutation RefetchScalar { + addFriend { + friend { + firstName @refetch + } + } + }`, + }, + }, + { + Name: "@refetch on an abstract field (positive)", + Pass: true, + Input: []string{ + `mutation RefetchNode { + updateNode @refetch { + id + } + }`, + }, + }, + { + Name: "@refetch on a plain list field (positive)", + Pass: true, + Input: []string{ + `mutation RefetchList { + addFriend { + friend { + friends @refetch { + id + } + } + } + }`, + }, + }, + { + Name: "@refetch combined with @paginate (negative)", + Pass: false, + Input: []string{ + `mutation RefetchAndPaginate { + addFriend { + friend { + believers @refetch @paginate { + edges { + node { + id + } + } + } + } + } + }`, + }, + }, + { + Name: "@plural fragment spread on a list field (positive)", + Pass: true, + Input: []string{ + `fragment PluralRow on User @plural { + firstName + }`, + `query PluralQuery { + users(limit: 10) { + ...PluralRow + } + }`, + }, + }, + { + Name: "@plural fragment spread on a non-list field (negative)", + Pass: false, + Input: []string{ + `fragment PluralRow on User @plural { + firstName + }`, + `query PluralQuery { + user(name: "foo") { + ...PluralRow + } + }`, + }, + }, + { + Name: "@plural combined with @paginate (negative)", + Pass: false, + Input: []string{ + `fragment PluralPaginated on User @plural { + believers @paginate { + edges { + node { + id + } + } + } + }`, + }, + }, + { + Name: "@endpoint on a mutation with a valid relative redirect and leaf path (positive)", + Pass: true, + Input: []string{ + `mutation AddFriendForm @endpoint(redirect: "/users/{ addFriend.friend.id }") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint without a redirect (positive)", + Pass: true, + Input: []string{ + `mutation AddFriendForm @endpoint { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint on a query (negative)", + Pass: false, + Input: []string{ + `query Whoami @endpoint { + user(name: "x") { id } + }`, + }, + }, + { + Name: "@endpoint redirect with an absolute URL (negative)", + Pass: false, + Input: []string{ + `mutation AddFriendForm @endpoint(redirect: "https://evil.com/users") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint redirect with a protocol-relative URL (negative)", + Pass: false, + Input: []string{ + `mutation AddFriendForm @endpoint(redirect: "//evil.com/users") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint redirect without a leading slash (negative)", + Pass: false, + Input: []string{ + `mutation AddFriendForm @endpoint(redirect: "users/new") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint redirect path missing from the selection set (negative)", + Pass: false, + Input: []string{ + `mutation AddFriendForm @endpoint(redirect: "/users/{ addFriend.friend.nope }") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint redirect path resolving to an object (negative)", + Pass: false, + Input: []string{ + `mutation AddFriendForm @endpoint(redirect: "/users/{ addFriend.friend }") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@endpoint fields entry matching a variable (positive)", + Pass: true, + Input: []string{ + `mutation UpdateForm($input: InputType) @endpoint(fields: ["input"]) { + update(input: $input) + }`, + }, + }, + { + Name: "@endpoint fields nested path on a real variable (positive)", + Pass: true, + Input: []string{ + `mutation UpdateForm($input: InputType) @endpoint(fields: ["input.field"]) { + update(input: $input) + }`, + }, + }, + { + Name: "@endpoint fields entry not matching any variable (negative)", + Pass: false, + Input: []string{ + `mutation UpdateForm($input: InputType) @endpoint(fields: ["nope"]) { + update(input: $input) + }`, + }, + }, + { + Name: "@session path resolving to an object (positive)", + Pass: true, + Input: []string{ + `mutation LoginForm @session(path: "addFriend.friend") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@session path resolving to a scalar (negative)", + Pass: false, + Input: []string{ + `mutation LoginForm @session(path: "addFriend.friend.id") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@session path missing from the selection set (negative)", + Pass: false, + Input: []string{ + `mutation LoginForm @session(path: "addFriend.nope") { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@session without a path (negative)", + Pass: false, + Input: []string{ + `mutation LoginForm @session { + addFriend { friend { id } } + }`, + }, + }, + { + Name: "@session on a query (negative)", + Pass: false, + Input: []string{ + `query Whoami @session(path: "user") { + user(name: "x") { id } + }`, + }, + }, }, }) } diff --git a/packages/houdini-core/runtime/client.ts b/packages/houdini-core/runtime/client.ts index 4d254c658e..3994281dc0 100644 --- a/packages/houdini-core/runtime/client.ts +++ b/packages/houdini-core/runtime/client.ts @@ -22,6 +22,7 @@ import { throwOnError as throwOnErrorPlugin, optimisticKeys, cachePolicy, + sessionRelay, } from './plugins/index.js' import pluginsFromPlugins from './plugins/injectedPlugins.js' @@ -30,7 +31,6 @@ export { fetch, mutation, query, subscription } from './plugins/index.js' export { DocumentStore, type ClientPlugin, type SendParams } from 'houdini/runtime/documentStore' export type HoudiniClientConstructorArgs = { - url?: string fetchParams?: FetchParamFn plugins?: NestedList pipeline?: NestedList @@ -49,14 +49,22 @@ export class HoudiniClient extends BaseClient { // store throwOnError operations for access by stores throwOnError_operations: string[] = [] - constructor({ - url, - fetchParams, - plugins, - pipeline, - throwOnError, - cache = cacheRef, - }: HoudiniClientConstructorArgs = {}) { + constructor(args: HoudiniClientConstructorArgs = {}) { + // Houdini 2.0 migration guard: the API url is no longer passed to the client. Pre-2.0 apps + // did `new HoudiniClient({ url })` (or `new HoudiniClient(url)`); that value would now be + // silently ignored, which is one of the widest blast radiuses in the migration — so fail + // loudly with the new home for it. + const legacyUrl = + typeof (args as unknown) === 'string' ? args : (args as { url?: unknown }).url + if (legacyUrl != null) { + throw new Error( + 'HoudiniClient no longer accepts a `url`. Set `url` in houdini.config.js for a remote ' + + 'API, or `endpoint` in src/server/+config to change the local API path.' + ) + } + + const { fetchParams, plugins, pipeline, throwOnError, cache = cacheRef } = args + // if we were given plugins and pipeline there's an error if (plugins && pipeline) { throw new Error( @@ -67,12 +75,16 @@ export class HoudiniClient extends BaseClient { const serverPort = globalThis.process?.env?.HOUDINI_PORT ?? globalThis.process?.env?.PORT ?? '5173' + // resolve the endpoint from config (no `url` arg anymore). An absolute endpoint (a remote + // api) is used as-is; a relative one (the local mount) is prefixed with the origin on SSR. + const endpoint = localApiEndpoint() + const resolvedUrl = /^https?:\/\//.test(endpoint) + ? endpoint + : (globalThis.window ? '' : `http://localhost:${serverPort}`) + endpoint + super({ config: getCurrentConfig, - url: - url ?? - (globalThis.window ? '' : `http://localhost:${serverPort}`) + - localApiEndpoint(getCurrentConfig()), + url: resolvedUrl, plugins: flatten( ([] as NestedList).concat( // if they specified a throw behavior @@ -85,6 +97,8 @@ export class HoudiniClient extends BaseClient { ( [ optimisticKeys(cache ?? cacheRef), + // relay an @session mutation's mint token to the auth endpoint (sets the cookie) + sessionRelay(), // make sure that documents always work queryPlugin(cache ?? cacheRef), mutationPlugin(cache ?? cacheRef), diff --git a/packages/houdini-core/runtime/config.ts b/packages/houdini-core/runtime/config.ts index 3c4f1d0238..2d6240cd11 100644 --- a/packages/houdini-core/runtime/config.ts +++ b/packages/houdini-core/runtime/config.ts @@ -1,4 +1,5 @@ import type { ConfigFile } from 'houdini' +import { resolveApiEndpoint } from 'houdini/runtime' import config from './imports/config.js' import pluginConfigs from './imports/pluginConfig.js' @@ -49,8 +50,10 @@ export function computeID(configFile: ConfigFile, type: string, data: any): stri // only compute the config file once let _configFile: ConfigFile | null = null -export function localApiEndpoint(configFile: ConfigFile) { - return configFile.router?.apiEndpoint ?? '/_api' +// the GraphQL endpoint comes straight from the public config bundle (no injection): the remote +// `url`, the local mount `apiURL`, or the default — see resolveApiEndpoint. +export function localApiEndpoint() { + return resolveApiEndpoint(getCurrentConfig()) } export function getCurrentConfig(): ConfigFile { diff --git a/packages/houdini-core/runtime/plugins/cache.ts b/packages/houdini-core/runtime/plugins/cache.ts index 13ec43991b..f5c0741a04 100644 --- a/packages/houdini-core/runtime/plugins/cache.ts +++ b/packages/houdini-core/runtime/plugins/cache.ts @@ -6,6 +6,32 @@ import cache from '../cache.js' const serverSide = typeof globalThis.window === 'undefined' +// walk the response data along a path collecting the record object(s) at the end. +// a path segment can resolve to a list (eg a @refetch field nested under a list) +// so each step may fan out to multiple records. +export function recordsAtPath( + data: GraphQLObject | null, + path: readonly string[] +): GraphQLObject[] { + let current: any[] = data ? [data] : [] + for (const key of path) { + const next: any[] = [] + for (const entry of current) { + if (entry == null) { + continue + } + const value = entry[key] + if (Array.isArray(value)) { + next.push(...value.flat(Infinity)) + } else if (value != null) { + next.push(value) + } + } + current = next + } + return current.filter((entry) => entry && typeof entry === 'object') +} + export const cachePolicy = ({ enabled, @@ -49,8 +75,12 @@ export const cachePolicy = // we can only use the result if its not a partial result const allowed = !value.partial || - // or the artifact allows for partial responses - (artifact.kind === ArtifactKind.Query && artifact.partial) + // or the artifact allows for partial responses, and the caller + // hasn't opted out (e.g. SinglePage pagination suppresses partial + // cache hits to avoid flashing intermediate states) + (artifact.kind === ArtifactKind.Query && + artifact.partial && + !ctx.cacheParams?.disablePartial) // if the policy is cacheOnly and we got this far, we need to return null (no network request will be sent) if (policy === CachePolicy.CacheOnly) { @@ -156,6 +186,40 @@ export const cachePolicy = variables: marshalVariables(ctx), }) + // document-level operations run once the response has been written. + // @refetch asks every document that depends on a record to reload + // itself. only mutations and subscriptions trigger this so a query + // can't refetch itself. + if ( + (ctx.artifact.kind === ArtifactKind.Mutation || + ctx.artifact.kind === ArtifactKind.Subscription) && + ctx.artifact.operations?.length && + !ctx.cacheParams?.disableWrite + ) { + // gather every record id tagged with @refetch, deduped, so a + // document that depends on several of them only refetches once + const refreshIDs = new Set() + for (const operation of ctx.artifact.operations) { + if (operation.action !== 'refetch') { + continue + } + + for (const record of recordsAtPath(value.data, operation.path)) { + const id = targetCache._internal_unstable.id( + (record.__typename as string) ?? operation.type, + record + ) + if (id) { + refreshIDs.add(id) + } + } + } + + if (refreshIDs.size > 0) { + targetCache.refresh([...refreshIDs]) + } + } + // we need to embed the fragment context values in our response // and apply masking other value transforms. In order to do that, // we're going to read back what we just wrote. This only incurs diff --git a/packages/houdini-core/runtime/plugins/fetch.ts b/packages/houdini-core/runtime/plugins/fetch.ts index 1471df4a9c..6c5e6adeb2 100644 --- a/packages/houdini-core/runtime/plugins/fetch.ts +++ b/packages/houdini-core/runtime/plugins/fetch.ts @@ -1,7 +1,10 @@ +import { getAuthUrl } from 'houdini/runtime' import type { ClientPlugin, ClientPluginContext } from 'houdini/runtime/documentStore' import { ArtifactKind, DataSource } from 'houdini/runtime/types' import type { RequestPayload, FetchContext } from 'houdini/runtime/types' +import { getCurrentConfig } from '../config.js' + export const fetch = (target?: RequestHandler | string): ClientPlugin => { return () => { return { @@ -38,6 +41,28 @@ export const fetch = (target?: RequestHandler | string): ClientPlugin => { } } + // a @session mutation against a REMOTE api has to go through Houdini's same-origin + // proxy so the server can sit in the request path and write the session cookie + // server-authoritatively. The app is remote exactly when `url` is set in the public + // config (with a local schema the mutation hits the local Yoga and the mint plugin + // signs inline). The proxy lives under the session endpoint. We override the URL but + // never a user-supplied custom fetch function (it owns transport). + const artifact = ctx.artifact as typeof ctx.artifact & { sessionPath?: string } + if ( + getCurrentConfig().url && + artifact.kind === ArtifactKind.Mutation && + artifact.sessionPath && + typeof target !== 'function' + ) { + // tell the proxy which operation this is via a header so it never has to parse the + // (possibly multipart) body to find the session path. Must stay in sync with + // HOUDINI_OPERATION_HEADER in router/server.ts. + fetchFn = defaultFetch(getAuthUrl() + '/proxy', { + ...ctx.fetchParams, + headers: { ...ctx.fetchParams?.headers, 'x-houdini-operation': ctx.name }, + }) + } + const result = await fetchFn({ // wrap the user's fetch function so we can identify SSR by checking // the response.url @@ -61,6 +86,9 @@ export const fetch = (target?: RequestHandler | string): ClientPlugin => { variables: ctx.variables ?? {}, data: result.data, errors: !result.errors || result.errors.length === 0 ? null : result.errors, + // surface response-level extensions (e.g. the @session mint + // token) so the runtime/hooks can read them off the result + extensions: result.extensions, partial: false, stale: false, source: DataSource.Network, @@ -90,6 +118,11 @@ const defaultFetch = ( headers: { Accept: 'application/graphql+json, application/json', 'Content-Type': 'application/json', + // a header a cross-origin
      /simple request cannot set. The server + // requires it for CORS-simple POSTs to the graphql endpoint (uploads use + // multipart, which bypasses preflight) so it can't be a CSRF channel. Must + // stay in sync with HOUDINI_REQUEST_HEADER in router/server.ts. + 'x-houdini-request': 'true', ...params?.headers, }, }) diff --git a/packages/houdini-core/runtime/plugins/fragment.ts b/packages/houdini-core/runtime/plugins/fragment.ts index 27e996f0e2..06b43ef56e 100644 --- a/packages/houdini-core/runtime/plugins/fragment.ts +++ b/packages/houdini-core/runtime/plugins/fragment.ts @@ -45,6 +45,7 @@ export const fragment = (cache: Cache) => // save the new subscription spec subscriptionSpec = { rootType: ctx.artifact.rootType, + kind: ctx.artifact.kind, selection: ctx.artifact.selection, variables: () => variables, parentID: ctx.stuff.parentID, diff --git a/packages/houdini-core/runtime/plugins/index.ts b/packages/houdini-core/runtime/plugins/index.ts index a3e9cde153..d22ff514a1 100644 --- a/packages/houdini-core/runtime/plugins/index.ts +++ b/packages/houdini-core/runtime/plugins/index.ts @@ -6,4 +6,5 @@ export * from './mutation.js' export * from './subscription.js' export * from './throwOnError.js' export * from './fetchParams.js' +export * from './sessionRelay.js' export { optimisticKeys } from './optimisticKeys.js' diff --git a/packages/houdini-core/runtime/plugins/query.ts b/packages/houdini-core/runtime/plugins/query.ts index 46913fe0e4..a9c6eb15d4 100644 --- a/packages/houdini-core/runtime/plugins/query.ts +++ b/packages/houdini-core/runtime/plugins/query.ts @@ -70,6 +70,7 @@ export const query = (cache: Cache) => // save the new subscription spec subscriptionSpec = { rootType: ctx.artifact.rootType, + kind: ctx.artifact.kind, selection: ctx.artifact.selection, variables: () => variables, onMessage: (message) => { diff --git a/packages/houdini-core/runtime/plugins/sessionRelay.ts b/packages/houdini-core/runtime/plugins/sessionRelay.ts new file mode 100644 index 0000000000..808818c7c7 --- /dev/null +++ b/packages/houdini-core/runtime/plugins/sessionRelay.ts @@ -0,0 +1,60 @@ +import { getAuthUrl, HOUDINI_SESSION_EVENT, valueAtPath } from 'houdini/runtime' +import type { ClientPlugin } from 'houdini/runtime/documentStore' +import { ArtifactKind } from 'houdini/runtime/types' + +// sessionRelay is the client half of @session. When a session mutation executes, the server +// signs the resolver's session subtree into a token in the response extensions +// (extensions.houdiniSession). This plugin relays that token to the auth endpoint, which +// verifies it and sets the httpOnly cookie — JS can't set httpOnly cookies itself — and then +// mirrors the same write into local state (a window event the router listens for) so +// useSession() updates without a refresh. +// +// It runs in the document pipeline, so it fires for ANY @session mutation execution +// (useMutation, useMutationForm, a raw send), not just form submits: session-by-mutation +// isn't tied to forms. The no-JS form path never reaches here (the server writes the cookie +// directly and marks its internal request so no token is minted). +export const sessionRelay = (): ClientPlugin => () => ({ + async end(ctx, { value, resolve }) { + const artifact = ctx.artifact as typeof ctx.artifact & { + sessionPath?: string + sessionMerge?: boolean + } + const result = value as { data?: any; extensions?: Record } | null + // the local-Yoga path mints a token here for the client to relay; the remote-api proxy + // instead writes the cookie itself and signals it with houdiniSessionApplied. Either way the + // session changed and useSession() must mirror it. + const token = result?.extensions?.houdiniSession + const proxyApplied = result?.extensions?.houdiniSessionApplied === true + const isSessionMutation = artifact.kind === ArtifactKind.Mutation && !!artifact.sessionPath + if (isSessionMutation && typeof window !== 'undefined' && (token || proxyApplied)) { + // relay the minted token to the auth endpoint to set the cookie. The proxy path already + // set the cookie server-side (proxyApplied, no token), so there's nothing to relay there. + if (token) { + try { + await fetch(getAuthUrl(), { + method: 'POST', + body: JSON.stringify({ token }), + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }) + } catch { + // best-effort — if the relay fails the cookie just isn't updated this time + } + } + + // mirror the write into local state so useSession() updates without a refresh. A null + // subtree clears (replace with {}); otherwise merge upserts and the default replaces. The + // cookie (set by the relay or the proxy) stays the source of truth; this just reflects it. + const next = valueAtPath(result?.data, artifact.sessionPath!.split('.')) + window.dispatchEvent( + new CustomEvent(HOUDINI_SESSION_EVENT, { + bubbles: true, + detail: { + session: next ?? {}, + merge: next != null && !!artifact.sessionMerge, + }, + }) + ) + } + resolve(ctx) + }, +}) diff --git a/packages/houdini-core/runtime/public/tests/refetchPath.test.ts b/packages/houdini-core/runtime/public/tests/refetchPath.test.ts new file mode 100644 index 0000000000..2a34114db9 --- /dev/null +++ b/packages/houdini-core/runtime/public/tests/refetchPath.test.ts @@ -0,0 +1,38 @@ +import { test, expect } from 'vitest' + +import { recordsAtPath } from '../../plugins/cache.js' + +test('walks a path to a singular record', () => { + const data = { addFriend: { friend: { __typename: 'User', id: '1' } } } + expect(recordsAtPath(data, ['addFriend', 'friend'])).toEqual([{ __typename: 'User', id: '1' }]) +}) + +test('returns every record when the @refetch field itself is a list', () => { + const data = { + updateFriends: [ + { __typename: 'User', id: '1' }, + { __typename: 'User', id: '2' }, + ], + } + expect(recordsAtPath(data, ['updateFriends'])).toEqual([ + { __typename: 'User', id: '1' }, + { __typename: 'User', id: '2' }, + ]) +}) + +test('fans out when a path segment is a list', () => { + const data = { + updateUsers: [{ bestFriend: { id: '2' } }, { bestFriend: { id: '3' } }], + } + expect(recordsAtPath(data, ['updateUsers', 'bestFriend'])).toEqual([{ id: '2' }, { id: '3' }]) +}) + +test('flattens nested lists along the path', () => { + const data = { groups: [[{ user: { id: 'a' } }], [{ user: { id: 'b' } }]] } + expect(recordsAtPath(data, ['groups', 'user'])).toEqual([{ id: 'a' }, { id: 'b' }]) +}) + +test('skips null links without throwing', () => { + expect(recordsAtPath({ addFriend: null }, ['addFriend', 'friend'])).toEqual([]) + expect(recordsAtPath(null, ['addFriend'])).toEqual([]) +}) diff --git a/packages/houdini-react/CHANGELOG.md b/packages/houdini-react/CHANGELOG.md index 983dd9bad8..61a911e303 100644 --- a/packages/houdini-react/CHANGELOG.md +++ b/packages/houdini-react/CHANGELOG.md @@ -1,336 +1,61 @@ # houdini-react -## 2.0.0-next.38 +## 2.0.2 ### Patch Changes -- [`892411c`](https://github.com/HoudiniGraphql/houdini/commit/892411c2938c93265583fbea9dca25cb4af1d9c1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix preload conflicting with navigations +- [#1708](https://github.com/HoudiniGraphql/houdini/pull/1708) [`1a7d56d`](https://github.com/HoudiniGraphql/houdini/commit/1a7d56d347402344311ea11332b89f0ea1308b0e) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix a query with `@loading` that errors during SSR hanging on its loading state instead of reaching the nearest `+error.tsx` boundary. -## 2.0.0-next.37 +- [#1706](https://github.com/HoudiniGraphql/houdini/pull/1706) [`c60923d`](https://github.com/HoudiniGraphql/houdini/commit/c60923d347c900c790fb201aa81738f52df319c4) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix a "Could not find router context" crash that could occur during development when editing a route file triggered an HMR update. -### Minor Changes - -- [#1655](https://github.com/HoudiniGraphql/houdini/pull/1655) [`2c796b8`](https://github.com/HoudiniGraphql/houdini/commit/2c796b82878d96da1d38e90b6eb46e1639c2c9f3) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add a `` component with a typed `to` prop checked at compile time against your app's route manifest, with `params` interpolation and custom scalar support. - -## 2.0.0-next.36 - -### Patch Changes - -- [#1654](https://github.com/HoudiniGraphql/houdini/pull/1654) [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - GraphQL errors now expose `locations`, `path`, and `extensions` per the spec; augment `App.GraphQLErrorExtensions` to type your server's extensions. - -- [#1650](https://github.com/HoudiniGraphql/houdini/pull/1650) [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix `useMutation` to return `[mutate, pending]` instead of `[pending, mutate]`, and fix list toggle operations accumulating across resolved optimistic mutation layers causing subsequent toggles to appear stuck. - -- Updated dependencies [[`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`3b5e7d6`](https://github.com/HoudiniGraphql/houdini/commit/3b5e7d661503b2102c0af20a7029102646ca2aa6), [`8bd7291`](https://github.com/HoudiniGraphql/houdini/commit/8bd72911a7a022ccb68e7c3b5047f144077c3e4c), [`03aba94`](https://github.com/HoudiniGraphql/houdini/commit/03aba94e0b473ed4aedd1f16ddb96d2cd64c0549), [`961a019`](https://github.com/HoudiniGraphql/houdini/commit/961a019e2ca2c9f202ec340e17e07eb6143966c0), [`b8b757a`](https://github.com/HoudiniGraphql/houdini/commit/b8b757a8c0b5c1db67a65af33cf9c684efab04a5), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa)]: - - houdini@2.0.0-next.34 - -## 2.0.0-next.35 - -### Patch Changes - -- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - prevent unnecessary re-renders on fragments by stabilizing returned values and skipping subscription updates when data hasn't changed - -- [`fec6727`](https://github.com/HoudiniGraphql/houdini/commit/fec672700d142c0e300da0529f7404b3e8521a09) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix gaps in pagination request deduplication: stale inflight entries no longer block new requests, and ssr_signals now covers client-side concurrent renders to prevent duplicate observer/send pairs - -## 2.0.0-next.34 - -### Patch Changes - -- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - write generated files atomically to prevent partial-read parse errors when Vite loads a module mid-pipeline - -- [`7e775ca`](https://github.com/HoudiniGraphql/houdini/commit/7e775ca4aa532e69559d19ae38403f964463c6ae) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix HMR not regenerating the router manifest when a new `+page` or `+layout` file is added; invalidate component fields cache after each HMR cycle - -## 2.0.0-next.33 - -### Patch Changes - -- [#1638](https://github.com/HoudiniGraphql/houdini/pull/1638) [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix FOUC in dev mode by collecting CSS from the Vite module graph and passing them as React 19 stylesheet links that get hoisted to during SSR - -- [#1638](https://github.com/HoudiniGraphql/houdini/pull/1638) [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix TS2304 error in generated useFragmentHandle.ts by importing DocumentHandle type from useDocumentHandle - -- [#1638](https://github.com/HoudiniGraphql/houdini/pull/1638) [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix TS2554 in generated injectedPlugins.ts by omitting arguments when a client plugin's config is null - -- [#1638](https://github.com/HoudiniGraphql/houdini/pull/1638) [`d3856da`](https://github.com/HoudiniGraphql/houdini/commit/d3856daaae60cd73f4daae83e809a103ff14c5f2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix SSR middleware intercepting Vite module requests and missing Content-Type header; fix FOUC by enforcing correct CSS link precedence and deduplicating links; silence pre-warm noise by checking file existence before ssrLoadModule; set HOUDINI_PORT on server listen - -## 2.0.0-next.32 - -### Patch Changes - -- [`bb2944a`](https://github.com/HoudiniGraphql/houdini/commit/bb2944a76c8c65efdb5cac76bc7ff838cb34ceec) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix plugin resolution when npm normalizes bin field to object form - -## 2.0.0-next.31 - -### Patch Changes - -- [`a095fcc`](https://github.com/HoudiniGraphql/houdini/commit/a095fcc4eb51d6863a9cabc04b145fa96a53f240) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - publish wasm packages - -## 2.0.0-next.30 - -### Patch Changes - -- [#1631](https://github.com/HoudiniGraphql/houdini/pull/1631) [`86cecd1`](https://github.com/HoudiniGraphql/houdini/commit/86cecd19a8f54662624913400a6d82192639901b) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Bump dependencies to latest: express ^5, graphql-yoga ^5, @whatwg-node/server ^0.11, react ^19.2.7 - -- [#1630](https://github.com/HoudiniGraphql/houdini/pull/1630) [`43d89e0`](https://github.com/HoudiniGraphql/houdini/commit/43d89e0a70b0daf8748ca9225a92b0b2b6bffa7a) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Added WebContainer compatible database layer - -## 2.0.0-next.29 - -### Patch Changes - -- Updated dependencies [[`5668b992`](https://github.com/HoudiniGraphql/houdini/commit/5668b9927ace9b9574faf396d1a559b3b5ccf769)]: - - houdini@2.0.0-next.28 - -## 2.0.0-next.28 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-next.27 - -## 2.0.0-next.27 - -### Patch Changes - -- Updated dependencies [[`899054d5`](https://github.com/HoudiniGraphql/houdini/commit/899054d5d0ec1416dc0e4a3d8bd745093b951642)]: - - houdini@2.0.0-next.26 - -## 2.0.0-next.26 - -### Patch Changes - -- [`03e91242`](https://github.com/HoudiniGraphql/houdini/commit/03e912421b88610e9686b600f8e25d0c320ffa37) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - create is more flexible - -## 2.0.0-next.25 - -### Patch Changes - -- [`dd9d1cbf`](https://github.com/HoudiniGraphql/houdini/commit/dd9d1cbf499b8b6f327c8d457edc3b04176d55a4) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix bug caused crashes during hydration - -- [`e929d0d2`](https://github.com/HoudiniGraphql/houdini/commit/e929d0d2325fd33d9128bcdfce21fbc47d16066e) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix refresh collapse - -- Updated dependencies [[`dd9d1cbf`](https://github.com/HoudiniGraphql/houdini/commit/dd9d1cbf499b8b6f327c8d457edc3b04176d55a4)]: - - houdini@2.0.0-next.25 - -## 2.0.0-next.24 - -### Patch Changes - -- [`a67c5fc6`](https://github.com/HoudiniGraphql/houdini/commit/a67c5fc671b0e53e77217fa9b43dfe53ec2bb0f6) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add missing extensions in modules - -- Updated dependencies [[`a67c5fc6`](https://github.com/HoudiniGraphql/houdini/commit/a67c5fc671b0e53e77217fa9b43dfe53ec2bb0f6)]: - - houdini@2.0.0-next.24 - -## 2.0.0-next.23 - -### Patch Changes - -- [#1615](https://github.com/HoudiniGraphql/houdini/pull/1615) [`86124847`](https://github.com/HoudiniGraphql/houdini/commit/861248477429683de8f329bcb2a4da075b9d6122) Thanks [@github-actions](https://github.com/apps/github-actions)! - Fix package.json included in generated runtime - -- Updated dependencies []: - - houdini@2.0.0-next.23 - -## 2.0.0-next.22 - -### Minor Changes - -- [`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add scalar module imports, align DocumentHandle with fetching and errors fields - -### Patch Changes - -- Updated dependencies [[`ef91e5c1`](https://github.com/HoudiniGraphql/houdini/commit/ef91e5c1d00526fea772d3eae5661a8617fd79ce)]: - - houdini@2.0.0-next.22 - -## 2.0.0-go.21 - -### Patch Changes - -- Updated dependencies [[`14fa602a`](https://github.com/HoudiniGraphql/houdini/commit/14fa602a4aaeee3f0863e7f0c93945f0eebac51e), [`cd3fa07a`](https://github.com/HoudiniGraphql/houdini/commit/cd3fa07a6405de85f08954faa84895296f032ef4)]: - - houdini@2.0.0-go.21 - -## 2.0.0-go.20 - -### Minor Changes - -- [#1609](https://github.com/HoudiniGraphql/houdini/pull/1609) [`bac3a2b5`](https://github.com/HoudiniGraphql/houdini/commit/bac3a2b554f6d990adb6ae6314d524977d7771b5) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Converted react plugin to new go compiler +## 2.0.1 ### Patch Changes -- Updated dependencies [[`d1848162`](https://github.com/HoudiniGraphql/houdini/commit/d18481625a443cd41f72d605b0999a0ca75c9555)]: - - houdini@2.0.0-go.20 +- [#1704](https://github.com/HoudiniGraphql/houdini/pull/1704) [`56a57d2`](https://github.com/HoudiniGraphql/houdini/commit/56a57d26837190503e5380ee1c3cd84c17cf613c) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix source maps for route and component files: rewriting `graphql()` tags no longer shifts stack traces and breakpoints off the original source lines. -## 2.0.0-go.19 - -### Minor Changes - -- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version - -### Patch Changes - -- Updated dependencies [[`d4472272`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f)]: - - houdini@2.0.0-go.19 - -## 2.0.0-go.18 - -### Patch Changes - -- Updated dependencies [[`86ed9d27`](https://github.com/HoudiniGraphql/houdini/commit/86ed9d279d11443df553e9d1d42ab930ba878393)]: - - houdini@2.0.0-go.18 - -## 2.0.0-go.17 +## 2.0.0 ### Major Changes -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang - -### Minor Changes - -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - add abortController to query and mutation args - -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - move graphql to peerDependencies with >=16 range, automatically compatible with v17 when it releases - -### Patch Changes - -- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - throw RuntimeGraphQLError from useMutation when response contains errors - -- Updated dependencies [[`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b4`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: - - houdini@2.0.0-go.17 - -## 2.0.0-go.16 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.16 - -## 2.0.0-go.15 - -### Patch Changes - -- Updated dependencies []: - - houdini@2.0.0-go.15 - -## 2.0.0-go.14 - -### Patch Changes - -- Updated dependencies [[`0610efa9`](https://github.com/HoudiniGraphql/houdini/commit/0610efa92e09344216bb1be1cf5610dbba3d570f), [`89252315`](https://github.com/HoudiniGraphql/houdini/commit/8925231525061d0fba35a6b78df5cfd2cde74920)]: - - houdini@2.0.0-go.14 - -## 2.0.0-go.13 - -### Patch Changes - -- Updated dependencies [[`62a0e62a`](https://github.com/HoudiniGraphql/houdini/commit/62a0e62a476d6183d50bda21ed939c8f267308f0)]: - - houdini@2.0.0-go.13 - -## 2.0.0-go.12 - -### Patch Changes - -- [`c90c92b1`](https://github.com/HoudiniGraphql/houdini/commit/c90c92b1e5966b9756676abafc314b6b8e6439fe) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix compatability issue with go binary shim sand pnpm - -- Updated dependencies []: - - houdini@2.0.0-go.12 - -## 2.0.0-go.11 +- [`15c9453`](https://github.com/HoudiniGraphql/houdini/commit/15c945382821d5c4f7ddc94892a86d922fcf2c76) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Align DocumentHandle with fetching and errors fields -### Patch Changes - -- Updated dependencies [[`2bf6cd4f`](https://github.com/HoudiniGraphql/houdini/commit/2bf6cd4fdfddec1324ba702d65436c46d50e3fe5)]: - - houdini@2.0.0-go.11 - -## 2.0.0-go.10 - -### Patch Changes - -- Updated dependencies [[`53fc6baa`](https://github.com/HoudiniGraphql/houdini/commit/53fc6baaa58d4022ae3495c1e0940b07e85d971c)]: - - houdini@2.0.0-go.10 - -## 2.0.0-go.9 - -### Patch Changes - -- Updated dependencies [[`d656515b`](https://github.com/HoudiniGraphql/houdini/commit/d656515bda5835d6e8a19b0e6eb8ecf5627fe34e)]: - - houdini@2.0.0-go.9 - -## 2.0.0-go.8 - -### Patch Changes - -- [`ae4cdfe4`](https://github.com/HoudiniGraphql/houdini/commit/ae4cdfe445503611ab56330fdc750f79a067ab8d) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix shim replacement for execution - -- Updated dependencies []: - - houdini@2.0.0-go.8 - -## 2.0.0-go.7 - -### Patch Changes - -- [`2d60bc70`](https://github.com/HoudiniGraphql/houdini/commit/2d60bc70818bdcbefd3ba177bb56fc69b33f90ea) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - rework postinstall script - -- Updated dependencies []: - - houdini@2.0.0-go.7 +- [#1599](https://github.com/HoudiniGraphql/houdini/pull/1599) [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f) Thanks [@SeppahBaws](https://github.com/SeppahBaws)! - Bump Vite version -## 2.0.0-go.6 +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Rewrote entire codegen pipeline in golang -### Patch Changes - -- [`d66db310`](https://github.com/HoudiniGraphql/houdini/commit/d66db31026f37c1e8b5f661b8fbc05173b618a0e) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Attempt to fix post install script - -- Updated dependencies []: - - houdini@2.0.0-go.6 - -## 2.0.0-go.5 +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Bump graphql dependency to >= 16 -### Patch Changes +- [#1693](https://github.com/HoudiniGraphql/houdini/pull/1693) [`7ffe142`](https://github.com/HoudiniGraphql/houdini/commit/7ffe1420c60c775a897ccb75618f23d6a25cf660) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Remove the `useCurrentVariables` hook. Route variables are available through `useRoute().params`. -- [`7822a62e`](https://github.com/HoudiniGraphql/houdini/commit/7822a62e0421192000dbdf55a1c4379cdfe29358) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Fix files entry in published package - -- Updated dependencies []: - - houdini@2.0.0-go.5 - -## 2.0.0-go.4 - -### Patch Changes +### Minor Changes -- [`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix shim paths +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - add abortController to query and mutation args -- Updated dependencies [[`9bcf4188`](https://github.com/HoudiniGraphql/houdini/commit/9bcf4188dce2f153a07f3a9a47ffbd905def9da2)]: - - houdini@2.0.0-go.4 +- [#1655](https://github.com/HoudiniGraphql/houdini/pull/1655) [`2c796b8`](https://github.com/HoudiniGraphql/houdini/commit/2c796b82878d96da1d38e90b6eb46e1639c2c9f3) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add a `` component with a typed `to` prop checked at compile time against your app's route manifest, with `params` interpolation and custom scalar support. -## 2.0.0-go.3 +- [#1677](https://github.com/HoudiniGraphql/houdini/pull/1677) [`ef5363e`](https://github.com/HoudiniGraphql/houdini/commit/ef5363ed9927cf52a97932dffa3eba983af6a8e9) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - add `createMock` for first-class testing support — returns a fully composed React component for any route, wired with a fresh cache and mock network client. -### Patch Changes +- [#1698](https://github.com/HoudiniGraphql/houdini/pull/1698) [`084d6c3`](https://github.com/HoudiniGraphql/houdini/commit/084d6c37e3ce1ee1639315b07aa1081b93211752) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add `@endpoint`, `@session`, and `useMutationForm` to support progressively enhanced forms -- [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - fix inter-workspace deps +- [`15c9453`](https://github.com/HoudiniGraphql/houdini/commit/15c945382821d5c4f7ddc94892a86d922fcf2c76) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - `useFragment` now reads a `@plural` fragment back as an array of data. -- Updated dependencies [[`043c4e29`](https://github.com/HoudiniGraphql/houdini/commit/043c4e29ce2c2f41b4a6750b191983e5d53a3540), [`a74bf5f8`](https://github.com/HoudiniGraphql/houdini/commit/a74bf5f803d97686d98b2d78f28ea542cb6f9448)]: - - houdini@2.0.0-go.3 +- [#1666](https://github.com/HoudiniGraphql/houdini/pull/1666) [`cb689af`](https://github.com/HoudiniGraphql/houdini/commit/cb689af828ef44ac3109dcfbcfda61a57b64fca8) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add `+error.tsx` route-level error boundaries and a full routing error toolkit (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`, `httpError()`, `isRoutingError`, `isApiError`) for the React adapter. -## 2.0.0-go.2 +- [`15c9453`](https://github.com/HoudiniGraphql/houdini/commit/15c945382821d5c4f7ddc94892a86d922fcf2c76) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - `useFragmentHandle` now returns a `.refetch()` for refetching a `@refetchable` fragment with new argument values. -### Patch Changes +- [#1685](https://github.com/HoudiniGraphql/houdini/pull/1685) [`cc47a1a`](https://github.com/HoudiniGraphql/houdini/commit/cc47a1ad7fbc1d8d7e7effc0d8935af80054e707) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Pages and layouts can now export a `headers()` function to set HTTP response headers for a route. -- Updated dependencies [[`6fe29007`](https://github.com/HoudiniGraphql/houdini/commit/6fe290071bf356ef71567ebcbf025b1802f5cb42)]: - - houdini@2.0.0-go.2 +- [#1691](https://github.com/HoudiniGraphql/houdini/pull/1691) [`257e195`](https://github.com/HoudiniGraphql/houdini/commit/257e195565013c25367c727fd44c2c73c289e791) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add search param integration into queries, Link, and goto, and read route params and search through useRoute() (replacing useLocation). -## 2.0.0-go.1 +- [#1700](https://github.com/HoudiniGraphql/houdini/pull/1700) [`caba000`](https://github.com/HoudiniGraphql/houdini/commit/caba000d1c52661f4562508137f40ce12df91e78) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Add server-backed sessions to the React runtime, first-class OAuth support, and harden security posture for authorization story ### Patch Changes -- [`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - bump houdini dep version - -- Updated dependencies [[`07347a95`](https://github.com/HoudiniGraphql/houdini/commit/07347a9505ea11ba0d3e533979e96963b9001c06)]: - - houdini@2.0.0-go.1 - -## 2.0.0-go.0 - -### Major Changes - -- [`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - Implement new compiler architecture - -### Patch Changes +- [#1593](https://github.com/HoudiniGraphql/houdini/pull/1593) [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480) Thanks [@AlecAivazis](https://github.com/AlecAivazis)! - throw RuntimeGraphQLError from useMutation when response contains errors -- Updated dependencies [[`3af119a2`](https://github.com/HoudiniGraphql/houdini/commit/3af119a28ba88dd3b0e8902fdf94563354ebb765)]: - - houdini@2.0.0-go.0 +- Updated dependencies [[`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f6e9636`](https://github.com/HoudiniGraphql/houdini/commit/f6e9636f223ff01737a4ca0a5e87aba3bbbeaf1a), [`d447227`](https://github.com/HoudiniGraphql/houdini/commit/d44722725c5e2302e041e3360020e386e098730f), [`6d40af6`](https://github.com/HoudiniGraphql/houdini/commit/6d40af6dac5490ff7046fef5fd48cb15941bfcd1), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480), [`f1ae542`](https://github.com/HoudiniGraphql/houdini/commit/f1ae542be6e094b4e39b1b181176c00d4eac1956), [`bf966b9`](https://github.com/HoudiniGraphql/houdini/commit/bf966b9eaf35166628bb6b3ed0f35b8a42700b6c), [`8f4a044`](https://github.com/HoudiniGraphql/houdini/commit/8f4a044487b9e042cc6dd162430ff6bdf741e0aa), [`8bd407b`](https://github.com/HoudiniGraphql/houdini/commit/8bd407b430687543944da269814344e01d2e8480)]: + - houdini@2.0.0 ## 1.3.2 diff --git a/packages/houdini-react/package.json b/packages/houdini-react/package.json index 179da90e34..62174adb85 100644 --- a/packages/houdini-react/package.json +++ b/packages/houdini-react/package.json @@ -1,6 +1,6 @@ { "name": "houdini-react", - "version": "2.0.0-next.38", + "version": "2.0.2", "description": "The React plugin for houdini", "keywords": [ "typescript", diff --git a/packages/houdini-react/package/vite/index.ts b/packages/houdini-react/package/vite/index.ts index 5190ea94da..b647ebd908 100644 --- a/packages/houdini-react/package/vite/index.ts +++ b/packages/houdini-react/package/vite/index.ts @@ -6,7 +6,7 @@ import { client_build_directory, } from 'houdini/router/conventions' import { load_manifest, type ProjectManifest } from 'houdini/router/manifest' -import { type RouterManifest } from 'houdini/router/types' +import { type RouterManifest, type RouterPageManifest } from 'houdini/router/types' import { VitePluginContext } from 'houdini/vite' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' @@ -144,11 +144,14 @@ export default function (ctx: VitePluginContext): PluginOption { assetFileNames: 'assets/[name].js', entryFileNames: '[name].js', }, + // only the client app entry belongs in the client build. The adapter entry + // imports src/server/+config (session keys, OAuth client secrets) and is built + // separately into the ssr/ directory by closeBundle below; building it here too + // would land a server bundle in the client-served assets dir and leak those + // secrets. Keeping client and server outputs in separate directories lets the + // adapter serve only the client assets. input: { 'entries/app': app_component_path(ctx.config), - ...(ctx.adapter - ? { 'entries/adapter': adapter_config_path(ctx.config) } - : {}), }, }, } @@ -188,7 +191,7 @@ export default function (ctx: VitePluginContext): PluginOption { cfCache = null }, - async transform(code: string, filepath: string) { + async transform(code: string, filepath: string, options?: { ssr?: boolean }) { filepath = path.posixify(filepath) if (filepath.startsWith('/src/')) { @@ -199,6 +202,10 @@ export default function (ctx: VitePluginContext): PluginOption { return } + // headers() is server-only; strip it from the client build of route + // views so it never reaches the browser bundle (see transform_file). + const stripHeaders = !options?.ssr && /(?:^|\/)\+(?:page|layout)\.[jt]sx?$/.test(filepath) + if (cfCache === null) { try { cfCache = ctx.db.all( @@ -216,7 +223,8 @@ export default function (ctx: VitePluginContext): PluginOption { filepath: path.posixify(filepath), watch_file: this.addWatchFile.bind(this), }, - cfCache + cfCache, + { stripHeaders } ) }, @@ -367,9 +375,20 @@ mount_static_app(App, manifest) return } - const { default: router_manifest } = (await server.ssrLoadModule( + const manifest_module = (await server.ssrLoadModule( path.join(plugin_dir(ctx.config, 'houdini-react'), 'runtime', 'manifest.ts') - )) as { default: RouterManifest } + )) as { + default: RouterManifest + route_headers?: Record['headers']> + } + const router_manifest = manifest_module.default + // route_headers is a server-only export; attach it to the manifest so the + // request handler can evaluate headers() before streaming + for (const id of Object.keys(manifest_module.route_headers ?? {})) { + if (router_manifest.pages[id]) { + router_manifest.pages[id].headers = manifest_module.route_headers![id] + } + } const { createServerAdapter } = (await server.ssrLoadModule( adapter_config_path(ctx.config) diff --git a/packages/houdini-react/package/vite/strip-headers.test.ts b/packages/houdini-react/package/vite/strip-headers.test.ts new file mode 100644 index 0000000000..3feb55aae7 --- /dev/null +++ b/packages/houdini-react/package/vite/strip-headers.test.ts @@ -0,0 +1,48 @@ +import { parseJS, printJS } from 'houdini' +import { test, expect, describe } from 'vitest' + +import { strip_named_export } from './strip-headers.js' + +async function strip(code: string): Promise { + const script = parseJS(code, { plugins: ['jsx'] }) + strip_named_export(script, 'headers') + const { code: out } = await printJS(script) + return out.trim() +} + +describe('strip_named_export', () => { + test('removes an exported function declaration', async () => { + const out = await strip( + `export function headers() { return { 'X-A': 'b' } }\nexport default () => null` + ) + expect(out).not.toContain('headers') + expect(out).toContain('export default') + }) + + test('removes an exported const declaration', async () => { + const out = await strip(`export const headers = () => ({})\nexport default () => null`) + expect(out).not.toContain('headers') + expect(out).toContain('export default') + }) + + test('removes a headers specifier from an export list but keeps the others', async () => { + const out = await strip( + `const headers = () => ({})\nconst other = 1\nexport { headers, other }\nexport default () => null` + ) + expect(out).not.toMatch(/export\s*\{[^}]*headers/) + expect(out).toContain('other') + }) + + test('removes an aliased headers export', async () => { + const out = await strip( + `const fn = () => ({})\nexport { fn as headers }\nexport default () => null` + ) + expect(out).not.toMatch(/as headers/) + }) + + test('leaves unrelated exports untouched', async () => { + const out = await strip(`export const other = 1\nexport default () => null`) + expect(out).toContain('other') + expect(out).toContain('export default') + }) +}) diff --git a/packages/houdini-react/package/vite/strip-headers.ts b/packages/houdini-react/package/vite/strip-headers.ts new file mode 100644 index 0000000000..2506dea272 --- /dev/null +++ b/packages/houdini-react/package/vite/strip-headers.ts @@ -0,0 +1,39 @@ +// strip_named_export removes a top-level named export (declaration or specifier) +// from a parsed program in place. It is used to drop the server-only headers() +// export of a +page/+layout from the client build. +export function strip_named_export(script: any, name: string): void { + const body = script.body + for (let i = body.length - 1; i >= 0; i--) { + const node = body[i] + if (node.type !== 'ExportNamedDeclaration') { + continue + } + + // export function headers() {} / export const headers = ... + if (node.declaration) { + const decl = node.declaration + if (decl.type === 'FunctionDeclaration' && decl.id?.name === name) { + body.splice(i, 1) + continue + } + if (decl.type === 'VariableDeclaration') { + decl.declarations = decl.declarations.filter( + (d: any) => !(d.id?.type === 'Identifier' && d.id.name === name) + ) + if (decl.declarations.length === 0) { + body.splice(i, 1) + } + continue + } + continue + } + + // export { headers } / export { foo as headers } + if (node.specifiers?.length) { + node.specifiers = node.specifiers.filter((s: any) => s.exported?.name !== name) + if (node.specifiers.length === 0) { + body.splice(i, 1) + } + } + } +} diff --git a/packages/houdini-react/package/vite/transform.ts b/packages/houdini-react/package/vite/transform.ts index 53614b2135..1c07790cd6 100644 --- a/packages/houdini-react/package/vite/transform.ts +++ b/packages/houdini-react/package/vite/transform.ts @@ -13,20 +13,32 @@ import { componentField_unit_path, houdini_root } from 'houdini/router/conventio import * as recast from 'recast' import type { SourceMapInput } from 'rollup' +import { strip_named_export } from './strip-headers.js' + const AST = recast.types.builders export type ComponentFieldRow = { type: string; field: string; fragment: string } export async function transform_file( page: TransformPage, - cfRows: ComponentFieldRow[] + cfRows: ComponentFieldRow[], + opts: { stripHeaders?: boolean } = {} ): Promise<{ code: string; map?: SourceMapInput }> { const isJSX = page.filepath.endsWith('.tsx') || page.filepath.endsWith('.jsx') if (!isJSX && !page.filepath.endsWith('.ts') && !page.filepath.endsWith('.js')) { return { code: page.content, map: page.map } } - const script = parseJS(page.content, isJSX ? { plugins: ['jsx'] } : {}) + const script = parseJS(page.content, isJSX ? { plugins: ['jsx'] } : {}, page.filepath) + + // The headers() export of a +page/+layout only ever runs on the server. When + // building for the client we strip it so server-only logic (secrets, env + // vars) it might read never ends up in the browser bundle. Rollup keeps it + // otherwise: route views become preserved-signature chunks, so an unused + // export isn't tree-shaken away on its own. + if (opts.stripHeaders) { + strip_named_export(script, 'headers') + } const cfMap: Record> = {} for (const row of cfRows) { @@ -43,10 +55,16 @@ export async function transform_file( const properties = [AST.objectProperty(AST.stringLiteral('artifact'), artifactRef)] - if (is_paginated(parsedDocument)) { + // both @paginate and @refetchable fragments embed the document in a + // separate query that useFragmentHandle uses to refetch. + if (is_paginated(parsedDocument) || is_refetchable(parsedDocument)) { if (artifact.kind !== ArtifactKind.Query) { - // fragment/subscription pagination: the refetch artifact is a separate query - const refetchName = artifact.name + '_Pagination_Query' + // fragment/subscription pagination (and @refetchable): the refetch + // artifact is a separate query. @paginate embeds a _Pagination_Query; + // @refetchable embeds a _Refetch_Query. + const refetchName = + artifact.name + + (is_paginated(parsedDocument) ? '_Pagination_Query' : '_Refetch_Query') const { id: refetchRef } = artifact_import({ page, script, @@ -109,3 +127,15 @@ function is_paginated(doc: graphql.DocumentNode): boolean { }) return paginated } + +function is_refetchable(doc: graphql.DocumentNode): boolean { + let refetchable = false + graphql.visit(doc, { + Directive(node) { + if (node.name.value === 'refetchable') { + refetchable = true + } + }, + }) + return refetchable +} diff --git a/packages/houdini-react/plugin/generate.go b/packages/houdini-react/plugin/generate.go index bd78fbf25d..90df7a836c 100644 --- a/packages/houdini-react/plugin/generate.go +++ b/packages/houdini-react/plugin/generate.go @@ -529,6 +529,7 @@ import { renderToStream } from 'houdini-react/server' import React from 'react' import { router_cache, StatusContext } from '../../runtime/routing' +import { escapeScriptTag } from '../../runtime/escape' // @ts-expect-error import client from '%s/src/+client' // @ts-expect-error @@ -537,6 +538,26 @@ import router_manifest from '$houdini/plugins/houdini-react/runtime/manifest' import config from '%s/houdini.config.js' +// route_headers maps a page id to its ordered headers() loaders. It is a +// server-only export so headers() stays out of the client bundle; attach it to +// the manifest here so the request handler can evaluate it before streaming. +import * as manifest_module from '$houdini/plugins/houdini-react/runtime/manifest' +for (const id of Object.keys(manifest_module.route_headers ?? {})) { + if (router_manifest.pages[id]) { + router_manifest.pages[id].headers = manifest_module.route_headers[id] + } +} +// form_actions is server-only too: attach the @endpoint mutation loaders so the no-JS +// form handler can resolve a submitted form's mutation artifact. +if (manifest_module.form_actions) { + router_manifest.formActions = manifest_module.form_actions +} +// session_mutations (name → sessionPath) is server-only too: the session-mint plugin and the +// no-JS form handler use it to find the result field that becomes the session. +if (manifest_module.session_mutations) { + router_manifest.sessionMutations = manifest_module.session_mutations +} + export const on_render = ({ assetPrefix, pipe, production, documentPremable, cssLinks }) => async ({ @@ -546,6 +567,10 @@ export const on_render = session, manifest, componentCache, + headers, + formResult, + formToken, + authUrl, }) => { const cache = new Cache({ disabled: false, @@ -562,6 +587,16 @@ export const on_render = // HoudiniErrorBoundary can set the correct HTTP status/location before streaming. const statusRef = { status: is404 ? 404 : 200, location: undefined } + // renderToStream only hands back injectToStream as a return value, i.e. after has + // already been constructed — too late to pass it down as a prop for the first render. + // We thread a stable wrapper that delegates to this holder, then fill the holder once + // renderToStream resolves. @loading queries resolve after the shell flushes (so the + // holder is set by the time their resolution scripts stream); non-@loading queries + // resolve before the shell and simply no-op the wrapper, falling back to the initial + // cache as before. Sourcing it this way (rather than react-streaming's useStream context) + // keeps the bare react-streaming import out of the isomorphic runtime, which trips a + // "loaded in browser" poison-pill assertion under browser-like test environments. + const streamHolder = {} const { readable, injectToStream, @@ -572,33 +607,53 @@ export const on_render = initialURL: url, cache: cache, session: session, + formResult: formResult ?? null, + formToken: formToken ?? null, assetPrefix: assetPrefix, manifest: manifest, cssLinks: cssLinks || [], + injectToStream: (chunk) => streamHolder.injectToStream?.(chunk), ...router_cache() }) ), { webStream: production, userAgent: 'Vite' } ) - - injectToStream(` + "`" + ` + streamHolder.injectToStream = injectToStream + + // The page bootstrap below is intentionally not async. On a streaming page (e.g. an + // @loading query renders its loading state inside a Suspense boundary, so the shell + // flushes immediately and the document stays open), an async module runs the moment + // it loads — before the deferred react-refresh preamble, which waits for the still-open + // document to finish parsing. That makes every JSX import throw "can't detect preamble". + // A plain (deferred) module runs in document order, after the preamble. + injectToStream(`+"`"+` ${documentPremable ?? ''} - ${match ? '' : ''} - ` + "`" + `) + ${match ? '' : ''} + `+"`"+`) if (pipeTo && pipe) { + // route headers must be set on the underlying response before any of + // the stream is written + if (headers && typeof pipe.setHeader === 'function') { + for (const [key, value] of Object.entries(headers)) { + pipe.setHeader(key, value) + } + } pipeTo(pipe) return true } else if (statusRef.location) { - return new Response(null, { status: statusRef.status, headers: { Location: statusRef.location } }) + return new Response(null, { status: statusRef.status, headers: { ...headers, Location: statusRef.location } }) } else { - return new Response(readable, { status: statusRef.status }) + return new Response(readable, { status: statusRef.status, headers }) } } @@ -624,11 +679,11 @@ export function createServerAdapter(options) { // config.js — varies by local_schema, local_yoga, and component fields schemaLine := "const schema = null" if manifest.LocalSchema { - schemaLine = fmt.Sprintf("import schema from '%s/src/api/+schema'", rootRel) + schemaLine = fmt.Sprintf("import schema from '%s/src/server/+schema'", rootRel) } yogaLine := "const yoga = null" if manifest.LocalYoga { - yogaLine = fmt.Sprintf("import yoga from '%s/src/api/+yoga'", rootRel) + yogaLine = fmt.Sprintf("import yoga from '%s/src/server/+yoga'", rootRel) } // Component field wrapper imports (relative from render/ to componentFields/) @@ -647,8 +702,18 @@ export function createServerAdapter(options) { cacheBody = "\n" + strings.Join(cfCacheEntries, "\n") + "\n" } + // server-only config: src/server/+config (HoudiniServerConfig) holds secrets — sessionKeys, and + // later oauth — that must never reach houdini.config, which the client bundles for scalars. It + // is passed to the adapter SEPARATELY from config_file (never merged), so the public and + // server configs stay distinct, mirroring how the build loads them. + serverConfigImport := "const server_config = {}" + if manifest.LocalConfig { + serverConfigImport = fmt.Sprintf("import server_config from '%s/src/server/+config'", rootRel) + } + configContent := fmt.Sprintf(`import { createServerAdapter as createAdapter } from './server' import config_file from '%s/houdini.config' +%s %s%s %s @@ -664,10 +729,11 @@ export function createServerAdapter(options) { componentCache, graphqlEndpoint: endpoint, config_file, + server_config, ...options, }) } -`, rootRel, cfImportBlock, schemaLine, yogaLine, apiEndpoint, cacheBody) +`, rootRel, serverConfigImport, cfImportBlock, schemaLine, yogaLine, apiEndpoint, cacheBody) configPath := filepath.Join(rDir, "config.js") if ok, err := writeIfChanged(p.Filesystem(), configPath, configContent); err != nil { @@ -773,7 +839,7 @@ func (p *HoudiniReact) GenerateTypeRoots(ctx context.Context) ([]string, error) func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries, layoutQueries, errorQueries []string, params map[string]*ParamTypeInfo) string { var b strings.Builder - b.WriteString(fmt.Sprintf("import { DocumentHandle, RouteProp } from '%s'\n", runtimeRel)) + b.WriteString(fmt.Sprintf("import { DocumentHandle } from '%s'\n", runtimeRel)) b.WriteString("import React from 'react'\n") for _, q := range allQueries { b.WriteString(fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '%s/%s'\n", @@ -783,9 +849,11 @@ func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries b.WriteString(fmt.Sprintf("import type { RoutingError } from '%s'\n", runtimeRel)) paramsType := formatParamsType(params) + routeKeys := formatRouteKeyUnion(params) - // PageProps - b.WriteString(fmt.Sprintf("\nexport type PageProps = {\n\tParams: %s,\n", paramsType)) + // PageProps — only the page's query results + handles. Route params and search live + // on PageRoute (read via useRoute), so they can't be accidentally destructured here. + b.WriteString("\nexport type PageProps = {\n") for _, q := range pageQueries { b.WriteString(fmt.Sprintf("\t%s: %s$result,\n", q, q)) b.WriteString(fmt.Sprintf("\t%s$handle: DocumentHandle<%s$artifact, %s$result, %s$input>,\n", q, q, q, q)) @@ -793,7 +861,7 @@ func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries b.WriteString("}\n") // LayoutProps - b.WriteString(fmt.Sprintf("\nexport type LayoutProps = {\n\tParams: %s,\n\tchildren: React.ReactNode,\n", paramsType)) + b.WriteString("\nexport type LayoutProps = {\n\tchildren: React.ReactNode,\n") for _, q := range layoutQueries { b.WriteString(fmt.Sprintf("\t%s: %s$result,\n", q, q)) b.WriteString(fmt.Sprintf("\t%s$handle: DocumentHandle<%s$artifact, %s$result, %s$input>,\n", q, q, q, q)) @@ -801,13 +869,21 @@ func generateTypeRoot(runtimeRel, artifactRelDir string, allQueries, pageQueries b.WriteString("}\n") // ErrorProps - b.WriteString(fmt.Sprintf("\nexport type ErrorProps = {\n\tParams: %s,\n\terrors: Array,\n\tchildren: React.ReactNode,\n", paramsType)) + b.WriteString("\nexport type ErrorProps = {\n\terrors: Array,\n\tchildren: React.ReactNode,\n") for _, q := range errorQueries { b.WriteString(fmt.Sprintf("\t%s: %s$result,\n", q, q)) b.WriteString(fmt.Sprintf("\t%s$handle: DocumentHandle<%s$artifact, %s$result, %s$input>,\n", q, q, q, q)) } b.WriteString("}\n") + // PageRoute / LayoutRoute / ErrorRoute — the route's params (from path segments) and + // search (the component's nullable, non-route query variables). Consumed via + // useRoute(). Both are derived from the query inputs so they carry the exact + // scalar types (including unmarshaled custom scalars like Date). + b.WriteString(formatRouteType("PageRoute", pageQueries, routeKeys, paramsType)) + b.WriteString(formatRouteType("LayoutRoute", layoutQueries, routeKeys, paramsType)) + b.WriteString(formatRouteType("ErrorRoute", errorQueries, routeKeys, paramsType)) + return b.String() } @@ -823,6 +899,45 @@ func formatParamsType(params map[string]*ParamTypeInfo) string { return "{ " + strings.Join(parts, ", ") + " }" } +// formatRouteKeyUnion returns the route's param names as a string-literal union (e.g. +// "'id' | 'postId'"), or "never" when the route has no dynamic segments. Used to split a +// query's input into its path-param and search halves. +func formatRouteKeyUnion(params map[string]*ParamTypeInfo) string { + if len(params) == 0 { + return "never" + } + keys := sortedKeys(params) + var quoted []string + for _, k := range keys { + quoted = append(quoted, fmt.Sprintf("'%s'", k)) + } + return strings.Join(quoted, " | ") +} + +// formatRouteType emits a PageRoute/LayoutRoute/ErrorRoute type: params are the route-key +// subset of the component's query inputs and search is everything else (the nullable, +// non-route variables). With no queries there's no input to derive from, so params falls +// back to the path-segment names typed as string and search is empty. +func formatRouteType(name string, queries []string, routeKeys, paramsType string) string { + var params, search string + if len(queries) == 0 { + params = paramsType + search = "{}" + } else { + inputs := make([]string, 0, len(queries)) + for _, q := range queries { + inputs = append(inputs, q+"$input") + } + combined := strings.Join(inputs, " & ") + if len(inputs) > 1 { + combined = "(" + combined + ")" + } + params = fmt.Sprintf("Pick<%s, Extract>", combined, combined, routeKeys) + search = fmt.Sprintf("Omit<%s, %s>", combined, routeKeys) + } + return fmt.Sprintf("\nexport type %s = {\n\tparams: %s,\n\tsearch: %s,\n}\n", name, params, search) +} + // ---- component field helpers ---- type componentField struct { diff --git a/packages/houdini-react/plugin/generate_test.go b/packages/houdini-react/plugin/generate_test.go index bc2658b8be..daddc22dde 100644 --- a/packages/houdini-react/plugin/generate_test.go +++ b/packages/houdini-react/plugin/generate_test.go @@ -726,6 +726,7 @@ export default ({ cssLinks, ...props }) => ( `, "render/config.js": `import { createServerAdapter as createAdapter } from './server' import config_file from '../../../../../houdini.config' +const server_config = {} const schema = null const yoga = null @@ -741,6 +742,7 @@ export function createServerAdapter(options) { componentCache, graphqlEndpoint: endpoint, config_file, + server_config, ...options, }) } @@ -753,13 +755,14 @@ export function createServerAdapter(options) { Pass: true, Extra: map[string]any{ "api_files": map[string]string{ - "src/api/+schema.js": "export default 'schema'", + "src/server/+schema.js": "export default 'schema'", }, "expected": map[string]string{ "render/config.js": `import { createServerAdapter as createAdapter } from './server' import config_file from '../../../../../houdini.config' +const server_config = {} -import schema from '../../../../../src/api/+schema' +import schema from '../../../../../src/server/+schema' const yoga = null export const endpoint = "/_api" @@ -773,6 +776,7 @@ export function createServerAdapter(options) { componentCache, graphqlEndpoint: endpoint, config_file, + server_config, ...options, }) } @@ -785,14 +789,15 @@ export function createServerAdapter(options) { Pass: true, Extra: map[string]any{ "api_files": map[string]string{ - "src/api/+yoga.js": "export default 'yoga'", + "src/server/+yoga.js": "export default 'yoga'", }, "expected": map[string]string{ "render/config.js": `import { createServerAdapter as createAdapter } from './server' import config_file from '../../../../../houdini.config' +const server_config = {} const schema = null -import yoga from '../../../../../src/api/+yoga' +import yoga from '../../../../../src/server/+yoga' export const endpoint = "/_api" @@ -805,6 +810,7 @@ export function createServerAdapter(options) { componentCache, graphqlEndpoint: endpoint, config_file, + server_config, ...options, }) } @@ -823,6 +829,7 @@ export function createServerAdapter(options) { "expected": map[string]string{ "render/config.js": `import { createServerAdapter as createAdapter } from './server' import config_file from '../../../../../houdini.config' +const server_config = {} import UserAvatar from '../componentFields/wrapper_UserAvatar.jsx' @@ -842,6 +849,7 @@ export function createServerAdapter(options) { componentCache, graphqlEndpoint: endpoint, config_file, + server_config, ...options, }) } @@ -920,32 +928,44 @@ func TestGenerateTypeRoots(t *testing.T) { // runtime: ../../plugins/houdini-react/runtime (no extra .houdini/) // artifacts: ../../artifacts/ "expected": map[string]string{ - "src/routes/$types.d.ts": `import { DocumentHandle, RouteProp } from '../../../plugins/houdini-react/runtime' + "src/routes/$types.d.ts": `import { DocumentHandle } from '../../../plugins/houdini-react/runtime' import React from 'react' import type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../artifacts/LayoutQuery' import type { GraphQLError } from 'houdini/runtime' import type { RoutingError } from '../../../plugins/houdini-react/runtime' export type PageProps = { - Params: {}, LayoutQuery: LayoutQuery$result, LayoutQuery$handle: DocumentHandle, } export type LayoutProps = { - Params: {}, children: React.ReactNode, } export type ErrorProps = { - Params: {}, errors: Array, children: React.ReactNode, LayoutQuery: LayoutQuery$result, LayoutQuery$handle: DocumentHandle, } + +export type PageRoute = { + params: Pick>, + search: Omit, +} + +export type LayoutRoute = { + params: {}, + search: {}, +} + +export type ErrorRoute = { + params: Pick>, + search: Omit, +} `, - "src/routes/(subRoute)/$types.d.ts": `import { DocumentHandle, RouteProp } from '../../../../plugins/houdini-react/runtime' + "src/routes/(subRoute)/$types.d.ts": `import { DocumentHandle } from '../../../../plugins/houdini-react/runtime' import React from 'react' import type { LayoutQuery$result, LayoutQuery$artifact, LayoutQuery$input } from '../../../../artifacts/LayoutQuery' import type { RootQuery$result, RootQuery$artifact, RootQuery$input } from '../../../../artifacts/RootQuery' @@ -954,7 +974,6 @@ import type { GraphQLError } from 'houdini/runtime' import type { RoutingError } from '../../../../plugins/houdini-react/runtime' export type PageProps = { - Params: {}, LayoutQuery: LayoutQuery$result, LayoutQuery$handle: DocumentHandle, RootQuery: RootQuery$result, @@ -964,12 +983,10 @@ export type PageProps = { } export type LayoutProps = { - Params: {}, children: React.ReactNode, } export type ErrorProps = { - Params: {}, errors: Array, children: React.ReactNode, LayoutQuery: LayoutQuery$result, @@ -977,6 +994,21 @@ export type ErrorProps = { RootQuery: RootQuery$result, RootQuery$handle: DocumentHandle, } + +export type PageRoute = { + params: Pick<(LayoutQuery$input & RootQuery$input & FinalQuery$input), Extract>, + search: Omit<(LayoutQuery$input & RootQuery$input & FinalQuery$input), never>, +} + +export type LayoutRoute = { + params: {}, + search: {}, +} + +export type ErrorRoute = { + params: Pick<(LayoutQuery$input & RootQuery$input), Extract>, + search: Omit<(LayoutQuery$input & RootQuery$input), never>, +} `, }, }, @@ -996,30 +1028,42 @@ export type ErrorProps = { "src/routes/[id]/+page.tsx": mockView([]string{"MyQuery"}), }, "expected": map[string]string{ - "src/routes/[id]/$types.d.ts": `import { DocumentHandle, RouteProp } from '../../../../plugins/houdini-react/runtime' + "src/routes/[id]/$types.d.ts": `import { DocumentHandle } from '../../../../plugins/houdini-react/runtime' import React from 'react' import type { MyQuery$result, MyQuery$artifact, MyQuery$input } from '../../../../artifacts/MyQuery' import type { GraphQLError } from 'houdini/runtime' import type { RoutingError } from '../../../../plugins/houdini-react/runtime' export type PageProps = { - Params: { id: string }, MyQuery: MyQuery$result, MyQuery$handle: DocumentHandle, } export type LayoutProps = { - Params: { id: string }, children: React.ReactNode, } export type ErrorProps = { - Params: { id: string }, errors: Array, children: React.ReactNode, MyQuery: MyQuery$result, MyQuery$handle: DocumentHandle, } + +export type PageRoute = { + params: Pick>, + search: Omit, +} + +export type LayoutRoute = { + params: { id: string }, + search: {}, +} + +export type ErrorRoute = { + params: Pick>, + search: Omit, +} `, }, }, diff --git a/packages/houdini-react/plugin/manifest.go b/packages/houdini-react/plugin/manifest.go index bd4d774aa0..3d71b281aa 100644 --- a/packages/houdini-react/plugin/manifest.go +++ b/packages/houdini-react/plugin/manifest.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "regexp" "sort" "strings" "sync" @@ -12,6 +13,7 @@ import ( plugins "code.houdinigraphql.com/plugins" pluginglob "code.houdinigraphql.com/plugins/glob" + "code.houdinigraphql.com/plugins/graphql" ) // ProjectManifest is the static description of a project's routes and queries. @@ -23,7 +25,24 @@ type ProjectManifest struct { Artifacts []string `json:"artifacts"` LocalSchema bool `json:"local_schema"` LocalYoga bool `json:"local_yoga"` + LocalConfig bool `json:"local_config"` ComponentFields map[string]ComponentFieldInfo `json:"component_fields"` + Mutations []string `json:"mutations"` + Subscriptions []string `json:"subscriptions"` + // FormActions are the names of mutations carrying @endpoint — the form-submittable + // ones whose artifacts the no-JS form handler loads server-side. + FormActions []string `json:"form_actions"` + // SessionMutations maps each @session mutation's name to where (path) and how (merge) it + // writes the session. Used by the session-mint plugin (any execution) and the no-JS form + // handler (inline cookie write). Independent of FormActions. + SessionMutations map[string]SessionMutationInfo `json:"session_mutations"` +} + +// SessionMutationInfo is how a @session mutation writes the session: the result field `Path` +// whose value is written, and whether it merges into (vs replaces) the existing session. +type SessionMutationInfo struct { + Path string `json:"path"` + Merge bool `json:"merge"` } type PageManifest struct { @@ -36,6 +55,14 @@ type PageManifest struct { Path string `json:"path"` ErrorPath string `json:"error_path"` Params map[string]*ParamTypeInfo `json:"params"` + // SearchParams are the nullable query variables in scope for this page that are + // not satisfied by a route segment. They can be supplied via URLSearchParams and + // are always optional (a missing one resolves to null), so they can never turn a + // query into a failing request. See issue #1210. + SearchParams map[string]*ParamTypeInfo `json:"search_params"` + // Headers is true when the view file exports a `headers()` function whose + // result should be merged into the HTTP response before streaming. + Headers bool `json:"headers"` } // ParamTypeInfo describes the GraphQL type of a URL route parameter. @@ -87,7 +114,7 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error } routesDir := filepath.Join(projectConfig.ProjectRoot, "src", "routes") - apiDir := filepath.Join(projectConfig.ProjectRoot, "src", "api") + serverDir := filepath.Join(projectConfig.ProjectRoot, "src", "server") manifest := ProjectManifest{ Pages: map[string]PageManifest{}, @@ -98,8 +125,17 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error ComponentFields: map[string]ComponentFieldInfo{}, } - // Load all route GQL documents from the database. - pageDocByDir, layoutDocByDir, err := p.loadRouteDocuments(ctx, projectConfig.ProjectRoot, routesDir) + // Load all route GQL documents from the database, plus mutation/subscription names. + var pageDocByDir map[string]routeDoc + var layoutDocByDir map[string]routeDoc + pageDocByDir, layoutDocByDir, manifest.Mutations, manifest.Subscriptions, manifest.FormActions, err = p.loadRouteDocuments(ctx, projectConfig.ProjectRoot, routesDir) + if err != nil { + return ProjectManifest{}, err + } + + // @session mutations (name → sessionPath) — independent of the route documents above, since a + // session-establishing mutation need not be a form. + manifest.SessionMutations, err = p.loadSessionMutations(ctx) if err != nil { return ProjectManifest{}, err } @@ -202,6 +238,8 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error Layouts: clone(state.availableLayouts), Path: relPath, Params: buildParams(url, newVariables), + SearchParams: buildSearchParams(url, newVariables), + Headers: info.layoutHeaders, } newLayoutIDs = append(newLayoutIDs, id) } @@ -250,6 +288,8 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error Path: relPath, ErrorPath: errorPath, Params: buildParams(url, allVars), + SearchParams: buildSearchParams(url, allVars), + Headers: info.pageHeaders, } } @@ -278,7 +318,7 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error } } - manifest.LocalSchema, manifest.LocalYoga, err = p.detectLocalAPI(apiDir) + manifest.LocalSchema, manifest.LocalYoga, manifest.LocalConfig, err = p.detectLocalServer(serverDir) if err != nil { return ProjectManifest{}, err } @@ -286,13 +326,41 @@ func (p *HoudiniReact) LoadManifest(ctx context.Context) (ProjectManifest, error return manifest, nil } +// loadSessionMutations returns every @session mutation keyed to where (path) and how (merge) +// it writes the session. +func (p *HoudiniReact) loadSessionMutations(ctx context.Context) (map[string]SessionMutationInfo, error) { + // allocated lazily so it stays nil (not an empty map) when no mutation carries @session, + // matching how FormActions is left nil when empty + var result map[string]SessionMutationInfo + err := p.DB.StepQuery(ctx, ` + SELECT d.name, av_path.raw, av_merge.raw + FROM documents d + JOIN document_directives dd ON dd.document = d.id AND dd.directive = $session_directive + LEFT JOIN document_directive_arguments dda_path ON dda_path.parent = dd.id AND dda_path.name = 'path' + LEFT JOIN argument_values av_path ON av_path.id = dda_path.value + LEFT JOIN document_directive_arguments dda_merge ON dda_merge.parent = dd.id AND dda_merge.name = 'merge' + LEFT JOIN argument_values av_merge ON av_merge.id = dda_merge.value + WHERE d.kind = 'mutation' + `, map[string]any{"session_directive": graphql.SessionDirective}, func(q plugins.Row) { + name := q.ColumnText(0) + path := q.ColumnText(1) + if name != "" && path != "" { + if result == nil { + result = map[string]SessionMutationInfo{} + } + result[name] = SessionMutationInfo{Path: path, Merge: q.ColumnText(2) == "true"} + } + }) + return result, err +} + // loadRouteDocuments queries the database for all +page.gql and +layout.gql documents -// and their variables in a single trip, keyed by directory path relative to routesDir. +// and their variables, plus all mutation and subscription names, in a single trip. func (p *HoudiniReact) loadRouteDocuments( ctx context.Context, projectRoot string, routesDir string, -) (pageDocByDir map[string]routeDoc, layoutDocByDir map[string]routeDoc, err error) { +) (pageDocByDir map[string]routeDoc, layoutDocByDir map[string]routeDoc, mutations []string, subscriptions []string, endpointMutations []string, err error) { pageDocByDir = map[string]routeDoc{} layoutDocByDir = map[string]routeDoc{} @@ -305,32 +373,59 @@ func (p *HoudiniReact) loadRouteDocuments( docsByID := map[int64]*docEntry{} err = p.DB.StepQuery(ctx, ` - SELECT d.id, d.name, rd.filepath, + SELECT d.id, d.name, d.kind, rd.filepath, CASE WHEN EXISTS( SELECT 1 FROM document_directives dd - WHERE dd.document = d.id AND dd.directive = 'loading' + WHERE dd.document = d.id AND dd.directive = $loading_directive ) THEN 1 ELSE 0 END AS loading, dv.name, dv.type, - COALESCE(dv.type_modifiers, '') + COALESCE(dv.type_modifiers, ''), + CASE WHEN EXISTS( + SELECT 1 FROM document_directives dd + WHERE dd.document = d.id AND dd.directive = $endpoint_directive + ) THEN 1 ELSE 0 END AS has_endpoint FROM documents d JOIN raw_documents rd ON d.raw_document = rd.id LEFT JOIN document_variables dv ON dv.document = d.id - WHERE d.kind = 'query' - AND (rd.filepath LIKE '%+page.gql' OR rd.filepath LIKE '%+layout.gql') + WHERE d.kind IN ('query', 'mutation', 'subscription') + AND (d.kind != 'query' OR rd.filepath LIKE '%+page.gql' OR rd.filepath LIKE '%+layout.gql') ORDER BY d.id - `, nil, func(q plugins.Row) { + `, map[string]any{ + "loading_directive": graphql.LoadingDirective, + "endpoint_directive": graphql.EndpointDirective, + }, func(q plugins.Row) { id := q.ColumnInt64(0) + name := q.ColumnText(1) + kind := q.ColumnText(2) + + if kind == "mutation" || kind == "subscription" { + if _, seen := docsByID[id]; !seen { + docsByID[id] = nil // mark as seen so we don't double-append + if kind == "mutation" { + mutations = append(mutations, name) + // mutations carrying @endpoint are form-submittable; the no-JS form + // handler needs their artifacts reachable on the server. + if q.ColumnInt(8) == 1 { + endpointMutations = append(endpointMutations, name) + } + } else { + subscriptions = append(subscriptions, name) + } + } + return + } + entry, ok := docsByID[id] if !ok { - fp := q.ColumnText(2) + fp := q.ColumnText(3) fullPath := filepath.Join(projectRoot, fp) dirKey, _ := filepath.Rel(routesDir, filepath.Dir(fullPath)) entry = &docEntry{ doc: routeDoc{ - name: q.ColumnText(1), + name: name, filepath: fp, - loading: q.ColumnInt(3) == 1, + loading: q.ColumnInt(4) == 1, variables: map[string]VariableTypeInfo{}, }, isPage: strings.HasSuffix(fp, "+page.gql"), @@ -338,19 +433,22 @@ func (p *HoudiniReact) loadRouteDocuments( } docsByID[id] = entry } - // columns 4-6 are NULL when there are no variables (LEFT JOIN) - if varName := q.ColumnText(4); varName != "" { + // columns 5-7 are NULL when there are no variables (LEFT JOIN) + if varName := q.ColumnText(5); varName != "" { entry.doc.variables[varName] = VariableTypeInfo{ - Type: q.ColumnText(5), - Wrappers: modifiersToWrappers(q.ColumnText(6)), + Type: q.ColumnText(6), + Wrappers: modifiersToWrappers(q.ColumnText(7)), } } }) if err != nil { - return nil, nil, err + return nil, nil, nil, nil, nil, err } for _, entry := range docsByID { + if entry == nil { + continue // mutation/subscription sentinel + } if entry.isPage { pageDocByDir[entry.dirKey] = entry.doc } else { @@ -358,13 +456,18 @@ func (p *HoudiniReact) loadRouteDocuments( } } - return pageDocByDir, layoutDocByDir, nil + sort.Strings(mutations) + sort.Strings(subscriptions) + sort.Strings(endpointMutations) + return pageDocByDir, layoutDocByDir, mutations, subscriptions, endpointMutations, nil } type viewInfo struct { pageViewPath string // absolute path to +page.tsx or +page.jsx, empty if absent layoutViewPath string // absolute path to +layout.tsx or +layout.jsx, empty if absent errorViewPath string // absolute path to +error.tsx or +error.jsx, empty if absent + pageHeaders bool // +page view exports a headers() function + layoutHeaders bool // +layout view exports a headers() function } // discoverViewFiles uses the parallel glob walker to find all +page and +layout view @@ -398,12 +501,24 @@ func (p *HoudiniReact) discoverViewFiles(ctx context.Context, routesDir string) absPath := filepath.Join(routesDir, relPath) base := filepath.Base(relPath) + // +page and +layout views may export a headers() function whose result is + // merged into the HTTP response. Detect it statically so the manifest only + // references modules that actually contribute headers. + hasHeaders := false + if strings.HasPrefix(base, "+page") || strings.HasPrefix(base, "+layout") { + if content, err := afero.ReadFile(p.Filesystem(), absPath); err == nil { + hasHeaders = fileExportsHeaders(string(content)) + } + } + mu.Lock() info := views[dir] if strings.HasPrefix(base, "+page") { info.pageViewPath = absPath + info.pageHeaders = hasHeaders } else if strings.HasPrefix(base, "+layout") { info.layoutViewPath = absPath + info.layoutHeaders = hasHeaders } else { info.errorViewPath = absPath } @@ -414,6 +529,35 @@ func (p *HoudiniReact) discoverViewFiles(ctx context.Context, routesDir string) return views, err } +var ( + headerFuncRe = regexp.MustCompile(`(?m)^\s*export\s+(?:async\s+)?function\s+headers\b`) + headerDeclRe = regexp.MustCompile(`(?m)^\s*export\s+(?:const|let|var)\s+headers\b`) + headerListRe = regexp.MustCompile(`(?ms)\bexport\s*\{([^}]*)\}`) +) + +// fileExportsHeaders reports whether a route view module exports a value named +// `headers`. It handles `export function headers`, `export const headers`, and +// named export lists (`export { headers }` / `export { foo as headers }`). +func fileExportsHeaders(content string) bool { + if headerFuncRe.MatchString(content) || headerDeclRe.MatchString(content) { + return true + } + for _, match := range headerListRe.FindAllStringSubmatch(content, -1) { + for _, clause := range strings.Split(match[1], ",") { + // The exported name is the alias after `as`, or the identifier itself. + fields := strings.Fields(clause) + if len(fields) == 0 { + continue + } + name := fields[len(fields)-1] + if name == "headers" { + return true + } + } + } + return false +} + // dirKeyToURL converts a routesDir-relative directory key (e.g. "(subRoute)/nested") // to its manifest URL (e.g. "/(subRoute)/nested/"). func dirKeyToURL(dirKey string) string { @@ -423,12 +567,12 @@ func dirKeyToURL(dirKey string) string { return "/" + filepath.ToSlash(dirKey) + "/" } -// detectLocalAPI checks src/api for +schema and +yoga files. -func (p *HoudiniReact) detectLocalAPI(apiDir string) (localSchema, localYoga bool, err error) { +// detectLocalServer checks src/server for +schema, +yoga, and +config files. +func (p *HoudiniReact) detectLocalServer(serverDir string) (localSchema, localYoga, localConfig bool, err error) { fs := p.Filesystem() - entries, err := afero.ReadDir(fs, apiDir) + entries, err := afero.ReadDir(fs, serverDir) if err != nil { - return false, false, nil // api dir doesn't exist — not an error + return false, false, false, nil // server dir doesn't exist — not an error } for _, entry := range entries { name := entry.Name() @@ -440,9 +584,11 @@ func (p *HoudiniReact) detectLocalAPI(apiDir string) (localSchema, localYoga boo localSchema = true case "+yoga": localYoga = true + case "+config": + localConfig = true } } - return localSchema, localYoga, nil + return localSchema, localYoga, localConfig, nil } // pageID converts a URL (like "/(subRoute)/nested/") to a manifest ID (like "__subRoute__nested"). @@ -481,7 +627,6 @@ func modifiersToWrappers(modifiers string) []string { return wrappers } - // routeRelPath returns the document filepath relative to routesDir for queries, // using forward slashes. func routeRelPath(dbFilepath, projectRoot, routesDir string) string { @@ -490,22 +635,70 @@ func routeRelPath(dbFilepath, projectRoot, routesDir string) string { return toSlash(rel) } -// buildParams extracts [param] segments from url and maps them to their types. +// routeSegmentParams extracts the param names declared by the dynamic segments of a +// route path (either a URL like "/shows/[id]" or a filepath under src/routes). It +// understands the supported segment forms — [id], [[optional]], and [...rest] — and +// normalizes each to the bare variable name. +// +// This is the single place the routing convention is decoded. Keeping it here means a +// new convention (or a different router) can be supported by changing one function +// rather than every consumer that needs to know which variables a route fills. +func routeSegmentParams(path string) []string { + var names []string + for _, part := range strings.Split(path, "/") { + if strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") { + name := strings.Trim(part, "[]") + name = strings.TrimPrefix(name, "...") + names = append(names, name) + } + } + return names +} + +// routeParamSet is routeSegmentParams as a lookup set. +func routeParamSet(path string) map[string]bool { + names := map[string]bool{} + for _, name := range routeSegmentParams(path) { + names[name] = true + } + return names +} + +// buildParams maps a route's dynamic segments to the types of the variables they fill. +// A segment with no matching variable maps to nil (an unconstrained param). func buildParams(url string, variables map[string]VariableTypeInfo) map[string]*ParamTypeInfo { params := map[string]*ParamTypeInfo{} - for _, part := range strings.Split(url, "/") { - if strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") { - name := part[1 : len(part)-1] - if info, ok := variables[name]; ok { - params[name] = &ParamTypeInfo{Type: info.Type, Wrappers: info.Wrappers} - } else { - params[name] = nil - } + for _, name := range routeSegmentParams(url) { + if info, ok := variables[name]; ok { + params[name] = &ParamTypeInfo{Type: info.Type, Wrappers: info.Wrappers} + } else { + params[name] = nil } } return params } +// buildSearchParams returns the variables in scope that can be supplied via +// URLSearchParams: every nullable variable that is not already consumed by a +// route segment. Required (NonNull) variables are excluded so that a missing +// search param can never produce a failing query (issue #1210). +func buildSearchParams(url string, variables map[string]VariableTypeInfo) map[string]*ParamTypeInfo { + routeNames := routeParamSet(url) + + searchParams := map[string]*ParamTypeInfo{} + for name, info := range variables { + if routeNames[name] { + continue + } + // a NonNull outer wrapper means the variable is required — skip it + if len(info.Wrappers) > 0 && info.Wrappers[0] == "NonNull" { + continue + } + searchParams[name] = &ParamTypeInfo{Type: info.Type, Wrappers: info.Wrappers} + } + return searchParams +} + func clone(s []string) []string { if s == nil { return []string{} diff --git a/packages/houdini-react/plugin/manifest_test.go b/packages/houdini-react/plugin/manifest_test.go index f2bc3c40ad..8fc2966d20 100644 --- a/packages/houdini-react/plugin/manifest_test.go +++ b/packages/houdini-react/plugin/manifest_test.go @@ -121,6 +121,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_", "__subRoute_"}, Path: "src/routes/(subRoute)/nested/+page.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, Layouts: map[string]plugin.PageManifest{ @@ -133,6 +134,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{}, Path: "src/routes/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, "__subRoute_": { ID: "__subRoute_", @@ -143,6 +145,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_"}, Path: "src/routes/(subRoute)/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, PageQueries: map[string]plugin.QueryManifest{ @@ -208,6 +211,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_"}, Path: "src/routes/+page.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, "_subRoute": { ID: "_subRoute", @@ -218,6 +222,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_", "_subRoute"}, Path: "src/routes/subRoute/+page.jsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, "_another": { ID: "_another", @@ -228,6 +233,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_", "_another"}, Path: "src/routes/another/+page.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, "_subRoute_nested": { ID: "_subRoute_nested", @@ -238,6 +244,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_", "_subRoute"}, Path: "src/routes/subRoute/nested/+page.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, Layouts: map[string]plugin.PageManifest{ @@ -250,6 +257,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{}, Path: "src/routes/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, "_subRoute": { ID: "_subRoute", @@ -260,6 +268,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_"}, Path: "src/routes/subRoute/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, "_another": { ID: "_another", @@ -270,6 +279,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{"_"}, Path: "src/routes/another/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, PageQueries: map[string]plugin.QueryManifest{ @@ -323,7 +333,7 @@ func TestLoadManifest(t *testing.T) { Pass: true, Extra: map[string]any{ "views": map[string]string{ - "src/api/+schema.js": "export default 'foo'", + "src/server/+schema.js": "export default 'foo'", }, "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{}, @@ -342,7 +352,7 @@ func TestLoadManifest(t *testing.T) { Pass: true, Extra: map[string]any{ "views": map[string]string{ - "src/api/+yoga.js": "export default 'foo'", + "src/server/+yoga.js": "export default 'foo'", }, "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{}, @@ -382,9 +392,10 @@ func TestLoadManifest(t *testing.T) { Params: map[string]*plugin.ParamTypeInfo{ "id": {Type: "ID", Wrappers: []string{"NonNull"}}, }, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, - Layouts: map[string]plugin.PageManifest{}, + Layouts: map[string]plugin.PageManifest{}, PageQueries: map[string]plugin.QueryManifest{}, LayoutQueries: map[string]plugin.QueryManifest{ "__id_": { @@ -405,15 +416,15 @@ func TestLoadManifest(t *testing.T) { }, }, { - Name: "+error.tsx sets ErrorPath on the page manifest", - Pass: true, - Input: []string{mockQuery("RootQuery", false)}, + Name: "+error.tsx sets ErrorPath on the page manifest", + Pass: true, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/+layout.gql"}, Extra: map[string]any{ "views": map[string]string{ - "src/routes/+layout.tsx": "export default ({children}) =>
      {children}
      ", - "src/routes/+page.tsx": mockView([]string{"RootQuery"}), - "src/routes/+error.tsx": "export default ({ errors }) =>
      {errors[0].message}
      ", + "src/routes/+layout.tsx": "export default ({children}) =>
      {children}
      ", + "src/routes/+page.tsx": mockView([]string{"RootQuery"}), + "src/routes/+error.tsx": "export default ({ errors }) =>
      {errors[0].message}
      ", }, "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{ @@ -427,6 +438,7 @@ func TestLoadManifest(t *testing.T) { Path: "src/routes/+page.tsx", ErrorPath: "src/routes/+error.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, Layouts: map[string]plugin.PageManifest{ @@ -439,6 +451,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{}, Path: "src/routes/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, PageQueries: map[string]plugin.QueryManifest{}, @@ -458,6 +471,64 @@ func TestLoadManifest(t *testing.T) { }, }, }, + { + Name: "headers() export sets Headers on page and layout manifests", + Pass: true, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+layout.tsx": "export function headers() { return { 'X-From': 'layout' } }\nexport default ({children}) =>
      {children}
      ", + "src/routes/+page.tsx": "export const headers = () => ({ 'X-From': 'page' })\nexport default () =>
      hello
      ", + "src/routes/plain/+page.tsx": mockView([]string{}), + }, + "expected": plugin.ProjectManifest{ + Pages: map[string]plugin.PageManifest{ + "_": { + ID: "_", + Queries: []string{}, + QueryOptions: []string{}, + LayoutQueries: []string{}, + URL: "/", + Layouts: []string{"_"}, + Path: "src/routes/+page.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, + Headers: true, + }, + "_plain": { + ID: "_plain", + Queries: []string{}, + QueryOptions: []string{}, + LayoutQueries: []string{}, + URL: "/plain", + Layouts: []string{"_"}, + Path: "src/routes/plain/+page.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, + }, + }, + Layouts: map[string]plugin.PageManifest{ + "_": { + ID: "_", + Queries: []string{}, + QueryOptions: []string{}, + LayoutQueries: []string{}, + URL: "/", + Layouts: []string{}, + Path: "src/routes/+layout.tsx", + Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, + Headers: true, + }, + }, + PageQueries: map[string]plugin.QueryManifest{}, + LayoutQueries: map[string]plugin.QueryManifest{}, + Artifacts: []string{}, + LocalSchema: false, + LocalYoga: false, + ComponentFields: map[string]plugin.ComponentFieldInfo{}, + }, + }, + }, { Name: "+error.tsx propagates to child pages when no sibling +page.tsx", Pass: true, @@ -471,9 +542,9 @@ func TestLoadManifest(t *testing.T) { }, Extra: map[string]any{ "views": map[string]string{ - "src/routes/+layout.tsx": "export default ({children}) =>
      {children}
      ", - "src/routes/+error.tsx": "export default ({ errors }) =>
      {errors[0].message}
      ", - "src/routes/child/+page.tsx": mockView([]string{"ChildQuery"}), + "src/routes/+layout.tsx": "export default ({children}) =>
      {children}
      ", + "src/routes/+error.tsx": "export default ({ errors }) =>
      {errors[0].message}
      ", + "src/routes/child/+page.tsx": mockView([]string{"ChildQuery"}), }, "expected": plugin.ProjectManifest{ Pages: map[string]plugin.PageManifest{ @@ -487,6 +558,7 @@ func TestLoadManifest(t *testing.T) { Path: "src/routes/child/+page.tsx", ErrorPath: "src/routes/+error.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, Layouts: map[string]plugin.PageManifest{ @@ -499,6 +571,7 @@ func TestLoadManifest(t *testing.T) { Layouts: []string{}, Path: "src/routes/+layout.tsx", Params: map[string]*plugin.ParamTypeInfo{}, + SearchParams: map[string]*plugin.ParamTypeInfo{}, }, }, PageQueries: map[string]plugin.QueryManifest{ @@ -527,9 +600,9 @@ func TestLoadManifest(t *testing.T) { }, }, { - Name: "page queries must be defined in the same directory as the page view", - Pass: true, - Input: []string{mockQuery("RootQuery", false)}, + Name: "page queries must be defined in the same directory as the page view", + Pass: true, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/+page.gql"}, Extra: map[string]any{ "views": map[string]string{ @@ -538,9 +611,9 @@ func TestLoadManifest(t *testing.T) { }, }, { - Name: "page query defined in a different directory above the page view", - Pass: false, - Input: []string{mockQuery("RootQuery", false)}, + Name: "page query defined in a different directory above the page view", + Pass: false, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/+page.gql"}, Extra: map[string]any{ "views": map[string]string{ @@ -549,9 +622,9 @@ func TestLoadManifest(t *testing.T) { }, }, { - Name: "page query defined in a different directory below the page view", - Pass: false, - Input: []string{mockQuery("RootQuery", false)}, + Name: "page query defined in a different directory below the page view", + Pass: false, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/subRoute/subSubRoute/+page.gql"}, Extra: map[string]any{ "views": map[string]string{ @@ -560,9 +633,9 @@ func TestLoadManifest(t *testing.T) { }, }, { - Name: "queries defined in layouts work in local directory", - Pass: true, - Input: []string{mockQuery("RootQuery", false)}, + Name: "queries defined in layouts work in local directory", + Pass: true, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/subRoute/+layout.gql"}, Extra: map[string]any{ "views": map[string]string{ @@ -571,9 +644,9 @@ func TestLoadManifest(t *testing.T) { }, }, { - Name: "queries defined in layouts work in far child directory", - Pass: true, - Input: []string{mockQuery("RootQuery", false)}, + Name: "queries defined in layouts work in far child directory", + Pass: true, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/subRoute/+layout.gql"}, Extra: map[string]any{ "views": map[string]string{ @@ -584,9 +657,9 @@ func TestLoadManifest(t *testing.T) { { // Without TSX prop parsing we cannot detect that a view references a query // defined only in a descendant layout scope. This passes silently in Go. - Name: "queries defined in layouts do not work in parent directory", - Pass: true, - Input: []string{mockQuery("RootQuery", false)}, + Name: "queries defined in layouts do not work in parent directory", + Pass: true, + Input: []string{mockQuery("RootQuery", false)}, Filepaths: []string{"src/routes/subRoute/subSubRoute/+layout.gql"}, Extra: map[string]any{ "views": map[string]string{ diff --git a/packages/houdini-react/plugin/runtime.go b/packages/houdini-react/plugin/runtime.go index 6e3fd95d6e..4b9e0ea67e 100644 --- a/packages/houdini-react/plugin/runtime.go +++ b/packages/houdini-react/plugin/runtime.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "path/filepath" + "sort" "strings" "github.com/spf13/afero" plugins "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/graphql" ) // TransformRuntime patches static runtime files as they are copied into the plugin directory. @@ -42,6 +44,37 @@ func (p *HoudiniReact) TransformRuntime(ctx context.Context, fp string, content return fmt.Sprintf("import client from '%s'\nexport default () => client\n", filepath.ToSlash(relPath)), nil + + case "index.tsx": + // expose loginURL from the runtime barrel ONLY when a login flow is configured: a redirect + // integration (router_config.redirect) OR first-class OAuth providers (router_config.providers), + // both from src/server/+config. Without either there is no /login to start, so the helper stays + // out of the public surface. login.ts itself is always copied but not re-exported by default. + var redirectURL, providers string + _ = p.DB.StepQuery(ctx, `SELECT redirect, providers FROM router_config LIMIT 1`, nil, func(q plugins.Row) { + redirectURL = q.ColumnText(0) + providers = q.ColumnText(1) + }) + if providers != "" { + // first-class OAuth: emit a typed loginURL whose `provider` is the configured union, so + // loginURL({ provider: 'twitter' }) is a compile error against the real provider set. + names := strings.Split(providers, ",") + quoted := make([]string, len(names)) + for i, name := range names { + quoted[i] = "'" + name + "'" + } + content += fmt.Sprintf( + "\nimport { loginURL as _loginURL } from './login.js'\n"+ + "export function loginURL(opts: { provider: %s; redirectTo?: string }): string {\n"+ + "\treturn _loginURL({ redirectTo: opts.redirectTo, params: { provider: opts.provider } })\n"+ + "}\n", + strings.Join(quoted, " | "), + ) + } else if redirectURL != "" { + // escape hatch: the generic loginURL (the worker owns provider selection) + content += "\nexport { loginURL } from './login.js'\n" + } + return content, nil } return content, nil @@ -227,29 +260,149 @@ func (p *HoudiniReact) GenerateRuntime(ctx context.Context) ([]string, error) { manifestPath := filepath.Join(runtimeDir, "manifest.ts") existing, _ := afero.ReadFile(p.Filesystem(), manifestPath) - if string(existing) == content { - return []string{}, nil + if string(existing) != content { + if err := p.Filesystem().MkdirAll(runtimeDir, 0755); err != nil { + return nil, err + } + if err := plugins.WriteFile(p.Filesystem(), manifestPath, []byte(content), 0644); err != nil { + return nil, err + } + changed = append(changed, manifestPath) } - if err := p.Filesystem().MkdirAll(runtimeDir, 0755); err != nil { + mockContent, err := formatMockFile(manifest) + if err != nil { return nil, err } - if err := plugins.WriteFile(p.Filesystem(), manifestPath, []byte(content), 0644); err != nil { - return nil, err + + mockPath := filepath.Join(runtimeDir, "mock.ts") + existingMock, _ := afero.ReadFile(p.Filesystem(), mockPath) + if string(existingMock) != mockContent { + if err := plugins.WriteFile(p.Filesystem(), mockPath, []byte(mockContent), 0644); err != nil { + return nil, err + } + changed = append(changed, mockPath) } - return append(changed, manifestPath), nil + return changed, nil +} + +// formatMockFile generates the typed createMock function for all routes. +// +// Uses a single generic function with precomputed route param types so TypeScript +// gives "Property 'params' is missing in type ... but required in type { params: { id: string } }" +// with concrete, human-readable types rather than the verbose _ParamObj form. +func formatMockFile(manifest ProjectManifest) (string, error) { + var sb strings.Builder + + sb.WriteString("import React from 'react'\n") + sb.WriteString("import { _createMock, buildMockPath } from './testing'\n") + + // Per-route param/search typing is shared with and goto (defined in routes.ts, + // derived from the manifest) so createMock accepts exactly the params and search the + // route declares, with no duplicate rules generated here. + if len(manifest.Pages) > 0 { + sb.WriteString("import type { RouteHrefs, ParamsForRoute, SearchForRoute } from './routes'\n") + } + + // Collect unique query and mutation names across all pages. + // Both import $unmasked (fully-resolved server payload, fragments inlined, no masks) and $input. + allQueryNames := map[string]bool{} + for _, page := range manifest.Pages { + for _, q := range page.Queries { + allQueryNames[q] = true + } + } + hasMutations := len(manifest.Mutations) > 0 && len(manifest.Pages) > 0 + hasSubscriptions := len(manifest.Subscriptions) > 0 && len(manifest.Pages) > 0 + + if len(allQueryNames) > 0 || hasMutations || hasSubscriptions { + sb.WriteString("\n") + for _, name := range sortedKeys(allQueryNames) { + sb.WriteString(fmt.Sprintf( + "import type { %s$unmasked, %s$input } from '$houdini/artifacts/%s'\n", + name, name, name, + )) + } + if hasMutations { + for _, m := range manifest.Mutations { + if !allQueryNames[m] { + sb.WriteString(fmt.Sprintf( + "import type { %s$unmasked, %s$input } from '$houdini/artifacts/%s'\n", + m, m, m, + )) + } + } + } + if hasSubscriptions { + for _, s := range manifest.Subscriptions { + if !allQueryNames[s] { + sb.WriteString(fmt.Sprintf( + "import type { %s$unmasked, %s$input } from '$houdini/artifacts/%s'\n", + s, s, s, + )) + } + } + } + } + + sb.WriteString("\ntype _MockValue = R | ((vars: V) => R)\n\n") + + if len(manifest.Pages) == 0 { + // No routes yet — simple stub so the file is still importable. + sb.WriteString("export function createMock({ url, params = {}, search, data }: { url: string; params?: Record; search?: Record; data: Record }): React.ComponentType<{}> {\n") + sb.WriteString("\treturn _createMock({ path: buildMockPath(url, params, search), data })\n") + sb.WriteString("}\n") + return sb.String(), nil + } + + // Per-route mock data types. Required keys are the queries the route uses; mutations + // and subscriptions are optional keys. Mutation handlers get vars typed as $input; + // subscription handlers are AsyncIterables that yield $unmasked values. + for _, id := range sortedKeys(manifest.Pages) { + page := manifest.Pages[id] + sb.WriteString(fmt.Sprintf("type _TestData_%s = {\n", id)) + for _, q := range page.Queries { + sb.WriteString(fmt.Sprintf("\t%s: _MockValue<%s$unmasked, %s$input>\n", q, q, q)) + } + for _, m := range manifest.Mutations { + sb.WriteString(fmt.Sprintf("\t%s?: _MockValue<%s$unmasked, %s$input>\n", m, m, m)) + } + for _, s := range manifest.Subscriptions { + sb.WriteString(fmt.Sprintf("\t%s?: _MockValue, %s$input>\n", s, s, s)) + } + sb.WriteString("}\n\n") + } + + // _RouteData maps each URL literal to its per-route mock-data type. This is the only + // route→type map the mock owns; the param and search typing comes from the shared + // ParamsForRoute / SearchForRoute imported above. + sb.WriteString("type _RouteData = {\n") + for _, id := range sortedKeys(manifest.Pages) { + page := manifest.Pages[id] + cleanURL := stripRouteGroups(page.URL) + sb.WriteString(fmt.Sprintf("\t%q: _TestData_%s\n", cleanURL, id)) + } + sb.WriteString("}\n") + sb.WriteString("type _DataForRoute = H extends keyof _RouteData ? _RouteData[H] : never\n\n") + + sb.WriteString("export function createMock(args: { url: H; data: _DataForRoute } & ParamsForRoute & SearchForRoute): React.ComponentType<{}> {\n") + sb.WriteString("\treturn _createMock({ path: buildMockPath(args.url as string, (args as any).params ?? {}, (args as any).search), data: args.data as Record })\n") + sb.WriteString("}\n") + + return sb.String(), nil } // hookSpec describes how to inject per-document overloads into one hook file. type hookSpec struct { - file string // filename within the hooks/ directory - kind string // "query", "mutation", "subscription", or "fragment" - marker string // text immediately before which overloads are inserted - preamble string // extra import line to prepend (empty if not needed) - // paginationQuery is the name of the pagination query document for paginated fragments, or "" - imports func(name string, paginationQuery string) string - overloads func(name string, paginationQuery string) string + file string // filename within the hooks/ directory + kind string // "query", "mutation", "subscription", or "fragment" + marker string // text immediately before which overloads are inserted + preamble string // extra import line to prepend (empty if not needed) + // paginationQuery is the name of the pagination query document for paginated fragments, or ""; + // plural is true when the document is a @plural fragment. + imports func(name string, paginationQuery string, plural bool) string + overloads func(name string, paginationQuery string, plural bool) string passthrough string // generic overload inserted last, bridges concrete overloads to the implementation } @@ -261,10 +414,10 @@ var hookSpecs = []hookSpec{ file: "useQuery.ts", kind: "query", marker: "export function useQuery<", - imports: func(name string, _ string) string { + imports: func(name string, _ string, _ bool) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string, _ string) string { + overloads: func(name string, _ string, _ bool) string { return fmt.Sprintf( "export function useQuery(document: { artifact: %s$artifact }, variables?: %s$input, config?: UseQueryConfig): %s$result\n", name, name, name, @@ -276,10 +429,10 @@ var hookSpecs = []hookSpec{ file: "useQueryHandle.ts", kind: "query", marker: "export function useQueryHandle<", - imports: func(name string, _ string) string { + imports: func(name string, _ string, _ bool) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string, _ string) string { + overloads: func(name string, _ string, _ bool) string { return fmt.Sprintf( "export function useQueryHandle(document: { artifact: %s$artifact }, variables?: %s$input, config?: UseQueryConfig): DocumentHandle<%s$artifact, %s$result, GraphQLVariables>\n", name, name, name, name, @@ -291,10 +444,20 @@ var hookSpecs = []hookSpec{ file: "useFragment.ts", kind: "fragment", marker: "export function useFragment<", - imports: func(name string, _ string) string { + imports: func(name string, _ string, _ bool) string { return fmt.Sprintf("import type { %s$data, %s$artifact } from '$houdini/artifacts/%s'\n", name, name, name) }, - overloads: func(name string, _ string) string { + overloads: func(name string, _ string, plural bool) string { + // @plural fragments are spread on a list field, so they take a list of + // references and return a list of data + if plural { + return fmt.Sprintf( + "export function useFragment(reference: ReadonlyArray<{ readonly %q: { %s: any } }>, document: { artifact: %s$artifact }): %s$data[]\n"+ + "export function useFragment(reference: ReadonlyArray<{ readonly %q: { %s: any } }> | null, document: { artifact: %s$artifact }): %s$data[] | null\n", + fragmentKeyLiteral, name, name, name, + fragmentKeyLiteral, name, name, name, + ) + } return fmt.Sprintf( "export function useFragment(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact }): %s$data\n"+ "export function useFragment(reference: { readonly %q: { %s: any } } | null, document: { artifact: %s$artifact }): %s$data | null\n", @@ -309,7 +472,7 @@ var hookSpecs = []hookSpec{ kind: "fragment", marker: "export function useFragmentHandle<", // For paginated fragments, import the pagination query artifact too. - imports: func(name string, paginationQuery string) string { + imports: func(name string, paginationQuery string, _ bool) string { base := fmt.Sprintf("import type { %s$data, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) if paginationQuery != "" { base += fmt.Sprintf("import type { %s$artifact } from '$houdini/artifacts/%s'\n", paginationQuery, paginationQuery) @@ -319,7 +482,7 @@ var hookSpecs = []hookSpec{ // For paginated fragments, return DocumentHandle typed with the pagination query artifact // so TypeScript exposes loadNext/loadPrevious/pageInfo on the returned handle. // For non-paginated fragments, fall back to DocumentHandle. - overloads: func(name string, paginationQuery string) string { + overloads: func(name string, paginationQuery string, _ bool) string { if paginationQuery != "" { return fmt.Sprintf( "export function useFragmentHandle(reference: { readonly %q: { %s: any } }, document: { artifact: %s$artifact; refetchArtifact?: %s$artifact }): DocumentHandle<%s$artifact, %s$data, %s$input>\n"+ @@ -341,10 +504,10 @@ var hookSpecs = []hookSpec{ file: "useMutation.ts", kind: "mutation", marker: "export function useMutation<", - imports: func(name string, _ string) string { + imports: func(name string, _ string, _ bool) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input, %s$optimistic } from '$houdini/artifacts/%s'\n", name, name, name, name, name) }, - overloads: func(name string, _ string) string { + overloads: func(name string, _ string, _ bool) string { return fmt.Sprintf( "export function useMutation(document: { artifact: %s$artifact }): [MutationHandler<%s$result, %s$input, %s$optimistic>, boolean]\n", name, name, name, name, @@ -352,14 +515,29 @@ var hookSpecs = []hookSpec{ }, passthrough: "export function useMutation<_Result extends GraphQLObject, _Input extends GraphQLVariables, _Optimistic extends GraphQLObject>(document: { artifact: MutationArtifact }): [MutationHandler<_Result, _Input, _Optimistic>, boolean]", }, + { + file: "useMutationForm.tsx", + kind: "mutation", + marker: "export function useMutationForm<", + imports: func(name string, _ string, _ bool) string { + return fmt.Sprintf("import type { %s$result, %s$artifact } from '$houdini/artifacts/%s'\n", name, name, name) + }, + overloads: func(name string, _ string, _ bool) string { + return fmt.Sprintf( + "export function useMutationForm(document: { artifact: %s$artifact }, opts?: UseMutationFormOptions<%s$result>): MutationForm<%s$result>\n", + name, name, name, + ) + }, + passthrough: "export function useMutationForm<_Result extends GraphQLObject, _Input extends GraphQLVariables>(document: { artifact: MutationArtifact }, opts?: UseMutationFormOptions<_Result>): MutationForm<_Result>", + }, { file: "useSubscription.ts", kind: "subscription", marker: "export function useSubscription<", - imports: func(name string, _ string) string { + imports: func(name string, _ string, _ bool) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string, _ string) string { + overloads: func(name string, _ string, _ bool) string { return fmt.Sprintf( "export function useSubscription(document: { artifact: %s$artifact }, variables?: %s$input): %s$result\n", name, name, name, @@ -371,10 +549,10 @@ var hookSpecs = []hookSpec{ file: "useSubscriptionHandle.ts", kind: "subscription", marker: "export function useSubscriptionHandle<", - imports: func(name string, _ string) string { + imports: func(name string, _ string, _ bool) string { return fmt.Sprintf("import type { %s$result, %s$artifact, %s$input } from '$houdini/artifacts/%s'\n", name, name, name, name) }, - overloads: func(name string, _ string) string { + overloads: func(name string, _ string, _ bool) string { return fmt.Sprintf( "export function useSubscriptionHandle(document: { artifact: %s$artifact }, variables?: %s$input): SubscriptionHandle<%s$result, %s$input>\n", name, name, name, name, @@ -449,34 +627,57 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) { hooksDir := filepath.Join(projectConfig.PluginRuntimeDirectory(p.Name()), "hooks") - // Load all visible documents grouped by kind in a single DB trip. + // Load all visible documents grouped by kind in a single DB trip, also noting which + // fragments are marked @plural (so useFragment overloads can take/return a list). docsByKind := map[string][]string{} // kind → []name + pluralFragments := map[string]bool{} err = p.DB.StepQuery(ctx, ` - SELECT d.name, d.kind + SELECT d.name, d.kind, dd.document IS NOT NULL AS plural FROM documents d + LEFT JOIN document_directives dd ON dd.document = d.id AND dd.directive = 'plural' WHERE d.visible = 1 ORDER BY d.name ASC `, nil, func(q plugins.Row) { - docsByKind[q.ColumnText(1)] = append(docsByKind[q.ColumnText(1)], q.ColumnText(0)) + name := q.ColumnText(0) + docsByKind[q.ColumnText(1)] = append(docsByKind[q.ColumnText(1)], name) + if q.ColumnInt(2) == 1 { + pluralFragments[name] = true + } }) if err != nil { return nil, err } - // Build the set of visible fragments that are paginated. We detect pagination - // via discovered_lists (populated during Validate) rather than looking for - // a pre-existing _Pagination_Query document, because GenerateRuntime runs - // concurrently with GenerateDocuments and the document may not exist yet. - paginatedFragments := map[string]string{} + // Build the set of visible fragments that get an embedded refetch query, so + // useFragmentHandle is wired up with the generated query as its refetchArtifact. + // This covers both @paginate fragments (detected via discovered_lists, populated + // during Validate) and @refetchable fragments (detected via the directive). We + // don't look for a pre-existing _Pagination_Query document because GenerateRuntime + // runs concurrently with GenerateDocuments and it may not exist yet. + refetchableFragments := map[string]string{} err = p.DB.StepQuery(ctx, ` - SELECT DISTINCT d.name + SELECT DISTINCT d.name, 0 AS refetchable FROM documents d JOIN discovered_lists dl ON dl.document = d.id WHERE d.visible = 1 AND d.kind = 'fragment' AND dl.paginate IS NOT NULL + + UNION + + SELECT DISTINCT d.name, 1 AS refetchable + FROM documents d + JOIN document_directives dd ON dd.document = d.id + WHERE d.visible = 1 AND d.kind = 'fragment' + AND dd.directive = 'refetchable' `, nil, func(q plugins.Row) { name := q.ColumnText(0) - paginatedFragments[name] = name + "_Pagination_Query" + // @paginate fragments embed a _Pagination_Query; @refetchable fragments + // embed a _Refetch_Query. both are wired up as the refetchArtifact. + if q.ColumnInt(1) == 1 { + refetchableFragments[name] = graphql.FragmentRefetchQueryName(name) + } else { + refetchableFragments[name] = graphql.FragmentPaginationQueryName(name) + } }) if err != nil { return nil, err @@ -514,13 +715,13 @@ func (p *HoudiniReact) UpdateHookFiles(ctx context.Context) ([]string, error) { top.WriteString("\n") } for _, name := range names { - top.WriteString(spec.imports(name, paginatedFragments[name])) + top.WriteString(spec.imports(name, refetchableFragments[name], pluralFragments[name])) } top.WriteString("\n") var before strings.Builder for _, name := range names { - before.WriteString(spec.overloads(name, paginatedFragments[name])) + before.WriteString(spec.overloads(name, refetchableFragments[name], pluralFragments[name])) } if spec.passthrough != "" { before.WriteString(spec.passthrough + "\n") @@ -559,6 +760,13 @@ func formatManifest( sb.WriteString("export default {\n") sb.WriteString("\tpages: {\n") + // Accumulate the ordered headers() loaders per page. These are emitted as a + // separate `route_headers` export (below) rather than nested in the manifest + // so that the client bundle, which only imports the default manifest, never + // references the source modules' headers() exports — letting dead-code + // elimination strip them from the client build. + headerLoadersByID := map[string][]string{} + for _, id := range sortedKeys(manifest.Pages) { page := manifest.Pages[id] @@ -582,6 +790,7 @@ func formatManifest( sb.WriteString(fmt.Sprintf("\t\t\turl: %q,\n", cleanURL)) sb.WriteString(fmt.Sprintf("\t\t\tpattern: %s,\n", pattern)) sb.WriteString(fmt.Sprintf("\t\t\tparams: %s,\n", formatParams(params, page.Params))) + sb.WriteString(fmt.Sprintf("\t\t\tsearchParams: %s,\n", formatSearchParams(page.SearchParams))) // Documents block. sb.WriteString("\t\t\tdocuments: {\n") @@ -612,22 +821,121 @@ func formatManifest( sb.WriteString(fmt.Sprintf("\t\t\tcomponent: () => import(%q),\n", filepath.ToSlash(componentRel))) sb.WriteString("\t\t},\n") + + // Collect the ordered headers() loaders for every segment in the layout + // chain (outermost first) and then the page itself. The server calls them + // in order and merges the results so the page wins over layouts and inner + // layouts win over outer ones. + var headerSources []string + for _, layoutID := range page.Layouts { + if layout, ok := manifest.Layouts[layoutID]; ok && layout.Headers { + headerSources = append(headerSources, layout.Path) + } + } + if page.Headers { + headerSources = append(headerSources, page.Path) + } + var loaders []string + for _, src := range headerSources { + srcAbs := stripViewExt(filepath.Join(projectRoot, src)) + srcRel, err := filepath.Rel(runtimeDir, srcAbs) + if err != nil { + return "", err + } + loaders = append(loaders, fmt.Sprintf("() => import(%q).then(m => m.headers)", filepath.ToSlash(srcRel))) + } + if len(loaders) > 0 { + headerLoadersByID[id] = loaders + } } sb.WriteString("\t},\n") + + // pagesByUrl maps each route's url to its page id so and goto can resolve a + // destination to its page in O(1), without scanning the manifest at runtime. + sb.WriteString("\tpagesByUrl: {\n") + for _, id := range sortedKeys(manifest.Pages) { + sb.WriteString(fmt.Sprintf("\t\t%q: %q,\n", stripRouteGroups(manifest.Pages[id].URL), id)) + } + sb.WriteString("\t},\n") + sb.WriteString("} as const satisfies RouterManifest\n") - // Export a name→TS-type map for custom scalars so Link.tsx can resolve - // _TSType<"DateTime"> → Date without any per-project codegen in the jsx file. + // route_headers is a server-only export: it maps a page id to the ordered + // list of headers() loaders for that page and its layout chain. It is kept + // out of the default manifest so the client build can tree-shake it away. + if len(headerLoadersByID) > 0 { + sb.WriteString("\nexport const route_headers = {\n") + for _, id := range sortedKeys(headerLoadersByID) { + sb.WriteString(fmt.Sprintf("\t%q: [\n", id)) + for _, loader := range headerLoadersByID[id] { + sb.WriteString(fmt.Sprintf("\t\t%s,\n", loader)) + } + sb.WriteString("\t],\n") + } + sb.WriteString("}\n") + } + + // form_actions is a server-only export: lazy literal-import thunks for the artifacts of + // mutations carrying @endpoint, keyed by mutation name. The no-JS form handler looks a + // submitted form's mutation up here. Kept out of the default manifest so the client + // build tree-shakes the mutation artifacts away. + if len(manifest.FormActions) > 0 { + sb.WriteString("\nexport const form_actions = {\n") + for _, name := range manifest.FormActions { + artifactAbs := filepath.Join(artifactDir, name) + artifactRel, err := filepath.Rel(runtimeDir, artifactAbs) + if err != nil { + return "", err + } + sb.WriteString(fmt.Sprintf("\t%s: () => import(%q),\n", name, filepath.ToSlash(artifactRel))) + } + sb.WriteString("}\n") + } + + // session_mutations maps each @session mutation to where (sessionPath) and how (merge) it + // writes the session. Server-only: the session-mint plugin and the no-JS form handler use it. + // Sorted for stable output, independent of form_actions. + if len(manifest.SessionMutations) > 0 { + sessionNames := make([]string, 0, len(manifest.SessionMutations)) + for name := range manifest.SessionMutations { + sessionNames = append(sessionNames, name) + } + sort.Strings(sessionNames) + sb.WriteString("\nexport const session_mutations = {\n") + for _, name := range sessionNames { + info := manifest.SessionMutations[name] + sb.WriteString(fmt.Sprintf("\t%s: { sessionPath: %q, merge: %v },\n", name, info.Path, info.Merge)) + } + sb.WriteString("}\n") + } + + // Export a name→TS-type map for custom scalars, plus the _TSType resolver that maps + // a GQL scalar name to its TS type. Both Link.tsx and the generated mock import + // _TSType from here so the resolution lives in exactly one place. sb.WriteString("\nexport type RouteScalars = {\n") for _, name := range sortedKeys(scalars) { sb.WriteString(fmt.Sprintf("\t%s: %s\n", name, scalars[name].Type)) } sb.WriteString("}\n") + sb.WriteString(tsTypeResolver) return sb.String(), nil } +// tsTypeResolver is the shared _TSType definition emitted into manifest.ts: custom +// scalars come from RouteScalars, built-ins map to their JS types, everything else is a +// string. Link.tsx and the mock file both import it rather than redefining it. +const tsTypeResolver = "\nexport type _TSType = T extends keyof RouteScalars\n" + + "\t? RouteScalars[T]\n" + + "\t: T extends 'Int' | 'Float'\n" + + "\t\t? number\n" + + "\t\t: T extends 'ID'\n" + + "\t\t\t? string | number\n" + + "\t\t\t: T extends 'Boolean'\n" + + "\t\t\t\t? boolean\n" + + "\t\t\t\t: string\n" + // parsePagePattern converts a page URL (e.g. "/(group)/[id]/nested") into a // TypeScript regex literal and a params array. Route groups like (foo) are // stripped since they don't affect the URL. @@ -669,7 +977,6 @@ func parsePagePattern(url string) (pattern string, params []routeParam, err erro type routeParam struct { Name string - Matcher string Optional bool Rest bool Chained bool @@ -713,10 +1020,6 @@ func formatParams(params []routeParam, pageParams map[string]*ParamTypeInfo) str } var parts []string for _, p := range params { - matcher := "" - if p.Matcher != "" { - matcher = p.Matcher - } // Emit the GQL type name so the manifest-driven _TSType utility can resolve // it against RouteScalars (custom scalars) and built-in GQL scalar names. gqlType := "String" @@ -724,8 +1027,39 @@ func formatParams(params []routeParam, pageParams map[string]*ParamTypeInfo) str gqlType = info.Type } parts = append(parts, fmt.Sprintf( - `{ name: %q, matcher: %q, optional: %v, rest: %v, chained: %v, type: %q }`, - p.Name, matcher, p.Optional, p.Rest, p.Chained, gqlType, + `{ name: %q, optional: %v, rest: %v, chained: %v, type: %q }`, + p.Name, p.Optional, p.Rest, p.Chained, gqlType, + )) + } + return "[\n\t\t\t\t" + strings.Join(parts, ",\n\t\t\t\t") + "\n\t\t\t]" +} + +// formatSearchParams renders the page's searchParams as a TypeScript array literal. +// Each entry carries the GQL type name (resolved against RouteScalars / built-in +// scalars by _TSType) and the wrapper chain so list-typed params can be +// serialized as repeated query keys. All search params are optional by construction. +func formatSearchParams(searchParams map[string]*ParamTypeInfo) string { + if len(searchParams) == 0 { + return "[]" + } + var parts []string + for _, name := range sortedKeys(searchParams) { + info := searchParams[name] + gqlType := "String" + wrappers := "[]" + if info != nil { + gqlType = info.Type + if len(info.Wrappers) > 0 { + quoted := make([]string, len(info.Wrappers)) + for i, w := range info.Wrappers { + quoted[i] = fmt.Sprintf("%q", w) + } + wrappers = "[" + strings.Join(quoted, ", ") + "]" + } + } + parts = append(parts, fmt.Sprintf( + `{ name: %q, type: %q, wrappers: %s }`, + name, gqlType, wrappers, )) } return "[\n\t\t\t\t" + strings.Join(parts, ",\n\t\t\t\t") + "\n\t\t\t]" @@ -926,7 +1260,6 @@ func stripRouteGroups(url string) string { return "/" + strings.Join(out, "/") } - // GenerateTsConfig writes .houdini/tsconfig.json by copying the template from the // plugin runtime directory (written there by IncludeRuntime). func (p *HoudiniReact) GenerateTsConfig(ctx context.Context) ([]string, error) { diff --git a/packages/houdini-react/plugin/runtime_test.go b/packages/houdini-react/plugin/runtime_test.go index a0fbc74348..4a584987cd 100644 --- a/packages/houdini-react/plugin/runtime_test.go +++ b/packages/houdini-react/plugin/runtime_test.go @@ -51,6 +51,77 @@ func TestTransformRuntime(t *testing.T) { }) } +// TestTransformRuntimeLoginURL verifies that the loginURL helper is re-exported from the runtime +// barrel (index.tsx) only when a redirect-login integration is configured (router_config.redirect, +// derived from src/server/+config auth.redirect.url). Without it the helper stays out of $houdini. +func TestTransformRuntimeLoginURL(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]) { + redirect, _ := test.Extra["redirect"].(string) + providers, _ := test.Extra["providers"].(string) + if redirect == "" && providers == "" { + return + } + ctx := context.Background() + conn, err := p.DB.Take(ctx) + require.NoError(t, err) + defer p.DB.Put(conn) + // session_keys is NOT NULL; redirect / providers are what gate the export + stmt, err := conn.Prepare( + `INSERT INTO router_config (redirect, providers, session_keys) VALUES ($r, $p, $s)`, + ) + require.NoError(t, err) + require.NoError( + t, + p.DB.ExecStatement(stmt, map[string]any{"r": redirect, "p": providers, "s": ""}), + ) + stmt.Finalize() + }, + + PerformTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + got, err := p.TransformRuntime(context.Background(), "index.tsx", test.Extra["input"].(string)) + require.NoError(t, err) + require.Equal(t, test.Extra["expected"].(string), got) + }, + + Tests: []tests.Test[coreConfig.PluginConfig]{ + { + Name: "re-exports loginURL when a redirect integration is configured", + Pass: true, + Extra: map[string]any{ + "redirect": "https://worker.example/login", + "input": "export * from './hooks'\n", + // .tsx files also receive the @refresh reset preamble + "expected": "// @refresh reset\nexport * from './hooks'\n\nexport { loginURL } from './login.js'\n", + }, + }, + { + Name: "emits a typed loginURL when first-class OAuth providers are configured", + Pass: true, + Extra: map[string]any{ + "providers": "github,google", + "input": "export * from './hooks'\n", + "expected": "// @refresh reset\nexport * from './hooks'\n" + + "\nimport { loginURL as _loginURL } from './login.js'\n" + + "export function loginURL(opts: { provider: 'github' | 'google'; redirectTo?: string }): string {\n" + + "\treturn _loginURL({ redirectTo: opts.redirectTo, params: { provider: opts.provider } })\n" + + "}\n", + }, + }, + { + Name: "omits loginURL when neither redirect nor providers is configured", + Pass: true, + Extra: map[string]any{ + "input": "export * from './hooks'\n", + "expected": "// @refresh reset\nexport * from './hooks'\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. @@ -129,8 +200,8 @@ func TestUpdateIndexFiles(t *testing.T) { }, }, { - Name: "missing index.ts skips gracefully", - Pass: true, + Name: "missing index.ts skips gracefully", + Pass: true, Input: []string{`query MyQuery { id }`}, Extra: map[string]any{ "no_stub": true, @@ -138,8 +209,8 @@ func TestUpdateIndexFiles(t *testing.T) { }, }, { - Name: "calling twice does not double-inject overloads", - Pass: true, + Name: "calling twice does not double-inject overloads", + Pass: true, Input: []string{`query MyQuery { id }`}, Extra: map[string]any{ "call_twice": true, @@ -290,6 +361,27 @@ func TestUpdateHookFiles(t *testing.T) { }, }, }, + { + Name: "injects array overloads for a @plural fragment", + Pass: true, + Input: []string{ + `fragment MyPluralFragment on Query @plural { id }`, + }, + Extra: map[string]any{ + "stubs": map[string]string{ + "useFragment.ts": "import { fragmentKey } from 'houdini/runtime'\nimport type { FragmentArtifact } from 'houdini/runtime'\n\nexport function useFragment<_A>(ref: any, doc: any): any {}\n", + }, + "expected": map[string]string{ + "useFragment.ts": "import type { MyPluralFragment$data, MyPluralFragment$artifact } from '$houdini/artifacts/MyPluralFragment'\n" + + "\n" + + "import { fragmentKey } from 'houdini/runtime'\nimport type { FragmentArtifact } from 'houdini/runtime'\n\n" + + "export function useFragment(reference: ReadonlyArray<{ readonly \" $fragments\": { MyPluralFragment: any } }>, document: { artifact: MyPluralFragment$artifact }): MyPluralFragment$data[]\n" + + "export function useFragment(reference: ReadonlyArray<{ readonly \" $fragments\": { MyPluralFragment: any } }> | null, document: { artifact: MyPluralFragment$artifact }): MyPluralFragment$data[] | null\n" + + "export function useFragment<_Data extends GraphQLObject, _ReferenceType extends {}, _Input extends GraphQLVariables>(reference: _Data | { \" $fragments\": _ReferenceType } | null, document: { artifact: FragmentArtifact }): _Data | null\n" + + "export function useFragment<_A>(ref: any, doc: any): any {}\n", + }, + }, + }, { Name: "injects useFragmentHandle overloads for non-paginated fragment", Pass: true, @@ -334,8 +426,8 @@ func TestUpdateHookFiles(t *testing.T) { }, }, { - Name: "skips files not present in plugin runtime dir", - Pass: true, + Name: "skips files not present in plugin runtime dir", + Pass: true, Input: []string{`query MyQuery { id }`}, Extra: map[string]any{ // no stubs written — files don't exist, should silently skip @@ -344,8 +436,8 @@ func TestUpdateHookFiles(t *testing.T) { }, }, { - Name: "calling twice does not double-inject", - Pass: true, + Name: "calling twice does not double-inject", + Pass: true, Input: []string{`query MyQuery { id }`}, Extra: map[string]any{ "call_twice_hooks": true, @@ -588,13 +680,33 @@ func TestInjectComponentFieldArtifactTypes(t *testing.T) { }) } +// tsTypeManifest is the shared _TSType resolver emitted into manifest.ts after the +// RouteScalars map (see tsTypeResolver in runtime.go). Begins with a blank line. +const tsTypeManifest = "\nexport type _TSType = T extends keyof RouteScalars\n" + + "\t? RouteScalars[T]\n" + + "\t: T extends 'Int' | 'Float'\n" + + "\t\t? number\n" + + "\t\t: T extends 'ID'\n" + + "\t\t\t? string | number\n" + + "\t\t\t: T extends 'Boolean'\n" + + "\t\t\t\t? boolean\n" + + "\t\t\t\t: string\n" + func TestGenerateRuntime(t *testing.T) { tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{ Schema: ` type Query { id: ID node(id: ID!): Node + search(q: String, tags: [String!], first: Int!): [Node!] } + type Subscription { + id: ID + } + type Mutation { + createUser(name: String!): User! + } + type User implements Node { id: ID! name: String! } interface Node { id: ID! } `, SetupAlwaysPasses: true, @@ -642,9 +754,42 @@ func TestGenerateRuntime(t *testing.T) { require.NoError(t, err) require.Equal(t, expected, string(got)) } + + // substring assertions for cases where a full-manifest golden would be brittle + if substrs, ok := test.Extra["containsManifest"].([]string); ok { + got, err := afero.ReadFile(p.Filesystem(), manifestPath) + require.NoError(t, err) + for _, substr := range substrs { + require.Contains(t, string(got), substr) + } + } + + mockPath := filepath.Join(config.PluginRuntimeDirectory(p.Name()), "mock.ts") + + if expectedMock, ok := test.Extra["expectedMock"].(string); ok { + require.Contains(t, changed, mockPath) + got, err := afero.ReadFile(p.Filesystem(), mockPath) + require.NoError(t, err) + require.Equal(t, expectedMock, string(got)) + } }, Tests: []tests.Test[coreConfig.PluginConfig]{ + { + Name: "@endpoint mutation generates a form_actions entry", + Pass: true, + Input: []string{ + `mutation CreateUser($name: String!) @endpoint(redirect: "/users/{ createUser.id }") { + createUser(name: $name) { id } + }`, + }, + Extra: map[string]any{ + "containsManifest": []string{ + "export const form_actions = {", + "CreateUser: () => import(", + }, + }, + }, { Name: "empty routes generates empty manifest", Pass: true, @@ -655,11 +800,19 @@ func TestGenerateRuntime(t *testing.T) { export default { pages: { }, + pagesByUrl: { + }, } as const satisfies RouterManifest export type RouteScalars = { } - `) + "\n", + `) + "\n" + tsTypeManifest, + "expectedMock": "import React from 'react'\n" + + "import { _createMock, buildMockPath } from './testing'\n" + + "\ntype _MockValue = R | ((vars: V) => R)\n\n" + + "export function createMock({ url, params = {}, search, data }: { url: string; params?: Record; search?: Record; data: Record }): React.ComponentType<{}> {\n" + + "\treturn _createMock({ path: buildMockPath(url, params, search), data })\n" + + "}\n", }, }, { @@ -683,6 +836,23 @@ func TestGenerateRuntime(t *testing.T) { // runtimeDir = /project/.houdini/plugins/houdini-react/runtime // artifacts = ../../../artifacts/ // component = ../units/entries/ (entry file, not source) + "expectedMock": "import React from 'react'\n" + + "import { _createMock, buildMockPath } from './testing'\n" + + "import type { RouteHrefs, ParamsForRoute, SearchForRoute } from './routes'\n" + + "\nimport type { FinalQuery$unmasked, FinalQuery$input } from '$houdini/artifacts/FinalQuery'\n" + + "import type { RootQuery$unmasked, RootQuery$input } from '$houdini/artifacts/RootQuery'\n" + + "\ntype _MockValue = R | ((vars: V) => R)\n\n" + + "type _TestData___subRoute__nested = {\n" + + "\tRootQuery: _MockValue\n" + + "\tFinalQuery: _MockValue\n" + + "}\n\n" + + "type _RouteData = {\n" + + "\t\"/nested\": _TestData___subRoute__nested\n" + + "}\n" + + "type _DataForRoute = H extends keyof _RouteData ? _RouteData[H] : never\n\n" + + "export function createMock(args: { url: H; data: _DataForRoute } & ParamsForRoute & SearchForRoute): React.ComponentType<{}> {\n" + + "\treturn _createMock({ path: buildMockPath(args.url as string, (args as any).params ?? {}, (args as any).search), data: args.data as Record })\n" + + "}\n", "expected": tests.Dedent(` import type { RouterManifest } from 'houdini/runtime' @@ -693,6 +863,7 @@ func TestGenerateRuntime(t *testing.T) { url: "/nested", pattern: /^\/nested\/?$/, params: [], + searchParams: [], documents: { RootQuery: { artifact: () => import("../../../artifacts/RootQuery"), @@ -708,11 +879,14 @@ func TestGenerateRuntime(t *testing.T) { component: () => import("../units/entries/__subRoute__nested"), }, }, + pagesByUrl: { + "/nested": "__subRoute__nested", + }, } as const satisfies RouterManifest export type RouteScalars = { } - `) + "\n", + `) + "\n" + tsTypeManifest, }, }, { @@ -728,6 +902,21 @@ func TestGenerateRuntime(t *testing.T) { "views": map[string]string{ "src/routes/[id]/+page.tsx": mockView([]string{"MyQuery"}), }, + "expectedMock": "import React from 'react'\n" + + "import { _createMock, buildMockPath } from './testing'\n" + + "import type { RouteHrefs, ParamsForRoute, SearchForRoute } from './routes'\n" + + "\nimport type { MyQuery$unmasked, MyQuery$input } from '$houdini/artifacts/MyQuery'\n" + + "\ntype _MockValue = R | ((vars: V) => R)\n\n" + + "type _TestData___id_ = {\n" + + "\tMyQuery: _MockValue\n" + + "}\n\n" + + "type _RouteData = {\n" + + "\t\"/[id]\": _TestData___id_\n" + + "}\n" + + "type _DataForRoute = H extends keyof _RouteData ? _RouteData[H] : never\n\n" + + "export function createMock(args: { url: H; data: _DataForRoute } & ParamsForRoute & SearchForRoute): React.ComponentType<{}> {\n" + + "\treturn _createMock({ path: buildMockPath(args.url as string, (args as any).params ?? {}, (args as any).search), data: args.data as Record })\n" + + "}\n", "expected": tests.Dedent(` import type { RouterManifest } from 'houdini/runtime' @@ -738,8 +927,9 @@ func TestGenerateRuntime(t *testing.T) { url: "/[id]", pattern: /^\/([^/]+?)\/?$/, params: [ - { name: "id", matcher: "", optional: false, rest: false, chained: false, type: "ID" } + { name: "id", optional: false, rest: false, chained: false, type: "ID" } ], + searchParams: [], documents: { MyQuery: { artifact: () => import("../../../artifacts/MyQuery"), @@ -750,11 +940,79 @@ func TestGenerateRuntime(t *testing.T) { component: () => import("../units/entries/__id_"), }, }, + pagesByUrl: { + "/[id]": "__id_", + }, } as const satisfies RouterManifest export type RouteScalars = { } - `) + "\n", + `) + "\n" + tsTypeManifest, + }, + }, + { + Name: "nullable non-route variables become search params", + Pass: true, + Input: []string{ + "query SearchQuery($q: String, $tags: [String!], $first: Int!) {\n\tsearch(q: $q, tags: $tags, first: $first) {\n\t\tid\n\t}\n}\n", + }, + Filepaths: []string{ + "src/routes/search/+page.gql", + }, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/search/+page.tsx": mockView([]string{"SearchQuery"}), + }, + // $q and $tags are nullable, so they surface as search params (the list + // keeps its wrapper chain). $first is required, so it is omitted — a + // missing search param can never make the query fail. + "expectedMock": "import React from 'react'\n" + + "import { _createMock, buildMockPath } from './testing'\n" + + "import type { RouteHrefs, ParamsForRoute, SearchForRoute } from './routes'\n" + + "\nimport type { SearchQuery$unmasked, SearchQuery$input } from '$houdini/artifacts/SearchQuery'\n" + + "\ntype _MockValue = R | ((vars: V) => R)\n\n" + + "type _TestData__search = {\n" + + "\tSearchQuery: _MockValue\n" + + "}\n\n" + + "type _RouteData = {\n" + + "\t\"/search\": _TestData__search\n" + + "}\n" + + "type _DataForRoute = H extends keyof _RouteData ? _RouteData[H] : never\n\n" + + "export function createMock(args: { url: H; data: _DataForRoute } & ParamsForRoute & SearchForRoute): React.ComponentType<{}> {\n" + + "\treturn _createMock({ path: buildMockPath(args.url as string, (args as any).params ?? {}, (args as any).search), data: args.data as Record })\n" + + "}\n", + "expected": tests.Dedent(` + import type { RouterManifest } from 'houdini/runtime' + + export default { + pages: { + "_search": { + id: "_search", + url: "/search", + pattern: /^\/search\/?$/, + params: [], + searchParams: [ + { name: "q", type: "String", wrappers: [] }, + { name: "tags", type: "String", wrappers: ["List", "NonNull"] } + ], + documents: { + SearchQuery: { + artifact: () => import("../../../artifacts/SearchQuery"), + loading: false, + variables: { first: { type: "Int" }, q: { type: "String" }, tags: { type: "String" } }, + }, + }, + component: () => import("../units/entries/_search"), + }, + }, + pagesByUrl: { + "/search": "_search", + }, + } as const satisfies RouterManifest + + export type RouteScalars = { + } + `) + "\n" + tsTypeManifest, }, }, { @@ -781,6 +1039,7 @@ func TestGenerateRuntime(t *testing.T) { url: "/", pattern: /^\/$/, params: [], + searchParams: [], documents: { PageQuery: { artifact: () => import("../../../artifacts/PageQuery"), @@ -791,11 +1050,88 @@ func TestGenerateRuntime(t *testing.T) { component: () => import("../units/entries/_"), }, }, + pagesByUrl: { + "/": "_", + }, } as const satisfies RouterManifest export type RouteScalars = { } - `) + "\n", + `) + "\n" + tsTypeManifest, + }, + }, + { + Name: "headers() exports emit a headers loader array", + Pass: true, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+layout.tsx": "export function headers() { return { 'X-From': 'layout' } }\nexport default ({children}) =>
      {children}
      ", + "src/routes/+page.tsx": "export const headers = () => ({ 'X-From': 'page' })\nexport default () =>
      hello
      ", + }, + "expected": tests.Dedent(` + import type { RouterManifest } from 'houdini/runtime' + + export default { + pages: { + "_": { + id: "_", + url: "/", + pattern: /^\/$/, + params: [], + searchParams: [], + documents: { + }, + component: () => import("../units/entries/_"), + }, + }, + pagesByUrl: { + "/": "_", + }, + } as const satisfies RouterManifest + + export const route_headers = { + "_": [ + () => import("../../../../src/routes/+layout").then(m => m.headers), + () => import("../../../../src/routes/+page").then(m => m.headers), + ], + } + + export type RouteScalars = { + } + `) + "\n" + tsTypeManifest, + }, + }, + { + Name: "subscription appears as optional MockValue field in mock", + Pass: true, + Input: []string{ + mockQuery("PageQuery", false), + "subscription UserEvents { id }", + }, + Filepaths: []string{ + "src/routes/+page.gql", + }, + Extra: map[string]any{ + "views": map[string]string{ + "src/routes/+page.tsx": mockView([]string{"PageQuery"}), + }, + "expectedMock": "import React from 'react'\n" + + "import { _createMock, buildMockPath } from './testing'\n" + + "import type { RouteHrefs, ParamsForRoute, SearchForRoute } from './routes'\n" + + "\nimport type { PageQuery$unmasked, PageQuery$input } from '$houdini/artifacts/PageQuery'\n" + + "import type { UserEvents$unmasked, UserEvents$input } from '$houdini/artifacts/UserEvents'\n" + + "\ntype _MockValue = R | ((vars: V) => R)\n\n" + + "type _TestData__ = {\n" + + "\tPageQuery: _MockValue\n" + + "\tUserEvents?: _MockValue, UserEvents$input>\n" + + "}\n\n" + + "type _RouteData = {\n" + + "\t\"/\": _TestData__\n" + + "}\n" + + "type _DataForRoute = H extends keyof _RouteData ? _RouteData[H] : never\n\n" + + "export function createMock(args: { url: H; data: _DataForRoute } & ParamsForRoute & SearchForRoute): React.ComponentType<{}> {\n" + + "\treturn _createMock({ path: buildMockPath(args.url as string, (args as any).params ?? {}, (args as any).search), data: args.data as Record })\n" + + "}\n", }, }, { @@ -814,12 +1150,14 @@ func TestGenerateRuntime(t *testing.T) { export default { pages: { }, + pagesByUrl: { + }, } as const satisfies RouterManifest export type RouteScalars = { DateTime: Date } - `) + "\n", + `) + "\n" + tsTypeManifest, }, }, }, diff --git a/packages/houdini-react/plugin/validate.go b/packages/houdini-react/plugin/validate.go new file mode 100644 index 0000000000..ac84532647 --- /dev/null +++ b/packages/houdini-react/plugin/validate.go @@ -0,0 +1,77 @@ +package plugin + +import ( + "context" + "fmt" + + "code.houdinigraphql.com/plugins" +) + +// Validate enforces routing-specific invariants that the core GraphQL validation +// cannot know about. +// +// The router can only populate a route query's variables from the URL: required +// (non-null) variables must come from a route segment, while nullable variables may +// also be supplied via URLSearchParams (issue #1210) and are therefore allowed to be +// absent. A required variable that is neither a route segment nor defaulted can never +// be satisfied by navigation, so the query would fail at request time. We catch that +// here instead, mirroring the build-time guarantee users get for route params. +func (p *HoudiniReact) Validate(ctx context.Context) error { + errs := &plugins.ErrorList{} + + // type_modifiers is stored inner→outer, so the outermost wrapper is the final + // character: a trailing "!" means the variable is non-null (required). We also + // skip variables with a default value since those are satisfiable without input. + query := ` + SELECT + d.name, + rd.filepath, + rd.offset_line, + rd.offset_column, + dv.name + FROM documents d + JOIN raw_documents rd ON rd.id = d.raw_document + JOIN document_variables dv ON dv.document = d.id + WHERE d.kind = 'query' + AND (rd.filepath LIKE '%+page.gql' OR rd.filepath LIKE '%+layout.gql') + AND dv.type_modifiers LIKE '%!' + AND dv.default_value IS NULL + ORDER BY d.name, dv.name + ` + + err := p.DB.StepQuery(ctx, query, nil, func(row plugins.Row) { + docName := row.ColumnText(0) + filepath := row.ColumnText(1) + line := row.ColumnInt(2) + column := row.ColumnInt(3) + varName := row.ColumnText(4) + + // satisfied by a route segment — nothing to flag + if routeParamSet(filepath)[varName] { + return + } + + errs.Append(&plugins.Error{ + Message: fmt.Sprintf( + "required variable $%s on %q can't be provided by the router: "+ + "add a [%s] route segment, give it a default value, or make it nullable "+ + "so it can be supplied via search params", + varName, docName, varName, + ), + Kind: plugins.ErrorKindValidation, + Locations: []*plugins.ErrorLocation{{ + Filepath: filepath, + Line: line, + Column: column, + }}, + }) + }) + if err != nil { + errs.Append(plugins.WrapError(err)) + } + + if errs.Len() > 0 { + return errs + } + return nil +} diff --git a/packages/houdini-react/plugin/validate_test.go b/packages/houdini-react/plugin/validate_test.go new file mode 100644 index 0000000000..906c178e1c --- /dev/null +++ b/packages/houdini-react/plugin/validate_test.go @@ -0,0 +1,78 @@ +package plugin_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + coreConfig "code.houdinigraphql.com/packages/houdini-core/config" + "code.houdinigraphql.com/packages/houdini-react/plugin" + "code.houdinigraphql.com/plugins" + "code.houdinigraphql.com/plugins/tests" +) + +func TestValidateRouteVariables(t *testing.T) { + tests.RunTable(t, tests.Table[coreConfig.PluginConfig, *plugin.HoudiniReact]{ + Schema: ` + type Query { + node(id: ID!): Node + search(q: String, first: Int, tags: [String!]): [Node!] + } + interface Node { id: ID! } + `, + + PerformTest: func(t *testing.T, p *plugin.HoudiniReact, test tests.Test[coreConfig.PluginConfig]) { + err := p.Validate(context.Background()) + if test.Pass { + if err != nil { + if list, ok := err.(*plugins.ErrorList); ok && list.Len() > 0 { + t.Fatal(list.GetItems()[0].Message) + } + require.NoError(t, err) + } + return + } + + require.Error(t, err) + list, ok := err.(*plugins.ErrorList) + require.True(t, ok, "expected an ErrorList") + require.Equal(t, plugins.ErrorKindValidation, list.GetItems()[0].Kind) + }, + + Tests: []tests.Test[coreConfig.PluginConfig]{ + { + Name: "required variable backed by a route segment is allowed", + Pass: true, + Input: []string{ + "query Q($id: ID!) {\n\tnode(id: $id) {\n\t\tid\n\t}\n}\n", + }, + Filepaths: []string{"src/routes/[id]/+page.gql"}, + }, + { + Name: "nullable non-route variable is allowed (becomes a search param)", + Pass: true, + Input: []string{ + "query Q($q: String) {\n\tsearch(q: $q) {\n\t\tid\n\t}\n}\n", + }, + Filepaths: []string{"src/routes/search/+page.gql"}, + }, + { + Name: "required variable with a default is allowed", + Pass: true, + Input: []string{ + "query Q($first: Int! = 10) {\n\tsearch(first: $first) {\n\t\tid\n\t}\n}\n", + }, + Filepaths: []string{"src/routes/search/+page.gql"}, + }, + { + Name: "required non-route variable is rejected", + Pass: false, + Input: []string{ + "query Q($q: String!) {\n\tsearch(q: $q) {\n\t\tid\n\t}\n}\n", + }, + Filepaths: []string{"src/routes/search/+page.gql"}, + }, + }, + }) +} diff --git a/packages/houdini-react/runtime/Link.tsx b/packages/houdini-react/runtime/Link.tsx index b02e027051..32dbcb8a7c 100644 --- a/packages/houdini-react/runtime/Link.tsx +++ b/packages/houdini-react/runtime/Link.tsx @@ -1,81 +1,50 @@ // this file is generated by houdini — do not edit // @refresh reset import type { AnchorHTMLAttributes, DetailedHTMLProps } from 'react' +import { getCurrentConfig } from '$houdini/runtime/config' import React from 'react' // @ts-ignore -import type rawManifest from './manifest.js' -// @ts-ignore -import type { RouteScalars } from './manifest.js' - -import { resolveHref } from './resolve-href.js' - -type _Pages = (typeof rawManifest)['pages'] -type _TSType = T extends keyof RouteScalars - ? RouteScalars[T] - : T extends 'Int' | 'Float' - ? number - : T extends 'ID' - ? string | number - : T extends 'Boolean' - ? boolean - : string -type _Param = { readonly name: string; readonly type: string; readonly optional: boolean } -type _ParamObj = { - [P in Ps[number] as P['optional'] extends true ? P['name'] : never]?: _TSType -} & { - [P in Ps[number] as P['optional'] extends true ? never : P['name']]: _TSType -} - -type _ExternalHref = - | `http://${string}` - | `https://${string}` - | `mailto:${string}` - | `tel:${string}` - | `blob:${string}` - | `data:${string}` - | `//${string}` - | `#${string}` - | `./${string}` - | `../${string}` +import manifest from './manifest.js' +import type { RouteHrefs, ExternalHref, ParamsForRoute, SearchForRoute } from './routes.js' -// All known app route URL strings — useful as a constraint for custom link wrappers. -export type RouteHrefs = _Pages[keyof _Pages] extends { readonly url: infer U extends string } - ? U - : never +import { buildHref, type RouteHrefInfo } from './resolve-href.js' -// Separate 'to' from 'params' so TypeScript evaluates them independently: -// - 'to' completions show all routes (including parameterized) because 'to' itself is always valid -// - 'params' is a separate intersection that errors when required but absent -type _PageForRoute = Extract<_Pages[keyof _Pages], { readonly url: H }> -type _ParamsForRoute = [_PageForRoute] extends [never] - ? { params?: never } - : _PageForRoute extends { readonly params: readonly [] } - ? { params?: never } - : _PageForRoute extends { readonly params: infer Ps extends readonly _Param[] } - ? { params: _ParamObj } - : { params?: never } +// re-exported for custom link wrappers that constrain their own `to` prop +export type { RouteHrefs } -export type LinkProps = Omit< +export type LinkProps = Omit< DetailedHTMLProps, HTMLAnchorElement>, 'href' > & { to: H disabled?: boolean preload?: boolean | 'data' | 'component' | 'page' -} & _ParamsForRoute +} & ParamsForRoute & + SearchForRoute -export function Link({ +export function Link({ to, params, + search, disabled, preload, ...rest }: LinkProps): React.ReactElement { + // look up the destination route (O(1) via the codegen'd pagesByUrl map) so custom-scalar + // param/search values can be marshaled into their transport form (e.g. a Date → + // timestamp) before they hit the URL. External hrefs won't match a page, so marshalers + // stay empty. + const m = manifest as any + const page = m.pages[m.pagesByUrl[to as string]] as RouteHrefInfo | undefined const href = disabled ? undefined - : params != null - ? resolveHref(to as string, params as Record) - : (to as string) + : buildHref( + to as string, + page, + getCurrentConfig()?.scalars, + params as Record | undefined, + search as Record | undefined + ) return React.createElement('a', { ...rest, href, 'data-houdini-preload': preload }) } diff --git a/packages/houdini-react/runtime/contexts.test.ts b/packages/houdini-react/runtime/contexts.test.ts new file mode 100644 index 0000000000..6e407b0fd7 --- /dev/null +++ b/packages/houdini-react/runtime/contexts.test.ts @@ -0,0 +1,49 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, test } from 'vitest' + +import * as contexts from './contexts.js' + +const runtimeDir = fileURLToPath(new URL('.', import.meta.url)) + +function runtimeSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : runtimeSourceFiles(full) + } + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [full] : [] + }) +} + +describe('runtime React contexts', () => { + // A React context is a module-level singleton: provider and consumer must share the + // object returned by createContext(). When a context is created inside a module that also + // exports components/hooks, Vite re-evaluates that module on an HMR update and mints a new + // context object, splitting an already-mounted provider from a freshly-bound consumer + // ("Could not find router context"). Every context therefore lives in contexts.ts, a + // dependency-only leaf module that the HMR graph never re-evaluates. This test fails if a + // createContext() call is reintroduced anywhere else in the runtime. + test('createContext is only called in the contexts leaf module', () => { + const offenders = runtimeSourceFiles(runtimeDir).filter( + (file) => + !file.endsWith(`${join('runtime', 'contexts.ts')}`) && + readFileSync(file, 'utf-8').includes('createContext') + ) + expect(offenders).toEqual([]) + }) + + test('the leaf exports every runtime context', () => { + for (const name of [ + 'RouterContextObject', + 'LocationContext', + 'Is404Context', + 'PageContext', + 'StatusContext', + 'FormStatusContext', + ] as const) { + expect(contexts[name]).toHaveProperty('Provider') + } + }) +}) diff --git a/packages/houdini-react/runtime/contexts.ts b/packages/houdini-react/runtime/contexts.ts new file mode 100644 index 0000000000..a36ac75e17 --- /dev/null +++ b/packages/houdini-react/runtime/contexts.ts @@ -0,0 +1,54 @@ +import { createContext } from 'react' + +import type { Goto } from './routes.js' +import type { RouterContext } from './routing/Router.js' + +// All of the runtime's React contexts are defined in this module on purpose. +// +// A React context is a module-level singleton: provider and consumer must hold +// the *same* object returned by createContext(). When a context is created +// inside a module that also exports components/hooks (like Router.tsx), Vite's +// dev server re-evaluates that module on an HMR update (for example when codegen +// rewrites a neighboring generated unit, or when react-refresh falls back to a +// full module re-run). Each re-evaluation mints a brand-new context object, and +// if a consumer rebinds to the new one while the still-mounted provider holds +// the old one, useContext() reads a context the provider never populated and +// throws "Could not find router context". +// +// This module imports only `react` at runtime (everything else is `import type`, +// erased at build time), so it is a pure leaf in the dependency graph. Route +// edits never re-evaluate it, so these context objects keep a stable identity +// across granular HMR updates. We deliberately avoid a globalThis registry to +// pin identity: multiple independent Router contexts can legitimately coexist in +// one process (for example across Astro islands), and a global singleton would +// wrongly collapse them into one. + +export const RouterContextObject = createContext(null) + +export const LocationContext = createContext<{ + pathname: string + params: Record + // the parsed query string of the current url (declared search params coerced to + // their scalar type, other keys raw; repeated keys are arrays). + search: Record + // a function to imperatively navigate to a url + goto: Goto +}>({ + pathname: '', + params: {}, + search: {}, + goto: () => {}, +}) + +export const Is404Context = createContext(false) + +export const PageContext = createContext<{ params: Record }>({ params: {} }) + +// Mutable ref passed from the server renderer so that a synchronous RoutingError +// or redirect() can propagate the correct HTTP status/location before streaming. +export const StatusContext = createContext<{ status: number; location?: string } | null>(null) + +// FormStatusContext carries the nearest 's pending state to useMutationFormStatus(), +// the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action +// submissions, so it can't see our forms). +export const FormStatusContext = createContext<{ pending: boolean }>({ pending: false }) diff --git a/packages/houdini-react/runtime/escape.test.ts b/packages/houdini-react/runtime/escape.test.ts new file mode 100644 index 0000000000..272298e22e --- /dev/null +++ b/packages/houdini-react/runtime/escape.test.ts @@ -0,0 +1,22 @@ +import { test, expect, describe } from 'vitest' + +import { escapeScriptTag } from './escape.js' + +describe('escapeScriptTag', () => { + test('neutralizes a breakout but stays valid JSON', () => { + const payload = { name: '' } + const out = escapeScriptTag(JSON.stringify(payload)) + // no raw "<" survives, so it cannot break out of the inline and