Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-falcons-repeat.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/proud-otters-hunt.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-pugs-brake.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/react/05-guides/01-authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Notice variant="warning">
**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.
</Notice>
Expand Down
2 changes: 1 addition & 1 deletion docs/react/06-api-reference/16-useQuery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions e2e/_api/graphql.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions e2e/_api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
61 changes: 61 additions & 0 deletions e2e/react/src/routes/use-query-abandon/+page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div id="name">{data.user.name}</div>
}

// 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 (
<button
id="update"
onClick={() =>
update({
variables: { snapshot: 'use-query-abandon', id: '1', name: 'Updated Name' },
})
}
>
update
</button>
)
}

export default function () {
return (
<>
<Suspense fallback={<div id="fallback">loading</div>}>
<UserName />
</Suspense>

<UpdateButton />
</>
)
}
32 changes: 32 additions & 0 deletions e2e/react/src/routes/use-query-abandon/test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
5 changes: 5 additions & 0 deletions e2e/react/src/routes/use-query-error/+error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { ErrorProps } from './$types'

export default function UseQueryErrorBoundary({ errors }: ErrorProps) {
return <div id="error-message">{errors[0]?.message}</div>
}
29 changes: 29 additions & 0 deletions e2e/react/src/routes/use-query-error/+page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div id="name">{data.user.name}</div>
}

export default function () {
return (
<Suspense fallback={<div id="fallback">loading</div>}>
<BrokenUser />
</Suspense>
)
}
20 changes: 20 additions & 0 deletions e2e/react/src/routes/use-query-error/test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
72 changes: 72 additions & 0 deletions e2e/react/src/routes/use-query-reactivity/+page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div id="name">{data.user.name}</div>
}

// 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 (
<button
id={buttonId}
onClick={() =>
update({
variables: { snapshot: 'use-query-reactivity', id, name },
})
}
>
{buttonId}
</button>
)
}

export default function () {
// which user the query renders. switching makes UserName re-suspend with new variables
const [userID, setUserID] = useState('1')

return (
<>
<Suspense fallback={<div id="fallback">loading</div>}>
<UserName id={userID} />
</Suspense>

<UpdateButton buttonId="update-1" id="1" name="Updated One" />
<UpdateButton buttonId="update-2" id="2" name="Updated Two" />
<button id="switch" onClick={() => setUserID('2')}>
switch
</button>
</>
)
}
37 changes: 37 additions & 0 deletions e2e/react/src/routes/use-query-reactivity/test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
37 changes: 37 additions & 0 deletions e2e/react/src/routes/use-query-rerender/+page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div id="result">{data.user?.name ?? 'MISSING'}</div>
}

export default function () {
const [count, setCount] = React.useState(0)

return (
<>
<button id="rerender" onClick={() => setCount(count + 1)}>
rerender {count}
</button>

<Suspense fallback={<div id="fallback">loading</div>}>
<UseQueryResult />
</Suspense>
</>
)
}
Loading
Loading