houdini-react 2.0.0-next.38 → 2.0.0-next.40

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.0-next.38",
3
+ "version": "2.0.0-next.40",
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.0-next.38",
85
- "houdini-react-darwin-arm64": "2.0.0-next.38",
86
- "houdini-react-linux-x64": "2.0.0-next.38",
87
- "houdini-react-linux-arm64": "2.0.0-next.38",
88
- "houdini-react-win32-x64": "2.0.0-next.38",
89
- "houdini-react-win32-arm64": "2.0.0-next.38",
90
- "houdini-react-wasm": "2.0.0-next.38"
84
+ "houdini-react-darwin-x64": "2.0.0-next.40",
85
+ "houdini-react-darwin-arm64": "2.0.0-next.40",
86
+ "houdini-react-linux-x64": "2.0.0-next.40",
87
+ "houdini-react-linux-arm64": "2.0.0-next.40",
88
+ "houdini-react-win32-x64": "2.0.0-next.40",
89
+ "houdini-react-win32-arm64": "2.0.0-next.40",
90
+ "houdini-react-wasm": "2.0.0-next.40"
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.0-next.38'
8
+ const BINARY_DISTRIBUTION_VERSION = '2.0.0-next.40'
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'
package/runtime/Link.tsx CHANGED
@@ -59,16 +59,22 @@ type _ParamsForRoute<H extends string> = [_PageForRoute<H>] extends [never]
59
59
  export type LinkProps<H extends RouteHrefs | _ExternalHref = RouteHrefs | _ExternalHref> = Omit<
60
60
  DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>,
61
61
  'href'
62
- > & { to: H; preload?: boolean | 'data' | 'component' | 'page' } & _ParamsForRoute<H>
62
+ > & {
63
+ to: H
64
+ disabled?: boolean
65
+ preload?: boolean | 'data' | 'component' | 'page'
66
+ } & _ParamsForRoute<H>
63
67
 
64
68
  export function Link<H extends RouteHrefs | _ExternalHref>({
65
69
  to,
66
70
  params,
71
+ disabled,
67
72
  preload,
68
73
  ...rest
69
74
  }: LinkProps<H>): React.ReactElement {
70
- const href =
71
- params != null
75
+ const href = disabled
76
+ ? undefined
77
+ : params != null
72
78
  ? resolveHref(to as string, params as Record<string, string | number | boolean>)
73
79
  : (to as string)
74
80
  return React.createElement('a', { ...rest, href, 'data-houdini-preload': preload })
@@ -33,6 +33,9 @@ export function useDocumentHandle<
33
33
  }): DocumentHandle<_Artifact, _Data, _Input> & { fetch: FetchFn<_Data, _Input> } {
34
34
  const [forwardPending, setForwardPending] = React.useState(false)
35
35
  const [backwardPending, setBackwardPending] = React.useState(false)
36
+ // Stable cursor stacks for SinglePage pagination — must survive re-renders caused by store updates
37
+ const previousCursorsRef = React.useRef<(string | null)[]>([])
38
+ const nextCursorsRef = React.useRef<(string | null)[]>([])
36
39
  const location = useLocation()
37
40
 
38
41
  // grab the current session value
@@ -127,6 +130,8 @@ export function useDocumentHandle<
127
130
  getState: () => storeValue.data,
128
131
  getVariables: () => storeValue.variables!,
129
132
  fetch: fetchQuery,
133
+ previousCursors: previousCursorsRef.current,
134
+ nextCursors: nextCursorsRef.current,
130
135
  fetchUpdate: (args, updates) => {
131
136
  return paginationObserver!.send({
132
137
  ...args,
@@ -1,12 +1,15 @@
1
+ import { extractPageInfo, cursorHandlers, offsetHandlers } from 'houdini/runtime'
1
2
  import type {
2
3
  GraphQLObject,
3
4
  FragmentArtifact,
4
5
  QueryArtifact,
5
6
  GraphQLVariables,
7
+ FetchFn,
6
8
  } from 'houdini/runtime'
9
+ import * as React from 'react'
7
10
 
8
- import { useDocumentHandle, type DocumentHandle } from './useDocumentHandle.js'
9
- import { useDocumentStore } from './useDocumentStore.js'
11
+ import { useClient, useSession } from '../routing/Router.js'
12
+ import type { DocumentHandle } from './useDocumentHandle.js'
10
13
  import { fragmentReference, useFragment } from './useFragment.js'
11
14
 
12
15
  // useFragmentHandle is just like useFragment except it also returns an imperative handle
@@ -22,25 +25,167 @@ export function useFragmentHandle<
22
25
  document: { artifact: FragmentArtifact; refetchArtifact?: QueryArtifact }
23
26
  ): any {
24
27
  // get the fragment values
25
- const data = useFragment<_Data, _ReferenceType, _Input>(reference, document)
28
+ const fragmentData = useFragment<_Data, _ReferenceType, _Input>(reference, document)
26
29
 
27
30
  // look at the fragment reference to get the variables
28
31
  const { variables } = fragmentReference<_Data, _Input, _ReferenceType>(reference, document)
29
32
 
30
- // use the pagination fragment for meta data if it exists.
31
- // if we pass this a fragment artifact, it won't add any data
32
- const [handleValue, handleObserver] = useDocumentStore<_Data, _Input>({
33
- artifact: document.refetchArtifact ?? document.artifact,
34
- })
35
- const handle = useDocumentHandle<_PaginationArtifact, _Data, _Input>({
36
- observer: handleObserver,
37
- storeValue: handleValue,
38
- artifact: document.refetchArtifact ?? document.artifact,
39
- })
33
+ const client = useClient()
34
+ const [session] = useSession()
35
+
36
+ const [forwardPending, setForwardPending] = React.useState(false)
37
+ const [backwardPending, setBackwardPending] = React.useState(false)
38
+
39
+ // Stable cursor stacks for SinglePage pagination — must survive re-renders
40
+ const previousCursorsRef = React.useRef<(string | null)[]>([])
41
+ const nextCursorsRef = React.useRef<(string | null)[]>([])
42
+
43
+ const refetchArtifact = document.refetchArtifact as QueryArtifact | undefined
44
+ const refetchPath = refetchArtifact?.refetch?.path
45
+
46
+ // Dedicated observer for pagination queries — separate from the fragment observer.
47
+ // cursorHandlers derives entity variables (e.g. { id }) and artifact defaults
48
+ // automatically via the type config, so no manual variable extraction is needed here.
49
+ const paginationObserver = React.useMemo(() => {
50
+ if (!refetchArtifact?.refetch?.paginated) return null
51
+ return client.observe<_Data, _Input>({ artifact: refetchArtifact })
52
+ }, [refetchArtifact?.name])
53
+
54
+ // Subscribe to the pagination observer so React re-renders whenever a new page is fetched
55
+ // or served from cache (CacheOrNetwork). The fragment store subscription only watches the
56
+ // initial page's cache key; the observer is the live source of truth for SinglePage
57
+ // pagination where each page lives at its own per-cursor cache key.
58
+ const subscribeToObserver = React.useCallback(
59
+ (onChange: () => void) => {
60
+ if (!paginationObserver) return () => {}
61
+ return paginationObserver.subscribe(onChange)
62
+ },
63
+ [paginationObserver]
64
+ )
65
+ const getObserverSnapshot = React.useCallback(
66
+ () => paginationObserver?.state.data ?? null,
67
+ [paginationObserver]
68
+ )
69
+ const paginationData = React.useSyncExternalStore(
70
+ subscribeToObserver,
71
+ getObserverSnapshot,
72
+ getObserverSnapshot
73
+ )
74
+
75
+ // Extract entity-level data from the pagination query response. For Node targetType
76
+ // the response is { node: EntityData }; we take the first root field to handle any type.
77
+ // Guard against partial cache hits (artifact has partial:true): only use the entity once the
78
+ // paginated connection field at refetch.path[0] is actually present in the response.
79
+ const paginationEntityData = React.useMemo<_Data | null>(() => {
80
+ if (!paginationData || !refetchArtifact?.selection?.fields) return null
81
+ const rootField = Object.keys(refetchArtifact.selection.fields)[0]
82
+ if (!rootField) return null
83
+ const entity = (paginationData as any)[rootField]
84
+ if (!entity) return null
85
+ const path = refetchArtifact.refetch?.path
86
+ if (path && path.length > 0 && (entity as any)[path[0]] == null) {
87
+ return null
88
+ }
89
+ return entity as _Data
90
+ }, [paginationData, refetchArtifact])
91
+
92
+ const isSinglePage = refetchArtifact?.refetch?.mode === 'SinglePage'
93
+
94
+ // For SinglePage: use the pagination observer's entity data (each page has its own
95
+ // cache key) once a page fetch has landed. For Infinite: always use fragmentData,
96
+ // which reads accumulated pages from cache via the fragment's cache subscription.
97
+ const displayData =
98
+ isSinglePage && paginationEntityData !== null ? paginationEntityData : fragmentData
99
+
100
+ const wrapLoad = <_Result>(
101
+ setLoading: (val: boolean) => void,
102
+ fn: (value: any) => Promise<_Result>
103
+ ) => {
104
+ return async (value: any) => {
105
+ setLoading(true)
106
+ let err: Error | null = null
107
+ let result: _Result | null = null
108
+ try {
109
+ result = await fn(value)
110
+ } catch (e) {
111
+ err = e as Error
112
+ }
113
+ setLoading(false)
114
+ if (err && err.name !== 'AbortError') throw err
115
+ return result
116
+ }
117
+ }
118
+
119
+ const handle = React.useMemo(() => {
120
+ if (!refetchArtifact?.refetch?.paginated || !paginationObserver) return null
121
+
122
+ const fetchFn: FetchFn<_Data, _Input> = (args) => {
123
+ return paginationObserver.send({ ...args, session })
124
+ }
125
+
126
+ const fetchUpdate = (args: any, updates: string[]) => {
127
+ return paginationObserver.send({
128
+ ...args,
129
+ cacheParams: {
130
+ ...args?.cacheParams,
131
+ disableSubscriptions: true,
132
+ applyUpdates: updates,
133
+ },
134
+ session,
135
+ })
136
+ }
137
+
138
+ if (refetchArtifact.refetch!.method === 'cursor') {
139
+ const handlers = cursorHandlers<_Data, _Input>({
140
+ artifact: refetchArtifact,
141
+ getState: () => displayData as _Data | null,
142
+ // Use the observer's own variable state so cursor history is preserved
143
+ // across page navigations without manual tracking in the hook.
144
+ getVariables: () =>
145
+ (paginationObserver.state.variables ?? variables) as NonNullable<_Input>,
146
+ fetch: fetchFn,
147
+ fetchUpdate,
148
+ getSession: async () => session,
149
+ previousCursors: previousCursorsRef.current,
150
+ nextCursors: nextCursorsRef.current,
151
+ })
152
+
153
+ return {
154
+ loadNext: wrapLoad(setForwardPending, handlers.loadNextPage),
155
+ loadNextPending: forwardPending,
156
+ loadPrevious: wrapLoad(setBackwardPending, handlers.loadPreviousPage),
157
+ loadPreviousPending: backwardPending,
158
+ pageInfo: refetchPath
159
+ ? extractPageInfo(displayData as GraphQLObject, refetchPath)
160
+ : null,
161
+ }
162
+ }
163
+
164
+ if (refetchArtifact.refetch!.method === 'offset') {
165
+ const handlers = offsetHandlers({
166
+ artifact: refetchArtifact,
167
+ getState: () => displayData as _Data | null,
168
+ getVariables: () =>
169
+ (paginationObserver.state.variables ?? variables) as NonNullable<_Input>,
170
+ storeName: refetchArtifact.name,
171
+ fetch: fetchFn,
172
+ fetchUpdate: async (args: any, updates = ['append']) =>
173
+ fetchUpdate(args, updates) as any,
174
+ getSession: async () => session,
175
+ })
176
+
177
+ return {
178
+ loadNext: wrapLoad(setForwardPending, handlers.loadNextPage),
179
+ loadNextPending: forwardPending,
180
+ }
181
+ }
182
+
183
+ return null
184
+ }, [refetchArtifact, paginationObserver, displayData, session, forwardPending, backwardPending])
40
185
 
41
186
  return {
42
187
  ...handle,
43
188
  variables,
44
- data,
189
+ data: displayData,
45
190
  }
46
191
  }
@@ -107,7 +107,11 @@ export function useQueryHandle<
107
107
  // when it resolves the cached value will be updated
108
108
  // and it will be picked up in the next render
109
109
  let resolve: () => void = () => {}
110
- const loadPromise = new Promise<void>((r) => (resolve = r))
110
+ let reject: (reason?: any) => void = () => {}
111
+ const loadPromise = new Promise<void>((res, rej) => {
112
+ resolve = res
113
+ reject = rej
114
+ })
111
115
 
112
116
  const suspenseUnit: QuerySuspenseUnit<_Data, _Input> = {
113
117
  // biome-ignore lint/suspicious/noThenProperty: suspense protocol requires a thenable
@@ -141,6 +145,10 @@ export function useQueryHandle<
141
145
 
142
146
  suspenseUnit.resolve()
143
147
  })
148
+ .catch((err) => {
149
+ promiseCache.delete(identifier)
150
+ reject(err)
151
+ })
144
152
  suspenseTracker.current = true
145
153
  throw suspenseUnit
146
154
  }
package/runtime/index.tsx CHANGED
@@ -6,7 +6,23 @@ import manifest from './manifest.js'
6
6
  import { Router as RouterImpl, type RouterCache, RouterContextProvider } from './routing/index.js'
7
7
 
8
8
  export * from './hooks/index.js'
9
- export { router_cache, useCache, useSession, useLocation, useRoute } from './routing/index.js'
9
+ export {
10
+ router_cache,
11
+ useCache,
12
+ useSession,
13
+ useLocation,
14
+ useRoute,
15
+ useCurrentVariables,
16
+ notFound,
17
+ unauthorized,
18
+ forbidden,
19
+ httpError,
20
+ redirect,
21
+ isRoutingError,
22
+ isApiError,
23
+ RoutingError,
24
+ RedirectError,
25
+ } from './routing/index.js'
10
26
  export * from './Link.js'
11
27
 
12
28
  export function Router({
@@ -7,16 +7,17 @@ import configFile from '$houdini/runtime/imports/config'
7
7
  import { deepEquals } from 'houdini/runtime'
8
8
  import type { LRUCache } from 'houdini/runtime'
9
9
  import { marshalSelection, marshalInputs } from 'houdini/runtime'
10
- import { find_match } from 'houdini/router/match'
10
+ import { find_match, find_prefix_match } from 'houdini/router/match'
11
11
  import type { RouterManifest, RouterPageManifest } from 'houdini/router/types'
12
12
  import React from 'react'
13
- import { useContext } from 'react'
13
+ import { useContext, useEffect } from 'react'
14
14
 
15
15
  import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
16
16
  import { useDocumentStore } from '../hooks/useDocumentStore.js'
17
17
  import { type SuspenseCache, suspense_cache } from './cache.js'
18
+ import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
18
19
 
19
- type PageComponent = React.ComponentType<{ url: string }>
20
+ type PageComponent = React.ComponentType<{ url: string; children?: React.ReactNode }>
20
21
 
21
22
  const PreloadWhich = {
22
23
  component: 'component',
@@ -55,9 +56,12 @@ export function Router({
55
56
 
56
57
  // find the matching page for the current route
57
58
  const [page, variables] = find_match(configFile, manifest, currentURL)
58
- // if we dont have a page, its a 404
59
- if (!page) {
60
- throw new Error('404')
59
+ const is404 = !page
60
+ // When no exact match, find the deepest prefix-matching page to render
61
+ // its layout chain with NotFoundGate throwing inside the appropriate boundary.
62
+ const targetPage = page ?? find_prefix_match(manifest, currentURL)
63
+ if (!targetPage) {
64
+ throw new RoutingError(404)
61
65
  }
62
66
 
63
67
  // the only time this component will directly suspend (instead of one of its children)
@@ -67,14 +71,14 @@ export function Router({
67
71
  // load the page assets (source, artifacts, data). this will suspend if the component is not available yet
68
72
  // this hook embeds pending requests in context so that the component can suspend if necessary14
69
73
  const { loadData, loadComponent } = usePageData({
70
- page,
74
+ page: targetPage,
71
75
  variables,
72
76
  assetPrefix,
73
77
  injectToStream,
74
78
  })
75
79
  // if we get this far, it's safe to load the component
76
80
  const { component_cache, data_cache, ssr_signals } = useRouterContext()
77
- const PageComponent = component_cache.get(page.id)!
81
+ const PageComponent = component_cache.get(targetPage.id)!
78
82
 
79
83
  // if we got this far then we're past suspense
80
84
 
@@ -150,7 +154,15 @@ export function Router({
150
154
  params: variables ?? {},
151
155
  }}
152
156
  >
153
- <PageComponent url={currentURL} key={page.id} />
157
+ <Is404Context.Provider value={is404}>
158
+ {is404 ? (
159
+ <NotFoundLayoutBoundary key={targetPage.id}>
160
+ <PageComponent url={currentURL} key={targetPage.id + '__404'} />
161
+ </NotFoundLayoutBoundary>
162
+ ) : (
163
+ <PageComponent url={currentURL} key={targetPage.id} />
164
+ )}
165
+ </Is404Context.Provider>
154
166
  </LocationContext.Provider>
155
167
  </VariableContext.Provider>
156
168
  )
@@ -159,6 +171,14 @@ export function Router({
159
171
  // export the location information in context
160
172
  export const useLocation = () => useContext(LocationContext)
161
173
 
174
+ export const ClientRedirect = ({ to }: { to: string }) => {
175
+ const { goto } = useLocation()
176
+ useEffect(() => {
177
+ goto(to)
178
+ }, [to])
179
+ return null
180
+ }
181
+
162
182
  /**
163
183
  * usePageData is responsible for kicking off the network requests necessary to render the page.
164
184
  * This includes loading the artifact, the component source, and any query results. This hook
@@ -240,9 +260,11 @@ function usePageData({
240
260
  .then(async () => {
241
261
  data_cache.set(id, observer)
242
262
 
243
- // if there is an error, we need to reject the promise
263
+ // if there is an error, signal completion (the error is visible via
264
+ // useQueryResult reading observer.state.errors) and clean up
244
265
  if (observer.state.errors && observer.state.errors.length > 0) {
245
- reject(observer.state.errors.map((e) => e.message).join('\n'))
266
+ ssr_signals.delete(id)
267
+ resolve()
246
268
  return
247
269
  }
248
270
 
@@ -340,6 +362,9 @@ function usePageData({
340
362
  })
341
363
  .catch((err) => {
342
364
  ssr_signals.delete(id)
365
+ if (err?.name === 'AbortError') {
366
+ return
367
+ }
343
368
  reject(err)
344
369
  })
345
370
  })
@@ -432,18 +457,11 @@ function usePageData({
432
457
  }
433
458
  }
434
459
 
435
- // if we don't have the component then we need to load it, save it in the cache, and
436
- // then suspend with a promise that will resolve once its in cache
437
460
  async function loadComponent(targetPage: RouterPageManifest<ComponentType>) {
438
- // if we already have the component, don't do anything
439
461
  if (component_cache.has(targetPage.id)) {
440
462
  return
441
463
  }
442
-
443
- // load the component and then save it in the cache
444
464
  const mod = await targetPage.component()
445
-
446
- // save the component in the cache
447
465
  component_cache.set(targetPage.id, mod.default)
448
466
  }
449
467
 
@@ -651,7 +669,7 @@ export function useQueryResult<_Data extends GraphQLObject, _Input extends Graph
651
669
 
652
670
  // if there is an error in the response we need to throw to the nearest boundary
653
671
  if (errors && errors.length > 0) {
654
- throw new Error(JSON.stringify(errors))
672
+ throw new GraphQLErrors(errors)
655
673
  }
656
674
  // create the handle that we will use to interact with the store
657
675
  const handle = useDocumentHandle({
@@ -853,6 +871,51 @@ export function router_cache({
853
871
  return result
854
872
  }
855
873
 
874
+ // Catches RoutingErrors that escape all HoudiniErrorBoundary instances during prefix-match
875
+ // (is404) rendering, preventing an infinite loop when a layout itself throws notFound().
876
+ class NotFoundLayoutBoundary extends React.Component<
877
+ { children: React.ReactNode },
878
+ { caught: boolean }
879
+ > {
880
+ static contextType = StatusContext
881
+ declare context: React.ContextType<typeof StatusContext>
882
+
883
+ constructor(props: { children: React.ReactNode }) {
884
+ super(props)
885
+ this.state = { caught: false }
886
+ }
887
+
888
+ static getDerivedStateFromError(error: unknown) {
889
+ if (error instanceof RoutingError) {
890
+ return { caught: true }
891
+ }
892
+ return null
893
+ }
894
+
895
+ componentDidCatch(error: Error): void {
896
+ if (error instanceof RoutingError && this.context) {
897
+ this.context.status = error.status
898
+ }
899
+ }
900
+
901
+ render() {
902
+ if (this.state.caught) {
903
+ return null
904
+ }
905
+ return this.props.children
906
+ }
907
+ }
908
+
909
+ export const Is404Context = React.createContext(false)
910
+
911
+ export function NotFoundGate({ children }: { children: React.ReactNode }) {
912
+ const is404 = React.useContext(Is404Context)
913
+ if (is404) {
914
+ throw new RoutingError(404)
915
+ }
916
+ return <>{children}</>
917
+ }
918
+
856
919
  const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
857
920
 
858
921
  export function PageContextProvider({
@@ -31,7 +31,10 @@ export class SuspenseCache<_Data> extends LRUCache<_Data> {
31
31
  })
32
32
  }
33
33
 
34
- // TODO: reject?
34
+ override clear() {
35
+ super.clear()
36
+ this.#callbacks.clear()
37
+ }
35
38
 
36
39
  set(key: string, value: _Data) {
37
40
  // perform the set like normal
@@ -0,0 +1,145 @@
1
+ import type { GraphQLError } from 'houdini/runtime'
2
+ import React from 'react'
3
+
4
+ export class GraphQLErrors extends Error {
5
+ graphqlErrors: GraphQLError[]
6
+
7
+ constructor(errors: GraphQLError[]) {
8
+ super(errors.map((e) => e.message).join('\n'))
9
+ this.name = 'GraphQLErrors'
10
+ this.graphqlErrors = errors
11
+ }
12
+ }
13
+
14
+ let _currentSegment: string | undefined
15
+
16
+ export function setCurrentSegment(id: string | undefined): void {
17
+ _currentSegment = id
18
+ }
19
+
20
+ function getCurrentSegment(): string | undefined {
21
+ return _currentSegment
22
+ }
23
+
24
+ export class RoutingError extends Error {
25
+ status: number
26
+ segment: string | undefined
27
+
28
+ constructor(status: number) {
29
+ super(`Routing error: ${status}`)
30
+ this.name = 'RoutingError'
31
+ this.status = status
32
+ this.segment = getCurrentSegment()
33
+ }
34
+ }
35
+
36
+ export class RedirectError extends Error {
37
+ status: number
38
+ location: string
39
+
40
+ constructor(status: number, location: string) {
41
+ super(`Redirect: ${status} ${location}`)
42
+ this.name = 'RedirectError'
43
+ this.status = status
44
+ this.location = location
45
+ }
46
+ }
47
+
48
+ export function isRoutingError(error: unknown): error is RoutingError {
49
+ return error instanceof RoutingError
50
+ }
51
+
52
+ export function isApiError(error: unknown): error is GraphQLErrors {
53
+ return error instanceof GraphQLErrors
54
+ }
55
+
56
+ export function notFound(): never {
57
+ throw new RoutingError(404)
58
+ }
59
+
60
+ export function unauthorized(): never {
61
+ throw new RoutingError(401)
62
+ }
63
+
64
+ export function forbidden(): never {
65
+ throw new RoutingError(403)
66
+ }
67
+
68
+ export function httpError(status: number): never {
69
+ throw new RoutingError(status)
70
+ }
71
+
72
+ export function redirect(status: 300 | 301 | 302 | 303 | 307 | 308, location: string): never {
73
+ throw new RedirectError(status, location)
74
+ }
75
+
76
+ // Mutable ref passed from the server renderer so that a synchronous RoutingError
77
+ // or redirect() can propagate the correct HTTP status/location before streaming.
78
+ export const StatusContext = React.createContext<{ status: number; location?: string } | null>(null)
79
+
80
+ type HoudiniErrorBoundaryProps = {
81
+ errorView: React.ComponentType<{
82
+ errors: Array<Error | GraphQLError>
83
+ children: React.ReactNode
84
+ }>
85
+ children: React.ReactNode
86
+ }
87
+
88
+ type HoudiniErrorBoundaryState = {
89
+ hasError: boolean
90
+ errors: Array<Error | GraphQLError>
91
+ }
92
+
93
+ export class HoudiniErrorBoundary extends React.Component<
94
+ HoudiniErrorBoundaryProps,
95
+ HoudiniErrorBoundaryState
96
+ > {
97
+ static contextType = StatusContext
98
+ declare context: React.ContextType<typeof StatusContext>
99
+
100
+ constructor(
101
+ props: HoudiniErrorBoundaryProps,
102
+ context: React.ContextType<typeof StatusContext>
103
+ ) {
104
+ super(props, context)
105
+ // Second-pass SSR: statusRef is pre-set to an error status by on_render after the first
106
+ // render threw. Start in error state immediately so children never render (and never throw).
107
+ if (typeof window === 'undefined' && context && context.status >= 400) {
108
+ this.state = {
109
+ hasError: true,
110
+ errors: [new RoutingError(context.status)],
111
+ }
112
+ } else {
113
+ this.state = { hasError: false, errors: [] }
114
+ }
115
+ }
116
+
117
+ static getDerivedStateFromError(error: unknown): HoudiniErrorBoundaryState {
118
+ if (error instanceof GraphQLErrors) {
119
+ return { hasError: true, errors: error.graphqlErrors }
120
+ }
121
+ return {
122
+ hasError: true,
123
+ errors: [error instanceof Error ? error : new Error(String(error))],
124
+ }
125
+ }
126
+
127
+ componentDidCatch(error: Error): void {
128
+ if (this.context) {
129
+ if (error instanceof RoutingError) {
130
+ this.context.status = error.status
131
+ } else if (error instanceof RedirectError) {
132
+ this.context.status = error.status
133
+ this.context.location = error.location
134
+ }
135
+ }
136
+ }
137
+
138
+ render() {
139
+ if (this.state.hasError) {
140
+ const ErrorView = this.props.errorView
141
+ return <ErrorView errors={this.state.errors}>{this.props.children}</ErrorView>
142
+ }
143
+ return this.props.children
144
+ }
145
+ }
@@ -1,2 +1,17 @@
1
1
  export * from './Router.js'
2
2
  export { type SuspenseCache, suspense_cache } from './cache.js'
3
+ export {
4
+ HoudiniErrorBoundary,
5
+ GraphQLErrors,
6
+ RoutingError,
7
+ RedirectError,
8
+ notFound,
9
+ unauthorized,
10
+ forbidden,
11
+ httpError,
12
+ redirect,
13
+ isRoutingError,
14
+ isApiError,
15
+ StatusContext,
16
+ setCurrentSegment,
17
+ } from './errors.js'