houdini-react 2.0.2 → 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.2",
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.2",
85
- "houdini-react-darwin-arm64": "2.0.2",
86
- "houdini-react-linux-x64": "2.0.2",
87
- "houdini-react-linux-arm64": "2.0.2",
88
- "houdini-react-win32-x64": "2.0.2",
89
- "houdini-react-win32-arm64": "2.0.2",
90
- "houdini-react-wasm": "2.0.2"
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.2'
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'
@@ -1,3 +1,4 @@
1
+ import type { GraphQLError } from 'houdini/runtime'
1
2
  import { createContext } from 'react'
2
3
 
3
4
  import type { Goto } from './routes.js'
@@ -42,11 +43,37 @@ export const LocationContext = createContext<{
42
43
 
43
44
  export const Is404Context = createContext(false)
44
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
+
45
68
  export const PageContext = createContext<{ params: Record<string, any> }>({ params: {} })
46
69
 
47
- // Mutable ref passed from the server renderer so that a synchronous RoutingError
48
- // or redirect() can propagate the correct HTTP status/location before streaming.
49
- export const StatusContext = createContext<{ status: number; location?: string } | null>(null)
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)
50
77
 
51
78
  // FormStatusContext carries the nearest <Form>'s pending state to useMutationFormStatus(),
52
79
  // the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
@@ -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,
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,
@@ -17,7 +17,9 @@ import {
17
17
  RouterContextObject as Context,
18
18
  LocationContext,
19
19
  Is404Context,
20
+ NavigationContext,
20
21
  PageContext,
22
+ PendingURLContext,
21
23
  } from '../contexts.js'
22
24
  import { escapeScriptTag } from '../escape.js'
23
25
  import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
@@ -27,7 +29,11 @@ import { useDocumentStore } from '../hooks/useDocumentStore.js'
27
29
  import { type SuspenseCache, suspense_cache } from './cache.js'
28
30
  import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
29
31
 
30
- 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
+ }>
31
37
 
32
38
  const PreloadWhich = {
33
39
  component: 'component',
@@ -37,6 +43,86 @@ const PreloadWhich = {
37
43
 
38
44
  type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
39
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
+
40
126
  /**
41
127
  * Router is the top level entry point for the filesystem-based router.
42
128
  * It is responsible for loading various page sources (including API fetches) and
@@ -64,6 +150,20 @@ export function Router({
64
150
  return initialURL || window.location.pathname + window.location.search
65
151
  })
66
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
+
67
167
  // find the matching page for the current route. find_match also hands back the parsed
68
168
  // query string (declared search params coerced, UI-only keys raw). custom-scalar route
69
169
  // and search params arrive in their url transport form, so we unmarshal them once here
@@ -97,8 +197,35 @@ export function Router({
97
197
  injectToStream,
98
198
  })
99
199
  // if we get this far, it's safe to load the component
100
- const { component_cache, data_cache, ssr_signals } = useRouterContext()
200
+ const { component_cache, data_cache, artifact_cache, ssr_signals } = useRouterContext()
101
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
+ })
102
229
 
103
230
  // if we got this far then we're past suspense
104
231
 
@@ -113,13 +240,96 @@ export function Router({
113
240
  }
114
241
  }, [currentURL])
115
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
+
116
322
  // when we first mount we should start listening to the back button
117
323
  React.useEffect(() => {
118
324
  if (!globalThis.window) {
119
325
  return
120
326
  }
121
327
  const onChange = (_evt: PopStateEvent) => {
122
- 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
+ })
123
333
  }
124
334
  window.addEventListener('popstate', onChange)
125
335
  return () => {
@@ -127,34 +337,72 @@ export function Router({
127
337
  }
128
338
  }, [])
129
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
+
130
362
  // the function to call to navigate. accepts either a ready-made url string or a
131
363
  // typed target { to, params, search } that is assembled (and custom scalars
132
364
  // marshaled) exactly the way <Link> builds its href. The typed surface is the
133
365
  // shared Goto contract; the implementation takes the loose runtime shape.
134
- const goto = ((
135
- target:
136
- | string
137
- | { to: string; params?: Record<string, unknown>; search?: Record<string, unknown> }
138
- ) => {
139
- const url =
140
- typeof target === 'string'
141
- ? target
142
- : buildHref(
143
- target.to,
144
- manifest.pages[manifest.pagesByUrl[target.to]],
145
- getCurrentConfig()?.scalars,
146
- target.params,
147
- target.search
148
- )
149
-
150
- // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
151
- data_cache.clear()
152
- // clear pending signals so the next render starts fresh load_query calls
153
- ssr_signals.clear()
154
-
155
- // perform the navigation
156
- setCurrentURL(url)
157
- }) 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
158
406
 
159
407
  // links are powered using anchor tags that we intercept and handle ourselves
160
408
  useLinkBehavior({
@@ -189,31 +437,71 @@ export function Router({
189
437
  },
190
438
  })
191
439
 
192
- // 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
+
193
459
  // render the component embedded in the necessary context so it can orchestrate
194
460
  // its needs
195
461
  return (
196
- <LocationContext.Provider
197
- value={{
198
- pathname: currentURL,
199
- goto,
200
- params: variables ?? {},
201
- search,
202
- }}
203
- >
204
- <Is404Context.Provider value={is404}>
205
- {is404 ? (
206
- <NotFoundLayoutBoundary key={targetPage.id}>
207
- <PageComponent url={currentURL} key={targetPage.id + '__404'} />
208
- </NotFoundLayoutBoundary>
209
- ) : (
210
- <PageComponent url={currentURL} key={targetPage.id} />
211
- )}
212
- </Is404Context.Provider>
213
- </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>
214
492
  )
215
493
  }
216
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
+
217
505
  // internal accessor for the raw location context. the public surface is useRoute, which
218
506
  // layers the per-route param/search types on top of this. not re-exported from the package
219
507
  // index, so it isn't part of the public API.
@@ -350,6 +638,12 @@ function usePageData({
350
638
  `)
351
639
  }
352
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
+
353
647
  // store the observer immediately so useQueryResult can access it
354
648
  // during SSR rendering before the fetch resolves
355
649
  let resolve: () => void = () => {}
@@ -364,6 +658,14 @@ function usePageData({
364
658
  session,
365
659
  })
366
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
+
367
669
  data_cache.set(id, observer)
368
670
 
369
671
  // if there is an error, stream it to the client (so an @loading query
@@ -490,6 +792,11 @@ function usePageData({
490
792
  if (err?.name === 'AbortError') {
491
793
  return
492
794
  }
795
+ // same stale-generation rule as the success path
796
+ if (data_cache.generation !== generation) {
797
+ resolve()
798
+ return
799
+ }
493
800
  // a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
494
801
  // seed it as a synthetic GraphQL error on the store and stream it to the client,
495
802
  // otherwise an @loading query that rejects hangs on the loading state
@@ -643,16 +950,26 @@ export function RouterContextProvider({
643
950
  // if we detect an event that contains a new session value. The detail carries the subtree
644
951
  // and whether to merge it into the current session (an @session(merge:) upsert) or replace
645
952
  // it wholesale; a legacy plain-session detail is treated as a replace.
646
- const handleNewSession = React.useCallback((event: Event) => {
647
- const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
648
- const isWrapped =
649
- detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
650
- const next = (
651
- isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
652
- ) as App.Session
653
- const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
654
- setSession((prev) => (merge ? { ...prev, ...next } : next))
655
- }, [])
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
+ )
656
973
 
657
974
  React.useEffect(() => {
658
975
  window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
@@ -778,6 +1095,21 @@ export function updateLocalSession(session: App.Session, merge = false) {
778
1095
  )
779
1096
  }
780
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
+
781
1113
  export function useSession(): [
782
1114
  App.Session,
783
1115
  (newSession: Partial<App.Session> | null) => Promise<void>,
@@ -790,9 +1122,8 @@ export function useSession(): [
790
1122
  // log out — clearing the local session and deleting the cookie. It's awaitable so callers
791
1123
  // can wait for the cookie to settle before navigating.
792
1124
  const updateSession = async (newSession: Partial<App.Session> | null) => {
793
- // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
794
- ctx.data_cache.clear()
795
- ctx.ssr_signals.clear()
1125
+ // drop everything derived from the previous session so queries refetch with the new one
1126
+ invalidate_session_caches(ctx)
796
1127
 
797
1128
  if (newSession === null) {
798
1129
  ctx.clearSession()
@@ -817,10 +1148,62 @@ export function useQueryResult<_Data extends GraphQLObject, _Input extends Graph
817
1148
  name: string
818
1149
  ): [_Data | null, DocumentHandle<any, _Data, _Input>] {
819
1150
  // pull the global context values
820
- 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>)
821
1187
 
822
- // load the store reference (this will suspend)
823
- const store_ref = data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>
1188
+ React.useEffect(() => {
1189
+ last_store.current = store_ref
1190
+ last_vars.current = (last_variables.get(name) as GraphQLVariables | null) ?? null
1191
+ })
1192
+
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])
824
1207
 
825
1208
  // get the live data from the store
826
1209
  const [storeValue, observer] = useDocumentStore<_Data, _Input>({
@@ -867,9 +1250,6 @@ function useLinkBehavior({
867
1250
  }
868
1251
 
869
1252
  function useLinkNavigation({ goto }: { goto: Goto }) {
870
- // navigations need to be registered as transitions
871
- const [_pending, startTransition] = React.useTransition()
872
-
873
1253
  React.useEffect(() => {
874
1254
  const onClick: HTMLAnchorElement['onclick'] = (e) => {
875
1255
  if (!e.target) {
@@ -918,10 +1298,11 @@ function useLinkNavigation({ goto }: { goto: Goto }) {
918
1298
  e.preventDefault()
919
1299
  e.stopPropagation()
920
1300
 
921
- // go to the next route as a low priority update
922
- startTransition(() => {
923
- goto(target)
924
- })
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)
925
1306
  }
926
1307
 
927
1308
  window.addEventListener('click', onClick)
@@ -1016,7 +1397,14 @@ export function router_cache({
1016
1397
  const result: RouterCache = {
1017
1398
  artifact_cache: suspense_cache(initialArtifacts),
1018
1399
  component_cache: suspense_cache(),
1019
- 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
+ }),
1020
1408
  ssr_signals: suspense_cache(),
1021
1409
  last_variables: suspense_cache(),
1022
1410
  }
@@ -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,7 +1,7 @@
1
1
  import type { GraphQLError } from 'houdini/runtime'
2
2
  import React from 'react'
3
3
 
4
- import { StatusContext } from '../contexts.js'
4
+ import { LocationContext, StatusContext } from '../contexts.js'
5
5
 
6
6
  export class GraphQLErrors extends Error {
7
7
  graphqlErrors: GraphQLError[]
@@ -90,33 +90,67 @@ type HoudiniErrorBoundaryProps = {
90
90
  type HoudiniErrorBoundaryState = {
91
91
  hasError: boolean
92
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
93
96
  }
94
97
 
95
- export class HoudiniErrorBoundary extends React.Component<
96
- HoudiniErrorBoundaryProps,
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} />
105
+ }
106
+
107
+ class ErrorBoundary extends React.Component<
108
+ HoudiniErrorBoundaryProps & { resetKey: string },
97
109
  HoudiniErrorBoundaryState
98
110
  > {
99
111
  static contextType = StatusContext
100
112
  declare context: React.ContextType<typeof StatusContext>
101
113
 
102
114
  constructor(
103
- props: HoudiniErrorBoundaryProps,
115
+ props: HoudiniErrorBoundaryProps & { resetKey: string },
104
116
  context: React.ContextType<typeof StatusContext>
105
117
  ) {
106
118
  super(props, context)
107
119
  // Second-pass SSR: statusRef is pre-set to an error status by on_render after the first
108
- // 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.
109
124
  if (typeof window === 'undefined' && context && context.status >= 400) {
110
125
  this.state = {
111
126
  hasError: true,
112
- errors: [new RoutingError(context.status)],
127
+ errors: context.errors ?? [new RoutingError(context.status)],
128
+ resetKey: props.resetKey,
113
129
  }
114
130
  } else {
115
- 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 }
116
149
  }
150
+ return null
117
151
  }
118
152
 
119
- static getDerivedStateFromError(error: unknown): HoudiniErrorBoundaryState {
153
+ static getDerivedStateFromError(error: unknown): Partial<HoudiniErrorBoundaryState> {
120
154
  if (error instanceof GraphQLErrors) {
121
155
  return { hasError: true, errors: error.graphqlErrors }
122
156
  }
@@ -126,17 +160,6 @@ export class HoudiniErrorBoundary extends React.Component<
126
160
  }
127
161
  }
128
162
 
129
- componentDidCatch(error: Error): void {
130
- if (this.context) {
131
- if (error instanceof RoutingError) {
132
- this.context.status = error.status
133
- } else if (error instanceof RedirectError) {
134
- this.context.status = error.status
135
- this.context.location = error.location
136
- }
137
- }
138
- }
139
-
140
163
  render() {
141
164
  if (this.state.hasError) {
142
165
  const ErrorView = this.props.errorView