diff --git a/.changeset/olive-falcons-repeat.md b/.changeset/olive-falcons-repeat.md
new file mode 100644
index 000000000..d8b2b7c8a
--- /dev/null
+++ b/.changeset/olive-falcons-repeat.md
@@ -0,0 +1,5 @@
+---
+'houdini': patch
+---
+
+Fix runtime scalars being silently dropped when the config is re-seeded on a persisted database (e.g. a long-running dev server after adding a new runtime scalar).
diff --git a/.changeset/proud-otters-hunt.md b/.changeset/proud-otters-hunt.md
new file mode 100644
index 000000000..5d6c5bef0
--- /dev/null
+++ b/.changeset/proud-otters-hunt.md
@@ -0,0 +1,5 @@
+---
+'houdini': patch
+---
+
+Hydrated cache data now registers with the stale manager, so markStale (and anything built on it, like session invalidation) reaches data that arrived via SSR hydration instead of silently skipping it.
diff --git a/.changeset/tidy-pugs-brake.md b/.changeset/tidy-pugs-brake.md
new file mode 100644
index 000000000..a23896a2d
--- /dev/null
+++ b/.changeset/tidy-pugs-brake.md
@@ -0,0 +1,5 @@
+---
+'houdini-react': minor
+---
+
+useQuery now renders on the server and ships a number of reliability fixes: components stay reactive to cache updates and session changes, and query errors surface at the nearest error boundary.
diff --git a/docs/react/05-guides/01-authentication.mdx b/docs/react/05-guides/01-authentication.mdx
index f1f62ef9c..6d191ffbd 100644
--- a/docs/react/05-guides/01-authentication.mdx
+++ b/docs/react/05-guides/01-authentication.mdx
@@ -51,6 +51,8 @@ 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.
+Either call invalidates every cached query result: active queries (route queries and `useQuery` alike) refetch with the new session, and the normalized cache is marked stale so results whose variables didn't change still revalidate against the network.
+
**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.
diff --git a/docs/react/06-api-reference/16-useQuery.mdx b/docs/react/06-api-reference/16-useQuery.mdx
index bc6da0924..b758bfcf2 100644
--- a/docs/react/06-api-reference/16-useQuery.mdx
+++ b/docs/react/06-api-reference/16-useQuery.mdx
@@ -5,7 +5,7 @@ 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.
+Fetches a query and returns the data. Suspends until the result is available. During server-side rendering the query resolves on the server and streams with the page; hydration serves it from the embedded cache snapshot without a client refetch.
```tsx
import { graphql, useQuery } from '$houdini'
diff --git a/e2e/_api/graphql.mjs b/e2e/_api/graphql.mjs
index 5f17146f7..d964ba5c5 100644
--- a/e2e/_api/graphql.mjs
+++ b/e2e/_api/graphql.mjs
@@ -178,6 +178,7 @@ export const typeDefs = /* GraphQL */ `
city(id: ID!, delay: Int): City
userNodesResult(snapshot: String!, forceMessage: Boolean!): UserNodesResult!
userResult(id: ID!, snapshot: String!, forceMessage: Boolean!): UserResult!
+ sessionTheme: String
rentedBooks: [RentedBook!]!
animals: AnimalConnection!
monkeys: MonkeyConnection!
@@ -502,6 +503,18 @@ export const resolvers = {
nodes: allData.splice(args.offset || 0, args.limit),
}
},
+ sessionTheme: (_, args, ctx) => {
+ // prefer the per-request header (the client pipeline forwards the CURRENT client
+ // session there, so it can't lag a just-written session) and fall back to the
+ // signed-cookie session, which is all the server-side SSR proxy carries
+ let header = null
+ ctx.request.headers.forEach((value, key) => {
+ if (key === 'x-session-theme' && value) {
+ header = value
+ }
+ })
+ return header ?? ctx.session?.theme ?? null
+ },
session: (_, args, info) => {
let token = null
info.request.headers.forEach((value, key) => {
diff --git a/e2e/_api/schema.graphql b/e2e/_api/schema.graphql
index caac85b85..882370ece 100644
--- a/e2e/_api/schema.graphql
+++ b/e2e/_api/schema.graphql
@@ -132,6 +132,7 @@ type Query {
city(id: ID!, delay: Int): City
userNodesResult(snapshot: String!, forceMessage: Boolean!): UserNodesResult!
userResult(id: ID!, snapshot: String!, forceMessage: Boolean!): UserResult!
+ sessionTheme: String
rentedBooks: [RentedBook!]!
animals: AnimalConnection!
monkeys: MonkeyConnection!
diff --git a/e2e/react/src/routes/use-query-abandon/+page.tsx b/e2e/react/src/routes/use-query-abandon/+page.tsx
new file mode 100644
index 000000000..9b0ffdf6d
--- /dev/null
+++ b/e2e/react/src/routes/use-query-abandon/+page.tsx
@@ -0,0 +1,61 @@
+import { Suspense } from 'react'
+import { graphql, useMutation, useQuery } from '$houdini'
+
+// A slow useQuery so the test can navigate away while the component is still suspended
+// (abandoning the in-flight fetch) and come back after it resolves. The returning mount
+// must pick the resolved suspense entry (and its store) back up: data renders without a
+// second fetch and cache writes still propagate.
+function UserName() {
+ const data = useQuery(
+ graphql(`
+ query UseQueryAbandonUser($snapshot: String!, $id: ID!, $delay: Int) {
+ user(id: $id, snapshot: $snapshot, delay: $delay) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-abandon', id: '1', delay: 2000 }
+ )
+
+ return
{data.user.name}
+}
+
+// sibling mutation, isolated from the query component (see use-query-reactivity)
+function UpdateButton() {
+ const [update] = useMutation(
+ graphql(`
+ mutation UseQueryAbandonUpdate($snapshot: String!, $id: ID!, $name: String!) {
+ updateUser(id: $id, snapshot: $snapshot, name: $name) {
+ id
+ name
+ }
+ }
+ `)
+ )
+
+ return (
+
+ )
+}
+
+export default function () {
+ return (
+ <>
+ loading}>
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/use-query-abandon/test.ts b/e2e/react/src/routes/use-query-abandon/test.ts
new file mode 100644
index 000000000..9fa12b6b5
--- /dev/null
+++ b/e2e/react/src/routes/use-query-abandon/test.ts
@@ -0,0 +1,32 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { expect_1_gql, expect_n_gql } from '~/utils/testsHelper.js'
+
+// Suspending, navigating away before the fetch lands, and coming back must reuse the
+// resolved suspense entry: the data shows without a second fetch, and the store that the
+// abandoned fetch created still carries cache updates. (The nav must be client-side —
+// a full page load would reset the module state this flow exercises.)
+test('abandoning a suspended useQuery and returning reuses the resolved fetch', async ({
+ page,
+}) => {
+ await page.goto(routes.hello)
+
+ // client-side navigate to the slow query: it suspends
+ await page.click('text="use_query_abandon"')
+ await expect(page.locator('#fallback')).toHaveText('loading')
+
+ // abandon it mid-flight
+ await page.click('text="hello"')
+ await expect(page.locator('#result')).toHaveText('Hello World! // From Houdini!')
+
+ // let the abandoned fetch resolve while we're away
+ await page.waitForTimeout(2500)
+
+ // returning must not fire a second fetch: the resolved entry is picked back up
+ await expect_n_gql(page, 'text="use_query_abandon"', 0)
+ await expect(page.locator('#name')).toHaveText('Bruce Willis')
+
+ // and the store the abandoned fetch created still carries cache updates
+ await expect_1_gql(page, 'button[id=update]')
+ await expect(page.locator('#name')).toHaveText('Updated Name')
+})
diff --git a/e2e/react/src/routes/use-query-error/+error.tsx b/e2e/react/src/routes/use-query-error/+error.tsx
new file mode 100644
index 000000000..09ed26878
--- /dev/null
+++ b/e2e/react/src/routes/use-query-error/+error.tsx
@@ -0,0 +1,5 @@
+import type { ErrorProps } from './$types'
+
+export default function UseQueryErrorBoundary({ errors }: ErrorProps) {
+ return
{errors[0]?.message}
+}
diff --git a/e2e/react/src/routes/use-query-error/+page.tsx b/e2e/react/src/routes/use-query-error/+page.tsx
new file mode 100644
index 000000000..9ec5fe128
--- /dev/null
+++ b/e2e/react/src/routes/use-query-error/+page.tsx
@@ -0,0 +1,29 @@
+import { Suspense } from 'react'
+import { graphql, useQuery } from '$houdini'
+
+// A useQuery whose fetch errors (the api throws "User not found" for id 999). The error
+// must reach the route's error boundary — not hang the suspense, loop refetches, or
+// commit the component with null data.
+function BrokenUser() {
+ const data = useQuery(
+ graphql(`
+ query UseQueryErrorUser($snapshot: String!) {
+ user(id: "999", snapshot: $snapshot) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-error' }
+ )
+
+ return
{data.user.name}
+}
+
+export default function () {
+ return (
+ loading}>
+
+
+ )
+}
diff --git a/e2e/react/src/routes/use-query-error/test.ts b/e2e/react/src/routes/use-query-error/test.ts
new file mode 100644
index 000000000..51c0663d1
--- /dev/null
+++ b/e2e/react/src/routes/use-query-error/test.ts
@@ -0,0 +1,20 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { goto } from '~/utils/testsHelper.js'
+
+// A useQuery whose fetch errors must surface the GraphQL error at the route's error
+// boundary — on a full (server-rendered) load and on a client-side navigation alike.
+test.describe('useQuery error', () => {
+ test('full load surfaces the error at the boundary', async ({ page }) => {
+ await page.goto(routes.use_query_error)
+
+ await expect(page.locator('#error-message')).toHaveText('User not found')
+ })
+
+ test('client-side navigation surfaces the error at the boundary', async ({ page }) => {
+ await goto(page, routes.hello)
+
+ await page.click('text="use_query_error"')
+ await expect(page.locator('#error-message')).toHaveText('User not found')
+ })
+})
diff --git a/e2e/react/src/routes/use-query-reactivity/+page.tsx b/e2e/react/src/routes/use-query-reactivity/+page.tsx
new file mode 100644
index 000000000..201c3c5dd
--- /dev/null
+++ b/e2e/react/src/routes/use-query-reactivity/+page.tsx
@@ -0,0 +1,72 @@
+import { Suspense, useState } from 'react'
+import { graphql, useMutation, useQuery } from '$houdini'
+
+// Sibling A: renders a user's name via useQuery inside Suspense. This component owns no
+// state of its own, so the only thing that can re-render it after the initial load is a
+// notification from the document store's subscription (or its id prop changing, which
+// makes it re-suspend with new variables).
+function UserName({ id }: { id: string }) {
+ const data = useQuery(
+ graphql(`
+ query UseQueryReactivityUser($snapshot: String!, $id: ID!) {
+ user(id: $id, snapshot: $snapshot) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-reactivity', id }
+ )
+
+ return
{data.user.name}
+}
+
+// Sibling B: fires a mutation that updates a user record in the cache. It is a sibling of
+// UserName (not a parent/child) and holds no state tied to the click, so clicking must
+// not re-render UserName for any reason other than the cache write propagating through
+// the store subscription. That isolation is what makes this a real test of reactivity:
+// if the subscription is muted, UserName never updates.
+function UpdateButton({ buttonId, id, name }: { buttonId: string; id: string; name: string }) {
+ const [update] = useMutation(
+ graphql(`
+ mutation UseQueryReactivityUpdate($snapshot: String!, $id: ID!, $name: String!) {
+ updateUser(id: $id, snapshot: $snapshot, name: $name) {
+ id
+ name
+ }
+ }
+ `)
+ )
+
+ return (
+
+ )
+}
+
+export default function () {
+ // which user the query renders. switching makes UserName re-suspend with new variables
+ const [userID, setUserID] = useState('1')
+
+ return (
+ <>
+ loading}>
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/use-query-reactivity/test.ts b/e2e/react/src/routes/use-query-reactivity/test.ts
new file mode 100644
index 000000000..a003b6fef
--- /dev/null
+++ b/e2e/react/src/routes/use-query-reactivity/test.ts
@@ -0,0 +1,37 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { goto } from '~/utils/testsHelper.js'
+
+// A useQuery component and mutations live in sibling components. One ordered flow (so the
+// steps don't fight over the shared api snapshot) pinning two reactivity contracts:
+//
+// 1. After the initial load (which suspended once), a sibling mutation's cache write must
+// re-render the query component. This pins the observer-reuse behavior in
+// useQueryHandle: suspending discards the component instance that started the fetch,
+// so the retry render has to pick the original store back up — the cache subscription
+// belongs to it, and a fresh store would never hear about the write.
+//
+// 2. After a variables change (which re-suspends the already-committed instance), a
+// sibling mutation's cache write must still re-render it. This pins the suspenseTracker
+// reset: re-suspending flips the mute flag on the committed instance's ref, and without
+// resetting it after the render commits, the subscription stays muted forever.
+test('useQuery reflects sibling mutation cache writes after load and after re-suspension', async ({
+ page,
+}) => {
+ // the query resolved during SSR; hydration serves it from the streamed cache snapshot
+ await goto(page, routes.use_query_reactivity)
+
+ await expect(page.locator('#name')).toHaveText('Bruce Willis')
+
+ // a mutation on the rendered record propagates to the queried sibling
+ await page.click('#update-1')
+ await expect(page.locator('#name')).toHaveText('Updated One')
+
+ // switching the id prop re-suspends the query component with new variables
+ await page.click('#switch')
+ await expect(page.locator('#name')).toHaveText('Samuel Jackson')
+
+ // and after that re-suspension, cache writes must still propagate
+ await page.click('#update-2')
+ await expect(page.locator('#name')).toHaveText('Updated Two')
+})
diff --git a/e2e/react/src/routes/use-query-rerender/+page.tsx b/e2e/react/src/routes/use-query-rerender/+page.tsx
new file mode 100644
index 000000000..d9e2df9da
--- /dev/null
+++ b/e2e/react/src/routes/use-query-rerender/+page.tsx
@@ -0,0 +1,37 @@
+import React, { Suspense } from 'react'
+import { graphql, useQuery } from '$houdini'
+
+// a parent that re-renders while its child is suspended on useQuery. the child must
+// stay suspended (fallback visible) until the data lands — a re-render mid-flight
+// must not commit the child with empty data.
+function UseQueryResult() {
+ const data = useQuery(
+ graphql(`
+ query UseQueryRerenderTest($snapshot: String!) {
+ user(id: "1", snapshot: $snapshot, delay: 2000) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-rerender' }
+ )
+
+ return
{data.user?.name ?? 'MISSING'}
+}
+
+export default function () {
+ const [count, setCount] = React.useState(0)
+
+ return (
+ <>
+
+
+ loading}>
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/use-query-rerender/test.ts b/e2e/react/src/routes/use-query-rerender/test.ts
new file mode 100644
index 000000000..2e19de333
--- /dev/null
+++ b/e2e/react/src/routes/use-query-rerender/test.ts
@@ -0,0 +1,29 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { goto } from '~/utils/testsHelper.js'
+
+// a component suspended on useQuery must stay suspended through unrelated parent
+// re-renders. the hook seeds its document store before the data arrives, and a
+// re-render mid-flight used to see that placeholder as "data" — committing the child
+// with an empty object instead of re-throwing the suspense promise.
+//
+// the route is reached by client-side navigation: on a full load the server streams the
+// resolved content (it waits out the query's delay), so the mid-flight window this test
+// needs only exists client-side.
+test('parent re-render while suspended keeps the fallback until data lands', async ({ page }) => {
+ await goto(page, routes.hello)
+
+ // client-side navigate to the route: the child suspends for the 2s server delay
+ await page.click('text="use_query_rerender"')
+ await expect(page.locator('#fallback')).toBeVisible()
+
+ // re-render the parent while the query is still in flight
+ await page.click('button[id=rerender]')
+
+ // the child must still be suspended — not committed with empty data
+ await expect(page.locator('#fallback')).toBeVisible()
+ await expect(page.locator('#result')).toHaveCount(0)
+
+ // and once the response lands, the real data shows up
+ await expect(page.locator('#result')).toHaveText('Bruce Willis', { timeout: 10000 })
+})
diff --git a/e2e/react/src/routes/use-query-session/+page.tsx b/e2e/react/src/routes/use-query-session/+page.tsx
new file mode 100644
index 000000000..b8ac1af37
--- /dev/null
+++ b/e2e/react/src/routes/use-query-session/+page.tsx
@@ -0,0 +1,49 @@
+import { Suspense } from 'react'
+import { graphql, useQuery, useSession } from '$houdini'
+
+// A session-dependent useQuery: sessionTheme reflects the request's session (the
+// x-session-theme header the client pipeline forwards, or the signed session cookie
+// during SSR). The queried component is a sibling of the session controls, so after a
+// session change the only thing that can refresh it is the suspense state being
+// invalidated and refetched.
+function SessionTheme() {
+ const data = useQuery(
+ graphql(`
+ query UseQuerySessionTheme {
+ sessionTheme
+ }
+ `)
+ )
+
+ return
{data.sessionTheme ?? '(none)'}
+}
+
+function UpdateButton() {
+ const [, updateSession] = useSession()
+
+ return (
+
+ )
+}
+
+export default function () {
+ return (
+ <>
+ loading}>
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/use-query-session/test.ts b/e2e/react/src/routes/use-query-session/test.ts
new file mode 100644
index 000000000..bef3b18a3
--- /dev/null
+++ b/e2e/react/src/routes/use-query-session/test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { goto } from '~/utils/testsHelper.js'
+
+// A session change (updateSession, login, logout) invalidates every cached query result.
+// Route queries refetch through the router's caches; this pins that useQuery's suspense
+// state is invalidated the same way — without it a session-dependent useQuery keeps
+// rendering data fetched under the old session.
+test('useQuery refetches when the session changes', async ({ page }) => {
+ await goto(page, routes.use_query_session)
+
+ // no theme in the session yet
+ await expect(page.locator('#theme')).toHaveText('(none)')
+
+ // writing the session must invalidate the suspense state and refetch
+ await page.click('#set-theme')
+ await expect(page.locator('#theme')).toHaveText('updated-theme')
+})
+
+// The SSR fetch must run with the REQUEST's session: two requests carrying different
+// session cookies get HTML rendered from their own session, for the same query and
+// variables. (Raw request.get, so no hydration can mask what the server rendered.)
+test('SSR renders each request with its own session', async ({ browser, request }) => {
+ // establish two different sessions in two isolated browser contexts (updateSession
+ // persists the signed session cookie through the auth endpoint) and capture the cookies
+ const cookies: string[] = []
+ for (const theme of ['theme-one', 'theme-two']) {
+ const context = await browser.newContext()
+ const page = await context.newPage()
+ await goto(page, `${routes.use_query_session}?theme=${theme}`)
+
+ const before = (await context.cookies()).find((c) => c.name === '__houdini__')?.value
+ await page.click('#set-theme')
+ // wait for the signed cookie to change (the persist is async)
+ await expect
+ .poll(async () => {
+ return (await context.cookies()).find((c) => c.name === '__houdini__')?.value
+ })
+ .not.toBe(before)
+
+ const value = (await context.cookies()).find((c) => c.name === '__houdini__')!.value
+ cookies.push(`__houdini__=${value}`)
+ await context.close()
+ }
+
+ // the same page, same query, same variables — each request must render its own session
+ const first = await request.get(routes.use_query_session, {
+ headers: { cookie: cookies[0] },
+ })
+ expect(await first.text()).toContain('theme-one')
+
+ const second = await request.get(routes.use_query_session, {
+ headers: { cookie: cookies[1] },
+ })
+ expect(await second.text()).toContain('theme-two')
+})
diff --git a/e2e/react/src/routes/use-query-shared/+page.tsx b/e2e/react/src/routes/use-query-shared/+page.tsx
new file mode 100644
index 000000000..1b2007f57
--- /dev/null
+++ b/e2e/react/src/routes/use-query-shared/+page.tsx
@@ -0,0 +1,67 @@
+import { Suspense, useState } from 'react'
+import { graphql, useMutation, useQuery } from '$houdini'
+
+// Two components render the SAME query with the SAME variables, so they share one
+// suspense identifier (and, through it, one document store). Unmounting one must not
+// tear down the store the other is still using: the survivor has to keep reflecting
+// cache writes.
+function UserName({ elementId }: { elementId: string }) {
+ const data = useQuery(
+ graphql(`
+ query UseQuerySharedUser($snapshot: String!, $id: ID!) {
+ user(id: $id, snapshot: $snapshot) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-shared', id: '1' }
+ )
+
+ return
{data.user.name}
+}
+
+// sibling mutation, isolated from the query components (see use-query-reactivity)
+function UpdateButton() {
+ const [update] = useMutation(
+ graphql(`
+ mutation UseQuerySharedUpdate($snapshot: String!, $id: ID!, $name: String!) {
+ updateUser(id: $id, snapshot: $snapshot, name: $name) {
+ id
+ name
+ }
+ }
+ `)
+ )
+
+ return (
+
+ )
+}
+
+export default function () {
+ const [showFirst, setShowFirst] = useState(true)
+
+ return (
+ <>
+ loading}>
+ {showFirst && }
+
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/use-query-shared/test.ts b/e2e/react/src/routes/use-query-shared/test.ts
new file mode 100644
index 000000000..8e92cdf96
--- /dev/null
+++ b/e2e/react/src/routes/use-query-shared/test.ts
@@ -0,0 +1,27 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { goto } from '~/utils/testsHelper.js'
+
+// Two useQuery components with the same query+variables share one document store (they
+// resolve through the same suspense identifier). This pins the store's refcounted
+// lifetime: unmounting one component must not tear down the store — and its cache
+// subscription — while the other still renders from it.
+test('unmounting one of two identical useQuery components keeps the survivor reactive', async ({
+ page,
+}) => {
+ // both components resolve from the single SSR fetch (hydration reads the streamed
+ // cache snapshot, so no client request fires)
+ await goto(page, routes.use_query_shared)
+
+ await expect(page.locator('#name-a')).toHaveText('Bruce Willis')
+ await expect(page.locator('#name-b')).toHaveText('Bruce Willis')
+
+ // unmount the first component; the second keeps using the shared store
+ await page.click('#unmount-a')
+ await expect(page.locator('#name-a')).toHaveCount(0)
+ await expect(page.locator('#name-b')).toHaveText('Bruce Willis')
+
+ // a cache write must still reach the survivor
+ await page.click('#update')
+ await expect(page.locator('#name-b')).toHaveText('Updated Name')
+})
diff --git a/e2e/react/src/routes/use-query-ssr/+page.tsx b/e2e/react/src/routes/use-query-ssr/+page.tsx
new file mode 100644
index 000000000..f23f067a4
--- /dev/null
+++ b/e2e/react/src/routes/use-query-ssr/+page.tsx
@@ -0,0 +1,31 @@
+import { Suspense } from 'react'
+import { graphql, useQuery } from '$houdini'
+
+// Rendered server-side, this useQuery resolves during streaming and its result lands in
+// the raw HTML. The suspense state that carries it must be scoped to the request: another
+// request for the same page must render from its OWN fetch, never from a previous
+// request's resolved data (which, for a session-dependent query, would be another user's
+// data).
+function UserName() {
+ const data = useQuery(
+ graphql(`
+ query UseQuerySsrUser($snapshot: String!, $id: ID!) {
+ user(id: $id, snapshot: $snapshot) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-ssr', id: '1' }
+ )
+
+ return
{data.user.name}
+}
+
+export default function () {
+ return (
+ loading}>
+
+
+ )
+}
diff --git a/e2e/react/src/routes/use-query-ssr/test.ts b/e2e/react/src/routes/use-query-ssr/test.ts
new file mode 100644
index 000000000..8fdf3626b
--- /dev/null
+++ b/e2e/react/src/routes/use-query-ssr/test.ts
@@ -0,0 +1,32 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+
+// useQuery's suspense state must be scoped to the request on the server. These requests
+// use the raw response body (no hydration runs), so what they see is exactly what the
+// server rendered: after the API data changes, a fresh request must render the fresh
+// value — a stale value here means the server served one request's resolved query to
+// another, which for a session-dependent query is one user's data in another user's HTML.
+test('SSR renders each request from its own fetch, not a previous request\'s data', async ({
+ request,
+}) => {
+ // first request warms the server: its HTML carries the current name
+ const first = await request.get(routes.use_query_ssr)
+ expect(await first.text()).toContain('Bruce Willis')
+
+ // change the data out-of-band (straight to the api, no browser involved)
+ const mutation = await request.post('/_api', {
+ data: {
+ query: `mutation {
+ updateUser(id: "1", snapshot: "use-query-ssr", name: "Changed Name") {
+ id
+ name
+ }
+ }`,
+ },
+ })
+ expect(mutation.ok()).toBe(true)
+
+ // a second request must fetch for itself and render the new name
+ const second = await request.get(routes.use_query_ssr)
+ expect(await second.text()).toContain('Changed Name')
+})
diff --git a/e2e/react/src/routes/use-query-strictmode/+page.tsx b/e2e/react/src/routes/use-query-strictmode/+page.tsx
new file mode 100644
index 000000000..ea33019b2
--- /dev/null
+++ b/e2e/react/src/routes/use-query-strictmode/+page.tsx
@@ -0,0 +1,63 @@
+import { StrictMode, Suspense } from 'react'
+import { graphql, useMutation, useQuery } from '$houdini'
+
+// The same sibling query/mutation shape as use-query-reactivity, but wrapped in
+// StrictMode. In development React double-invokes effects (mount, simulated unmount,
+// mount again), so any teardown wired to "unmount" runs while the component is still
+// alive: the store has to survive that cycle with its cache subscription intact. The
+// production build renders this identically (StrictMode is a no-op there); the dev
+// server is where the test bites.
+function UserName() {
+ const data = useQuery(
+ graphql(`
+ query UseQueryStrictModeUser($snapshot: String!, $id: ID!) {
+ user(id: $id, snapshot: $snapshot) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query-strictmode', id: '1' }
+ )
+
+ return
{data.user.name}
+}
+
+// sibling mutation, isolated from the query component (see use-query-reactivity)
+function UpdateButton() {
+ const [update] = useMutation(
+ graphql(`
+ mutation UseQueryStrictModeUpdate($snapshot: String!, $id: ID!, $name: String!) {
+ updateUser(id: $id, snapshot: $snapshot, name: $name) {
+ id
+ name
+ }
+ }
+ `)
+ )
+
+ return (
+
+ )
+}
+
+export default function () {
+ return (
+
+ loading}>
+
+
+
+
+
+ )
+}
diff --git a/e2e/react/src/routes/use-query-strictmode/test.ts b/e2e/react/src/routes/use-query-strictmode/test.ts
new file mode 100644
index 000000000..c42cb9a6c
--- /dev/null
+++ b/e2e/react/src/routes/use-query-strictmode/test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from '@playwright/test'
+
+// This test runs against the Vite DEV server: StrictMode double-invokes effects only in
+// development builds, and that cycle (mount, simulated unmount, mount again) is exactly
+// what it pins — teardown wired to "unmount" must not kill the document store while the
+// component is still alive. Same dev-server spirit as the oauth and static-assets tests.
+const DEV_ORIGIN = 'http://localhost:3009'
+
+test('useQuery stays reactive under StrictMode double-invoked effects (dev)', async ({
+ page,
+}) => {
+ await page.goto(`${DEV_ORIGIN}/use-query-strictmode`)
+
+ await expect(page.locator('#name')).toHaveText('Bruce Willis')
+
+ // after the double-effect cycle, a sibling mutation's cache write must still propagate
+ await page.click('#update')
+ await expect(page.locator('#name')).toHaveText('Updated Name')
+})
diff --git a/e2e/react/src/routes/use-query/+page.tsx b/e2e/react/src/routes/use-query/+page.tsx
new file mode 100644
index 000000000..be0199087
--- /dev/null
+++ b/e2e/react/src/routes/use-query/+page.tsx
@@ -0,0 +1,37 @@
+import React, { Suspense } from 'react'
+import { graphql, useQuery } from '$houdini'
+
+// useQuery issues a query imperatively from inside a component (rather than receiving it
+// as a route prop) and suspends until the data is available. The result is the data
+// directly, and changing the variables re-runs the query. This pins that contract e2e.
+function UseQueryResult({ limit }: { limit: number }) {
+ const data = useQuery(
+ graphql(`
+ query UseQueryTest($snapshot: String!, $limit: Int!) {
+ usersList(snapshot: $snapshot, limit: $limit) {
+ id
+ name
+ }
+ }
+ `),
+ { snapshot: 'use-query', limit }
+ )
+
+ return
+}
+
+export default function () {
+ const [limit, setLimit] = React.useState(2)
+
+ return (
+ <>
+ loading}>
+
+
+
+
+ >
+ )
+}
diff --git a/e2e/react/src/routes/use-query/test.ts b/e2e/react/src/routes/use-query/test.ts
new file mode 100644
index 000000000..e94953228
--- /dev/null
+++ b/e2e/react/src/routes/use-query/test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from '@playwright/test'
+import { routes } from '~/utils/routes'
+import { expect_1_gql, goto } from '~/utils/testsHelper.js'
+
+// useQuery fetches a query imperatively from inside a component and suspends until the
+// data lands. These tests pin the API: the initial load is served by the server (the
+// query resolves during SSR and hydration reads the streamed cache snapshot, so no
+// client request fires), and changing the variables re-runs the query with the new
+// result.
+test.describe('useQuery', () => {
+ test('renders data fetched from inside a component', async ({ page }) => {
+ // the query resolved during SSR; hydration serves it from the cache
+ await goto(page, routes.use_query)
+
+ await expect(page.locator('#result')).toHaveText('Bruce Willis, Samuel Jackson')
+ })
+
+ test('re-runs when the variables change', async ({ page }) => {
+ await goto(page, routes.use_query)
+
+ await expect(page.locator('#result')).toHaveText('Bruce Willis, Samuel Jackson')
+
+ // bumping the limit variable re-issues the query and renders the larger result
+ await expect_1_gql(page, 'button[id=more]')
+
+ await expect(page.locator('#result')).toHaveText(
+ 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks'
+ )
+ })
+})
diff --git a/e2e/react/src/server/+schema.js b/e2e/react/src/server/+schema.js
index 6e24463df..be1dd0672 100644
--- a/e2e/react/src/server/+schema.js
+++ b/e2e/react/src/server/+schema.js
@@ -168,6 +168,7 @@ export const typeDefs = /* GraphQL */ `
city(id: ID!, delay: Int): City
userNodesResult(snapshot: String!, forceMessage: Boolean!): UserNodesResult!
userResult(id: ID!, snapshot: String!, forceMessage: Boolean!): UserResult!
+ sessionTheme: String
rentedBooks: [RentedBook!]!
animals: AnimalConnection!
monkeys: MonkeyConnection!
diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts
index f0bdfa6d8..da152abad 100644
--- a/e2e/react/src/utils/routes.ts
+++ b/e2e/react/src/utils/routes.ts
@@ -1,6 +1,15 @@
export const routes = {
api: '/_api',
hello: '/hello-world',
+ use_query: '/use-query',
+ use_query_rerender: '/use-query-rerender',
+ use_query_reactivity: '/use-query-reactivity',
+ use_query_shared: '/use-query-shared',
+ use_query_abandon: '/use-query-abandon',
+ use_query_strictmode: '/use-query-strictmode',
+ use_query_ssr: '/use-query-ssr',
+ use_query_session: '/use-query-session',
+ use_query_error: '/use-query-error',
scalars: '/scalars',
componentFields_simple: '/component_fields/simple',
componentFields_arguments: '/component_fields/arguments',
diff --git a/packages/houdini-react/runtime/hooks/observerRefs.test.ts b/packages/houdini-react/runtime/hooks/observerRefs.test.ts
new file mode 100644
index 000000000..43c8630e8
--- /dev/null
+++ b/packages/houdini-react/runtime/hooks/observerRefs.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, beforeEach, expect, test, vi } from 'vitest'
+
+import { isObserverRetained, releaseObserver, retainObserver } from './observerRefs'
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+// a minimal store: retain/release only need subscribe, and the tests only need to know
+// whether the hold subscription is still open
+function fakeObserver() {
+ let subscribers = 0
+ return {
+ subscribe: (_fn: (value: any) => void) => {
+ subscribers++
+ return () => {
+ subscribers--
+ }
+ },
+ get held() {
+ return subscribers > 0
+ },
+ }
+}
+
+test('retaining opens a hold subscription; the last release drops it after a tick', () => {
+ const observer = fakeObserver()
+
+ retainObserver(observer)
+ expect(observer.held).toBe(true)
+ expect(isObserverRetained(observer)).toBe(true)
+
+ releaseObserver(observer)
+ // disposal is deferred a tick so strict mode's synchronous re-retain can cancel it
+ expect(observer.held).toBe(true)
+ vi.runAllTimers()
+ expect(observer.held).toBe(false)
+ expect(isObserverRetained(observer)).toBe(false)
+})
+
+test('the hold survives while any holder remains', () => {
+ const observer = fakeObserver()
+
+ retainObserver(observer)
+ retainObserver(observer)
+ releaseObserver(observer)
+ vi.runAllTimers()
+
+ // one holder left: still held
+ expect(observer.held).toBe(true)
+ expect(isObserverRetained(observer)).toBe(true)
+
+ releaseObserver(observer)
+ vi.runAllTimers()
+ expect(observer.held).toBe(false)
+})
+
+test('a synchronous re-retain cancels the pending disposal (strict mode replay)', () => {
+ const observer = fakeObserver()
+
+ // mount → simulated unmount → mount again, all before the timer fires
+ retainObserver(observer)
+ releaseObserver(observer)
+ retainObserver(observer)
+ vi.runAllTimers()
+
+ expect(observer.held).toBe(true)
+ expect(isObserverRetained(observer)).toBe(true)
+
+ // and the store still tears down once the real unmount happens
+ releaseObserver(observer)
+ vi.runAllTimers()
+ expect(observer.held).toBe(false)
+})
+
+test('releasing an unretained observer is a no-op', () => {
+ const observer = fakeObserver()
+
+ releaseObserver(observer)
+ vi.runAllTimers()
+
+ expect(observer.held).toBe(false)
+ expect(isObserverRetained(observer)).toBe(false)
+
+ // and it can still be retained normally afterwards
+ retainObserver(observer)
+ expect(observer.held).toBe(true)
+})
diff --git a/packages/houdini-react/runtime/hooks/observerRefs.ts b/packages/houdini-react/runtime/hooks/observerRefs.ts
new file mode 100644
index 000000000..94d00ac51
--- /dev/null
+++ b/packages/houdini-react/runtime/hooks/observerRefs.ts
@@ -0,0 +1,51 @@
+// Committed components hold a reference on their document store: two components
+// rendering the same query+variables share one store through the suspense cache, so the
+// store can only tear down when the LAST holder unmounts. The count is backed by a real
+// (no-op) subscription because the underlying store runs its plugin cleanups (dropping
+// the cache subscription) whenever its subscriber count touches zero — which react's
+// strict-mode effect replay does to the component's own subscription.
+
+// the only thing retain/release need from a store is subscribe
+type Holdable = {
+ subscribe: (fn: (value: any) => void) => () => void
+}
+
+const observerRefs = new WeakMap void }>()
+
+export function retainObserver(observer: Holdable) {
+ const entry = observerRefs.get(observer)
+ if (entry) {
+ entry.count++
+ return
+ }
+ observerRefs.set(observer, { count: 1, hold: observer.subscribe(() => {}) })
+}
+
+export function releaseObserver(observer: Holdable) {
+ const entry = observerRefs.get(observer)
+ if (!entry) {
+ return
+ }
+ entry.count--
+ if (entry.count > 0) {
+ return
+ }
+ // defer the disposal a tick: strict mode releases and synchronously re-retains, and
+ // dropping the hold at zero immediately would tear the store down mid-replay
+ setTimeout(() => {
+ const current = observerRefs.get(observer)
+ if (!current || current.count > 0) {
+ return
+ }
+ observerRefs.delete(observer)
+ // dropping the hold makes the store's own last-unsubscriber teardown run the
+ // plugin cleanups (including the cache unsubscribe)
+ current.hold()
+ }, 0)
+}
+
+// whether any committed component currently holds the store (used to decide if an
+// evicted suspense unit's store was abandoned and needs explicit disposal)
+export function isObserverRetained(observer: Holdable): boolean {
+ return observerRefs.has(observer)
+}
diff --git a/packages/houdini-react/runtime/hooks/suspenseCache.ts b/packages/houdini-react/runtime/hooks/suspenseCache.ts
new file mode 100644
index 000000000..e06d94e5c
--- /dev/null
+++ b/packages/houdini-react/runtime/hooks/suspenseCache.ts
@@ -0,0 +1,58 @@
+import { createLRUCache } from 'houdini/runtime'
+import type { GraphQLObject, GraphQLVariables, QueryArtifact, LRUCache } from 'houdini/runtime'
+import type { Cache } from 'houdini/runtime/cache'
+import type { DocumentStore } from 'houdini/runtime/client'
+
+import { isObserverRetained } from './observerRefs.js'
+import type { DocumentHandle } from './useDocumentHandle.js'
+
+export type QuerySuspenseUnit<
+ _Data extends GraphQLObject = GraphQLObject,
+ _Input extends GraphQLVariables = GraphQLVariables,
+> = {
+ resolve: () => void
+ resolved?: DocumentHandle
+ // a failed fetch parks its error here (and resolves the thenable): the suspense
+ // protocol retries the render on resolution, and the retry throws this to the
+ // nearest error boundary. rejecting the thenable instead would make react retry a
+ // render that starts a brand new fetch — an error loop.
+ rejected?: unknown
+ then: (val: any) => any
+ // the store that started the fetch. suspending discards the component instance that
+ // created it, so the retry render has to pick this store back up — the cache
+ // subscription created by the fetch belongs to it, and a fresh store would never
+ // hear about later cache updates (a mutation write, a list operation)
+ observer: DocumentStore
+}
+
+// suspense state is scoped to the Cache instance: the browser has exactly one so the
+// scoping is invisible there, but on the server the cache is created per request — and
+// suspense units carry resolved query data, which can be session-dependent. a module-wide
+// cache would let one request's render serve another user's data.
+const promiseCaches = new WeakMap>()
+
+export function promiseCacheFor(cache: Cache): LRUCache {
+ let result = promiseCaches.get(cache)
+ if (!result) {
+ result = createLRUCache(1000, (unit) => {
+ // a unit leaving the cache whose store no committed component ever picked up is
+ // an abandoned suspense (suspended, then unmounted before commit) or an errored
+ // fetch — dispose the store so its cache subscription doesn't outlive it.
+ // retained stores are governed by their holders instead.
+ if (!isObserverRetained(unit.observer)) {
+ unit.observer.cleanup()
+ }
+ })
+ promiseCaches.set(cache, result)
+ }
+ return result
+}
+
+// a session change invalidates every cached query result. the router clears its own
+// caches (data_cache et al); this is the same sweep for useQuery's suspense state, so a
+// session-dependent useQuery refetches instead of serving data fetched under the old
+// session. clearing evicts every unit (disposing unretained stores); mounted components
+// re-render off the session context, miss the cache, and re-suspend into a fresh fetch.
+export function invalidateSuspenseCache(cache: Cache) {
+ promiseCaches.get(cache)?.clear()
+}
diff --git a/packages/houdini-react/runtime/hooks/useQueryHandle.ts b/packages/houdini-react/runtime/hooks/useQueryHandle.ts
index 4aff85292..a6858931c 100644
--- a/packages/houdini-react/runtime/hooks/useQueryHandle.ts
+++ b/packages/houdini-react/runtime/hooks/useQueryHandle.ts
@@ -1,8 +1,10 @@
-import { createLRUCache } from 'houdini/runtime'
import type { GraphQLObject, CachePolicies, QueryArtifact, GraphQLVariables } from 'houdini/runtime'
+import type { DocumentStore } from 'houdini/runtime/client'
import React from 'react'
-import { useClient } from '../routing/index.js'
+import { GraphQLErrors, useRouterContext } from '../routing/index.js'
+import { releaseObserver, retainObserver } from './observerRefs.js'
+import { promiseCacheFor, type QuerySuspenseUnit } from './suspenseCache.js'
import type { DocumentHandle } from './useDocumentHandle.js'
import { useDocumentHandle } from './useDocumentHandle.js'
import { useIsMountedRef } from './useIsMounted.js'
@@ -17,16 +19,9 @@ import { useIsMountedRef } from './useIsMounted.js'
// - If we have a cached promise that's been resolved, we should return that value
//
// When the Component unmounts, we need to remove the entry from the cache (so we can load again)
-
-const promiseCache = createLRUCache()
-type QuerySuspenseUnit<
- _Data extends GraphQLObject = GraphQLObject,
- _Input extends GraphQLVariables = GraphQLVariables,
-> = {
- resolve: () => void
- resolved?: DocumentHandle
- then: (val: any) => any
-}
+//
+// The suspense state itself lives in suspenseCache.ts, scoped per Cache instance (i.e.
+// per request on the server) and invalidated on session changes.
export function useQueryHandle<
_Artifact extends QueryArtifact,
@@ -37,22 +32,45 @@ export function useQueryHandle<
variables: any = null,
config: UseQueryConfig = {}
): any {
+ // the client, the per-request cache (a singleton in the browser), and — during a
+ // server render — the stream injector all come from the router context
+ const { client, cache, injectToStream } = useRouterContext()
+
+ // suspense state lives on the per-request cache so SSR requests can't see each other's
+ const promiseCache = promiseCacheFor(cache)
+
// figure out the identifier so we know what to look for
const identifier = queryIdentifier({ artifact, variables, config })
// see if we have an entry in the cache for the identifier
const suspenseValue = promiseCache.get(identifier)
- const client = useClient()
+ // a failed fetch: surface the error to the nearest boundary, and drop the unit so a
+ // later mount (e.g. navigating back after the error boundary took over) retries
+ // instead of re-throwing the stale error forever
+ if (suspenseValue?.rejected) {
+ promiseCache.delete(identifier)
+ throw suspenseValue.rejected
+ }
const isMountedRef = useIsMountedRef()
- // hold onto an observer we'll use
+ // hold onto an observer we'll use. if a fetch for this identifier already started, we
+ // have to reuse the store that started it: suspending threw away the component
+ // instance that created it, and the cache subscription set up by that fetch belongs
+ // to that store — a fresh one would render fine but never hear about later cache
+ // updates. the initial value has to stay null (not an empty object) until the
+ // suspense promise resolves: every "do we have data yet" check below is a truthiness
+ // check, and a truthy empty object makes a re-render that happens mid-flight (eg a
+ // parent state update) commit the component with empty data instead of re-throwing
+ // the pending promise.
const [observer] = React.useState(
- client.observe<_Data, _Input>({
- artifact,
- initialValue: (suspenseValue?.resolved?.data ?? {}) as _Data,
- })
+ () =>
+ (suspenseValue?.observer as DocumentStore<_Data, _Input> | undefined) ??
+ client.observe<_Data, _Input>({
+ artifact,
+ initialValue: (suspenseValue?.resolved?.data ?? null) as _Data,
+ })
)
// a ref flag we'll enable before throwing so that we don't update while suspend
@@ -74,8 +92,14 @@ export function useQueryHandle<
[observer, isMountedRef.current]
)
- // get a safe reference to the cache
- const storeValue = React.useSyncExternalStore(subscribe, () => box.current)
+ // get a safe reference to the cache. the server snapshot is what lets this hook render
+ // during SSR at all: without it react throws before the fetch ever starts and the whole
+ // subtree falls back to client rendering.
+ const storeValue = React.useSyncExternalStore(
+ subscribe,
+ () => box.current,
+ () => box.current
+ )
// compute the imperative handle for this artifact
const handle = useDocumentHandle<_Artifact, _Data, _Input>({
@@ -92,13 +116,24 @@ export function useQueryHandle<
}
}, [identifier])
- // when we unmount, we need to clean up
+ // a committed component holds a reference on its store; the store tears down when
+ // the last holder unmounts (see observerRefs.ts)
React.useEffect(() => {
+ retainObserver(observer)
return () => {
- observer.cleanup()
+ releaseObserver(observer)
}
}, [observer])
+ // suspenseTracker mutes store notifications while we're suspended. on the initial
+ // mount the flag dies with the discarded pre-commit instance, but when a committed
+ // instance re-suspends (its variables changed) the flag flips on its own ref and
+ // nothing else clears it — the subscription would stay muted forever. effects only
+ // run for committed (non-throwing) renders, so this is the spot to unmute.
+ React.useEffect(() => {
+ suspenseTracker.current = false
+ })
+
// if the promise has resolved, let's use that for our first render
const result = storeValue.data
@@ -106,17 +141,18 @@ export function useQueryHandle<
// we are going to cache the promise and then throw it
// when it resolves the cached value will be updated
// and it will be picked up in the next render
+ // note: the thenable only ever resolves — failures park on suspenseUnit.rejected
+ // and resolve, so the retry render throws them (see the rejected check above)
let resolve: () => void = () => {}
- let reject: (reason?: any) => void = () => {}
- const loadPromise = new Promise((res, rej) => {
+ const loadPromise = new Promise((res) => {
resolve = res
- reject = rej
})
const suspenseUnit: QuerySuspenseUnit<_Data, _Input> = {
// biome-ignore lint/suspicious/noThenProperty: suspense protocol requires a thenable
then: loadPromise.then.bind(loadPromise),
resolve,
+ observer: observer as unknown as DocumentStore,
// @ts-expect-error
variables,
}
@@ -135,6 +171,18 @@ export function useQueryHandle<
},
})
.then((value) => {
+ // a graphql error must reach the error boundary, not resolve the suspense
+ // with null data (the component would crash reading its fields). same error
+ // shape route queries throw, so +error boundaries see the graphql errors.
+ // park it on the unit and resolve — the retry render throws it (see the
+ // rejected check above; rejecting the thenable would make react retry into
+ // a brand new fetch, an error loop)
+ if (value.errors && value.errors.length > 0) {
+ suspenseUnit.rejected = new GraphQLErrors(value.errors)
+ suspenseUnit.resolve()
+ return
+ }
+
// the final value
suspenseUnit.resolved = {
...handle,
@@ -143,11 +191,39 @@ export function useQueryHandle<
artifact,
} as unknown as DocumentHandle
+ // on the server, ship the resolved cache snapshot to the browser the same way
+ // route queries stream theirs: hydration then serves this query straight from
+ // the cache instead of refetching over the network. (a query that resolves
+ // before the shell flushes doesn't need this — its data rides the initial
+ // cache snapshot the server embeds in the document — and the injector wrapper
+ // no-ops in that window.)
+ if (!globalThis.window) {
+ injectToStream?.(`
+
+ `)
+ }
+
suspenseUnit.resolve()
})
.catch((err) => {
- promiseCache.delete(identifier)
- reject(err)
+ // same protocol as graphql errors: park and resolve so the retry render
+ // throws to the boundary instead of starting a fresh fetch
+ suspenseUnit.rejected = err
+ suspenseUnit.resolve()
})
suspenseTracker.current = true
throw suspenseUnit
diff --git a/packages/houdini-react/runtime/index.tsx b/packages/houdini-react/runtime/index.tsx
index 5bda0f226..61a735fab 100644
--- a/packages/houdini-react/runtime/index.tsx
+++ b/packages/houdini-react/runtime/index.tsx
@@ -66,6 +66,7 @@ export function Router({
session={session}
formResult={formResult}
formToken={formToken}
+ injectToStream={injectToStream}
>
void
}) {
// the session is top level state
// on the server, we can just use
@@ -974,12 +977,12 @@ export function RouterContextProvider({
// navigation no longer clears the data cache, so without this an event-driven
// session change (updateLocalSession) would keep serving results fetched under the
// old session
- invalidate_session_caches({ data_cache, ssr_signals, last_variables })
+ invalidate_session_caches({ cache, data_cache, ssr_signals, last_variables })
latestSession.current = merge ? { ...latestSession.current, ...next } : next
setSession(latestSession.current)
},
- [data_cache, ssr_signals, last_variables]
+ [cache, data_cache, ssr_signals, last_variables]
)
React.useEffect(() => {
@@ -1017,6 +1020,7 @@ export function RouterContextProvider({
},
formResult,
formToken,
+ injectToStream,
}}
>
{children}
@@ -1071,6 +1075,11 @@ export type RouterContext = {
// the session-bound CSRF token forms render in their hidden field (always present from a
// server render; null only when there is no server, e.g. a static export).
formToken: string | null
+
+ // present only during a server render: appends a chunk to the response stream. queries
+ // that resolve after the shell flushes use this to ship their cache snapshot to the
+ // browser (route queries via load_query, component-level useQuery via useQueryHandle).
+ injectToStream?: (chunk: string) => void
}
// FormResult mirrors the server's injected shape: a no-JS submission's result keyed by
@@ -1128,6 +1137,7 @@ export function updateLocalSession(session: App.Session, merge = false) {
// suspends into its loading state and refetches). One helper shared by both session-change
// paths (updateSession and the HOUDINI_SESSION_EVENT listener) so they can't drift.
function invalidate_session_caches(caches: {
+ cache: Cache
data_cache: SuspenseCache>
ssr_signals: PendingCache
last_variables: LRUCache
@@ -1135,6 +1145,14 @@ function invalidate_session_caches(caches: {
caches.data_cache.clear()
caches.ssr_signals.clear()
caches.last_variables.clear()
+ // useQuery's suspense state is keyed off the Cache instance and holds resolved query
+ // data too — sweep it as well so session-dependent useQuery components refetch
+ invalidateSuspenseCache(caches.cache)
+ // the normalized cache still holds values fetched under the old session. mark it all
+ // stale (not clear — the data can render while revalidating) so a refetch whose
+ // variables didn't change with the session still continues to the network instead of
+ // being served the old session's value from cache.
+ caches.cache.markTypeStale()
}
export function useSession(): [
diff --git a/packages/houdini/src/lib/database.ts b/packages/houdini/src/lib/database.ts
index 197af025f..fc37af695 100644
--- a/packages/houdini/src/lib/database.ts
+++ b/packages/houdini/src/lib/database.ts
@@ -575,6 +575,10 @@ export async function write_config(
db.run('DELETE FROM watch_schema_config')
db.run('DELETE FROM scalar_config')
db.run('DELETE FROM type_configs')
+ // runtime_scalar_definitions.name is a UNIQUE primary key, so re-seeding on a
+ // persisted db without clearing first throws on the duplicate insert — which
+ // aborts the seed loop and silently drops any newly-added runtime scalars.
+ db.run('DELETE FROM runtime_scalar_definitions')
// write the config to the database
db.run(
diff --git a/packages/houdini/src/runtime/cache/index.ts b/packages/houdini/src/runtime/cache/index.ts
index 51253eaf0..6ba8d9304 100644
--- a/packages/houdini/src/runtime/cache/index.ts
+++ b/packages/houdini/src/runtime/cache/index.ts
@@ -255,7 +255,19 @@ export class Cache {
}
hydrate(...args: Parameters) {
- return this._internal_unstable.storage.hydrate(...args)
+ const layer = this._internal_unstable.storage.hydrate(...args)
+
+ // let the stale manager know about the snapshot: field times are normally recorded
+ // on write, and a field without one reads as "not stale" forever — so without this,
+ // hydrated data would be invisible to markStale and could never be invalidated
+ // (e.g. by a session change). registration is O(1) here; the per-field bookkeeping
+ // is deferred to the first mark (see StaleManager.registerHydration).
+ const snapshot = args[0]
+ if (snapshot) {
+ this._internal_unstable.staleManager.registerHydration(snapshot)
+ }
+
+ return layer
}
clearLayer(layerID: Layer['id']) {
diff --git a/packages/houdini/src/runtime/cache/staleManager.ts b/packages/houdini/src/runtime/cache/staleManager.ts
index 1616b1bf6..29e38b544 100644
--- a/packages/houdini/src/runtime/cache/staleManager.ts
+++ b/packages/houdini/src/runtime/cache/staleManager.ts
@@ -17,10 +17,46 @@ export class StaleManager {
// nulls mean that the value is stale, and the number is the time that the value was set
private fieldsTime: Map> = new Map()
+ // snapshots that arrived via hydration and haven't been registered yet. field times
+ // are normally recorded per write, but hydration assigns whole layers at once and has
+ // to stay O(1) — so we hold onto the snapshot and only materialize its field times
+ // when a mark* actually needs them (staleness marks are rare; hydration happens on
+ // every page load). until then a hydrated field reads as "no entry", exactly how it
+ // read before any mark existed.
+ private pendingHydrated: HydratedSnapshot[] = []
+
constructor(cache: Cache) {
this.cache = cache
}
+ // note a hydrated snapshot whose fields should participate in staleness. O(1): the
+ // per-field registration is deferred to the first mark* call (see #flushHydrated)
+ registerHydration(snapshot: HydratedSnapshot) {
+ this.pendingHydrated.push(snapshot)
+ }
+
+ #flushHydrated() {
+ if (this.pendingHydrated.length === 0) {
+ return
+ }
+ const now = Date.now()
+ for (const snapshot of this.pendingHydrated) {
+ for (const source of [snapshot.fields, snapshot.links]) {
+ for (const [id, fields] of Object.entries(source ?? {})) {
+ this.#initMapId(id)
+ const map = this.fieldsTime.get(id)!
+ for (const field of Object.keys(fields)) {
+ // explicit writes (and marks) since hydration win over the snapshot
+ if (!map.has(field)) {
+ map.set(field, now)
+ }
+ }
+ }
+ }
+ }
+ this.pendingHydrated = []
+ }
+
#initMapId = (id: string) => {
if (!this.fieldsTime.get(id)) {
this.fieldsTime.set(id, new Map())
@@ -57,6 +93,7 @@ export class StaleManager {
}
markAllStale(): void {
+ this.#flushHydrated()
for (const [id, fieldMap] of this.fieldsTime.entries()) {
for (const [field] of fieldMap.entries()) {
this.markFieldStale(id, field)
@@ -65,6 +102,7 @@ export class StaleManager {
}
markRecordStale(id: string): void {
+ this.#flushHydrated()
const fieldsTimeOfType = this.fieldsTime.get(id)
if (fieldsTimeOfType) {
for (const [field] of fieldsTimeOfType.entries()) {
@@ -74,6 +112,7 @@ export class StaleManager {
}
markTypeStale(type: string): void {
+ this.#flushHydrated()
for (const [id, fieldMap] of this.fieldsTime.entries()) {
// if starts lile `User:` (it will catch `User:1` for example)
if (id.startsWith(`${type}:`)) {
@@ -85,6 +124,7 @@ export class StaleManager {
}
markTypeFieldStale(type: string, field: string, when?: {}): void {
+ this.#flushHydrated()
const key = computeKey({ field, args: when })
for (const [id, fieldMap] of this.fieldsTime.entries()) {
@@ -116,5 +156,13 @@ export class StaleManager {
reset() {
this.fieldsTime.clear()
+ this.pendingHydrated = []
}
}
+
+// the only thing registration needs from a hydrated snapshot is which fields each
+// record carries
+type HydratedSnapshot = {
+ fields?: Record>
+ links?: Record>
+}
diff --git a/packages/houdini/src/runtime/cache/tests/hydrate.test.ts b/packages/houdini/src/runtime/cache/tests/hydrate.test.ts
new file mode 100644
index 000000000..dfa42df2e
--- /dev/null
+++ b/packages/houdini/src/runtime/cache/tests/hydrate.test.ts
@@ -0,0 +1,100 @@
+import { expect, test } from 'vitest'
+
+import { testConfigFile } from '../../../test/index.js'
+import type { SubscriptionSelection } from '../../types.js'
+import { Cache } from '../index.js'
+
+const config = testConfigFile()
+
+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',
+ },
+ },
+ },
+ },
+ },
+}
+
+// hydrated fields must participate in staleness like written fields: field times are
+// normally recorded on write, and a field without one reads as "not stale" forever — so
+// without registration at hydrate time, hydrated data could never be invalidated (e.g.
+// by a session change marking everything stale).
+test('hydrated data can be marked stale', () => {
+ // write into one cache and serialize (the SSR side)
+ const server = new Cache(config)
+ server.write({
+ selection,
+ data: {
+ viewer: {
+ id: '1',
+ firstName: 'bob',
+ },
+ },
+ })
+ const snapshot = JSON.parse(server.serialize())
+
+ // hydrate a fresh cache from the snapshot (the browser side)
+ const browser = new Cache(config)
+ browser.hydrate(snapshot)
+
+ // the data reads back fresh
+ const before = browser.read({ selection })
+ expect(before.data).toEqual({ viewer: { id: '1', firstName: 'bob' } })
+ expect(before.stale).toBe(false)
+
+ // marking everything stale must reach the hydrated fields
+ browser.markTypeStale()
+ expect(browser.read({ selection }).stale).toBe(true)
+})
+
+// hydrated fields register with the stale manager lazily (on the first mark). a field
+// explicitly marked before that first flush must keep its mark: the deferred
+// registration only fills in fields that have no entry yet.
+test('an explicit stale mark set after hydration survives the deferred registration', () => {
+ const server = new Cache(config)
+ server.write({
+ selection,
+ data: {
+ viewer: {
+ id: '1',
+ firstName: 'bob',
+ },
+ },
+ })
+ const snapshot = JSON.parse(server.serialize())
+
+ const browser = new Cache(config)
+ browser.hydrate(snapshot)
+
+ // mark one hydrated field stale directly (this is the first mark, so it also
+ // triggers the deferred registration of everything else)
+ browser.markRecordStale('User:1', {})
+ expect(browser.read({ selection }).stale).toBe(true)
+
+ // and a fresh write brings it back
+ browser.write({
+ selection,
+ data: {
+ viewer: {
+ id: '1',
+ firstName: 'anne',
+ },
+ },
+ })
+ expect(browser.read({ selection }).stale).toBe(false)
+})
diff --git a/packages/houdini/src/runtime/lru.test.ts b/packages/houdini/src/runtime/lru.test.ts
new file mode 100644
index 000000000..a08b644e0
--- /dev/null
+++ b/packages/houdini/src/runtime/lru.test.ts
@@ -0,0 +1,78 @@
+import { expect, test, vi } from 'vitest'
+
+import { createLRUCache } from './lru'
+
+test('evicts the least recently used entry past capacity', () => {
+ const cache = createLRUCache(2)
+ cache.set('a', 'A')
+ cache.set('b', 'B')
+ cache.set('c', 'C')
+
+ expect(cache.has('a')).toBe(false)
+ expect(cache.get('b')).toBe('B')
+ expect(cache.get('c')).toBe('C')
+})
+
+test('get refreshes recency', () => {
+ const cache = createLRUCache(2)
+ cache.set('a', 'A')
+ cache.set('b', 'B')
+ // touch a so b becomes the least recently used
+ cache.get('a')
+ cache.set('c', 'C')
+
+ expect(cache.has('a')).toBe(true)
+ expect(cache.has('b')).toBe(false)
+})
+
+test('onEvict fires for capacity evictions with the evicted value', () => {
+ const onEvict = vi.fn()
+ const cache = createLRUCache(2, onEvict)
+ cache.set('a', 'A')
+ cache.set('b', 'B')
+ cache.set('c', 'C')
+
+ expect(onEvict).toHaveBeenCalledTimes(1)
+ expect(onEvict).toHaveBeenCalledWith('A', 'a')
+})
+
+test('onEvict fires for explicit deletes', () => {
+ const onEvict = vi.fn()
+ const cache = createLRUCache(10, onEvict)
+ cache.set('a', 'A')
+ cache.delete('a')
+
+ expect(onEvict).toHaveBeenCalledTimes(1)
+ expect(onEvict).toHaveBeenCalledWith('A', 'a')
+
+ // deleting a missing key does not fire
+ cache.delete('missing')
+ expect(onEvict).toHaveBeenCalledTimes(1)
+})
+
+test('onEvict fires when a key is overwritten with a different value', () => {
+ const onEvict = vi.fn()
+ const cache = createLRUCache(10, onEvict)
+ cache.set('a', 'A')
+ cache.set('a', 'A2')
+
+ expect(onEvict).toHaveBeenCalledTimes(1)
+ expect(onEvict).toHaveBeenCalledWith('A', 'a')
+
+ // re-setting the same value is an LRU touch, not an eviction
+ cache.set('a', 'A2')
+ expect(onEvict).toHaveBeenCalledTimes(1)
+})
+
+test('onEvict fires for every entry on clear', () => {
+ const onEvict = vi.fn()
+ const cache = createLRUCache(10, onEvict)
+ cache.set('a', 'A')
+ cache.set('b', 'B')
+ cache.clear()
+
+ expect(onEvict).toHaveBeenCalledTimes(2)
+ expect(onEvict).toHaveBeenCalledWith('A', 'a')
+ expect(onEvict).toHaveBeenCalledWith('B', 'b')
+ expect(cache.size()).toBe(0)
+})
diff --git a/packages/houdini/src/runtime/lru.ts b/packages/houdini/src/runtime/lru.ts
index b1eddd18a..8c156677c 100644
--- a/packages/houdini/src/runtime/lru.ts
+++ b/packages/houdini/src/runtime/lru.ts
@@ -18,19 +18,33 @@
export class LRUCache {
_capacity: number
_map: Map
+ // invoked whenever a value leaves the cache (capacity eviction, delete, overwrite,
+ // clear) so owners of resources with teardown (subscriptions, stores) can dispose them
+ _onEvict?: (value: T, key: string) => void
- constructor(capacity: number = 1000) {
+ constructor(capacity: number = 1000, onEvict?: (value: T, key: string) => void) {
this._capacity = capacity
this._map = new Map()
+ this._onEvict = onEvict
}
set(key: string, value: T): void {
+ // an overwrite with a different value evicts the old one (deleting and
+ // reinserting the same value is just the LRU touch, not an eviction)
+ const existing = this._map.get(key)
this._map.delete(key)
+ if (existing !== undefined && existing !== value) {
+ this._onEvict?.(existing, key)
+ }
this._map.set(key, value)
if (this._map.size > this._capacity) {
const firstKey = this._map.keys().next()
if (!firstKey.done) {
+ const evicted = this._map.get(firstKey.value)
this._map.delete(firstKey.value)
+ if (evicted !== undefined) {
+ this._onEvict?.(evicted, firstKey.value)
+ }
}
}
}
@@ -49,7 +63,11 @@ export class LRUCache {
}
delete(key: string): void {
+ const existing = this._map.get(key)
this._map.delete(key)
+ if (existing !== undefined) {
+ this._onEvict?.(existing, key)
+ }
}
size(): number {
@@ -61,12 +79,19 @@ export class LRUCache {
}
clear(): void {
+ const entries = [...this._map.entries()]
this._map.clear()
+ for (const [key, value] of entries) {
+ this._onEvict?.(value, key)
+ }
}
}
-export function createLRUCache(capacity: number = 1000): LRUCache {
- return new LRUCache(capacity)
+export function createLRUCache(
+ capacity: number = 1000,
+ onEvict?: (value: T, key: string) => void
+): LRUCache {
+ return new LRUCache(capacity, onEvict)
}
/**