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 +8 -8
- package/postInstall.js +1 -1
- package/runtime/Link.tsx +9 -3
- package/runtime/hooks/useDocumentHandle.ts +5 -0
- package/runtime/hooks/useFragmentHandle.ts +159 -14
- package/runtime/hooks/useQueryHandle.ts +9 -1
- package/runtime/index.tsx +17 -1
- package/runtime/routing/Router.tsx +82 -19
- package/runtime/routing/cache.ts +4 -1
- package/runtime/routing/errors.tsx +145 -0
- package/runtime/routing/index.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "houdini-react",
|
|
3
|
-
"version": "2.0.0-next.
|
|
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.
|
|
85
|
-
"houdini-react-darwin-arm64": "2.0.0-next.
|
|
86
|
-
"houdini-react-linux-x64": "2.0.0-next.
|
|
87
|
-
"houdini-react-linux-arm64": "2.0.0-next.
|
|
88
|
-
"houdini-react-win32-x64": "2.0.0-next.
|
|
89
|
-
"houdini-react-win32-arm64": "2.0.0-next.
|
|
90
|
-
"houdini-react-wasm": "2.0.0-next.
|
|
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.
|
|
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
|
-
> & {
|
|
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
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
-
<
|
|
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,
|
|
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
|
-
|
|
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
|
|
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({
|
package/runtime/routing/cache.ts
CHANGED
|
@@ -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
|
+
}
|
package/runtime/routing/index.ts
CHANGED
|
@@ -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'
|