houdini-react 2.0.0-next.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/bin/houdini-react +173 -0
  2. package/package.json +63 -39
  3. package/postInstall.js +360 -0
  4. package/runtime/Link.tsx +50 -0
  5. package/runtime/client.ts +5 -0
  6. package/runtime/clientPlugin.ts +17 -0
  7. package/runtime/componentFields.ts +79 -0
  8. package/runtime/escape.ts +15 -0
  9. package/runtime/hooks/index.ts +17 -0
  10. package/runtime/hooks/recycleNodesInto.ts +60 -0
  11. package/runtime/hooks/useDeepCompareEffect.ts +93 -0
  12. package/runtime/hooks/useDocumentHandle.ts +243 -0
  13. package/runtime/hooks/useDocumentStore.ts +112 -0
  14. package/runtime/hooks/useDocumentSubscription.ts +65 -0
  15. package/runtime/hooks/useFragment.ts +269 -0
  16. package/runtime/hooks/useFragmentHandle.ts +274 -0
  17. package/runtime/hooks/useIsMounted.ts +14 -0
  18. package/runtime/hooks/useLogoutForm.tsx +60 -0
  19. package/runtime/hooks/useMutation.ts +80 -0
  20. package/runtime/hooks/useMutationForm.tsx +163 -0
  21. package/runtime/hooks/useQuery.ts +12 -0
  22. package/runtime/hooks/useQueryHandle.ts +194 -0
  23. package/runtime/hooks/useSubscription.ts +12 -0
  24. package/runtime/hooks/useSubscriptionHandle.ts +42 -0
  25. package/runtime/hydration.tsx +186 -0
  26. package/runtime/index.tsx +77 -0
  27. package/runtime/login.ts +22 -0
  28. package/runtime/manifest.ts +6 -0
  29. package/runtime/mock.ts +9 -0
  30. package/runtime/package.json +1 -0
  31. package/runtime/resolve-href.ts +151 -0
  32. package/runtime/routes.ts +93 -0
  33. package/runtime/routing/Router.tsx +1101 -0
  34. package/runtime/routing/cache.ts +57 -0
  35. package/runtime/routing/errors.tsx +145 -0
  36. package/runtime/routing/index.ts +17 -0
  37. package/runtime/testing.tsx +163 -0
  38. package/runtime/tsconfig.json +39 -0
  39. package/server/index.d.ts +1 -0
  40. package/server/index.js +4 -0
  41. package/server/react-streaming.d.js +0 -0
  42. package/vite/index.d.ts +3 -0
  43. package/vite/index.js +397 -0
  44. package/vite/strip-headers.d.ts +1 -0
  45. package/vite/strip-headers.js +35 -0
  46. package/vite/transform.d.ts +13 -0
  47. package/vite/transform.js +105 -0
  48. package/README.md +0 -36
  49. package/build/plugin/codegen/entries/documentWrappers.d.ts +0 -6
  50. package/build/plugin/codegen/entries/fallbacks.d.ts +0 -5
  51. package/build/plugin/codegen/entries/index.d.ts +0 -16
  52. package/build/plugin/codegen/entries/pages.d.ts +0 -2
  53. package/build/plugin/codegen/index.d.ts +0 -17
  54. package/build/plugin/codegen/manifest.d.ts +0 -5
  55. package/build/plugin/codegen/render.d.ts +0 -7
  56. package/build/plugin/codegen/router.d.ts +0 -7
  57. package/build/plugin/codegen/typeRoot.d.ts +0 -5
  58. package/build/plugin/config.d.ts +0 -4
  59. package/build/plugin/dedent.d.ts +0 -1
  60. package/build/plugin/extract.d.ts +0 -6
  61. package/build/plugin/index.d.ts +0 -5
  62. package/build/plugin/state.d.ts +0 -3
  63. package/build/plugin/transform.d.ts +0 -6
  64. package/build/plugin/vite.d.ts +0 -27
  65. package/build/plugin-cjs/index.js +0 -90125
  66. package/build/plugin-cjs/package.json +0 -1
  67. package/build/plugin-esm/index.js +0 -90121
  68. package/build/runtime/client.d.ts +0 -3
  69. package/build/runtime/clientPlugin.d.ts +0 -3
  70. package/build/runtime/componentFields.d.ts +0 -9
  71. package/build/runtime/hooks/index.d.ts +0 -8
  72. package/build/runtime/hooks/useDeepCompareEffect.d.ts +0 -35
  73. package/build/runtime/hooks/useDocumentHandle.d.ts +0 -36
  74. package/build/runtime/hooks/useDocumentStore.d.ts +0 -11
  75. package/build/runtime/hooks/useDocumentSubscription.d.ts +0 -11
  76. package/build/runtime/hooks/useFragment.d.ts +0 -16
  77. package/build/runtime/hooks/useFragmentHandle.d.ts +0 -8
  78. package/build/runtime/hooks/useIsMounted.d.ts +0 -3
  79. package/build/runtime/hooks/useMutation.d.ts +0 -14
  80. package/build/runtime/hooks/useQuery.d.ts +0 -5
  81. package/build/runtime/hooks/useQueryHandle.d.ts +0 -10
  82. package/build/runtime/hooks/useSubscription.d.ts +0 -4
  83. package/build/runtime/hooks/useSubscriptionHandle.d.ts +0 -25
  84. package/build/runtime/index.d.ts +0 -14
  85. package/build/runtime/manifest.d.ts +0 -3
  86. package/build/runtime/routing/Router.d.ts +0 -62
  87. package/build/runtime/routing/cache.d.ts +0 -7
  88. package/build/runtime/routing/hooks.d.ts +0 -40
  89. package/build/runtime/routing/index.d.ts +0 -3
  90. package/build/runtime-cjs/client.d.ts +0 -3
  91. package/build/runtime-cjs/client.js +0 -25
  92. package/build/runtime-cjs/clientPlugin.d.ts +0 -3
  93. package/build/runtime-cjs/clientPlugin.js +0 -37
  94. package/build/runtime-cjs/componentFields.d.ts +0 -9
  95. package/build/runtime-cjs/componentFields.js +0 -83
  96. package/build/runtime-cjs/hooks/index.d.ts +0 -8
  97. package/build/runtime-cjs/hooks/index.js +0 -45
  98. package/build/runtime-cjs/hooks/useDeepCompareEffect.d.ts +0 -35
  99. package/build/runtime-cjs/hooks/useDeepCompareEffect.js +0 -76
  100. package/build/runtime-cjs/hooks/useDocumentHandle.d.ts +0 -36
  101. package/build/runtime-cjs/hooks/useDocumentHandle.js +0 -177
  102. package/build/runtime-cjs/hooks/useDocumentStore.d.ts +0 -11
  103. package/build/runtime-cjs/hooks/useDocumentStore.js +0 -76
  104. package/build/runtime-cjs/hooks/useDocumentSubscription.d.ts +0 -11
  105. package/build/runtime-cjs/hooks/useDocumentSubscription.js +0 -76
  106. package/build/runtime-cjs/hooks/useFragment.d.ts +0 -16
  107. package/build/runtime-cjs/hooks/useFragment.js +0 -102
  108. package/build/runtime-cjs/hooks/useFragmentHandle.d.ts +0 -8
  109. package/build/runtime-cjs/hooks/useFragmentHandle.js +0 -47
  110. package/build/runtime-cjs/hooks/useIsMounted.d.ts +0 -3
  111. package/build/runtime-cjs/hooks/useIsMounted.js +0 -38
  112. package/build/runtime-cjs/hooks/useMutation.d.ts +0 -14
  113. package/build/runtime-cjs/hooks/useMutation.js +0 -67
  114. package/build/runtime-cjs/hooks/useQuery.d.ts +0 -5
  115. package/build/runtime-cjs/hooks/useQuery.js +0 -32
  116. package/build/runtime-cjs/hooks/useQueryHandle.d.ts +0 -10
  117. package/build/runtime-cjs/hooks/useQueryHandle.js +0 -131
  118. package/build/runtime-cjs/hooks/useSubscription.d.ts +0 -4
  119. package/build/runtime-cjs/hooks/useSubscription.js +0 -32
  120. package/build/runtime-cjs/hooks/useSubscriptionHandle.d.ts +0 -25
  121. package/build/runtime-cjs/hooks/useSubscriptionHandle.js +0 -42
  122. package/build/runtime-cjs/index.d.ts +0 -14
  123. package/build/runtime-cjs/index.js +0 -88
  124. package/build/runtime-cjs/manifest.d.ts +0 -3
  125. package/build/runtime-cjs/manifest.js +0 -25
  126. package/build/runtime-cjs/package.json +0 -1
  127. package/build/runtime-cjs/routing/Router.d.ts +0 -62
  128. package/build/runtime-cjs/routing/Router.js +0 -540
  129. package/build/runtime-cjs/routing/cache.d.ts +0 -7
  130. package/build/runtime-cjs/routing/cache.js +0 -61
  131. package/build/runtime-cjs/routing/hooks.d.ts +0 -40
  132. package/build/runtime-cjs/routing/hooks.js +0 -93
  133. package/build/runtime-cjs/routing/index.d.ts +0 -3
  134. package/build/runtime-cjs/routing/index.js +0 -33
  135. package/build/runtime-esm/client.d.ts +0 -3
  136. package/build/runtime-esm/client.js +0 -5
  137. package/build/runtime-esm/clientPlugin.d.ts +0 -3
  138. package/build/runtime-esm/clientPlugin.js +0 -17
  139. package/build/runtime-esm/componentFields.d.ts +0 -9
  140. package/build/runtime-esm/componentFields.js +0 -59
  141. package/build/runtime-esm/hooks/index.d.ts +0 -8
  142. package/build/runtime-esm/hooks/index.js +0 -15
  143. package/build/runtime-esm/hooks/useDeepCompareEffect.d.ts +0 -35
  144. package/build/runtime-esm/hooks/useDeepCompareEffect.js +0 -41
  145. package/build/runtime-esm/hooks/useDocumentHandle.d.ts +0 -36
  146. package/build/runtime-esm/hooks/useDocumentHandle.js +0 -143
  147. package/build/runtime-esm/hooks/useDocumentStore.d.ts +0 -11
  148. package/build/runtime-esm/hooks/useDocumentStore.js +0 -42
  149. package/build/runtime-esm/hooks/useDocumentSubscription.d.ts +0 -11
  150. package/build/runtime-esm/hooks/useDocumentSubscription.js +0 -42
  151. package/build/runtime-esm/hooks/useFragment.d.ts +0 -16
  152. package/build/runtime-esm/hooks/useFragment.js +0 -67
  153. package/build/runtime-esm/hooks/useFragmentHandle.d.ts +0 -8
  154. package/build/runtime-esm/hooks/useFragmentHandle.js +0 -23
  155. package/build/runtime-esm/hooks/useIsMounted.d.ts +0 -3
  156. package/build/runtime-esm/hooks/useIsMounted.js +0 -14
  157. package/build/runtime-esm/hooks/useMutation.d.ts +0 -14
  158. package/build/runtime-esm/hooks/useMutation.js +0 -42
  159. package/build/runtime-esm/hooks/useQuery.d.ts +0 -5
  160. package/build/runtime-esm/hooks/useQuery.js +0 -8
  161. package/build/runtime-esm/hooks/useQueryHandle.d.ts +0 -10
  162. package/build/runtime-esm/hooks/useQueryHandle.js +0 -97
  163. package/build/runtime-esm/hooks/useSubscription.d.ts +0 -4
  164. package/build/runtime-esm/hooks/useSubscription.js +0 -8
  165. package/build/runtime-esm/hooks/useSubscriptionHandle.d.ts +0 -25
  166. package/build/runtime-esm/hooks/useSubscriptionHandle.js +0 -18
  167. package/build/runtime-esm/index.d.ts +0 -14
  168. package/build/runtime-esm/index.js +0 -48
  169. package/build/runtime-esm/manifest.d.ts +0 -3
  170. package/build/runtime-esm/manifest.js +0 -5
  171. package/build/runtime-esm/routing/Router.d.ts +0 -62
  172. package/build/runtime-esm/routing/Router.js +0 -499
  173. package/build/runtime-esm/routing/cache.d.ts +0 -7
  174. package/build/runtime-esm/routing/cache.js +0 -36
  175. package/build/runtime-esm/routing/hooks.d.ts +0 -40
  176. package/build/runtime-esm/routing/hooks.js +0 -53
  177. package/build/runtime-esm/routing/index.d.ts +0 -3
  178. package/build/runtime-esm/routing/index.js +0 -6
  179. package/build/server/index.d.ts +0 -1
  180. package/build/server-cjs/index.js +0 -28
  181. package/build/server-cjs/package.json +0 -1
  182. package/build/server-esm/index.js +0 -4
  183. package/build/server-esm/package.json +0 -1
  184. /package/{build/plugin-esm → server}/package.json +0 -0
  185. /package/{build/runtime-esm → vite}/package.json +0 -0
@@ -0,0 +1,1101 @@
1
+ import type { GraphQLObject, GraphQLVariables } from 'houdini/runtime'
2
+ import type { QueryArtifact } from 'houdini/runtime'
3
+ import type { Cache } from 'houdini/runtime/cache'
4
+ import type { DocumentStore, HoudiniClient } from 'houdini/runtime/client'
5
+ import { getCurrentConfig } from '$houdini/runtime'
6
+ import configFile from '$houdini/runtime/imports/config'
7
+ import { deepEquals } from 'houdini/runtime'
8
+ import type { LRUCache } from 'houdini/runtime'
9
+ import { marshalSelection, marshalInputs, getAuthUrl, HOUDINI_SESSION_EVENT } from 'houdini/runtime'
10
+ import type { HoudiniSessionEventDetail } from 'houdini/runtime'
11
+ import { find_match, find_prefix_match } from 'houdini/router/match'
12
+ import type { RouterManifest, RouterPageManifest } from 'houdini/router/types'
13
+ import React from 'react'
14
+ import { useContext, useEffect } from 'react'
15
+
16
+ import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
17
+ import type { Goto } from '../routes.js'
18
+ import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
19
+ import { useDocumentStore } from '../hooks/useDocumentStore.js'
20
+ import { type SuspenseCache, suspense_cache } from './cache.js'
21
+ import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
22
+
23
+ type PageComponent = React.ComponentType<{ url: string; children?: React.ReactNode }>
24
+
25
+ const PreloadWhich = {
26
+ component: 'component',
27
+ data: 'data',
28
+ page: 'page',
29
+ } as const
30
+
31
+ type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
32
+ type ComponentType = any
33
+ /**
34
+ * Router is the top level entry point for the filesystem-based router.
35
+ * It is responsible for loading various page sources (including API fetches) and
36
+ * then rendering when appropriate.
37
+ */
38
+ // In order to enable streaming SSR, individual page and layouts components
39
+ // must suspend. We can't just have one big suspense that we handle
40
+ // or else we can't isolate the first chunk. That being said, we
41
+ // don't want network waterfalls. So we need to send the request for everything all
42
+ // at once and then wrap the children in the necessary context so that when they render
43
+ // they can grab what they need if its ready and suspend if not.
44
+ export function Router({
45
+ manifest,
46
+ initialURL,
47
+ assetPrefix,
48
+ injectToStream,
49
+ }: {
50
+ manifest: RouterManifest<ComponentType>
51
+ initialURL?: string
52
+ assetPrefix: string
53
+ injectToStream?: undefined | ((chunk: string) => void)
54
+ }) {
55
+ // the current route is just a string in state.
56
+ const [currentURL, setCurrentURL] = React.useState(() => {
57
+ return initialURL || window.location.pathname + window.location.search
58
+ })
59
+
60
+ // find the matching page for the current route. find_match also hands back the parsed
61
+ // query string (declared search params coerced, UI-only keys raw). custom-scalar route
62
+ // and search params arrive in their url transport form, so we unmarshal them once here
63
+ // — the rich values feed both the query variables (which marshalInputs re-marshals for
64
+ // the request) and useRoute()'s params/search.
65
+ const [page, rawVariables, rawSearch] = find_match(manifest, currentURL)
66
+ const unmarshalers = scalarUnmarshalers(
67
+ [...(page?.params ?? []), ...(page?.searchParams ?? [])],
68
+ getCurrentConfig()?.scalars
69
+ )
70
+ const variables = unmarshalScalars(rawVariables ?? {}, unmarshalers)
71
+ const search = unmarshalScalars(rawSearch, unmarshalers)
72
+ const is404 = !page
73
+ // When no exact match, find the deepest prefix-matching page to render
74
+ // its layout chain with NotFoundGate throwing inside the appropriate boundary.
75
+ const targetPage = page ?? find_prefix_match(manifest, currentURL)
76
+ if (!targetPage) {
77
+ throw new RoutingError(404)
78
+ }
79
+
80
+ // the only time this component will directly suspend (instead of one of its children)
81
+ // is if we don't have the component source. Dependencies on query results or artifacts
82
+ // will be resolved by the component itself
83
+
84
+ // load the page assets (source, artifacts, data). this will suspend if the component is not available yet
85
+ // this hook embeds pending requests in context so that the component can suspend if necessary14
86
+ const { loadData, loadComponent } = usePageData({
87
+ page: targetPage,
88
+ variables,
89
+ assetPrefix,
90
+ injectToStream,
91
+ })
92
+ // if we get this far, it's safe to load the component
93
+ const { component_cache, data_cache, ssr_signals } = useRouterContext()
94
+ const PageComponent = component_cache.get(targetPage.id)!
95
+
96
+ // if we got this far then we're past suspense
97
+
98
+ //
99
+ // Now that we know we aren't going to throw, let's set up the event listeners
100
+ //
101
+
102
+ // whenever the route changes, we need to make sure the browser's stack is up to date
103
+ React.useEffect(() => {
104
+ if (globalThis.window && window.location.pathname + window.location.search !== currentURL) {
105
+ window.history.pushState({}, '', currentURL)
106
+ }
107
+ }, [currentURL])
108
+
109
+ // when we first mount we should start listening to the back button
110
+ React.useEffect(() => {
111
+ if (!globalThis.window) {
112
+ return
113
+ }
114
+ const onChange = (_evt: PopStateEvent) => {
115
+ setCurrentURL(window.location.pathname + window.location.search)
116
+ }
117
+ window.addEventListener('popstate', onChange)
118
+ return () => {
119
+ window.removeEventListener('popstate', onChange)
120
+ }
121
+ }, [])
122
+
123
+ // the function to call to navigate. accepts either a ready-made url string or a
124
+ // typed target { to, params, search } that is assembled (and custom scalars
125
+ // marshaled) exactly the way <Link> builds its href. The typed surface is the
126
+ // shared Goto contract; the implementation takes the loose runtime shape.
127
+ const goto = ((
128
+ target:
129
+ | string
130
+ | { to: string; params?: Record<string, unknown>; search?: Record<string, unknown> }
131
+ ) => {
132
+ const url =
133
+ typeof target === 'string'
134
+ ? target
135
+ : buildHref(
136
+ target.to,
137
+ manifest.pages[manifest.pagesByUrl[target.to]],
138
+ getCurrentConfig()?.scalars,
139
+ target.params,
140
+ target.search
141
+ )
142
+
143
+ // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
144
+ data_cache.clear()
145
+ // clear pending signals so the next render starts fresh load_query calls
146
+ ssr_signals.clear()
147
+
148
+ // perform the navigation
149
+ setCurrentURL(url)
150
+ }) as Goto
151
+
152
+ // links are powered using anchor tags that we intercept and handle ourselves
153
+ useLinkBehavior({
154
+ goto,
155
+ preload(url: string, which: PreloadWhichValue) {
156
+ // there are 2 things that we could preload: the page component and the data
157
+
158
+ // look for the matching route information
159
+ const [page, rawVariables] = find_match(manifest, url)
160
+ if (!page) {
161
+ return
162
+ }
163
+ // unmarshal any custom-scalar route/search params so the preloaded query
164
+ // marshals them the same way the rendered one does
165
+ const variables = unmarshalScalars(
166
+ rawVariables ?? {},
167
+ scalarUnmarshalers(
168
+ [...page.params, ...page.searchParams],
169
+ getCurrentConfig()?.scalars
170
+ )
171
+ )
172
+
173
+ // load the page component if necessary
174
+ if (['page', 'component'].includes(which)) {
175
+ loadComponent(page)
176
+ }
177
+
178
+ // load the page component if necessary
179
+ if (['page', 'data'].includes(which)) {
180
+ loadData(page, variables)
181
+ }
182
+ },
183
+ })
184
+
185
+ // TODO: cleanup navigation caches
186
+ // render the component embedded in the necessary context so it can orchestrate
187
+ // its needs
188
+ return (
189
+ <LocationContext.Provider
190
+ value={{
191
+ pathname: currentURL,
192
+ goto,
193
+ params: variables ?? {},
194
+ search,
195
+ }}
196
+ >
197
+ <Is404Context.Provider value={is404}>
198
+ {is404 ? (
199
+ <NotFoundLayoutBoundary key={targetPage.id}>
200
+ <PageComponent url={currentURL} key={targetPage.id + '__404'} />
201
+ </NotFoundLayoutBoundary>
202
+ ) : (
203
+ <PageComponent url={currentURL} key={targetPage.id} />
204
+ )}
205
+ </Is404Context.Provider>
206
+ </LocationContext.Provider>
207
+ )
208
+ }
209
+
210
+ // internal accessor for the raw location context. the public surface is useRoute, which
211
+ // layers the per-route param/search types on top of this. not re-exported from the package
212
+ // index, so it isn't part of the public API.
213
+ export const useLocationContext = () => useContext(LocationContext)
214
+
215
+ export const ClientRedirect = ({ to }: { to: string }) => {
216
+ const { goto } = useLocationContext()
217
+ useEffect(() => {
218
+ goto(to)
219
+ }, [to])
220
+ return null
221
+ }
222
+
223
+ /**
224
+ * usePageData is responsible for kicking off the network requests necessary to render the page.
225
+ * This includes loading the artifact, the component source, and any query results. This hook
226
+ * only suspends if the component source is not available. The other cases are handled by the specific
227
+ * page that is being rendered so that nested suspense boundaries are properly wired up.
228
+ */
229
+ function usePageData({
230
+ page,
231
+ variables,
232
+ assetPrefix: _assetPrefix,
233
+ injectToStream,
234
+ }: {
235
+ page: RouterPageManifest<ComponentType>
236
+ variables: GraphQLVariables
237
+ assetPrefix: string
238
+ injectToStream: undefined | ((chunk: string) => void)
239
+ }): {
240
+ loadData: (page: RouterPageManifest<ComponentType>, variables: {} | null) => void
241
+ loadComponent: (page: RouterPageManifest<ComponentType>) => void
242
+ } {
243
+ // grab context values
244
+ const {
245
+ client,
246
+ cache,
247
+ data_cache,
248
+ component_cache,
249
+ artifact_cache,
250
+ ssr_signals,
251
+ last_variables,
252
+ } = useRouterContext()
253
+
254
+ // grab the current session value
255
+ const [session] = useSession()
256
+
257
+ // the function to load a query using the cache references
258
+ function load_query({
259
+ id,
260
+ artifact,
261
+ variables,
262
+ }: {
263
+ id: string
264
+ artifact: QueryArtifact
265
+ variables: GraphQLVariables
266
+ }): Promise<void> {
267
+ // TODO: better tracking - only register the variables that were used
268
+ // track the new variables
269
+ for (const artifact of Object.keys(page.documents)) {
270
+ last_variables.set(artifact, variables)
271
+ }
272
+
273
+ // TODO: AbortController on send()
274
+ // TODO: we can read from cache here before making an asynchronous network call
275
+
276
+ // if there is a pending request and we were asked to load, don't do anything
277
+ if (ssr_signals.has(id)) {
278
+ return ssr_signals.get(id)!
279
+ }
280
+
281
+ // send the request
282
+ const observer: DocumentStore<GraphQLObject, GraphQLVariables> = data_cache.has(
283
+ artifact.name
284
+ )
285
+ ? data_cache.get(artifact.name)!
286
+ : client.observe({ artifact, cache })
287
+
288
+ // store the observer immediately so useQueryResult can access it
289
+ // during SSR rendering before the fetch resolves
290
+ let resolve: () => void = () => {}
291
+ let reject: (message: string) => void = () => {}
292
+ const promise = new Promise<void>((res, rej) => {
293
+ resolve = res
294
+ reject = rej
295
+
296
+ observer
297
+ .send({
298
+ variables: variables,
299
+ session,
300
+ })
301
+ .then(async () => {
302
+ data_cache.set(id, observer)
303
+
304
+ // if there is an error, signal completion (the error is visible via
305
+ // useQueryResult reading observer.state.errors) and clean up
306
+ if (observer.state.errors && observer.state.errors.length > 0) {
307
+ ssr_signals.delete(id)
308
+ resolve()
309
+ return
310
+ }
311
+
312
+ // if we are building up a stream (on the server), we want to add something
313
+ // to the client that resolves the pending request with the
314
+ // data that we just got
315
+ injectToStream?.(`
316
+ <script>
317
+ {
318
+ // the resolved cache snapshot for this streamed query. when the bootstrap
319
+ // module has already run (data arrived after hydration) we hydrate the live
320
+ // cache directly; otherwise the module is still deferred (an @loading query
321
+ // streams its data while the document is open) so we queue the snapshot for
322
+ // hydrate_page to apply once it creates the cache. without this the snapshot
323
+ // would be dropped and the query would hydrate with null data.
324
+ const __houdini__snapshot__ = ${cache.serialize()}
325
+ if (window.__houdini__cache__) {
326
+ // hydrate into a fresh layer and merge it down, rather than clobbering the
327
+ // shared hydration layer (which would drop everything hydrated before it)
328
+ const __houdini__layer__ = window.__houdini__cache__.hydrate(__houdini__snapshot__)
329
+ if (__houdini__layer__) {
330
+ window.__houdini__cache__._internal_unstable.storage.resolveLayer(__houdini__layer__.id)
331
+ }
332
+ } else {
333
+ (window.__houdini__pending_cache__ = window.__houdini__pending_cache__ || []).push(__houdini__snapshot__)
334
+ }
335
+
336
+ const artifactName = "${artifact.name}"
337
+ const value = ${JSON.stringify(
338
+ marshalSelection({
339
+ selection: observer.artifact.selection,
340
+ data: observer.state.data,
341
+ config: getCurrentConfig(),
342
+ })
343
+ )}
344
+
345
+ // if the data is pending, we need to resolve it
346
+ if (window.__houdini__nav_caches__?.data_cache.has(artifactName)) {
347
+ // before we resolve the pending signals,
348
+ // fill the data cache with values we got on the server
349
+ const new_store = window.__houdini__client__.observe({
350
+ artifact: window.__houdini__nav_caches__.artifact_cache.get(artifactName),
351
+ cache: window.__houdini__cache__,
352
+ })
353
+
354
+ // we're pushing this store onto the client, it should be initialized
355
+ window.__houdini__nav_caches__.data_cache.get(artifactName).send({
356
+ setup: true,
357
+ variables: ${JSON.stringify(
358
+ marshalInputs({
359
+ artifact: observer.artifact,
360
+ input: variables,
361
+ config: configFile,
362
+ })
363
+ )}
364
+ }).then(() => {
365
+ window.__houdini__nav_caches__?.data_cache.set(artifactName, new_store)
366
+ })
367
+
368
+ }
369
+
370
+
371
+ // if there are no data caches available we need to populate the pending one instead
372
+ if (!window.__houdini__nav_caches__) {
373
+ if (!window.__houdini__pending_data__) {
374
+ window.__houdini__pending_data__ = {}
375
+ }
376
+
377
+ if (!window.__houdini__pending_variables__) {
378
+ window.__houdini__pending_variables__ = {}
379
+ }
380
+
381
+ if (!window.__houdini__pending_artifacts__) {
382
+ window.__houdini__pending_artifacts__ = {}
383
+ }
384
+ }
385
+
386
+ window.__houdini__pending_variables__[artifactName] = ${JSON.stringify(observer.state.variables)}
387
+ window.__houdini__pending_data__[artifactName] = value
388
+ window.__houdini__pending_artifacts__[artifactName] = ${JSON.stringify(artifact)}
389
+
390
+ // if this payload finishes off an ssr request, we need to resolve the signal
391
+ if (window.__houdini__nav_caches__?.ssr_signals.has(artifactName)) {
392
+
393
+ // if the data showed up on the client before
394
+ if (window.__houdini__nav_caches__.data_cache.has(artifactName)) {
395
+ // we're pushing this store onto the client, it should be initialized
396
+ window.__houdini__nav_caches__.data_cache.get(artifactName).send({
397
+ setup: true,
398
+ variables: ${JSON.stringify(
399
+ marshalInputs({
400
+ artifact: observer.artifact,
401
+ input: variables,
402
+ config: configFile,
403
+ })
404
+ )}
405
+ })
406
+ }
407
+
408
+
409
+ // trigger the signal
410
+ window.__houdini__nav_caches__.ssr_signals.get(artifactName).resolve()
411
+ window.__houdini__nav_caches__.ssr_signals.delete(artifactName)
412
+ }
413
+ }
414
+ </script>
415
+ `)
416
+
417
+ ssr_signals.delete(id)
418
+ resolve()
419
+ })
420
+ .catch((err) => {
421
+ ssr_signals.delete(id)
422
+ if (err?.name === 'AbortError') {
423
+ return
424
+ }
425
+ reject(err)
426
+ })
427
+ })
428
+
429
+ // register the pending signal on both client and server so that concurrent React renders
430
+ // (concurrent mode / strict mode) that call load_query before data_cache is populated
431
+ // find the existing signal and don't create a duplicate observer+send
432
+ const resolvable = { ...promise, resolve, reject }
433
+ ssr_signals.set(id, resolvable)
434
+
435
+ // we're done
436
+ return resolvable
437
+ }
438
+
439
+ // the function that loads all of the data for a page using the caches
440
+ function loadData(
441
+ targetPage: RouterPageManifest<ComponentType>,
442
+ variables: GraphQLVariables | null
443
+ ) {
444
+ if (!targetPage) {
445
+ return
446
+ }
447
+
448
+ // if any of the artifacts that this page on have new variables, we need to clear the data cache
449
+ for (const [artifact, { variables: pageVariables }] of Object.entries(
450
+ targetPage.documents
451
+ )) {
452
+ // if there are no last variables, there's nothing to do
453
+ if (!last_variables.has(artifact)) {
454
+ continue
455
+ }
456
+
457
+ // compare the last known variables with the current set
458
+ const last: GraphQLVariables = {}
459
+ const usedVariables: GraphQLVariables = {}
460
+ for (const variable of Object.keys(pageVariables)) {
461
+ last[variable] = last_variables.get(artifact)![variable]
462
+ usedVariables[variable] = variables?.[variable]
463
+ }
464
+
465
+ // before we can compare we need to only look at the variables that the artifact cares about
466
+ if (Object.keys(usedVariables ?? {}).length > 0 && !deepEquals(last, usedVariables)) {
467
+ data_cache.delete(artifact)
468
+ ssr_signals.delete(artifact)
469
+ }
470
+ }
471
+
472
+ // in order to avoid waterfalls, we need to kick off APIs requests in parallel
473
+ // to use loading any missing artifacts or the page component.
474
+
475
+ // group the necessary based on wether we have their artifact or not
476
+ const missing_artifacts: string[] = []
477
+ const found_artifacts: Record<string, QueryArtifact> = {}
478
+ for (const key of Object.keys(targetPage.documents)) {
479
+ if (artifact_cache.has(key)) {
480
+ found_artifacts[key] = artifact_cache.get(key)!
481
+ } else {
482
+ missing_artifacts.push(key)
483
+ }
484
+ }
485
+
486
+ // any missing artifacts need to be loaded and then have their queries loaded
487
+ for (const artifact_id of missing_artifacts) {
488
+ // load the artifact
489
+ targetPage.documents[artifact_id]
490
+ .artifact()
491
+ .then((mod) => {
492
+ // the artifact is the default export
493
+ const artifact = mod.default
494
+
495
+ // save the artifact in the cache
496
+ artifact_cache.set(artifact_id, artifact)
497
+
498
+ // now that we have the artifact, we can load the query too
499
+ load_query({ id: artifact.name, artifact, variables })
500
+ })
501
+ .catch((err) => {
502
+ // TODO: handle error
503
+ console.log(err)
504
+ })
505
+ }
506
+
507
+ // we need to make sure that every artifact we found is loaded
508
+ // or else we need to load the query
509
+ for (const artifact of Object.values(found_artifacts)) {
510
+ // if we don't have the query, load it
511
+ if (!data_cache.has(artifact.name)) {
512
+ load_query({ id: artifact.name, artifact, variables })
513
+ }
514
+ }
515
+ }
516
+
517
+ async function loadComponent(targetPage: RouterPageManifest<ComponentType>) {
518
+ if (component_cache.has(targetPage.id)) {
519
+ return
520
+ }
521
+ const mod = await targetPage.component()
522
+ component_cache.set(targetPage.id, mod.default)
523
+ }
524
+
525
+ // kick off requests for the current page
526
+ loadData(page, variables)
527
+
528
+ // if we haven't loaded the component yet, suspend and do so
529
+ if (!component_cache.has(page.id)) {
530
+ throw loadComponent(page)
531
+ }
532
+
533
+ return {
534
+ loadData,
535
+ loadComponent,
536
+ }
537
+ }
538
+
539
+ export function RouterContextProvider({
540
+ children,
541
+ client,
542
+ cache,
543
+ artifact_cache,
544
+ component_cache,
545
+ data_cache,
546
+ ssr_signals,
547
+ last_variables,
548
+ session: ssrSession = {},
549
+ formResult = null,
550
+ formToken = null,
551
+ }: {
552
+ children: React.ReactNode
553
+ client: HoudiniClient
554
+ cache: Cache
555
+ artifact_cache: SuspenseCache<QueryArtifact>
556
+ component_cache: SuspenseCache<PageComponent>
557
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
558
+ ssr_signals: PendingCache
559
+ last_variables: LRUCache<GraphQLVariables>
560
+ session?: App.Session
561
+ formResult?: FormResult | null
562
+ formToken?: string | null
563
+ }) {
564
+ // the session is top level state
565
+ // on the server, we can just use
566
+ const [session, setSession] = React.useState<App.Session>(ssrSession)
567
+
568
+ // if we detect an event that contains a new session value. The detail carries the subtree
569
+ // and whether to merge it into the current session (an @session(merge:) upsert) or replace
570
+ // it wholesale; a legacy plain-session detail is treated as a replace.
571
+ const handleNewSession = React.useCallback((event: Event) => {
572
+ const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
573
+ const isWrapped =
574
+ detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
575
+ const next = (
576
+ isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
577
+ ) as App.Session
578
+ const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
579
+ setSession((prev) => (merge ? { ...prev, ...next } : next))
580
+ }, [])
581
+
582
+ React.useEffect(() => {
583
+ window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
584
+
585
+ // cleanup this component
586
+ return () => {
587
+ window.removeEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
588
+ }
589
+ }, [handleNewSession])
590
+
591
+ return (
592
+ <Context.Provider
593
+ value={{
594
+ client,
595
+ cache,
596
+ artifact_cache,
597
+ component_cache,
598
+ data_cache,
599
+ ssr_signals,
600
+ last_variables,
601
+ session,
602
+ setSession: (newSession) => setSession((old) => ({ ...old, ...newSession })),
603
+ replaceSession: (next) => setSession(next),
604
+ clearSession: () => setSession({}),
605
+ formResult,
606
+ formToken,
607
+ }}
608
+ >
609
+ {children}
610
+ </Context.Provider>
611
+ )
612
+ }
613
+
614
+ type RouterContext = {
615
+ client: HoudiniClient
616
+ cache: Cache
617
+
618
+ // We also need a cache for artifacts so that we can avoid suspending to
619
+ // load them if possible.
620
+ artifact_cache: SuspenseCache<QueryArtifact>
621
+
622
+ // We also need a cache for component references so we can avoid suspending
623
+ // when we load the same page multiple times
624
+ component_cache: SuspenseCache<PageComponent>
625
+
626
+ // Pages need a way to wait for data
627
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
628
+
629
+ // A way to dedupe requests for a query
630
+ ssr_signals: PendingCache
631
+
632
+ // A way to track the last known good variables
633
+ last_variables: LRUCache<GraphQLVariables>
634
+
635
+ // The current session
636
+ session: App.Session
637
+
638
+ // a function to call that sets the client-side session singletone
639
+ setSession: (newSession: Partial<App.Session>) => void
640
+
641
+ // replace the client-side session wholesale (login establishes a fresh session). Local
642
+ // state only — the cookie is set separately (the @session token relay / server form handler).
643
+ replaceSession: (next: App.Session) => void
644
+
645
+ // replace the client-side session with an empty one (logout); pairs with the server clear
646
+ clearSession: () => void
647
+
648
+ // the result of a no-JS form submission (keyed by form id), injected by the server on
649
+ // the PRG error re-render so useMutationForm can seed its initial state inline.
650
+ formResult: FormResult | null
651
+
652
+ // the session-bound CSRF token forms render in their hidden field (always present from a
653
+ // server render; null only when there is no server, e.g. a static export).
654
+ formToken: string | null
655
+ }
656
+
657
+ // FormResult mirrors the server's injected shape: a no-JS submission's result keyed by
658
+ // form id (the mutation name, or an explicit useMutationForm({ id })).
659
+ export type FormResult = Record<string, { data: any; errors: any }>
660
+
661
+ export type PendingCache = SuspenseCache<
662
+ Promise<void> & { resolve: () => void; reject: (message: string) => void }
663
+ >
664
+
665
+ const Context = React.createContext<RouterContext | null>(null)
666
+
667
+ export const useRouterContext = () => {
668
+ const ctx = React.useContext(Context)
669
+
670
+ if (!ctx) {
671
+ throw new Error('Could not find router context')
672
+ }
673
+
674
+ return ctx
675
+ }
676
+
677
+ // useFormResult returns the server-injected result for a given form id, or null. The
678
+ // result is threaded through the router context on both render paths (the server passes it
679
+ // as a prop; the client hydration entry reads it from the streamed window global), so the
680
+ // enhanced form's initial state matches the no-JS re-rendered HTML.
681
+ export function useFormResult(formId: string): { data: any; errors: any } | null {
682
+ return useRouterContext().formResult?.[formId] ?? null
683
+ }
684
+
685
+ // useFormToken returns the session-bound CSRF token to render in a form's hidden field, or
686
+ // null when there is no server render to mint it (e.g. a static export).
687
+ export function useFormToken(): string | null {
688
+ return useRouterContext().formToken
689
+ }
690
+
691
+ export function useClient() {
692
+ return useRouterContext().client
693
+ }
694
+
695
+ export function useCache() {
696
+ return useRouterContext().cache
697
+ }
698
+
699
+ export function updateLocalSession(session: App.Session, merge = false) {
700
+ window.dispatchEvent(
701
+ new CustomEvent<HoudiniSessionEventDetail>(HOUDINI_SESSION_EVENT, {
702
+ bubbles: true,
703
+ detail: { session, merge },
704
+ })
705
+ )
706
+ }
707
+
708
+ export function useSession(): [
709
+ App.Session,
710
+ (newSession: Partial<App.Session> | null) => Promise<void>,
711
+ ] {
712
+ const ctx = useRouterContext()
713
+
714
+ // updateSession does two things: (1) update the local client state, and (2) persist to the
715
+ // session cookie through the always-on auth endpoint (Origin-gated, so a cross-origin page
716
+ // can't forge a write). Pass a partial object to merge it into the session, or `null` to
717
+ // log out — clearing the local session and deleting the cookie. It's awaitable so callers
718
+ // can wait for the cookie to settle before navigating.
719
+ const updateSession = async (newSession: Partial<App.Session> | null) => {
720
+ // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
721
+ ctx.data_cache.clear()
722
+ ctx.ssr_signals.clear()
723
+
724
+ if (newSession === null) {
725
+ ctx.clearSession()
726
+ } else {
727
+ ctx.setSession(newSession)
728
+ }
729
+
730
+ await fetch(getAuthUrl(), {
731
+ method: 'POST',
732
+ body: JSON.stringify({ session: newSession }),
733
+ headers: {
734
+ 'Content-Type': 'application/json',
735
+ Accept: 'application/json',
736
+ },
737
+ })
738
+ }
739
+
740
+ return [ctx.session, updateSession]
741
+ }
742
+
743
+ const LocationContext = React.createContext<{
744
+ pathname: string
745
+ params: Record<string, any>
746
+ // the parsed query string of the current url (declared search params coerced to
747
+ // their scalar type, other keys raw; repeated keys are arrays).
748
+ search: Record<string, any>
749
+ // a function to imperatively navigate to a url
750
+ goto: Goto
751
+ }>({
752
+ pathname: '',
753
+ params: {},
754
+ search: {},
755
+ goto: () => {},
756
+ })
757
+
758
+ export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>(
759
+ name: string
760
+ ): [_Data | null, DocumentHandle<any, _Data, _Input>] {
761
+ // pull the global context values
762
+ const { data_cache, artifact_cache } = useRouterContext()
763
+
764
+ // load the store reference (this will suspend)
765
+ const store_ref = data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>
766
+
767
+ // get the live data from the store
768
+ const [storeValue, observer] = useDocumentStore<_Data, _Input>({
769
+ artifact: store_ref.artifact,
770
+ observer: store_ref,
771
+ })
772
+
773
+ // pull out the store values we care about
774
+ const { data, errors } = storeValue
775
+
776
+ // if there is an error in the response we need to throw to the nearest boundary
777
+ if (errors && errors.length > 0) {
778
+ throw new GraphQLErrors(errors)
779
+ }
780
+ // create the handle that we will use to interact with the store
781
+ const handle = useDocumentHandle({
782
+ artifact: artifact_cache.get(name)!,
783
+ observer,
784
+ storeValue,
785
+ })
786
+
787
+ // we're done
788
+ return [data, handle]
789
+ }
790
+
791
+ function useLinkBehavior({
792
+ goto,
793
+ preload,
794
+ }: {
795
+ goto: Goto
796
+ preload: (url: string, which: PreloadWhichValue) => void
797
+ }) {
798
+ // always use the click handler
799
+ useLinkNavigation({ goto })
800
+
801
+ // only use the preload handler if the browser hasn't chosen to reduce data usage
802
+ // this doesn't break the rule of hooks because it will only ever have one value
803
+ // in the lifetime of the app
804
+ // @ts-expect-error
805
+ if (!globalThis.navigator?.connection?.saveData) {
806
+ // biome-ignore lint/correctness/useHookAtTopLevel: value is constant for the lifetime of the app
807
+ usePreload({ preload })
808
+ }
809
+ }
810
+
811
+ function useLinkNavigation({ goto }: { goto: Goto }) {
812
+ // navigations need to be registered as transitions
813
+ const [_pending, startTransition] = React.useTransition()
814
+
815
+ React.useEffect(() => {
816
+ const onClick: HTMLAnchorElement['onclick'] = (e) => {
817
+ if (!e.target) {
818
+ return
819
+ }
820
+
821
+ const link = (e.target as HTMLElement | null | undefined)?.closest('a')
822
+ // its a link we want to handle so don't navigate like normal
823
+
824
+ // we only want to capture a "normal click" ie something that indicates a route transition
825
+ // in the current tab
826
+ // courtesy of: https://gist.github.com/devongovett/919dc0f06585bd88af053562fd7c41b7
827
+ if (
828
+ !(
829
+ link &&
830
+ link instanceof HTMLAnchorElement &&
831
+ link.href &&
832
+ (!link.target || link.target === '_self') &&
833
+ link.origin === location.origin &&
834
+ !link.hasAttribute('download') &&
835
+ e.button === 0 && // left clicks only
836
+ !e.metaKey && // open in new tab (mac)
837
+ !e.ctrlKey && // open in new tab (windows)
838
+ !e.altKey && // download
839
+ !e.shiftKey &&
840
+ !e.defaultPrevented
841
+ )
842
+ ) {
843
+ return
844
+ }
845
+
846
+ // we need to figure out the target url by looking at the href attribute
847
+ const target = link.attributes.getNamedItem('href')?.value
848
+ // make sure its a link we recognize
849
+ if (!target?.startsWith('/')) {
850
+ return
851
+ }
852
+
853
+ // the session/auth endpoint and its sub-paths (e.g. the /login redirect-login entry) are
854
+ // server endpoints, not client routes — let the browser navigate so the redirect flow runs
855
+ if (target.startsWith(getAuthUrl())) {
856
+ return
857
+ }
858
+
859
+ // its a link we want to handle so don't navigate like normal
860
+ e.preventDefault()
861
+ e.stopPropagation()
862
+
863
+ // go to the next route as a low priority update
864
+ startTransition(() => {
865
+ goto(target)
866
+ })
867
+ }
868
+
869
+ window.addEventListener('click', onClick)
870
+ return () => {
871
+ window.removeEventListener('click', onClick!)
872
+ }
873
+ }, [goto])
874
+ }
875
+
876
+ function usePreload({ preload }: { preload: (url: string, which: PreloadWhichValue) => void }) {
877
+ const timeoutRef: React.MutableRefObject<NodeJS.Timeout | null> = React.useRef(null)
878
+
879
+ // if the mouse pauses on an element for 20ms then we register it as a hover
880
+ // this avoids that annoying double tap on mobile when the click captures the hover
881
+ React.useEffect(() => {
882
+ const mouseMove: HTMLAnchorElement['onmousemove'] = (e) => {
883
+ const target = e.target
884
+ if (!(target instanceof HTMLElement)) {
885
+ return
886
+ }
887
+
888
+ const anchor = target.closest('a')
889
+ if (!anchor) {
890
+ return
891
+ }
892
+
893
+ // if the anchor doesn't explicitly opt in to preloading, don't do anything
894
+ const preloadAttr = anchor.attributes.getNamedItem('data-houdini-preload')
895
+ if (!preloadAttr) {
896
+ return
897
+ }
898
+ const preloadWhichRaw = preloadAttr.value
899
+ const preloadWhich: PreloadWhichValue =
900
+ !preloadWhichRaw || preloadWhichRaw === 'true'
901
+ ? 'page'
902
+ : (preloadWhichRaw as PreloadWhichValue)
903
+
904
+ // validate the preload option
905
+ if (!PreloadWhich[preloadWhich]) {
906
+ console.log(
907
+ `invalid preload value "${preloadWhich}" must be "${PreloadWhich.component}", "${PreloadWhich.data}", or "${PreloadWhich.page}"`
908
+ )
909
+ return
910
+ }
911
+
912
+ // if we already have a timeout, remove it
913
+ if (timeoutRef.current) {
914
+ clearTimeout(timeoutRef.current)
915
+ }
916
+
917
+ // set the new timeout to track _this_ anchor
918
+ timeoutRef.current = setTimeout(() => {
919
+ const url = anchor.attributes.getNamedItem('href')?.value
920
+ if (!url) {
921
+ return
922
+ }
923
+ preload(url, preloadWhich)
924
+ }, 20)
925
+ }
926
+
927
+ // register/cleanup the event handler
928
+ document.addEventListener('mousemove', mouseMove)
929
+ return () => {
930
+ document.removeEventListener('mousemove', mouseMove)
931
+ }
932
+ }, [preload])
933
+ }
934
+
935
+ export type RouterCache = {
936
+ artifact_cache: SuspenseCache<QueryArtifact>
937
+ component_cache: SuspenseCache<PageComponent>
938
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
939
+ last_variables: LRUCache<GraphQLVariables>
940
+ ssr_signals: PendingCache
941
+ }
942
+
943
+ export function router_cache({
944
+ pending_queries = [],
945
+ artifacts = {},
946
+ components = {},
947
+ initialData = {},
948
+ initialVariables = {},
949
+ initialArtifacts = {},
950
+ }: {
951
+ pending_queries?: string[]
952
+ artifacts?: Record<string, QueryArtifact>
953
+ components?: Record<string, PageComponent>
954
+ initialData?: Record<string, DocumentStore<GraphQLObject, GraphQLVariables>>
955
+ initialVariables?: Record<string, GraphQLVariables>
956
+ initialArtifacts?: Record<string, QueryArtifact>
957
+ } = {}): RouterCache {
958
+ const result: RouterCache = {
959
+ artifact_cache: suspense_cache(initialArtifacts),
960
+ component_cache: suspense_cache(),
961
+ data_cache: suspense_cache(initialData),
962
+ ssr_signals: suspense_cache(),
963
+ last_variables: suspense_cache(),
964
+ }
965
+
966
+ // we need to fill each query with an externally resolvable promise
967
+ for (const query of pending_queries) {
968
+ result.ssr_signals.set(query, signal_promise())
969
+ }
970
+
971
+ for (const [name, artifact] of Object.entries(artifacts)) {
972
+ result.artifact_cache.set(name, artifact)
973
+ }
974
+
975
+ for (const [name, component] of Object.entries(components)) {
976
+ result.component_cache.set(name, component)
977
+ }
978
+
979
+ for (const [name, variables] of Object.entries(initialVariables)) {
980
+ result.last_variables.set(name, variables)
981
+ }
982
+
983
+ return result
984
+ }
985
+
986
+ // Catches RoutingErrors that escape all HoudiniErrorBoundary instances during prefix-match
987
+ // (is404) rendering, preventing an infinite loop when a layout itself throws notFound().
988
+ class NotFoundLayoutBoundary extends React.Component<
989
+ { children: React.ReactNode },
990
+ { caught: boolean }
991
+ > {
992
+ static contextType = StatusContext
993
+ declare context: React.ContextType<typeof StatusContext>
994
+
995
+ constructor(props: { children: React.ReactNode }) {
996
+ super(props)
997
+ this.state = { caught: false }
998
+ }
999
+
1000
+ static getDerivedStateFromError(error: unknown) {
1001
+ if (error instanceof RoutingError) {
1002
+ return { caught: true }
1003
+ }
1004
+ return null
1005
+ }
1006
+
1007
+ componentDidCatch(error: Error): void {
1008
+ if (error instanceof RoutingError && this.context) {
1009
+ this.context.status = error.status
1010
+ }
1011
+ }
1012
+
1013
+ render() {
1014
+ if (this.state.caught) {
1015
+ return null
1016
+ }
1017
+ return this.props.children
1018
+ }
1019
+ }
1020
+
1021
+ export const Is404Context = React.createContext(false)
1022
+
1023
+ export function NotFoundGate({ children }: { children: React.ReactNode }) {
1024
+ const is404 = React.useContext(Is404Context)
1025
+ if (is404) {
1026
+ throw new RoutingError(404)
1027
+ }
1028
+ return <>{children}</>
1029
+ }
1030
+
1031
+ const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
1032
+
1033
+ export function PageContextProvider({
1034
+ keys,
1035
+ children,
1036
+ }: {
1037
+ keys: string[]
1038
+ children: React.ReactNode
1039
+ }) {
1040
+ const location = useLocationContext()
1041
+ const params = Object.fromEntries(
1042
+ Object.entries(location.params).filter(([key]) => keys.includes(key))
1043
+ )
1044
+
1045
+ return <PageContext.Provider value={{ params }}>{children}</PageContext.Provider>
1046
+ }
1047
+
1048
+ // useRoute is the single hook for reading the current route: the route's params (scoped to
1049
+ // this route's path segments) and search, both typed when a generated PageRoute/LayoutRoute
1050
+ // is supplied, plus the current pathname and goto. This replaces the old useLocation —
1051
+ // params/search live here rather than on the component props. Unlike a conventional
1052
+ // useLocation it also carries params and goto, which is why it's named for the route.
1053
+ export function useRoute<
1054
+ // the default leaves params/search empty (not a loose record) so that reading them
1055
+ // without passing the route's generated PageRoute type is a compile error, while
1056
+ // pathname and goto stay available for navigation-only code.
1057
+ _Route extends { params: any; search: any } = { params: {}; search: {} },
1058
+ >(): {
1059
+ pathname: string
1060
+ params: _Route['params']
1061
+ search: _Route['search']
1062
+ goto: Goto
1063
+ } {
1064
+ const location = useLocationContext()
1065
+ const route = useContext(PageContext)
1066
+ return {
1067
+ pathname: location.pathname,
1068
+ params: route.params as _Route['params'],
1069
+ search: location.search as _Route['search'],
1070
+ goto: location.goto,
1071
+ }
1072
+ }
1073
+
1074
+ // A route shape for useRoute. A route's generated PageRoute/LayoutRoute already satisfies the
1075
+ // `{ params; search }` shape, so the common case is useRoute<PageRoute>(). For a route-agnostic
1076
+ // component (e.g. a reusable paginator that assumes its route exposes `after`/`first` search
1077
+ // params) there's no single generated type to pass — use GenericRoute to type the axis you
1078
+ // depend on and leave the other `never` (which falls back to a loose record). Search comes
1079
+ // first since that's the usual reason to reach for it, so a search-only component writes
1080
+ // GenericRoute<{ ... }> and a params-only one writes GenericRoute<never, { ... }>.
1081
+ export type GenericRoute<Search = never, Params = never> = {
1082
+ params: [Params] extends [never] ? Record<string, any> : Params
1083
+ search: [Search] extends [never] ? Record<string, any> : Search
1084
+ }
1085
+
1086
+ // a signal promise is a promise is used to send signals by having listeners attach
1087
+ // actions to the then()
1088
+ function signal_promise(): Promise<void> & { resolve: () => void; reject: () => void } {
1089
+ let resolve: () => void = () => {}
1090
+ let reject: () => void = () => {}
1091
+ const promise = new Promise<void>((res, rej) => {
1092
+ resolve = res
1093
+ reject = rej
1094
+ })
1095
+
1096
+ return {
1097
+ ...promise,
1098
+ resolve,
1099
+ reject,
1100
+ }
1101
+ }