houdini-react 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "houdini-react",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "The React plugin for houdini",
5
5
  "keywords": [
6
6
  "typescript",
@@ -81,13 +81,13 @@
81
81
  }
82
82
  },
83
83
  "optionalDependencies": {
84
- "houdini-react-darwin-x64": "2.0.1",
85
- "houdini-react-darwin-arm64": "2.0.1",
86
- "houdini-react-linux-x64": "2.0.1",
87
- "houdini-react-linux-arm64": "2.0.1",
88
- "houdini-react-win32-x64": "2.0.1",
89
- "houdini-react-win32-arm64": "2.0.1",
90
- "houdini-react-wasm": "2.0.1"
84
+ "houdini-react-darwin-x64": "2.1.0",
85
+ "houdini-react-darwin-arm64": "2.1.0",
86
+ "houdini-react-linux-x64": "2.1.0",
87
+ "houdini-react-linux-arm64": "2.1.0",
88
+ "houdini-react-win32-x64": "2.1.0",
89
+ "houdini-react-win32-arm64": "2.1.0",
90
+ "houdini-react-wasm": "2.1.0"
91
91
  },
92
92
  "scripts": {
93
93
  "compile": "scripts build-go",
package/postInstall.js CHANGED
@@ -5,7 +5,7 @@ const https = require('https')
5
5
  const child_process = require('child_process')
6
6
 
7
7
  // Adjust the version you want to install. You can also make this dynamic.
8
- const BINARY_DISTRIBUTION_VERSION = '2.0.1'
8
+ const BINARY_DISTRIBUTION_VERSION = '2.1.0'
9
9
 
10
10
  // Windows binaries end with .exe so we need to special case them.
11
11
  const binaryName = process.platform === 'win32' ? 'houdini-react.exe' : 'houdini-react'
@@ -0,0 +1,81 @@
1
+ import type { GraphQLError } from 'houdini/runtime'
2
+ import { createContext } from 'react'
3
+
4
+ import type { Goto } from './routes.js'
5
+ import type { RouterContext } from './routing/Router.js'
6
+
7
+ // All of the runtime's React contexts are defined in this module on purpose.
8
+ //
9
+ // A React context is a module-level singleton: provider and consumer must hold
10
+ // the *same* object returned by createContext(). When a context is created
11
+ // inside a module that also exports components/hooks (like Router.tsx), Vite's
12
+ // dev server re-evaluates that module on an HMR update (for example when codegen
13
+ // rewrites a neighboring generated unit, or when react-refresh falls back to a
14
+ // full module re-run). Each re-evaluation mints a brand-new context object, and
15
+ // if a consumer rebinds to the new one while the still-mounted provider holds
16
+ // the old one, useContext() reads a context the provider never populated and
17
+ // throws "Could not find router context".
18
+ //
19
+ // This module imports only `react` at runtime (everything else is `import type`,
20
+ // erased at build time), so it is a pure leaf in the dependency graph. Route
21
+ // edits never re-evaluate it, so these context objects keep a stable identity
22
+ // across granular HMR updates. We deliberately avoid a globalThis registry to
23
+ // pin identity: multiple independent Router contexts can legitimately coexist in
24
+ // one process (for example across Astro islands), and a global singleton would
25
+ // wrongly collapse them into one.
26
+
27
+ export const RouterContextObject = createContext<RouterContext | null>(null)
28
+
29
+ export const LocationContext = createContext<{
30
+ pathname: string
31
+ params: Record<string, any>
32
+ // the parsed query string of the current url (declared search params coerced to
33
+ // their scalar type, other keys raw; repeated keys are arrays).
34
+ search: Record<string, any>
35
+ // a function to imperatively navigate to a url
36
+ goto: Goto
37
+ }>({
38
+ pathname: '',
39
+ params: {},
40
+ search: {},
41
+ goto: () => {},
42
+ })
43
+
44
+ export const Is404Context = createContext(false)
45
+
46
+ // PendingURLContext (internal) carries the raw navigation target. Unlike
47
+ // NavigationContext.to it is NOT nulled when the router considers itself idle, so it
48
+ // reads the same in every render lane — the transition lane renders with isPending
49
+ // false, which would hide the target from it. useQueryResult uses this to tell whether
50
+ // the render it is part of is the destination of an in-flight navigation (suspend on a
51
+ // missing store) or the still-visible previous page (keep rendering the store it has).
52
+ export const PendingURLContext = createContext<string | null>(null)
53
+
54
+ // NavigationContext carries the router's in-flight navigation, if any: `pending` is true
55
+ // from the moment a navigation starts until the destination renders its actual content
56
+ // (it stays true while the @loading state shows), and `to` is the destination url while
57
+ // pending. Read through useNavigation().
58
+ export const NavigationContext = createContext<{
59
+ pending: boolean
60
+ to: string | null
61
+ goto: Goto
62
+ }>({
63
+ pending: false,
64
+ to: null,
65
+ goto: () => {},
66
+ })
67
+
68
+ export const PageContext = createContext<{ params: Record<string, any> }>({ params: {} })
69
+
70
+ // Mutable ref passed from the server renderer. It carries the HTTP status/location for
71
+ // the response and, when the first SSR render pass threw (error boundaries don't run
72
+ // during SSR), the errors the boundary should render on the second pass.
73
+ export const StatusContext = createContext<{
74
+ status: number
75
+ errors?: Array<Error | GraphQLError>
76
+ } | null>(null)
77
+
78
+ // FormStatusContext carries the nearest <Form>'s pending state to useMutationFormStatus(),
79
+ // the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
80
+ // submissions, so it can't see our forms).
81
+ export const FormStatusContext = createContext<{ pending: boolean }>({ pending: false })
@@ -103,6 +103,15 @@ export function useDocumentHandle<
103
103
  ...args?.variables,
104
104
  },
105
105
  session,
106
+ // handle-driven sends (pagination, manual refetch) must not republish the
107
+ // document's @loading state — the skeleton is for router-driven loads. The
108
+ // page keeps its current data while the request is in flight; callers track
109
+ // progress through loadNextPending/loadPreviousPending instead. (matches
110
+ // useQueryHandle/useFragmentHandle and the svelte pagination stores)
111
+ stuff: {
112
+ silenceLoading: true,
113
+ ...(args as { stuff?: App.Stuff } | undefined)?.stuff,
114
+ },
106
115
  })
107
116
  }
108
117
 
@@ -141,6 +150,7 @@ export function useDocumentHandle<
141
150
  applyUpdates: updates,
142
151
  },
143
152
  session,
153
+ stuff: { silenceLoading: true, ...args?.stuff },
144
154
  })
145
155
  },
146
156
  getSession: async () => session,
@@ -172,6 +182,7 @@ export function useDocumentHandle<
172
182
  applyUpdates: updates,
173
183
  ...args?.cacheParams,
174
184
  },
185
+ stuff: { silenceLoading: true, ...args?.stuff },
175
186
  })
176
187
  },
177
188
  getSession: async () => session,
@@ -3,6 +3,7 @@ import type { MutationArtifact, GraphQLObject, GraphQLVariables } from 'houdini/
3
3
  import { coerceFormData, interpolateRedirect } from 'houdini/runtime'
4
4
  import React from 'react'
5
5
 
6
+ import { FormStatusContext } from '../contexts.js'
6
7
  import { useSession, useRoute, useFormResult, useFormToken } from '../routing/Router.js'
7
8
  import { useDocumentStore } from './useDocumentStore.js'
8
9
 
@@ -40,10 +41,9 @@ export type MutationForm<_Result = any> = {
40
41
  pending: boolean
41
42
  }
42
43
 
43
- // FormStatusContext carries the nearest <Form>'s pending state to useMutationFormStatus(),
44
- // the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
45
- // submissions, so it can't see our forms).
46
- const FormStatusContext = React.createContext<{ pending: boolean }>({ pending: false })
44
+ // FormStatusContext (defined in ../contexts.js) carries the nearest <Form>'s pending state
45
+ // to useMutationFormStatus(), the no-prop-drilling ergonomic of React's useFormStatus (which
46
+ // only tracks function-action submissions, so it can't see our forms).
47
47
 
48
48
  /** useMutationFormStatus reads the pending state of the nearest <Form> from a child. */
49
49
  export function useMutationFormStatus(): { pending: boolean } {
@@ -26,6 +26,7 @@ declare global {
26
26
  __houdini__auth_url__?: string | null
27
27
  __houdini__pending_artifacts__?: Record<string, QueryArtifact>
28
28
  __houdini__pending_data__?: Record<string, any>
29
+ __houdini__pending_errors__?: Record<string, any>
29
30
  __houdini__pending_variables__?: Record<string, GraphQLVariables>
30
31
  __houdini__pending_cache__?: any[]
31
32
  __houdini__nav_caches__?: RouterCache
@@ -127,6 +128,26 @@ export function hydrate_page(
127
128
  }
128
129
  }
129
130
 
131
+ // an @loading query that errored streams its errors here (its data is null, so it has no
132
+ // pending_data entry above). build a store that already carries the errors so the page's
133
+ // useQueryResult throws to the error boundary instead of hanging on the loading frame.
134
+ for (const [artifactName, errors] of Object.entries(window.__houdini__pending_errors__ ?? {})) {
135
+ const artifact = window.__houdini__pending_artifacts__?.[artifactName]
136
+ if (!artifact) {
137
+ continue
138
+ }
139
+ initialArtifacts[artifactName] = artifact
140
+ const variables = window.__houdini__pending_variables__?.[artifactName]
141
+ const observer = window.__houdini__client__!.observe({
142
+ artifact,
143
+ cache: window.__houdini__cache__,
144
+ initialVariables: variables,
145
+ })
146
+ observer.update((state) => ({ ...state, fetching: false, errors }))
147
+ initialData[artifactName] = observer
148
+ }
149
+ window.__houdini__pending_errors__ = {}
150
+
130
151
  if (!window.__houdini__nav_caches__) {
131
152
  window.__houdini__nav_caches__ = router_cache({
132
153
  pending_queries: pendingQueries,
package/runtime/index.tsx CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  useCache,
17
17
  useSession,
18
18
  useRoute,
19
+ useNavigation,
19
20
  notFound,
20
21
  unauthorized,
21
22
  forbidden,
@@ -1,4 +1,4 @@
1
- import type { GraphQLObject, GraphQLVariables } from 'houdini/runtime'
1
+ import type { GraphQLError, GraphQLObject, GraphQLVariables } from 'houdini/runtime'
2
2
  import type { QueryArtifact } from 'houdini/runtime'
3
3
  import type { Cache } from 'houdini/runtime/cache'
4
4
  import type { DocumentStore, HoudiniClient } from 'houdini/runtime/client'
@@ -13,6 +13,15 @@ import type { RouterManifest, RouterPageManifest } from 'houdini/router/types'
13
13
  import React from 'react'
14
14
  import { useContext, useEffect } from 'react'
15
15
 
16
+ import {
17
+ RouterContextObject as Context,
18
+ LocationContext,
19
+ Is404Context,
20
+ NavigationContext,
21
+ PageContext,
22
+ PendingURLContext,
23
+ } from '../contexts.js'
24
+ import { escapeScriptTag } from '../escape.js'
16
25
  import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
17
26
  import type { Goto } from '../routes.js'
18
27
  import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
@@ -20,7 +29,11 @@ import { useDocumentStore } from '../hooks/useDocumentStore.js'
20
29
  import { type SuspenseCache, suspense_cache } from './cache.js'
21
30
  import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
22
31
 
23
- type PageComponent = React.ComponentType<{ url: string; children?: React.ReactNode }>
32
+ type PageComponent = React.ComponentType<{
33
+ url: string
34
+ showLoading?: boolean
35
+ children?: React.ReactNode
36
+ }>
24
37
 
25
38
  const PreloadWhich = {
26
39
  component: 'component',
@@ -30,6 +43,86 @@ const PreloadWhich = {
30
43
 
31
44
  type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
32
45
  type ComponentType = any
46
+
47
+ // useLoadingState decides whether to show the route's @loading state during navigation.
48
+ // `active` is the navigation transition's pending flag. The state flips on only once
49
+ // `active` has been pending for at least `loadingDelay` ms (so fast navigations never
50
+ // show it). Once shown it stays on until BOTH of these are true, so a response landing
51
+ // just after the delay doesn't cause a skeleton flicker:
52
+ // - it has been visible for at least `minDuration` ms
53
+ // - the target page's data has landed (`waitForData` resolves)
54
+ // A navigation that starts while the state is already showing re-arms the minDuration
55
+ // clock, so the new destination's data can't flash in right as the original hold expires
56
+ // — `target` (the destination url) is a dependency for exactly that reason: a second
57
+ // navigation can start without `active` ever flipping (the first transition is still
58
+ // pending), and the target change is what re-runs the effect.
59
+ // Note that `active` flips false as soon as the loading frame commits (the frame doesn't
60
+ // suspend, so the transition finishes with it on screen) — which is why the hide side
61
+ // waits on the data explicitly instead of trusting `active`.
62
+ function useLoadingState({
63
+ active,
64
+ target,
65
+ loadingDelay,
66
+ minDuration,
67
+ waitForData,
68
+ }: {
69
+ active: boolean
70
+ target: string | null
71
+ loadingDelay: number
72
+ minDuration: number
73
+ waitForData: () => Promise<void>
74
+ }): boolean {
75
+ const [show, setShow] = React.useState(false)
76
+ const shownAt = React.useRef<number | null>(null)
77
+
78
+ React.useEffect(() => {
79
+ if (active) {
80
+ // already showing — a new navigation is starting while the loading state is
81
+ // up. Keep it up, but re-arm the minimum-duration clock: measured from the
82
+ // first show, the hold could expire right as this navigation's data lands,
83
+ // flashing the freshly-loaded content in and out of the loading state.
84
+ if (show) {
85
+ shownAt.current = performance.now()
86
+ return
87
+ }
88
+ // wait out the delay; if we're still pending, switch the loading state on
89
+ const timeout = setTimeout(() => {
90
+ shownAt.current = performance.now()
91
+ setShow(true)
92
+ }, loadingDelay)
93
+ return () => clearTimeout(timeout)
94
+ }
95
+
96
+ // navigation finished. if we never showed the loading state, there's nothing to do
97
+ if (!show) {
98
+ return
99
+ }
100
+ // otherwise wait for the page's data, then keep the loading state up until it has
101
+ // been visible for at least minDuration. waiting on the data here means the page
102
+ // never mounts with its query still missing (which would re-suspend it into the
103
+ // Suspense fallback and briefly double-render the frame).
104
+ let cancelled = false
105
+ let timeout: ReturnType<typeof setTimeout> | undefined
106
+ waitForData().then(() => {
107
+ if (cancelled) {
108
+ return
109
+ }
110
+ const elapsed = performance.now() - (shownAt.current ?? performance.now())
111
+ const remaining = Math.max(0, minDuration - elapsed)
112
+ timeout = setTimeout(() => {
113
+ shownAt.current = null
114
+ setShow(false)
115
+ }, remaining)
116
+ })
117
+ return () => {
118
+ cancelled = true
119
+ clearTimeout(timeout)
120
+ }
121
+ }, [active, show, target, loadingDelay, minDuration])
122
+
123
+ return show
124
+ }
125
+
33
126
  /**
34
127
  * Router is the top level entry point for the filesystem-based router.
35
128
  * It is responsible for loading various page sources (including API fetches) and
@@ -57,6 +150,20 @@ export function Router({
57
150
  return initialURL || window.location.pathname + window.location.search
58
151
  })
59
152
 
153
+ // Navigation runs inside a transition so React keeps the previously-rendered route
154
+ // on screen while the next one loads (instead of immediately falling back to the
155
+ // loading state). If the transition stays pending longer than `loadingDelay`, we
156
+ // surface the route's @loading frame (see showLoading → the entry's page slot);
157
+ // fast navigations never show it, and once shown it stays up for `minDuration`.
158
+ const [isNavigating, startNavigation] = React.useTransition()
159
+
160
+ // pendingURL tracks the navigation target *urgently* (outside the transition), so a
161
+ // render that happens while the transition is still pending can tell whether it is
162
+ // looking at the destination (transition lane: currentURL === pendingURL) or at the
163
+ // still-committed previous route (urgent lane: currentURL lags behind). The loading
164
+ // frame is only ever shown on the destination — see the showLoading prop below.
165
+ const [pendingURL, setPendingURL] = React.useState<string | null>(null)
166
+
60
167
  // find the matching page for the current route. find_match also hands back the parsed
61
168
  // query string (declared search params coerced, UI-only keys raw). custom-scalar route
62
169
  // and search params arrive in their url transport form, so we unmarshal them once here
@@ -90,8 +197,35 @@ export function Router({
90
197
  injectToStream,
91
198
  })
92
199
  // if we get this far, it's safe to load the component
93
- const { component_cache, data_cache, ssr_signals } = useRouterContext()
200
+ const { component_cache, data_cache, artifact_cache, ssr_signals } = useRouterContext()
94
201
  const PageComponent = component_cache.get(targetPage.id)!
202
+ const [session] = useSession()
203
+
204
+ // decide whether the entry should render its @loading frame instead of the page.
205
+ // values come from the router config (bundled client-side; they're UI timing, not
206
+ // secrets), clamped so a bad value can't schedule a negative timeout.
207
+ // The machine only arms when the *destination* can actually render a frame (one of
208
+ // its documents is @loading): a frameless destination just holds the previous page
209
+ // for the whole transition, and arming the invisible state anyway would keep
210
+ // useNavigation().pending true for up to minDuration after the content committed.
211
+ const routerConfig = getCurrentConfig()?.router ?? {}
212
+ const [pendingPage] = pendingURL !== null ? find_match(manifest, pendingURL) : [null]
213
+ const destinationHasFrame = pendingPage
214
+ ? Object.values(pendingPage.documents).some((document) => document.loading)
215
+ : false
216
+ const showLoading = useLoadingState({
217
+ active: isNavigating && destinationHasFrame,
218
+ target: pendingURL,
219
+ loadingDelay: Math.max(0, routerConfig.loadingDelay ?? 200),
220
+ minDuration: Math.max(0, routerConfig.minDuration ?? 400),
221
+ // resolves once every query of the page being rendered has a store in data_cache
222
+ // (including error stores — load_query seeds those too, so an errored query
223
+ // releases the loading state instead of pinning it)
224
+ waitForData: () =>
225
+ Promise.all(
226
+ Object.keys(targetPage.documents).map((name) => data_cache.waitFor(name))
227
+ ).then(() => {}),
228
+ })
95
229
 
96
230
  // if we got this far then we're past suspense
97
231
 
@@ -106,13 +240,96 @@ export function Router({
106
240
  }
107
241
  }, [currentURL])
108
242
 
243
+ // (dev only) hot-swap regenerated query artifacts. The router loads artifacts through
244
+ // dynamic import and keeps them — and the DocumentStores built from them — in caches
245
+ // keyed by document name, so vite's module-level HMR can never reach them: after
246
+ // codegen rewrites an artifact, the caches keep serving the old document and the page
247
+ // renders stale data. The compiler emits a custom event listing the changed artifact
248
+ // modules (see flushClientUpdates in houdini's vite plugin); re-import each one fresh,
249
+ // swap it into the artifact cache, evict the stale store, and reload the current
250
+ // page's data. useQueryResult keeps the committed store on screen while the
251
+ // replacement loads (the same keep-last-store path as an abandoned navigation), so
252
+ // the swap doesn't flash a loading state.
253
+ const [, forceRender] = React.useReducer((n: number) => n + 1, 0)
254
+ // the listener is registered once but must act on the latest render's page/variables
255
+ // (and loadData's closure over the current session)
256
+ const hmr = React.useRef({ page: targetPage, variables, loadData })
257
+ hmr.current = { page: targetPage, variables, loadData }
258
+ React.useEffect(() => {
259
+ // dev only: vite statically replaces both tokens in production builds
260
+ // (env.DEV → false, hot → undefined), so the whole listener is dead-code
261
+ // eliminated. The casts erase to the literal `import.meta.env.DEV` /
262
+ // `import.meta.hot` tokens — don't alias import.meta or add optional
263
+ // chaining, or the replacement (and vite's hot-context injection) stops
264
+ // matching.
265
+ if (!(import.meta as unknown as { env: { DEV?: boolean } }).env.DEV) {
266
+ return
267
+ }
268
+ const hot = (import.meta as { hot?: any }).hot
269
+ if (!hot) {
270
+ return
271
+ }
272
+ const onArtifactUpdate = async ({
273
+ artifacts,
274
+ }: {
275
+ artifacts: Array<{ name: string; url: string }>
276
+ }) => {
277
+ let stale = false
278
+ for (const { name, url } of artifacts) {
279
+ // only documents this client has loaded can be stale
280
+ if (!artifact_cache.has(name)) {
281
+ continue
282
+ }
283
+ // bust the browser's module cache — re-importing the bare url would just
284
+ // return the module we already have
285
+ let artifact: QueryArtifact | undefined
286
+ try {
287
+ const bust = `${url.includes('?') ? '&' : '?'}t=${Date.now()}`
288
+ artifact = (await import(/* @vite-ignore */ `${url}${bust}`))?.default
289
+ } catch {
290
+ // the module failed to load (e.g. the document was deleted) — leave the
291
+ // caches alone rather than evicting a store we can't replace
292
+ continue
293
+ }
294
+ if (!artifact) {
295
+ continue
296
+ }
297
+ artifact_cache.set(name, artifact)
298
+ // evict the store built from the old artifact so the reload below creates a
299
+ // replacement. deliberately no cleanup(): mounted components keep rendering
300
+ // the old store until the new one lands (see SuspenseCache's delete() note)
301
+ data_cache.delete(name)
302
+ ssr_signals.delete(name)
303
+ stale = true
304
+ }
305
+ if (!stale) {
306
+ return
307
+ }
308
+ const { page, variables, loadData } = hmr.current
309
+ // the reload honors each query's cache policy: an edit that doesn't change
310
+ // what the document asks for (or asks for data the cache already holds)
311
+ // resolves instantly from cache; anything the old selection didn't cover
312
+ // misses and goes to the network
313
+ loadData(page, variables)
314
+ // re-render so useQueryResult sees the eviction and subscribes to the
315
+ // replacement — deleting a cache entry doesn't notify anyone on its own
316
+ forceRender()
317
+ }
318
+ hot.on('houdini:artifact-update', onArtifactUpdate)
319
+ return () => hot.off?.('houdini:artifact-update', onArtifactUpdate)
320
+ }, [])
321
+
109
322
  // when we first mount we should start listening to the back button
110
323
  React.useEffect(() => {
111
324
  if (!globalThis.window) {
112
325
  return
113
326
  }
114
327
  const onChange = (_evt: PopStateEvent) => {
115
- setCurrentURL(window.location.pathname + window.location.search)
328
+ const url = window.location.pathname + window.location.search
329
+ setPendingURL(url)
330
+ startNavigation(() => {
331
+ setCurrentURL(url)
332
+ })
116
333
  }
117
334
  window.addEventListener('popstate', onChange)
118
335
  return () => {
@@ -120,34 +337,72 @@ export function Router({
120
337
  }
121
338
  }, [])
122
339
 
340
+ // On navigation (but not the initial mount), re-fire the route's already-cached
341
+ // queries so each one's cache policy is honored (stale-while-revalidate). The
342
+ // observers are kept, so a query whose variables are unchanged and whose policy
343
+ // allows a cache read resolves without a loading frame; queries whose variables
344
+ // changed (or aren't cached) are evicted + reloaded by loadData with their loading
345
+ // state. We reuse the variables already unmarshaled for this render rather than
346
+ // re-parsing the URL. This also covers back/forward (popstate), which bypasses goto.
347
+ // The guard compares URLs (not a boolean) so Strict Mode's double effect invocation
348
+ // on mount doesn't slip past it and revalidate the initial page.
349
+ const lastRevalidatedURL = React.useRef(currentURL)
350
+ React.useEffect(() => {
351
+ if (lastRevalidatedURL.current === currentURL) {
352
+ return
353
+ }
354
+ lastRevalidatedURL.current = currentURL
355
+ for (const name of Object.keys(targetPage.documents)) {
356
+ if (data_cache.has(name)) {
357
+ data_cache.get(name).send({ variables, session })
358
+ }
359
+ }
360
+ }, [currentURL])
361
+
123
362
  // the function to call to navigate. accepts either a ready-made url string or a
124
363
  // typed target { to, params, search } that is assembled (and custom scalars
125
364
  // marshaled) exactly the way <Link> builds its href. The typed surface is the
126
365
  // shared Goto contract; the implementation takes the loose runtime shape.
127
- const goto = ((
128
- target:
129
- | string
130
- | { to: string; params?: Record<string, unknown>; search?: Record<string, unknown> }
131
- ) => {
132
- const url =
133
- typeof target === 'string'
134
- ? target
135
- : buildHref(
136
- target.to,
137
- manifest.pages[manifest.pagesByUrl[target.to]],
138
- getCurrentConfig()?.scalars,
139
- target.params,
140
- target.search
141
- )
142
-
143
- // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
144
- data_cache.clear()
145
- // clear pending signals so the next render starts fresh load_query calls
146
- ssr_signals.clear()
147
-
148
- // perform the navigation
149
- setCurrentURL(url)
150
- }) as Goto
366
+ // Referentially stable (everything it closes over is): it ships in the memoized
367
+ // NavigationContext value and anchors the link listener effect, so a new identity
368
+ // per render would re-render every useNavigation consumer and re-attach listeners.
369
+ const goto = React.useCallback(
370
+ (
371
+ target:
372
+ | string
373
+ | {
374
+ to: string
375
+ params?: Record<string, unknown>
376
+ search?: Record<string, unknown>
377
+ }
378
+ ) => {
379
+ const url =
380
+ typeof target === 'string'
381
+ ? target
382
+ : buildHref(
383
+ target.to,
384
+ manifest.pages[manifest.pagesByUrl[target.to]],
385
+ getCurrentConfig()?.scalars,
386
+ target.params,
387
+ target.search
388
+ )
389
+
390
+ // We intentionally don't blanket-clear the data cache on navigation (that would
391
+ // force every query back through its loading state). Observers and their data
392
+ // survive, and per-query revalidation is handled by the navigation effect above
393
+ // (which honors each query's cache policy). A real session change still clears
394
+ // the cache (updateSession / the session event listener).
395
+
396
+ // track the destination urgently (so in-flight renders can identify it) and
397
+ // perform the navigation inside a transition so React keeps the current route on
398
+ // screen until the next one is ready (or until showLoading swaps in the frame).
399
+ setPendingURL(url)
400
+ startNavigation(() => {
401
+ setCurrentURL(url)
402
+ })
403
+ },
404
+ [manifest]
405
+ ) as Goto
151
406
 
152
407
  // links are powered using anchor tags that we intercept and handle ourselves
153
408
  useLinkBehavior({
@@ -182,31 +437,71 @@ export function Router({
182
437
  },
183
438
  })
184
439
 
185
- // TODO: cleanup navigation caches
440
+ // The loading frame only renders on the destination of the navigation: while a
441
+ // transition is pending, the committed tree still has the previous currentURL
442
+ // (pendingURL differs), so an urgent re-render of it — e.g. the showLoading flip —
443
+ // keeps showing the previous page instead of swapping it for its own frame. The
444
+ // destination (transition lane, where currentURL === pendingURL) renders the frame,
445
+ // which doesn't suspend, so the transition commits as soon as the rest of the entry
446
+ // (component, artifacts, layout data) is renderable.
447
+ const showFrame = showLoading && currentURL === pendingURL
448
+
449
+ // the public pending-navigation surface (useNavigation). A navigation counts as
450
+ // pending until the destination shows its actual content: the transition can commit
451
+ // with the @loading frame on screen (isNavigating flips false then), so the loading
452
+ // state extends it. Memoized so consumers only re-render when it actually changes.
453
+ const navigating = isNavigating || showLoading
454
+ const navigation = React.useMemo(
455
+ () => ({ pending: navigating, to: navigating ? pendingURL : null, goto }),
456
+ [navigating, pendingURL, goto]
457
+ )
458
+
186
459
  // render the component embedded in the necessary context so it can orchestrate
187
460
  // its needs
188
461
  return (
189
- <LocationContext.Provider
190
- value={{
191
- pathname: currentURL,
192
- goto,
193
- params: variables ?? {},
194
- search,
195
- }}
196
- >
197
- <Is404Context.Provider value={is404}>
198
- {is404 ? (
199
- <NotFoundLayoutBoundary key={targetPage.id}>
200
- <PageComponent url={currentURL} key={targetPage.id + '__404'} />
201
- </NotFoundLayoutBoundary>
202
- ) : (
203
- <PageComponent url={currentURL} key={targetPage.id} />
204
- )}
205
- </Is404Context.Provider>
206
- </LocationContext.Provider>
462
+ <PendingURLContext.Provider value={pendingURL}>
463
+ <NavigationContext.Provider value={navigation}>
464
+ <LocationContext.Provider
465
+ value={{
466
+ pathname: currentURL,
467
+ goto,
468
+ params: variables ?? {},
469
+ search,
470
+ }}
471
+ >
472
+ <Is404Context.Provider value={is404}>
473
+ {is404 ? (
474
+ <NotFoundLayoutBoundary key={targetPage.id}>
475
+ <PageComponent
476
+ url={currentURL}
477
+ showLoading={showFrame}
478
+ key={targetPage.id + '__404'}
479
+ />
480
+ </NotFoundLayoutBoundary>
481
+ ) : (
482
+ <PageComponent
483
+ url={currentURL}
484
+ showLoading={showFrame}
485
+ key={targetPage.id}
486
+ />
487
+ )}
488
+ </Is404Context.Provider>
489
+ </LocationContext.Provider>
490
+ </NavigationContext.Provider>
491
+ </PendingURLContext.Provider>
207
492
  )
208
493
  }
209
494
 
495
+ // useNavigation exposes the router's in-flight navigation. `pending` is true from the
496
+ // moment a navigation starts until the destination renders its actual content — it stays
497
+ // true while the destination's @loading state is showing — and `to` carries the
498
+ // destination url while pending (null when idle). It also carries `goto` — the same
499
+ // navigate function useRoute exposes — so navigation chrome (progress bars, nav menus,
500
+ // per-link spinners) only needs this one hook.
501
+ export function useNavigation(): { pending: boolean; to: string | null; goto: Goto } {
502
+ return useContext(NavigationContext)
503
+ }
504
+
210
505
  // internal accessor for the raw location context. the public surface is useRoute, which
211
506
  // layers the per-route param/search types on top of this. not re-exported from the package
212
507
  // index, so it isn't part of the public API.
@@ -285,6 +580,70 @@ function usePageData({
285
580
  ? data_cache.get(artifact.name)!
286
581
  : client.observe({ artifact, cache })
287
582
 
583
+ // surface a query error to the client. an @loading query streams its result while the
584
+ // document is still open, so on the initial SSR load the client is parked on the loading
585
+ // frame waiting for this query's pending signal to resolve. unlike the success path the
586
+ // store carries no data we can hydrate from the cache (errors aren't cache data), so we
587
+ // stream the errors directly and rebuild an erroring store on the client. without this the
588
+ // pending signal never resolves and the page hangs on the loading state instead of reaching
589
+ // the nearest error boundary.
590
+ function stream_error(errors: GraphQLError[]) {
591
+ injectToStream?.(`
592
+ <script>
593
+ {
594
+ const artifactName = ${escapeScriptTag(JSON.stringify(artifact.name))}
595
+ const errors = ${escapeScriptTag(JSON.stringify(errors))}
596
+ const variables = ${escapeScriptTag(JSON.stringify(observer.state.variables))}
597
+ const artifact = ${escapeScriptTag(JSON.stringify(artifact))}
598
+
599
+ // build a document store that already carries the errors so useQueryResult
600
+ // throws to the nearest boundary the moment it reads the store
601
+ const __houdini__error_store__ = (knownArtifact) => {
602
+ const store = window.__houdini__client__.observe({
603
+ artifact: knownArtifact ?? artifact,
604
+ cache: window.__houdini__cache__,
605
+ initialVariables: variables,
606
+ })
607
+ store.update((state) => ({ ...state, fetching: false, errors }))
608
+ return store
609
+ }
610
+
611
+ // if the client has already hydrated (a client-side navigation to an @loading
612
+ // route) push the error store straight into the live caches and release the signal
613
+ if (window.__houdini__nav_caches__) {
614
+ const caches = window.__houdini__nav_caches__
615
+ caches.data_cache.set(
616
+ artifactName,
617
+ __houdini__error_store__(caches.artifact_cache.get(artifactName))
618
+ )
619
+ if (caches.ssr_signals.has(artifactName)) {
620
+ caches.ssr_signals.get(artifactName).resolve()
621
+ caches.ssr_signals.delete(artifactName)
622
+ }
623
+ } else {
624
+ // otherwise the page module hasn't run yet (the common @loading case): stash the
625
+ // error so hydrate_page can build the error store when it sets up the caches
626
+ const pendingArtifacts = (window.__houdini__pending_artifacts__ =
627
+ window.__houdini__pending_artifacts__ || {})
628
+ pendingArtifacts[artifactName] = artifact
629
+ const pendingVariables = (window.__houdini__pending_variables__ =
630
+ window.__houdini__pending_variables__ || {})
631
+ pendingVariables[artifactName] = variables
632
+ const pendingErrors = (window.__houdini__pending_errors__ =
633
+ window.__houdini__pending_errors__ || {})
634
+ pendingErrors[artifactName] = errors
635
+ }
636
+ }
637
+ </script>
638
+ `)
639
+ }
640
+
641
+ // remember which cache generation this send belongs to: if the cache is
642
+ // invalidated while the request is in flight (a session change clears it), the
643
+ // result was fetched under state that no longer applies and must not be
644
+ // re-inserted — the invalidation already triggered a fresh send
645
+ const generation = data_cache.generation
646
+
288
647
  // store the observer immediately so useQueryResult can access it
289
648
  // during SSR rendering before the fetch resolves
290
649
  let resolve: () => void = () => {}
@@ -299,11 +658,22 @@ function usePageData({
299
658
  session,
300
659
  })
301
660
  .then(async () => {
661
+ // a stale-generation result: release the signal so nothing hangs, but
662
+ // don't put the observer (or its stream scripts) anywhere
663
+ if (data_cache.generation !== generation) {
664
+ ssr_signals.delete(id)
665
+ resolve()
666
+ return
667
+ }
668
+
302
669
  data_cache.set(id, observer)
303
670
 
304
- // if there is an error, signal completion (the error is visible via
305
- // useQueryResult reading observer.state.errors) and clean up
671
+ // if there is an error, stream it to the client (so an @loading query
672
+ // reaches the error boundary instead of hanging on the loading state) and
673
+ // clean up. on the client data_cache.set above is enough for useQueryResult
674
+ // to read the errors and throw; the stream is what carries them across SSR.
306
675
  if (observer.state.errors && observer.state.errors.length > 0) {
676
+ stream_error(observer.state.errors)
307
677
  ssr_signals.delete(id)
308
678
  resolve()
309
679
  return
@@ -422,7 +792,19 @@ function usePageData({
422
792
  if (err?.name === 'AbortError') {
423
793
  return
424
794
  }
425
- reject(err)
795
+ // same stale-generation rule as the success path
796
+ if (data_cache.generation !== generation) {
797
+ resolve()
798
+ return
799
+ }
800
+ // a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
801
+ // seed it as a synthetic GraphQL error on the store and stream it to the client,
802
+ // otherwise an @loading query that rejects hangs on the loading state
803
+ const errors = [{ message: err?.message ?? String(err) }]
804
+ observer.update((state) => ({ ...state, fetching: false, errors }))
805
+ data_cache.set(id, observer)
806
+ stream_error(errors)
807
+ resolve()
426
808
  })
427
809
  })
428
810
 
@@ -568,16 +950,26 @@ export function RouterContextProvider({
568
950
  // if we detect an event that contains a new session value. The detail carries the subtree
569
951
  // and whether to merge it into the current session (an @session(merge:) upsert) or replace
570
952
  // it wholesale; a legacy plain-session detail is treated as a replace.
571
- const handleNewSession = React.useCallback((event: Event) => {
572
- const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
573
- const isWrapped =
574
- detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
575
- const next = (
576
- isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
577
- ) as App.Session
578
- const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
579
- setSession((prev) => (merge ? { ...prev, ...next } : next))
580
- }, [])
953
+ const handleNewSession = React.useCallback(
954
+ (event: Event) => {
955
+ const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
956
+ const isWrapped =
957
+ detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
958
+ const next = (
959
+ isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
960
+ ) as App.Session
961
+ const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
962
+
963
+ // a new session invalidates every cached query result, exactly like updateSession():
964
+ // navigation no longer clears the data cache, so without this an event-driven
965
+ // session change (updateLocalSession) would keep serving results fetched under the
966
+ // old session
967
+ invalidate_session_caches({ data_cache, ssr_signals, last_variables })
968
+
969
+ setSession((prev) => (merge ? { ...prev, ...next } : next))
970
+ },
971
+ [data_cache, ssr_signals, last_variables]
972
+ )
581
973
 
582
974
  React.useEffect(() => {
583
975
  window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
@@ -611,7 +1003,7 @@ export function RouterContextProvider({
611
1003
  )
612
1004
  }
613
1005
 
614
- type RouterContext = {
1006
+ export type RouterContext = {
615
1007
  client: HoudiniClient
616
1008
  cache: Cache
617
1009
 
@@ -662,8 +1054,6 @@ export type PendingCache = SuspenseCache<
662
1054
  Promise<void> & { resolve: () => void; reject: (message: string) => void }
663
1055
  >
664
1056
 
665
- const Context = React.createContext<RouterContext | null>(null)
666
-
667
1057
  export const useRouterContext = () => {
668
1058
  const ctx = React.useContext(Context)
669
1059
 
@@ -705,6 +1095,21 @@ export function updateLocalSession(session: App.Session, merge = false) {
705
1095
  )
706
1096
  }
707
1097
 
1098
+ // invalidate_session_caches drops everything derived from the previous session: query
1099
+ // results, in-flight signal bookkeeping, and the last-sent variables (so useQueryResult's
1100
+ // keep-last-store check can't match a store fetched under the old session — the page
1101
+ // suspends into its loading state and refetches). One helper shared by both session-change
1102
+ // paths (updateSession and the HOUDINI_SESSION_EVENT listener) so they can't drift.
1103
+ function invalidate_session_caches(caches: {
1104
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
1105
+ ssr_signals: PendingCache
1106
+ last_variables: LRUCache<GraphQLVariables>
1107
+ }) {
1108
+ caches.data_cache.clear()
1109
+ caches.ssr_signals.clear()
1110
+ caches.last_variables.clear()
1111
+ }
1112
+
708
1113
  export function useSession(): [
709
1114
  App.Session,
710
1115
  (newSession: Partial<App.Session> | null) => Promise<void>,
@@ -717,9 +1122,8 @@ export function useSession(): [
717
1122
  // log out — clearing the local session and deleting the cookie. It's awaitable so callers
718
1123
  // can wait for the cookie to settle before navigating.
719
1124
  const updateSession = async (newSession: Partial<App.Session> | null) => {
720
- // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
721
- ctx.data_cache.clear()
722
- ctx.ssr_signals.clear()
1125
+ // drop everything derived from the previous session so queries refetch with the new one
1126
+ invalidate_session_caches(ctx)
723
1127
 
724
1128
  if (newSession === null) {
725
1129
  ctx.clearSession()
@@ -740,29 +1144,66 @@ export function useSession(): [
740
1144
  return [ctx.session, updateSession]
741
1145
  }
742
1146
 
743
- const LocationContext = React.createContext<{
744
- pathname: string
745
- params: Record<string, any>
746
- // the parsed query string of the current url (declared search params coerced to
747
- // their scalar type, other keys raw; repeated keys are arrays).
748
- search: Record<string, any>
749
- // a function to imperatively navigate to a url
750
- goto: Goto
751
- }>({
752
- pathname: '',
753
- params: {},
754
- search: {},
755
- goto: () => {},
756
- })
757
-
758
1147
  export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>(
759
1148
  name: string
760
1149
  ): [_Data | null, DocumentHandle<any, _Data, _Input>] {
761
1150
  // pull the global context values
762
- const { data_cache, artifact_cache } = useRouterContext()
1151
+ const { data_cache, artifact_cache, last_variables } = useRouterContext()
1152
+ const { pathname } = useLocationContext()
1153
+ const pendingURL = React.useContext(PendingURLContext)
1154
+ // the store (and the router-level variables it was sent with) from this component's
1155
+ // last COMMIT. Written in an effect, never during render: the ref object is shared
1156
+ // between a fiber and its work-in-progress alternate, so a render-phase write from a
1157
+ // transition lane that never commits would leak the wrong store into the committed
1158
+ // tree's next render.
1159
+ const last_store = React.useRef<DocumentStore<_Data, _Input> | null>(null)
1160
+ const last_vars = React.useRef<GraphQLVariables | null>(null)
1161
+ const [, bumpStore] = React.useReducer((n: number) => n + 1, 0)
1162
+
1163
+ // Decide which store to render. data_cache.get suspends when the store is missing —
1164
+ // the right behavior for a first render or for the navigation destination (a
1165
+ // transition waits on it, or the page's @loading boundary catches it). But the router
1166
+ // evicts a document mid-navigation to reload it with new variables, and a re-render
1167
+ // of the still-visible previous page must NOT re-suspend into its own loading state —
1168
+ // it keeps rendering the store from its last commit and the in-flight navigation
1169
+ // swaps the tree when the replacement resolves. Two cases keep the last store:
1170
+ // - the render's URL lags the navigation target (pendingURL is lane-independent —
1171
+ // see PendingURLContext). A lagging render always prefers its committed store,
1172
+ // even when the cache already holds a replacement for different variables.
1173
+ // - the store is missing but the router's current variables for this document
1174
+ // still match the ones the committed store was sent with: the eviction belongs
1175
+ // to a navigation that was abandoned (e.g. goto back to the committed URL), so
1176
+ // this render should keep its data while the document reloads.
1177
+ // The bump effect re-renders once a replacement lands, so a kept-stale commit always
1178
+ // converges to the fresh store.
1179
+ const missing = !data_cache.has(name)
1180
+ const lagging = pendingURL !== null && pathname !== pendingURL
1181
+ const matches_render =
1182
+ last_vars.current !== null && deepEquals(last_vars.current, last_variables.get(name))
1183
+ const store_ref =
1184
+ last_store.current && (lagging || (missing && matches_render))
1185
+ ? last_store.current
1186
+ : (data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>)
1187
+
1188
+ React.useEffect(() => {
1189
+ last_store.current = store_ref
1190
+ last_vars.current = (last_variables.get(name) as GraphQLVariables | null) ?? null
1191
+ })
763
1192
 
764
- // load the store reference (this will suspend)
765
- const store_ref = data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>
1193
+ React.useEffect(() => {
1194
+ if (!missing) {
1195
+ return
1196
+ }
1197
+ let cancelled = false
1198
+ data_cache.waitFor(name).then(() => {
1199
+ if (!cancelled) {
1200
+ bumpStore()
1201
+ }
1202
+ })
1203
+ return () => {
1204
+ cancelled = true
1205
+ }
1206
+ }, [missing, name])
766
1207
 
767
1208
  // get the live data from the store
768
1209
  const [storeValue, observer] = useDocumentStore<_Data, _Input>({
@@ -809,9 +1250,6 @@ function useLinkBehavior({
809
1250
  }
810
1251
 
811
1252
  function useLinkNavigation({ goto }: { goto: Goto }) {
812
- // navigations need to be registered as transitions
813
- const [_pending, startTransition] = React.useTransition()
814
-
815
1253
  React.useEffect(() => {
816
1254
  const onClick: HTMLAnchorElement['onclick'] = (e) => {
817
1255
  if (!e.target) {
@@ -860,10 +1298,11 @@ function useLinkNavigation({ goto }: { goto: Goto }) {
860
1298
  e.preventDefault()
861
1299
  e.stopPropagation()
862
1300
 
863
- // go to the next route as a low priority update
864
- startTransition(() => {
865
- goto(target)
866
- })
1301
+ // goto() runs the URL update in its own transition and tracks the pending
1302
+ // destination urgently. Don't wrap the call in another transition here: that
1303
+ // would drag the urgent bookkeeping (pendingURL) into the transition lane,
1304
+ // and the committed tree couldn't tell where the navigation is headed.
1305
+ goto(target)
867
1306
  }
868
1307
 
869
1308
  window.addEventListener('click', onClick)
@@ -958,7 +1397,14 @@ export function router_cache({
958
1397
  const result: RouterCache = {
959
1398
  artifact_cache: suspense_cache(initialArtifacts),
960
1399
  component_cache: suspense_cache(),
961
- data_cache: suspense_cache(initialData),
1400
+ // observers accumulate across navigations now that goto doesn't clear the cache,
1401
+ // so the LRU capacity limit is reachable on long sessions. When it silently evicts
1402
+ // a store, let the store's plugins release whatever they hold. Best-effort: a
1403
+ // cleanup that rejects must not become an unhandled rejection (which kills a node
1404
+ // process outright).
1405
+ data_cache: suspense_cache(initialData, (store) => {
1406
+ store.cleanup().catch(() => {})
1407
+ }),
962
1408
  ssr_signals: suspense_cache(),
963
1409
  last_variables: suspense_cache(),
964
1410
  }
@@ -1018,7 +1464,8 @@ class NotFoundLayoutBoundary extends React.Component<
1018
1464
  }
1019
1465
  }
1020
1466
 
1021
- export const Is404Context = React.createContext(false)
1467
+ // re-exported (defined in ../contexts.js) so existing `routing` barrel consumers keep working
1468
+ export { Is404Context }
1022
1469
 
1023
1470
  export function NotFoundGate({ children }: { children: React.ReactNode }) {
1024
1471
  const is404 = React.useContext(Is404Context)
@@ -1028,8 +1475,6 @@ export function NotFoundGate({ children }: { children: React.ReactNode }) {
1028
1475
  return <>{children}</>
1029
1476
  }
1030
1477
 
1031
- const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
1032
-
1033
1478
  export function PageContextProvider({
1034
1479
  keys,
1035
1480
  children,
@@ -3,8 +3,11 @@
3
3
  // is thrown that resolves when a value is passed to set()
4
4
  import { LRUCache } from 'houdini/runtime'
5
5
 
6
- export function suspense_cache<T>(initialData?: Record<string, T>): SuspenseCache<T> {
7
- const cache = new SuspenseCache<T>()
6
+ export function suspense_cache<T>(
7
+ initialData?: Record<string, T>,
8
+ dispose?: (value: T) => void
9
+ ): SuspenseCache<T> {
10
+ const cache = new SuspenseCache<T>(dispose)
8
11
 
9
12
  for (const [key, value] of Object.entries(initialData ?? {})) {
10
13
  cache.set(key, value)
@@ -18,6 +21,26 @@ export class SuspenseCache<_Data> extends LRUCache<_Data> {
18
21
  // that means we need a place to put our callbacks
19
22
  #callbacks: Map<string, { resolve: () => void; reject: () => void }[]> = new Map()
20
23
 
24
+ // called when a value is silently evicted by the LRU capacity limit so it can
25
+ // release any resources it holds (e.g. a DocumentStore's plugin state). Explicit
26
+ // delete()/clear() don't dispose: their values may still be rendered by a mounted
27
+ // component that is about to re-suspend and receive a replacement.
28
+ #dispose?: (value: _Data) => void
29
+
30
+ constructor(dispose?: (value: _Data) => void) {
31
+ super()
32
+ this.#dispose = dispose
33
+ }
34
+
35
+ // bumped by clear() so async work started before an invalidation can tell its results
36
+ // are stale (see load_query, which drops a completed send from a previous generation
37
+ // instead of re-inserting data fetched under an old session)
38
+ #generation = 0
39
+
40
+ get generation(): number {
41
+ return this.#generation
42
+ }
43
+
21
44
  get(key: string): _Data {
22
45
  // if there is a value, use that
23
46
  if (super.has(key)) {
@@ -26,17 +49,40 @@ export class SuspenseCache<_Data> extends LRUCache<_Data> {
26
49
 
27
50
  // we don't have a value, so we need to throw a promise
28
51
  // that resolves when a value is passed to set()
29
- throw new Promise<void>((resolve, reject) => {
52
+ throw this.waitFor(key)
53
+ }
54
+
55
+ // waitFor resolves when the key holds a value — immediately if it already does,
56
+ // otherwise when set() is next called for it. The non-throwing counterpart to get()
57
+ // for code that isn't rendering (so it can't suspend).
58
+ waitFor(key: string): Promise<void> {
59
+ if (super.has(key)) {
60
+ return Promise.resolve()
61
+ }
62
+ return new Promise<void>((resolve, reject) => {
30
63
  this.#subscribe(key, resolve, reject)
31
64
  })
32
65
  }
33
66
 
34
67
  override clear() {
35
68
  super.clear()
36
- this.#callbacks.clear()
69
+ this.#generation++
70
+ // deliberately NOT clearing #callbacks: a waitFor()/get() subscriber is waiting
71
+ // for the key to hold a value, and after an invalidation the router refetches and
72
+ // set()s it again — the pre-clear subscribers must resolve then (with the fresh
73
+ // value) instead of hanging forever on a promise nothing can settle.
37
74
  }
38
75
 
39
76
  set(key: string, value: _Data) {
77
+ // inserting a new key at capacity silently evicts the least-recently-used entry —
78
+ // hand it to the dispose hook first so it can clean up after itself
79
+ if (this.#dispose && !super.has(key) && this.size() >= this._capacity) {
80
+ const oldest = this._map.keys().next()
81
+ if (!oldest.done) {
82
+ this.#dispose(this._map.get(oldest.value)!)
83
+ }
84
+ }
85
+
40
86
  // perform the set like normal
41
87
  super.set(key, value)
42
88
 
@@ -1,6 +1,8 @@
1
1
  import type { GraphQLError } from 'houdini/runtime'
2
2
  import React from 'react'
3
3
 
4
+ import { LocationContext, StatusContext } from '../contexts.js'
5
+
4
6
  export class GraphQLErrors extends Error {
5
7
  graphqlErrors: GraphQLError[]
6
8
 
@@ -73,9 +75,9 @@ export function redirect(status: 300 | 301 | 302 | 303 | 307 | 308, location: st
73
75
  throw new RedirectError(status, location)
74
76
  }
75
77
 
76
- // Mutable ref passed from the server renderer so that a synchronous RoutingError
77
- // or redirect() can propagate the correct HTTP status/location before streaming.
78
- export const StatusContext = React.createContext<{ status: number; location?: string } | null>(null)
78
+ // StatusContext is defined in ../contexts.js (a dependency-only leaf module) so its
79
+ // identity survives Vite HMR re-evaluation; re-exported here to keep the existing surface.
80
+ export { StatusContext }
79
81
 
80
82
  type HoudiniErrorBoundaryProps = {
81
83
  errorView: React.ComponentType<{
@@ -88,33 +90,67 @@ type HoudiniErrorBoundaryProps = {
88
90
  type HoudiniErrorBoundaryState = {
89
91
  hasError: boolean
90
92
  errors: Array<Error | GraphQLError>
93
+ // the resetKey (current pathname) the boundary last rendered for — a caught error is
94
+ // cleared when it changes (see getDerivedStateFromProps)
95
+ resetKey: string
96
+ }
97
+
98
+ // HoudiniErrorBoundary wraps the class boundary so it can read the current pathname:
99
+ // route boundaries persist across same-route navigations (they aren't remounted per URL
100
+ // anymore), so the boundary has to clear a caught error itself when the URL changes —
101
+ // otherwise navigating away from an errored page would keep rendering the error view.
102
+ export function HoudiniErrorBoundary(props: HoudiniErrorBoundaryProps) {
103
+ const { pathname } = React.useContext(LocationContext)
104
+ return <ErrorBoundary resetKey={pathname} {...props} />
91
105
  }
92
106
 
93
- export class HoudiniErrorBoundary extends React.Component<
94
- HoudiniErrorBoundaryProps,
107
+ class ErrorBoundary extends React.Component<
108
+ HoudiniErrorBoundaryProps & { resetKey: string },
95
109
  HoudiniErrorBoundaryState
96
110
  > {
97
111
  static contextType = StatusContext
98
112
  declare context: React.ContextType<typeof StatusContext>
99
113
 
100
114
  constructor(
101
- props: HoudiniErrorBoundaryProps,
115
+ props: HoudiniErrorBoundaryProps & { resetKey: string },
102
116
  context: React.ContextType<typeof StatusContext>
103
117
  ) {
104
118
  super(props, context)
105
119
  // Second-pass SSR: statusRef is pre-set to an error status by on_render after the first
106
- // render threw. Start in error state immediately so children never render (and never throw).
120
+ // render threw. Start in error state immediately so children never render (and never
121
+ // throw). The ref carries the actual thrown errors when it has them (e.g. GraphQL
122
+ // errors from a query); a bare status (a routing error / unmatched URL) renders as a
123
+ // RoutingError so the view can branch on the status code.
107
124
  if (typeof window === 'undefined' && context && context.status >= 400) {
108
125
  this.state = {
109
126
  hasError: true,
110
- errors: [new RoutingError(context.status)],
127
+ errors: context.errors ?? [new RoutingError(context.status)],
128
+ resetKey: props.resetKey,
111
129
  }
112
130
  } else {
113
- this.state = { hasError: false, errors: [] }
131
+ this.state = { hasError: false, errors: [], resetKey: props.resetKey }
132
+ }
133
+ }
134
+
135
+ // a navigation is a retry: clear a caught error the moment a render arrives for a new
136
+ // URL. Doing this in derived state (not componentDidUpdate + setState) matters: the
137
+ // reset happens inside whatever render is already in flight, so when the router
138
+ // navigates away from an errored route the children render — and suspend — inside the
139
+ // router's own transition. isNavigating/showLoading account for the wait, the error
140
+ // view stays on screen until the destination is renderable, and the destination's
141
+ // @loading frame can show. If the retry throws again the boundary re-catches, and
142
+ // since resetKey is unchanged then, it can't loop.
143
+ static getDerivedStateFromProps(
144
+ props: HoudiniErrorBoundaryProps & { resetKey: string },
145
+ state: HoudiniErrorBoundaryState
146
+ ): Partial<HoudiniErrorBoundaryState> | null {
147
+ if (props.resetKey !== state.resetKey) {
148
+ return { hasError: false, errors: [], resetKey: props.resetKey }
114
149
  }
150
+ return null
115
151
  }
116
152
 
117
- static getDerivedStateFromError(error: unknown): HoudiniErrorBoundaryState {
153
+ static getDerivedStateFromError(error: unknown): Partial<HoudiniErrorBoundaryState> {
118
154
  if (error instanceof GraphQLErrors) {
119
155
  return { hasError: true, errors: error.graphqlErrors }
120
156
  }
@@ -124,17 +160,6 @@ export class HoudiniErrorBoundary extends React.Component<
124
160
  }
125
161
  }
126
162
 
127
- componentDidCatch(error: Error): void {
128
- if (this.context) {
129
- if (error instanceof RoutingError) {
130
- this.context.status = error.status
131
- } else if (error instanceof RedirectError) {
132
- this.context.status = error.status
133
- this.context.location = error.location
134
- }
135
- }
136
- }
137
-
138
163
  render() {
139
164
  if (this.state.hasError) {
140
165
  const ErrorView = this.props.errorView