houdini-react 2.0.2 → 2.1.1

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.1",
4
4
  "description": "The React plugin for houdini",
5
5
  "keywords": [
6
6
  "typescript",
@@ -34,6 +34,7 @@
34
34
  "estraverse": "^5.3.0",
35
35
  "express": "^5.1.0",
36
36
  "graphql-yoga": "^5.21.1",
37
+ "mrmime": "^2.0.1",
37
38
  "react": "^19.2.7",
38
39
  "react-dom": "^19.2.7",
39
40
  "react-streaming": "^0.4.17",
@@ -81,13 +82,13 @@
81
82
  }
82
83
  },
83
84
  "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"
85
+ "houdini-react-darwin-x64": "2.1.1",
86
+ "houdini-react-darwin-arm64": "2.1.1",
87
+ "houdini-react-linux-x64": "2.1.1",
88
+ "houdini-react-linux-arm64": "2.1.1",
89
+ "houdini-react-win32-x64": "2.1.1",
90
+ "houdini-react-win32-arm64": "2.1.1",
91
+ "houdini-react-wasm": "2.1.1"
91
92
  },
92
93
  "scripts": {
93
94
  "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.1'
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,
@@ -4,8 +4,7 @@ import type { Cache } from 'houdini/runtime/cache'
4
4
  import type { DocumentStore, HoudiniClient } from 'houdini/runtime/client'
5
5
  import { getCurrentConfig } from '$houdini/runtime'
6
6
  import configFile from '$houdini/runtime/imports/config'
7
- import { deepEquals } from 'houdini/runtime'
8
- import type { LRUCache } from 'houdini/runtime'
7
+ import { deepEquals, LRUCache } from 'houdini/runtime'
9
8
  import { marshalSelection, marshalInputs, getAuthUrl, HOUDINI_SESSION_EVENT } from 'houdini/runtime'
10
9
  import type { HoudiniSessionEventDetail } from 'houdini/runtime'
11
10
  import { find_match, find_prefix_match } from 'houdini/router/match'
@@ -17,7 +16,9 @@ import {
17
16
  RouterContextObject as Context,
18
17
  LocationContext,
19
18
  Is404Context,
19
+ NavigationContext,
20
20
  PageContext,
21
+ PendingURLContext,
21
22
  } from '../contexts.js'
22
23
  import { escapeScriptTag } from '../escape.js'
23
24
  import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
@@ -27,7 +28,11 @@ import { useDocumentStore } from '../hooks/useDocumentStore.js'
27
28
  import { type SuspenseCache, suspense_cache } from './cache.js'
28
29
  import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
29
30
 
30
- type PageComponent = React.ComponentType<{ url: string; children?: React.ReactNode }>
31
+ type PageComponent = React.ComponentType<{
32
+ url: string
33
+ showLoading?: boolean
34
+ children?: React.ReactNode
35
+ }>
31
36
 
32
37
  const PreloadWhich = {
33
38
  component: 'component',
@@ -37,6 +42,86 @@ const PreloadWhich = {
37
42
 
38
43
  type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
39
44
  type ComponentType = any
45
+
46
+ // useLoadingState decides whether to show the route's @loading state during navigation.
47
+ // `active` is the navigation transition's pending flag. The state flips on only once
48
+ // `active` has been pending for at least `loadingDelay` ms (so fast navigations never
49
+ // show it). Once shown it stays on until BOTH of these are true, so a response landing
50
+ // just after the delay doesn't cause a skeleton flicker:
51
+ // - it has been visible for at least `minDuration` ms
52
+ // - the target page's data has landed (`waitForData` resolves)
53
+ // A navigation that starts while the state is already showing re-arms the minDuration
54
+ // clock, so the new destination's data can't flash in right as the original hold expires
55
+ // — `target` (the destination url) is a dependency for exactly that reason: a second
56
+ // navigation can start without `active` ever flipping (the first transition is still
57
+ // pending), and the target change is what re-runs the effect.
58
+ // Note that `active` flips false as soon as the loading frame commits (the frame doesn't
59
+ // suspend, so the transition finishes with it on screen) — which is why the hide side
60
+ // waits on the data explicitly instead of trusting `active`.
61
+ function useLoadingState({
62
+ active,
63
+ target,
64
+ loadingDelay,
65
+ minDuration,
66
+ waitForData,
67
+ }: {
68
+ active: boolean
69
+ target: string | null
70
+ loadingDelay: number
71
+ minDuration: number
72
+ waitForData: () => Promise<void>
73
+ }): boolean {
74
+ const [show, setShow] = React.useState(false)
75
+ const shownAt = React.useRef<number | null>(null)
76
+
77
+ React.useEffect(() => {
78
+ if (active) {
79
+ // already showing — a new navigation is starting while the loading state is
80
+ // up. Keep it up, but re-arm the minimum-duration clock: measured from the
81
+ // first show, the hold could expire right as this navigation's data lands,
82
+ // flashing the freshly-loaded content in and out of the loading state.
83
+ if (show) {
84
+ shownAt.current = performance.now()
85
+ return
86
+ }
87
+ // wait out the delay; if we're still pending, switch the loading state on
88
+ const timeout = setTimeout(() => {
89
+ shownAt.current = performance.now()
90
+ setShow(true)
91
+ }, loadingDelay)
92
+ return () => clearTimeout(timeout)
93
+ }
94
+
95
+ // navigation finished. if we never showed the loading state, there's nothing to do
96
+ if (!show) {
97
+ return
98
+ }
99
+ // otherwise wait for the page's data, then keep the loading state up until it has
100
+ // been visible for at least minDuration. waiting on the data here means the page
101
+ // never mounts with its query still missing (which would re-suspend it into the
102
+ // Suspense fallback and briefly double-render the frame).
103
+ let cancelled = false
104
+ let timeout: ReturnType<typeof setTimeout> | undefined
105
+ waitForData().then(() => {
106
+ if (cancelled) {
107
+ return
108
+ }
109
+ const elapsed = performance.now() - (shownAt.current ?? performance.now())
110
+ const remaining = Math.max(0, minDuration - elapsed)
111
+ timeout = setTimeout(() => {
112
+ shownAt.current = null
113
+ setShow(false)
114
+ }, remaining)
115
+ })
116
+ return () => {
117
+ cancelled = true
118
+ clearTimeout(timeout)
119
+ }
120
+ }, [active, show, target, loadingDelay, minDuration])
121
+
122
+ return show
123
+ }
124
+
40
125
  /**
41
126
  * Router is the top level entry point for the filesystem-based router.
42
127
  * It is responsible for loading various page sources (including API fetches) and
@@ -64,6 +149,20 @@ export function Router({
64
149
  return initialURL || window.location.pathname + window.location.search
65
150
  })
66
151
 
152
+ // Navigation runs inside a transition so React keeps the previously-rendered route
153
+ // on screen while the next one loads (instead of immediately falling back to the
154
+ // loading state). If the transition stays pending longer than `loadingDelay`, we
155
+ // surface the route's @loading frame (see showLoading → the entry's page slot);
156
+ // fast navigations never show it, and once shown it stays up for `minDuration`.
157
+ const [isNavigating, startNavigation] = React.useTransition()
158
+
159
+ // pendingURL tracks the navigation target *urgently* (outside the transition), so a
160
+ // render that happens while the transition is still pending can tell whether it is
161
+ // looking at the destination (transition lane: currentURL === pendingURL) or at the
162
+ // still-committed previous route (urgent lane: currentURL lags behind). The loading
163
+ // frame is only ever shown on the destination — see the showLoading prop below.
164
+ const [pendingURL, setPendingURL] = React.useState<string | null>(null)
165
+
67
166
  // find the matching page for the current route. find_match also hands back the parsed
68
167
  // query string (declared search params coerced, UI-only keys raw). custom-scalar route
69
168
  // and search params arrive in their url transport form, so we unmarshal them once here
@@ -97,9 +196,36 @@ export function Router({
97
196
  injectToStream,
98
197
  })
99
198
  // if we get this far, it's safe to load the component
100
- const { component_cache, data_cache, ssr_signals } = useRouterContext()
199
+ const { component_cache, data_cache, artifact_cache, ssr_signals, latestSession } =
200
+ useRouterContext()
101
201
  const PageComponent = component_cache.get(targetPage.id)!
102
202
 
203
+ // decide whether the entry should render its @loading frame instead of the page.
204
+ // values come from the router config (bundled client-side; they're UI timing, not
205
+ // secrets), clamped so a bad value can't schedule a negative timeout.
206
+ // The machine only arms when the *destination* can actually render a frame (one of
207
+ // its documents is @loading): a frameless destination just holds the previous page
208
+ // for the whole transition, and arming the invisible state anyway would keep
209
+ // useNavigation().pending true for up to minDuration after the content committed.
210
+ const routerConfig = getCurrentConfig()?.router ?? {}
211
+ const [pendingPage] = pendingURL !== null ? find_match(manifest, pendingURL) : [null]
212
+ const destinationHasFrame = pendingPage
213
+ ? Object.values(pendingPage.documents).some((document) => document.loading)
214
+ : false
215
+ const showLoading = useLoadingState({
216
+ active: isNavigating && destinationHasFrame,
217
+ target: pendingURL,
218
+ loadingDelay: Math.max(0, routerConfig.loadingDelay ?? 200),
219
+ minDuration: Math.max(0, routerConfig.minDuration ?? 400),
220
+ // resolves once every query of the page being rendered has a store in data_cache
221
+ // (including error stores — load_query seeds those too, so an errored query
222
+ // releases the loading state instead of pinning it)
223
+ waitForData: () =>
224
+ Promise.all(
225
+ Object.keys(targetPage.documents).map((name) => data_cache.waitFor(name))
226
+ ).then(() => {}),
227
+ })
228
+
103
229
  // if we got this far then we're past suspense
104
230
 
105
231
  //
@@ -113,13 +239,96 @@ export function Router({
113
239
  }
114
240
  }, [currentURL])
115
241
 
242
+ // (dev only) hot-swap regenerated query artifacts. The router loads artifacts through
243
+ // dynamic import and keeps them — and the DocumentStores built from them — in caches
244
+ // keyed by document name, so vite's module-level HMR can never reach them: after
245
+ // codegen rewrites an artifact, the caches keep serving the old document and the page
246
+ // renders stale data. The compiler emits a custom event listing the changed artifact
247
+ // modules (see flushClientUpdates in houdini's vite plugin); re-import each one fresh,
248
+ // swap it into the artifact cache, evict the stale store, and reload the current
249
+ // page's data. useQueryResult keeps the committed store on screen while the
250
+ // replacement loads (the same keep-last-store path as an abandoned navigation), so
251
+ // the swap doesn't flash a loading state.
252
+ const [, forceRender] = React.useReducer((n: number) => n + 1, 0)
253
+ // the listener is registered once but must act on the latest render's page/variables
254
+ // (and loadData's closure over the current session)
255
+ const hmr = React.useRef({ page: targetPage, variables, loadData })
256
+ hmr.current = { page: targetPage, variables, loadData }
257
+ React.useEffect(() => {
258
+ // dev only: vite statically replaces both tokens in production builds
259
+ // (env.DEV → false, hot → undefined), so the whole listener is dead-code
260
+ // eliminated. The casts erase to the literal `import.meta.env.DEV` /
261
+ // `import.meta.hot` tokens — don't alias import.meta or add optional
262
+ // chaining, or the replacement (and vite's hot-context injection) stops
263
+ // matching.
264
+ if (!(import.meta as unknown as { env: { DEV?: boolean } }).env.DEV) {
265
+ return
266
+ }
267
+ const hot = (import.meta as { hot?: any }).hot
268
+ if (!hot) {
269
+ return
270
+ }
271
+ const onArtifactUpdate = async ({
272
+ artifacts,
273
+ }: {
274
+ artifacts: Array<{ name: string; url: string }>
275
+ }) => {
276
+ let stale = false
277
+ for (const { name, url } of artifacts) {
278
+ // only documents this client has loaded can be stale
279
+ if (!artifact_cache.has(name)) {
280
+ continue
281
+ }
282
+ // bust the browser's module cache — re-importing the bare url would just
283
+ // return the module we already have
284
+ let artifact: QueryArtifact | undefined
285
+ try {
286
+ const bust = `${url.includes('?') ? '&' : '?'}t=${Date.now()}`
287
+ artifact = (await import(/* @vite-ignore */ `${url}${bust}`))?.default
288
+ } catch {
289
+ // the module failed to load (e.g. the document was deleted) — leave the
290
+ // caches alone rather than evicting a store we can't replace
291
+ continue
292
+ }
293
+ if (!artifact) {
294
+ continue
295
+ }
296
+ artifact_cache.set(name, artifact)
297
+ // evict the store built from the old artifact so the reload below creates a
298
+ // replacement. deliberately no cleanup(): mounted components keep rendering
299
+ // the old store until the new one lands (see SuspenseCache's delete() note)
300
+ data_cache.delete(name)
301
+ ssr_signals.delete(name)
302
+ stale = true
303
+ }
304
+ if (!stale) {
305
+ return
306
+ }
307
+ const { page, variables, loadData } = hmr.current
308
+ // the reload honors each query's cache policy: an edit that doesn't change
309
+ // what the document asks for (or asks for data the cache already holds)
310
+ // resolves instantly from cache; anything the old selection didn't cover
311
+ // misses and goes to the network
312
+ loadData(page, variables)
313
+ // re-render so useQueryResult sees the eviction and subscribes to the
314
+ // replacement — deleting a cache entry doesn't notify anyone on its own
315
+ forceRender()
316
+ }
317
+ hot.on('houdini:artifact-update', onArtifactUpdate)
318
+ return () => hot.off?.('houdini:artifact-update', onArtifactUpdate)
319
+ }, [])
320
+
116
321
  // when we first mount we should start listening to the back button
117
322
  React.useEffect(() => {
118
323
  if (!globalThis.window) {
119
324
  return
120
325
  }
121
326
  const onChange = (_evt: PopStateEvent) => {
122
- setCurrentURL(window.location.pathname + window.location.search)
327
+ const url = window.location.pathname + window.location.search
328
+ setPendingURL(url)
329
+ startNavigation(() => {
330
+ setCurrentURL(url)
331
+ })
123
332
  }
124
333
  window.addEventListener('popstate', onChange)
125
334
  return () => {
@@ -127,34 +336,72 @@ export function Router({
127
336
  }
128
337
  }, [])
129
338
 
339
+ // On navigation (but not the initial mount), re-fire the route's already-cached
340
+ // queries so each one's cache policy is honored (stale-while-revalidate). The
341
+ // observers are kept, so a query whose variables are unchanged and whose policy
342
+ // allows a cache read resolves without a loading frame; queries whose variables
343
+ // changed (or aren't cached) are evicted + reloaded by loadData with their loading
344
+ // state. We reuse the variables already unmarshaled for this render rather than
345
+ // re-parsing the URL. This also covers back/forward (popstate), which bypasses goto.
346
+ // The guard compares URLs (not a boolean) so Strict Mode's double effect invocation
347
+ // on mount doesn't slip past it and revalidate the initial page.
348
+ const lastRevalidatedURL = React.useRef(currentURL)
349
+ React.useEffect(() => {
350
+ if (lastRevalidatedURL.current === currentURL) {
351
+ return
352
+ }
353
+ lastRevalidatedURL.current = currentURL
354
+ for (const name of Object.keys(targetPage.documents)) {
355
+ if (data_cache.has(name)) {
356
+ data_cache.get(name).send({ variables, session: latestSession.current })
357
+ }
358
+ }
359
+ }, [currentURL])
360
+
130
361
  // the function to call to navigate. accepts either a ready-made url string or a
131
362
  // typed target { to, params, search } that is assembled (and custom scalars
132
363
  // marshaled) exactly the way <Link> builds its href. The typed surface is the
133
364
  // 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
365
+ // Referentially stable (everything it closes over is): it ships in the memoized
366
+ // NavigationContext value and anchors the link listener effect, so a new identity
367
+ // per render would re-render every useNavigation consumer and re-attach listeners.
368
+ const goto = React.useCallback(
369
+ (
370
+ target:
371
+ | string
372
+ | {
373
+ to: string
374
+ params?: Record<string, unknown>
375
+ search?: Record<string, unknown>
376
+ }
377
+ ) => {
378
+ const url =
379
+ typeof target === 'string'
380
+ ? target
381
+ : buildHref(
382
+ target.to,
383
+ manifest.pages[manifest.pagesByUrl[target.to]],
384
+ getCurrentConfig()?.scalars,
385
+ target.params,
386
+ target.search
387
+ )
388
+
389
+ // We intentionally don't blanket-clear the data cache on navigation (that would
390
+ // force every query back through its loading state). Observers and their data
391
+ // survive, and per-query revalidation is handled by the navigation effect above
392
+ // (which honors each query's cache policy). A real session change still clears
393
+ // the cache (updateSession / the session event listener).
394
+
395
+ // track the destination urgently (so in-flight renders can identify it) and
396
+ // perform the navigation inside a transition so React keeps the current route on
397
+ // screen until the next one is ready (or until showLoading swaps in the frame).
398
+ setPendingURL(url)
399
+ startNavigation(() => {
400
+ setCurrentURL(url)
401
+ })
402
+ },
403
+ [manifest]
404
+ ) as Goto
158
405
 
159
406
  // links are powered using anchor tags that we intercept and handle ourselves
160
407
  useLinkBehavior({
@@ -189,31 +436,71 @@ export function Router({
189
436
  },
190
437
  })
191
438
 
192
- // TODO: cleanup navigation caches
439
+ // The loading frame only renders on the destination of the navigation: while a
440
+ // transition is pending, the committed tree still has the previous currentURL
441
+ // (pendingURL differs), so an urgent re-render of it — e.g. the showLoading flip —
442
+ // keeps showing the previous page instead of swapping it for its own frame. The
443
+ // destination (transition lane, where currentURL === pendingURL) renders the frame,
444
+ // which doesn't suspend, so the transition commits as soon as the rest of the entry
445
+ // (component, artifacts, layout data) is renderable.
446
+ const showFrame = showLoading && currentURL === pendingURL
447
+
448
+ // the public pending-navigation surface (useNavigation). A navigation counts as
449
+ // pending until the destination shows its actual content: the transition can commit
450
+ // with the @loading frame on screen (isNavigating flips false then), so the loading
451
+ // state extends it. Memoized so consumers only re-render when it actually changes.
452
+ const navigating = isNavigating || showLoading
453
+ const navigation = React.useMemo(
454
+ () => ({ pending: navigating, to: navigating ? pendingURL : null, goto }),
455
+ [navigating, pendingURL, goto]
456
+ )
457
+
193
458
  // render the component embedded in the necessary context so it can orchestrate
194
459
  // its needs
195
460
  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>
461
+ <PendingURLContext.Provider value={pendingURL}>
462
+ <NavigationContext.Provider value={navigation}>
463
+ <LocationContext.Provider
464
+ value={{
465
+ pathname: currentURL,
466
+ goto,
467
+ params: variables ?? {},
468
+ search,
469
+ }}
470
+ >
471
+ <Is404Context.Provider value={is404}>
472
+ {is404 ? (
473
+ <NotFoundLayoutBoundary key={targetPage.id}>
474
+ <PageComponent
475
+ url={currentURL}
476
+ showLoading={showFrame}
477
+ key={targetPage.id + '__404'}
478
+ />
479
+ </NotFoundLayoutBoundary>
480
+ ) : (
481
+ <PageComponent
482
+ url={currentURL}
483
+ showLoading={showFrame}
484
+ key={targetPage.id}
485
+ />
486
+ )}
487
+ </Is404Context.Provider>
488
+ </LocationContext.Provider>
489
+ </NavigationContext.Provider>
490
+ </PendingURLContext.Provider>
214
491
  )
215
492
  }
216
493
 
494
+ // useNavigation exposes the router's in-flight navigation. `pending` is true from the
495
+ // moment a navigation starts until the destination renders its actual content — it stays
496
+ // true while the destination's @loading state is showing — and `to` carries the
497
+ // destination url while pending (null when idle). It also carries `goto` — the same
498
+ // navigate function useRoute exposes — so navigation chrome (progress bars, nav menus,
499
+ // per-link spinners) only needs this one hook.
500
+ export function useNavigation(): { pending: boolean; to: string | null; goto: Goto } {
501
+ return useContext(NavigationContext)
502
+ }
503
+
217
504
  // internal accessor for the raw location context. the public surface is useRoute, which
218
505
  // layers the per-route param/search types on top of this. not re-exported from the package
219
506
  // index, so it isn't part of the public API.
@@ -256,11 +543,9 @@ function usePageData({
256
543
  artifact_cache,
257
544
  ssr_signals,
258
545
  last_variables,
546
+ latestSession,
259
547
  } = useRouterContext()
260
548
 
261
- // grab the current session value
262
- const [session] = useSession()
263
-
264
549
  // the function to load a query using the cache references
265
550
  function load_query({
266
551
  id,
@@ -271,11 +556,12 @@ function usePageData({
271
556
  artifact: QueryArtifact
272
557
  variables: GraphQLVariables
273
558
  }): Promise<void> {
274
- // TODO: better tracking - only register the variables that were used
275
- // track the new variables
276
- for (const artifact of Object.keys(page.documents)) {
277
- last_variables.set(artifact, variables)
278
- }
559
+ // record the variables this document is being sent with. only the loaded document:
560
+ // a preload loads the *destination's* queries while another page is still rendered,
561
+ // so writing the whole page's documents here (the old behavior) would tag the
562
+ // current page's documents with the destination's variables — and never tag the
563
+ // preloaded document at all
564
+ last_variables.set(id, variables)
279
565
 
280
566
  // TODO: AbortController on send()
281
567
  // TODO: we can read from cache here before making an asynchronous network call
@@ -350,6 +636,12 @@ function usePageData({
350
636
  `)
351
637
  }
352
638
 
639
+ // remember which cache generation this send belongs to: if the cache is
640
+ // invalidated while the request is in flight (a session change clears it), the
641
+ // result was fetched under state that no longer applies and must not be
642
+ // re-inserted — the invalidation already triggered a fresh send
643
+ const generation = data_cache.generation
644
+
353
645
  // store the observer immediately so useQueryResult can access it
354
646
  // during SSR rendering before the fetch resolves
355
647
  let resolve: () => void = () => {}
@@ -361,9 +653,20 @@ function usePageData({
361
653
  observer
362
654
  .send({
363
655
  variables: variables,
364
- session,
656
+ // read the ref, not the render-scoped session: this send can run during a
657
+ // render whose committed session predates a just-written one, and it must
658
+ // not repopulate the invalidated caches with stale-session results
659
+ session: latestSession.current,
365
660
  })
366
661
  .then(async () => {
662
+ // a stale-generation result: release the signal so nothing hangs, but
663
+ // don't put the observer (or its stream scripts) anywhere
664
+ if (data_cache.generation !== generation) {
665
+ ssr_signals.delete(id)
666
+ resolve()
667
+ return
668
+ }
669
+
367
670
  data_cache.set(id, observer)
368
671
 
369
672
  // if there is an error, stream it to the client (so an @loading query
@@ -490,6 +793,11 @@ function usePageData({
490
793
  if (err?.name === 'AbortError') {
491
794
  return
492
795
  }
796
+ // same stale-generation rule as the success path
797
+ if (data_cache.generation !== generation) {
798
+ resolve()
799
+ return
800
+ }
493
801
  // a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
494
802
  // seed it as a synthetic GraphQL error on the store and stream it to the client,
495
803
  // otherwise an @loading query that rejects hangs on the loading state
@@ -640,19 +948,39 @@ export function RouterContextProvider({
640
948
  // on the server, we can just use
641
949
  const [session, setSession] = React.useState<App.Session>(ssrSession)
642
950
 
951
+ // the React state above lags behind session writes: a setSession scheduled inside a
952
+ // transition hasn't committed when another lane renders (e.g. the urgent isPending
953
+ // flip of a navigation started in the same transition), so a render-phase load_query
954
+ // in that window would send with the previous session and repopulate the caches the
955
+ // session change just invalidated — the new-session render then finds everything
956
+ // "cached" and never refetches. Every session write updates this ref synchronously,
957
+ // and query sends read it instead of the render-scoped state.
958
+ const latestSession = React.useRef<App.Session>(ssrSession)
959
+
643
960
  // if we detect an event that contains a new session value. The detail carries the subtree
644
961
  // and whether to merge it into the current session (an @session(merge:) upsert) or replace
645
962
  // 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
- }, [])
963
+ const handleNewSession = React.useCallback(
964
+ (event: Event) => {
965
+ const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
966
+ const isWrapped =
967
+ detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
968
+ const next = (
969
+ isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
970
+ ) as App.Session
971
+ const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
972
+
973
+ // a new session invalidates every cached query result, exactly like updateSession():
974
+ // navigation no longer clears the data cache, so without this an event-driven
975
+ // session change (updateLocalSession) would keep serving results fetched under the
976
+ // old session
977
+ invalidate_session_caches({ data_cache, ssr_signals, last_variables })
978
+
979
+ latestSession.current = merge ? { ...latestSession.current, ...next } : next
980
+ setSession(latestSession.current)
981
+ },
982
+ [data_cache, ssr_signals, last_variables]
983
+ )
656
984
 
657
985
  React.useEffect(() => {
658
986
  window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
@@ -674,9 +1002,19 @@ export function RouterContextProvider({
674
1002
  ssr_signals,
675
1003
  last_variables,
676
1004
  session,
677
- setSession: (newSession) => setSession((old) => ({ ...old, ...newSession })),
678
- replaceSession: (next) => setSession(next),
679
- clearSession: () => setSession({}),
1005
+ latestSession,
1006
+ setSession: (newSession) => {
1007
+ latestSession.current = { ...latestSession.current, ...newSession }
1008
+ setSession(latestSession.current)
1009
+ },
1010
+ replaceSession: (next) => {
1011
+ latestSession.current = next
1012
+ setSession(next)
1013
+ },
1014
+ clearSession: () => {
1015
+ latestSession.current = {}
1016
+ setSession({})
1017
+ },
680
1018
  formResult,
681
1019
  formToken,
682
1020
  }}
@@ -710,6 +1048,12 @@ export type RouterContext = {
710
1048
  // The current session
711
1049
  session: App.Session
712
1050
 
1051
+ // the most recent session value, written synchronously by every session setter. The
1052
+ // `session` state above only updates when React commits, which can lag the write by a
1053
+ // render (e.g. a setSession inside a transition) — anything that fires a request during
1054
+ // render (load_query) must read this instead so it can't send a stale session.
1055
+ latestSession: { current: App.Session }
1056
+
713
1057
  // a function to call that sets the client-side session singletone
714
1058
  setSession: (newSession: Partial<App.Session>) => void
715
1059
 
@@ -778,6 +1122,21 @@ export function updateLocalSession(session: App.Session, merge = false) {
778
1122
  )
779
1123
  }
780
1124
 
1125
+ // invalidate_session_caches drops everything derived from the previous session: query
1126
+ // results, in-flight signal bookkeeping, and the last-sent variables (so useQueryResult's
1127
+ // keep-last-store check can't match a store fetched under the old session — the page
1128
+ // suspends into its loading state and refetches). One helper shared by both session-change
1129
+ // paths (updateSession and the HOUDINI_SESSION_EVENT listener) so they can't drift.
1130
+ function invalidate_session_caches(caches: {
1131
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
1132
+ ssr_signals: PendingCache
1133
+ last_variables: LRUCache<GraphQLVariables>
1134
+ }) {
1135
+ caches.data_cache.clear()
1136
+ caches.ssr_signals.clear()
1137
+ caches.last_variables.clear()
1138
+ }
1139
+
781
1140
  export function useSession(): [
782
1141
  App.Session,
783
1142
  (newSession: Partial<App.Session> | null) => Promise<void>,
@@ -790,9 +1149,8 @@ export function useSession(): [
790
1149
  // log out — clearing the local session and deleting the cookie. It's awaitable so callers
791
1150
  // can wait for the cookie to settle before navigating.
792
1151
  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()
1152
+ // drop everything derived from the previous session so queries refetch with the new one
1153
+ invalidate_session_caches(ctx)
796
1154
 
797
1155
  if (newSession === null) {
798
1156
  ctx.clearSession()
@@ -817,10 +1175,62 @@ export function useQueryResult<_Data extends GraphQLObject, _Input extends Graph
817
1175
  name: string
818
1176
  ): [_Data | null, DocumentHandle<any, _Data, _Input>] {
819
1177
  // pull the global context values
820
- const { data_cache, artifact_cache } = useRouterContext()
1178
+ const { data_cache, artifact_cache, last_variables } = useRouterContext()
1179
+ const { pathname } = useLocationContext()
1180
+ const pendingURL = React.useContext(PendingURLContext)
1181
+ // the store (and the router-level variables it was sent with) from this component's
1182
+ // last COMMIT. Written in an effect, never during render: the ref object is shared
1183
+ // between a fiber and its work-in-progress alternate, so a render-phase write from a
1184
+ // transition lane that never commits would leak the wrong store into the committed
1185
+ // tree's next render.
1186
+ const last_store = React.useRef<DocumentStore<_Data, _Input> | null>(null)
1187
+ const last_vars = React.useRef<GraphQLVariables | null>(null)
1188
+ const [, bumpStore] = React.useReducer((n: number) => n + 1, 0)
1189
+
1190
+ // Decide which store to render. data_cache.get suspends when the store is missing —
1191
+ // the right behavior for a first render or for the navigation destination (a
1192
+ // transition waits on it, or the page's @loading boundary catches it). But the router
1193
+ // evicts a document mid-navigation to reload it with new variables, and a re-render
1194
+ // of the still-visible previous page must NOT re-suspend into its own loading state —
1195
+ // it keeps rendering the store from its last commit and the in-flight navigation
1196
+ // swaps the tree when the replacement resolves. Two cases keep the last store:
1197
+ // - the render's URL lags the navigation target (pendingURL is lane-independent —
1198
+ // see PendingURLContext). A lagging render always prefers its committed store,
1199
+ // even when the cache already holds a replacement for different variables.
1200
+ // - the store is missing but the router's current variables for this document
1201
+ // still match the ones the committed store was sent with: the eviction belongs
1202
+ // to a navigation that was abandoned (e.g. goto back to the committed URL), so
1203
+ // this render should keep its data while the document reloads.
1204
+ // The bump effect re-renders once a replacement lands, so a kept-stale commit always
1205
+ // converges to the fresh store.
1206
+ const missing = !data_cache.has(name)
1207
+ const lagging = pendingURL !== null && pathname !== pendingURL
1208
+ const matches_render =
1209
+ last_vars.current !== null && deepEquals(last_vars.current, last_variables.get(name))
1210
+ const store_ref =
1211
+ last_store.current && (lagging || (missing && matches_render))
1212
+ ? last_store.current
1213
+ : (data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>)
1214
+
1215
+ React.useEffect(() => {
1216
+ last_store.current = store_ref
1217
+ last_vars.current = (last_variables.get(name) as GraphQLVariables | null) ?? null
1218
+ })
821
1219
 
822
- // load the store reference (this will suspend)
823
- const store_ref = data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>
1220
+ React.useEffect(() => {
1221
+ if (!missing) {
1222
+ return
1223
+ }
1224
+ let cancelled = false
1225
+ data_cache.waitFor(name).then(() => {
1226
+ if (!cancelled) {
1227
+ bumpStore()
1228
+ }
1229
+ })
1230
+ return () => {
1231
+ cancelled = true
1232
+ }
1233
+ }, [missing, name])
824
1234
 
825
1235
  // get the live data from the store
826
1236
  const [storeValue, observer] = useDocumentStore<_Data, _Input>({
@@ -867,9 +1277,6 @@ function useLinkBehavior({
867
1277
  }
868
1278
 
869
1279
  function useLinkNavigation({ goto }: { goto: Goto }) {
870
- // navigations need to be registered as transitions
871
- const [_pending, startTransition] = React.useTransition()
872
-
873
1280
  React.useEffect(() => {
874
1281
  const onClick: HTMLAnchorElement['onclick'] = (e) => {
875
1282
  if (!e.target) {
@@ -918,10 +1325,11 @@ function useLinkNavigation({ goto }: { goto: Goto }) {
918
1325
  e.preventDefault()
919
1326
  e.stopPropagation()
920
1327
 
921
- // go to the next route as a low priority update
922
- startTransition(() => {
923
- goto(target)
924
- })
1328
+ // goto() runs the URL update in its own transition and tracks the pending
1329
+ // destination urgently. Don't wrap the call in another transition here: that
1330
+ // would drag the urgent bookkeeping (pendingURL) into the transition lane,
1331
+ // and the committed tree couldn't tell where the navigation is headed.
1332
+ goto(target)
925
1333
  }
926
1334
 
927
1335
  window.addEventListener('click', onClick)
@@ -1016,9 +1424,19 @@ export function router_cache({
1016
1424
  const result: RouterCache = {
1017
1425
  artifact_cache: suspense_cache(initialArtifacts),
1018
1426
  component_cache: suspense_cache(),
1019
- data_cache: suspense_cache(initialData),
1427
+ // observers accumulate across navigations now that goto doesn't clear the cache,
1428
+ // so the LRU capacity limit is reachable on long sessions. When it silently evicts
1429
+ // a store, let the store's plugins release whatever they hold. Best-effort: a
1430
+ // cleanup that rejects must not become an unhandled rejection (which kills a node
1431
+ // process outright).
1432
+ data_cache: suspense_cache(initialData, (store) => {
1433
+ store.cleanup().catch(() => {})
1434
+ }),
1020
1435
  ssr_signals: suspense_cache(),
1021
- last_variables: suspense_cache(),
1436
+ // a plain LRU, NOT a suspense cache: readers look up documents that may have no
1437
+ // entry yet (useQueryResult reads it during render and in its commit effect, where
1438
+ // a suspense cache's thrown promise would land in the nearest error boundary)
1439
+ last_variables: new LRUCache(),
1022
1440
  }
1023
1441
 
1024
1442
  // we need to fill each query with an externally resolvable promise
@@ -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
package/vite/index.js CHANGED
@@ -6,7 +6,8 @@ import {
6
6
  client_build_directory
7
7
  } from "houdini/router/conventions";
8
8
  import { load_manifest } from "houdini/router/manifest";
9
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { lookup } from "mrmime";
10
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
10
11
  import { createRequire } from "node:module";
11
12
  import { build } from "vite";
12
13
  import { transform_file } from "./transform.js";
@@ -287,7 +288,7 @@ mount_static_app(App, manifest)
287
288
  return;
288
289
  }
289
290
  const url = req.url.split("?")[0];
290
- if (url.startsWith("/@") || url.startsWith("/virtual:") || url.startsWith("/node_modules/") || /\.[a-z]+$/i.test(url)) {
291
+ if (url.startsWith("/@") || url.startsWith("/virtual:") || url.startsWith("/node_modules/") || /\.[a-z]+$/i.test(url) || lookup(url) !== void 0 || is_public_file(url, server.config.publicDir)) {
291
292
  next();
292
293
  return;
293
294
  }
@@ -304,18 +305,16 @@ mount_static_app(App, manifest)
304
305
  adapter_config_path(ctx.config)
305
306
  );
306
307
  const requestHeaders = new Headers();
307
- for (const header of Object.entries(req.headers ?? {})) {
308
- requestHeaders.set(header[0], header[1]);
308
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
309
+ requestHeaders.set(name, Array.isArray(value) ? value.join(", ") : value ?? "");
309
310
  }
310
311
  const port = server.config.server.port ?? 5173;
311
- const request = new Request(
312
- `http://localhost:${port}` + req.url,
313
- req.method === "POST" ? {
314
- method: req.method,
315
- headers: requestHeaders,
316
- body: await getBody(req)
317
- } : void 0
318
- );
312
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
313
+ const request = new Request(`http://localhost:${port}` + req.url, {
314
+ method: req.method,
315
+ headers: requestHeaders,
316
+ ...hasBody ? { body: await getBody(req) } : {}
317
+ });
319
318
  let documentPremable = `<script type="module" src="/@vite/client" async=""></script>`;
320
319
  try {
321
320
  const transformed = await server.transformIndexHtml(
@@ -353,20 +352,17 @@ mount_static_app(App, manifest)
353
352
  if (res.closed) {
354
353
  return;
355
354
  }
356
- for (const header of result.headers ?? []) {
357
- res.setHeader(header[0], header[1]);
355
+ for (const [key, value] of result.headers ?? []) {
356
+ if (key.toLowerCase() !== "set-cookie") {
357
+ res.setHeader(key, value);
358
+ }
359
+ }
360
+ const setCookies = result.headers?.getSetCookie() ?? [];
361
+ if (setCookies.length > 0) {
362
+ res.setHeader("Set-Cookie", setCookies);
358
363
  }
359
364
  if (result.status >= 300 && result.status < 400) {
360
- res.writeHead(result.status, {
361
- Location: result.headers.get("Location") ?? "",
362
- ...[...result.headers].reduce(
363
- (headers, [key, value]) => ({
364
- ...headers,
365
- [key]: value
366
- }),
367
- {}
368
- )
369
- });
365
+ res.writeHead(result.status);
370
366
  } else {
371
367
  res.write(await result.text());
372
368
  }
@@ -380,6 +376,16 @@ mount_static_app(App, manifest)
380
376
  }
381
377
  };
382
378
  }
379
+ function is_public_file(url, publicDir) {
380
+ if (!publicDir) {
381
+ return false;
382
+ }
383
+ try {
384
+ return statSync(path.join(publicDir, decodeURIComponent(url))).isFile();
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
383
389
  function getBody(request) {
384
390
  return new Promise((resolve) => {
385
391
  const bodyParts = [];