houdini-react 2.0.0-go.4 → 2.0.0-go.5
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 +13 -9
- package/postInstall.js +117 -0
- package/runtime/client.ts +5 -0
- package/runtime/clientPlugin.ts +17 -0
- package/runtime/componentFields.ts +79 -0
- package/runtime/hooks/index.ts +9 -0
- package/runtime/hooks/useDeepCompareEffect.ts +89 -0
- package/runtime/hooks/useDocumentHandle.ts +224 -0
- package/runtime/hooks/useDocumentStore.ts +76 -0
- package/runtime/hooks/useDocumentSubscription.ts +62 -0
- package/runtime/hooks/useFragment.ts +102 -0
- package/runtime/hooks/useFragmentHandle.ts +47 -0
- package/runtime/hooks/useIsMounted.ts +14 -0
- package/runtime/hooks/useMutation.ts +54 -0
- package/runtime/hooks/useQuery.ts +17 -0
- package/runtime/hooks/useQueryHandle.ts +184 -0
- package/runtime/hooks/useSubscription.ts +12 -0
- package/runtime/hooks/useSubscriptionHandle.ts +33 -0
- package/runtime/index.tsx +49 -0
- package/runtime/manifest.ts +6 -0
- package/runtime/package.json +1 -0
- package/runtime/routing/Router.tsx +887 -0
- package/runtime/routing/cache.ts +52 -0
- package/runtime/routing/index.ts +2 -0
- package/server/index.d.ts +1 -0
- package/server/index.js +4 -0
- package/server/package.json +1 -0
- package/vite/index.d.ts +3 -0
- package/vite/index.js +11 -0
- package/vite/package.json +1 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import { GraphQLObject, GraphQLVariables } from 'houdini/runtime'
|
|
2
|
+
import { QueryArtifact } from 'houdini/runtime'
|
|
3
|
+
import type { Cache } from 'houdini/runtime/cache'
|
|
4
|
+
import { 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 { LRUCache } from 'houdini/runtime'
|
|
9
|
+
import { marshalSelection, marshalInputs } from 'houdini/runtime'
|
|
10
|
+
import { find_match } from 'houdini/router/match'
|
|
11
|
+
import type { RouterManifest, RouterPageManifest } from 'houdini/router/types'
|
|
12
|
+
import React from 'react'
|
|
13
|
+
import { useContext } from 'react'
|
|
14
|
+
|
|
15
|
+
import { DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle'
|
|
16
|
+
import { useDocumentStore } from '../hooks/useDocumentStore'
|
|
17
|
+
import { SuspenseCache, suspense_cache } from './cache'
|
|
18
|
+
|
|
19
|
+
type PageComponent = React.ComponentType<{ url: string }>
|
|
20
|
+
|
|
21
|
+
const PreloadWhich = {
|
|
22
|
+
component: 'component',
|
|
23
|
+
data: 'data',
|
|
24
|
+
page: 'page',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
|
|
28
|
+
type ComponentType = any
|
|
29
|
+
/**
|
|
30
|
+
* Router is the top level entry point for the filesystem-based router.
|
|
31
|
+
* It is responsible for loading various page sources (including API fetches) and
|
|
32
|
+
* then rendering when appropriate.
|
|
33
|
+
*/
|
|
34
|
+
// In order to enable streaming SSR, individual page and layouts components
|
|
35
|
+
// must suspend. We can't just have one big suspense that we handle
|
|
36
|
+
// or else we can't isolate the first chunk. That being said, we
|
|
37
|
+
// don't want network waterfalls. So we need to send the request for everything all
|
|
38
|
+
// at once and then wrap the children in the necessary context so that when they render
|
|
39
|
+
// they can grab what they need if its ready and suspend if not.
|
|
40
|
+
export function Router({
|
|
41
|
+
manifest,
|
|
42
|
+
initialURL,
|
|
43
|
+
assetPrefix,
|
|
44
|
+
injectToStream,
|
|
45
|
+
}: {
|
|
46
|
+
manifest: RouterManifest<ComponentType>
|
|
47
|
+
initialURL?: string
|
|
48
|
+
assetPrefix: string
|
|
49
|
+
injectToStream?: undefined | ((chunk: string) => void)
|
|
50
|
+
}) {
|
|
51
|
+
// the current route is just a string in state.
|
|
52
|
+
const [currentURL, setCurrentURL] = React.useState(() => {
|
|
53
|
+
return initialURL || window.location.pathname
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// find the matching page for the current route
|
|
57
|
+
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')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// the only time this component will directly suspend (instead of one of its children)
|
|
64
|
+
// is if we don't have the component source. Dependencies on query results or artifacts
|
|
65
|
+
// will be resolved by the component itself
|
|
66
|
+
|
|
67
|
+
// load the page assets (source, artifacts, data). this will suspend if the component is not available yet
|
|
68
|
+
// this hook embeds pending requests in context so that the component can suspend if necessary14
|
|
69
|
+
const { loadData, loadComponent } = usePageData({
|
|
70
|
+
page,
|
|
71
|
+
variables,
|
|
72
|
+
assetPrefix,
|
|
73
|
+
injectToStream,
|
|
74
|
+
})
|
|
75
|
+
// if we get this far, it's safe to load the component
|
|
76
|
+
const { component_cache, data_cache } = useRouterContext()
|
|
77
|
+
const PageComponent = component_cache.get(page.id)!
|
|
78
|
+
|
|
79
|
+
// if we got this far then we're past suspense
|
|
80
|
+
|
|
81
|
+
//
|
|
82
|
+
// Now that we know we aren't going to throw, let's set up the event listeners
|
|
83
|
+
//
|
|
84
|
+
|
|
85
|
+
// whenever the route changes, we need to make sure the browser's stack is up to date
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (globalThis.window && window.location.pathname !== currentURL) {
|
|
88
|
+
window.history.pushState({}, '', currentURL)
|
|
89
|
+
}
|
|
90
|
+
}, [currentURL])
|
|
91
|
+
|
|
92
|
+
// when we first mount we should start listening to the back button
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (!globalThis.window) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const onChange = (evt: PopStateEvent) => {
|
|
98
|
+
setCurrentURL(window.location.pathname)
|
|
99
|
+
}
|
|
100
|
+
window.addEventListener('popstate', onChange)
|
|
101
|
+
return () => {
|
|
102
|
+
window.removeEventListener('popstate', onChange)
|
|
103
|
+
}
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
// the function to call to navigate to a url
|
|
107
|
+
const goto = (url: string) => {
|
|
108
|
+
// clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
|
|
109
|
+
data_cache.clear()
|
|
110
|
+
|
|
111
|
+
// perform the navigation
|
|
112
|
+
setCurrentURL(url)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// links are powered using anchor tags that we intercept and handle ourselves
|
|
116
|
+
useLinkBehavior({
|
|
117
|
+
goto,
|
|
118
|
+
preload(url: string, which: PreloadWhichValue) {
|
|
119
|
+
// there are 2 things that we could preload: the page component and the data
|
|
120
|
+
|
|
121
|
+
// look for the matching route information
|
|
122
|
+
const [page, variables] = find_match(configFile, manifest, url)
|
|
123
|
+
if (!page) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// load the page component if necessary
|
|
128
|
+
if (['page', 'component'].includes(which)) {
|
|
129
|
+
loadComponent(page)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// load the page component if necessary
|
|
133
|
+
if (['page', 'data'].includes(which)) {
|
|
134
|
+
loadData(page, variables)
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// TODO: cleanup navigation caches
|
|
140
|
+
// render the component embedded in the necessary context so it can orchestrate
|
|
141
|
+
// its needs
|
|
142
|
+
return (
|
|
143
|
+
<VariableContext.Provider value={variables}>
|
|
144
|
+
<LocationContext.Provider
|
|
145
|
+
value={{
|
|
146
|
+
pathname: currentURL,
|
|
147
|
+
goto,
|
|
148
|
+
params: variables ?? {},
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<PageComponent url={currentURL} key={page.id} />
|
|
152
|
+
</LocationContext.Provider>
|
|
153
|
+
</VariableContext.Provider>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// export the location information in context
|
|
158
|
+
export const useLocation = () => useContext(LocationContext)
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* usePageData is responsible for kicking off the network requests necessary to render the page.
|
|
162
|
+
* This includes loading the artifact, the component source, and any query results. This hook
|
|
163
|
+
* only suspends if the component source is not available. The other cases are handled by the specific
|
|
164
|
+
* page that is being rendered so that nested suspense boundaries are properly wired up.
|
|
165
|
+
*/
|
|
166
|
+
function usePageData({
|
|
167
|
+
page,
|
|
168
|
+
variables,
|
|
169
|
+
assetPrefix,
|
|
170
|
+
injectToStream,
|
|
171
|
+
}: {
|
|
172
|
+
page: RouterPageManifest<ComponentType>
|
|
173
|
+
variables: GraphQLVariables
|
|
174
|
+
assetPrefix: string
|
|
175
|
+
injectToStream: undefined | ((chunk: string) => void)
|
|
176
|
+
}): {
|
|
177
|
+
loadData: (page: RouterPageManifest<ComponentType>, variables: {} | null) => void
|
|
178
|
+
loadComponent: (page: RouterPageManifest<ComponentType>) => void
|
|
179
|
+
} {
|
|
180
|
+
// grab context values
|
|
181
|
+
const {
|
|
182
|
+
client,
|
|
183
|
+
cache,
|
|
184
|
+
data_cache,
|
|
185
|
+
component_cache,
|
|
186
|
+
artifact_cache,
|
|
187
|
+
ssr_signals,
|
|
188
|
+
last_variables,
|
|
189
|
+
} = useRouterContext()
|
|
190
|
+
|
|
191
|
+
// grab the current session value
|
|
192
|
+
const [session] = useSession()
|
|
193
|
+
|
|
194
|
+
// the function to load a query using the cache references
|
|
195
|
+
function load_query({
|
|
196
|
+
id,
|
|
197
|
+
artifact,
|
|
198
|
+
variables,
|
|
199
|
+
}: {
|
|
200
|
+
id: string
|
|
201
|
+
artifact: QueryArtifact
|
|
202
|
+
variables: GraphQLVariables
|
|
203
|
+
}): Promise<void> {
|
|
204
|
+
// TODO: better tracking - only register the variables that were used
|
|
205
|
+
// track the new variables
|
|
206
|
+
for (const artifact of Object.keys(page.documents)) {
|
|
207
|
+
last_variables.set(artifact, variables)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// TODO: AbortController on send()
|
|
211
|
+
// TODO: we can read from cache here before making an asynchronous network call
|
|
212
|
+
|
|
213
|
+
// if there is a pending request and we were asked to load, don't do anything
|
|
214
|
+
if (ssr_signals.has(id)) {
|
|
215
|
+
return ssr_signals.get(id)!
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// send the request
|
|
219
|
+
const observer: DocumentStore<GraphQLObject, GraphQLVariables> = data_cache.has(artifact.name)
|
|
220
|
+
? data_cache.get(artifact.name)!
|
|
221
|
+
: client.observe({ artifact, cache })
|
|
222
|
+
|
|
223
|
+
let resolve: () => void = () => {}
|
|
224
|
+
let reject: (message: string) => void = () => {}
|
|
225
|
+
const promise = new Promise<void>((res, rej) => {
|
|
226
|
+
resolve = res
|
|
227
|
+
reject = rej
|
|
228
|
+
|
|
229
|
+
observer
|
|
230
|
+
.send({
|
|
231
|
+
variables: variables,
|
|
232
|
+
session,
|
|
233
|
+
})
|
|
234
|
+
.then(async () => {
|
|
235
|
+
data_cache.set(id, observer)
|
|
236
|
+
|
|
237
|
+
// if there is an error, we need to reject the promise
|
|
238
|
+
if (observer.state.errors && observer.state.errors.length > 0) {
|
|
239
|
+
reject(observer.state.errors.map((e) => e.message).join('\n'))
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// if we are building up a stream (on the server), we want to add something
|
|
244
|
+
// to the client that resolves the pending request with the
|
|
245
|
+
// data that we just got
|
|
246
|
+
injectToStream?.(`
|
|
247
|
+
<script>
|
|
248
|
+
{
|
|
249
|
+
window.__houdini__cache__?.hydrate(${cache.serialize()}, window.__houdini__hydration__layer)
|
|
250
|
+
|
|
251
|
+
const artifactName = "${artifact.name}"
|
|
252
|
+
const value = ${JSON.stringify(
|
|
253
|
+
marshalSelection({
|
|
254
|
+
selection: observer.artifact.selection,
|
|
255
|
+
data: observer.state.data,
|
|
256
|
+
config: getCurrentConfig(),
|
|
257
|
+
})
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
// if the data is pending, we need to resolve it
|
|
261
|
+
if (window.__houdini__nav_caches__?.data_cache.has(artifactName)) {
|
|
262
|
+
// before we resolve the pending signals,
|
|
263
|
+
// fill the data cache with values we got on the server
|
|
264
|
+
const new_store = window.__houdini__client__.observe({
|
|
265
|
+
artifact: window.__houdini__nav_caches__.artifact_cache.get(artifactName),
|
|
266
|
+
cache: window.__houdini__cache__,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// we're pushing this store onto the client, it should be initialized
|
|
270
|
+
window.__houdini__nav_caches__.data_cache.get(artifactName).send({
|
|
271
|
+
setup: true,
|
|
272
|
+
variables: ${JSON.stringify(
|
|
273
|
+
marshalInputs({
|
|
274
|
+
artifact: observer.artifact,
|
|
275
|
+
input: variables,
|
|
276
|
+
config: configFile,
|
|
277
|
+
})
|
|
278
|
+
)}
|
|
279
|
+
}).then(() => {
|
|
280
|
+
window.__houdini__nav_caches__?.data_cache.set(artifactName, new_store)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
// if there are no data caches available we need to populate the pending one instead
|
|
287
|
+
if (!window.__houdini__nav_caches__) {
|
|
288
|
+
if (!window.__houdini__pending_data__) {
|
|
289
|
+
window.__houdini__pending_data__ = {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!window.__houdini__pending_variables__) {
|
|
293
|
+
window.__houdini__pending_variables__ = {}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!window.__houdini__pending_artifacts__) {
|
|
297
|
+
window.__houdini__pending_artifacts__ = {}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
window.__houdini__pending_variables__[artifactName] = ${JSON.stringify(observer.state.variables)}
|
|
302
|
+
window.__houdini__pending_data__[artifactName] = value
|
|
303
|
+
window.__houdini__pending_artifacts__[artifactName] = ${JSON.stringify(artifact)}
|
|
304
|
+
|
|
305
|
+
// if this payload finishes off an ssr request, we need to resolve the signal
|
|
306
|
+
if (window.__houdini__nav_caches__?.ssr_signals.has(artifactName)) {
|
|
307
|
+
|
|
308
|
+
// if the data showed up on the client before
|
|
309
|
+
if (window.__houdini__nav_caches__.data_cache.has(artifactName)) {
|
|
310
|
+
// we're pushing this store onto the client, it should be initialized
|
|
311
|
+
window.__houdini__nav_caches__.data_cache.get(artifactName).send({
|
|
312
|
+
setup: true,
|
|
313
|
+
variables: ${JSON.stringify(
|
|
314
|
+
marshalInputs({
|
|
315
|
+
artifact: observer.artifact,
|
|
316
|
+
input: variables,
|
|
317
|
+
config: configFile,
|
|
318
|
+
})
|
|
319
|
+
)}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
// trigger the signal
|
|
325
|
+
window.__houdini__nav_caches__.ssr_signals.get(artifactName).resolve()
|
|
326
|
+
window.__houdini__nav_caches__.ssr_signals.delete(artifactName)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
</script>
|
|
330
|
+
`)
|
|
331
|
+
|
|
332
|
+
resolve()
|
|
333
|
+
})
|
|
334
|
+
.catch(reject)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// if we are on the server, we need to save a signal that we can use to
|
|
338
|
+
// communicate with the client when we're done
|
|
339
|
+
const resolvable = { ...promise, resolve, reject }
|
|
340
|
+
if (!globalThis.window) {
|
|
341
|
+
ssr_signals.set(id, resolvable)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// we're done
|
|
345
|
+
return resolvable
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// the function that loads all of the data for a page using the caches
|
|
349
|
+
function loadData(
|
|
350
|
+
targetPage: RouterPageManifest<ComponentType>,
|
|
351
|
+
variables: GraphQLVariables | null
|
|
352
|
+
) {
|
|
353
|
+
if (!targetPage) {
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// if any of the artifacts that this page on have new variables, we need to clear the data cache
|
|
358
|
+
for (const [artifact, { variables: pageVariables }] of Object.entries(
|
|
359
|
+
targetPage.documents
|
|
360
|
+
)) {
|
|
361
|
+
// if there are no last variables, there's nothing to do
|
|
362
|
+
if (!last_variables.has(artifact)) {
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// compare the last known variables with the current set
|
|
367
|
+
let last: GraphQLVariables = {}
|
|
368
|
+
let usedVariables: GraphQLVariables = {}
|
|
369
|
+
for (const variable of Object.keys(pageVariables)) {
|
|
370
|
+
last[variable] = last_variables.get(artifact)![variable]
|
|
371
|
+
usedVariables[variable] = (variables ?? {})[variable]
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// before we can compare we need to only look at the variables that the artifact cares about
|
|
375
|
+
if (Object.keys(usedVariables ?? {}).length > 0 && !deepEquals(last, usedVariables)) {
|
|
376
|
+
data_cache.delete(artifact)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// in order to avoid waterfalls, we need to kick off APIs requests in parallel
|
|
381
|
+
// to use loading any missing artifacts or the page component.
|
|
382
|
+
|
|
383
|
+
// group the necessary based on wether we have their artifact or not
|
|
384
|
+
const missing_artifacts: string[] = []
|
|
385
|
+
const found_artifacts: Record<string, QueryArtifact> = {}
|
|
386
|
+
for (const key of Object.keys(targetPage.documents)) {
|
|
387
|
+
if (artifact_cache.has(key)) {
|
|
388
|
+
found_artifacts[key] = artifact_cache.get(key)!
|
|
389
|
+
} else {
|
|
390
|
+
missing_artifacts.push(key)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// any missing artifacts need to be loaded and then have their queries loaded
|
|
395
|
+
for (const artifact_id of missing_artifacts) {
|
|
396
|
+
// load the artifact
|
|
397
|
+
targetPage.documents[artifact_id]
|
|
398
|
+
.artifact()
|
|
399
|
+
.then((mod) => {
|
|
400
|
+
// the artifact is the default export
|
|
401
|
+
const artifact = mod.default
|
|
402
|
+
|
|
403
|
+
// save the artifact in the cache
|
|
404
|
+
artifact_cache.set(artifact_id, artifact)
|
|
405
|
+
|
|
406
|
+
// add a script to load the artifact
|
|
407
|
+
injectToStream?.(`
|
|
408
|
+
<script type="module" src="${assetPrefix}/artifacts/${artifact.name}.js" async=""></script>
|
|
409
|
+
`)
|
|
410
|
+
|
|
411
|
+
// now that we have the artifact, we can load the query too
|
|
412
|
+
load_query({ id: artifact.name, artifact, variables })
|
|
413
|
+
})
|
|
414
|
+
.catch((err) => {
|
|
415
|
+
// TODO: handle error
|
|
416
|
+
console.log(err)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// we need to make sure that every artifact we found is loaded
|
|
421
|
+
// or else we need to load the query
|
|
422
|
+
for (const artifact of Object.values(found_artifacts)) {
|
|
423
|
+
// if we don't have the query, load it
|
|
424
|
+
if (!data_cache.has(artifact.name)) {
|
|
425
|
+
load_query({ id: artifact.name, artifact, variables })
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// if we don't have the component then we need to load it, save it in the cache, and
|
|
431
|
+
// then suspend with a promise that will resolve once its in cache
|
|
432
|
+
async function loadComponent(targetPage: RouterPageManifest<ComponentType>) {
|
|
433
|
+
// if we already have the component, don't do anything
|
|
434
|
+
if (component_cache.has(targetPage.id)) {
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// load the component and then save it in the cache
|
|
439
|
+
const mod = await targetPage.component()
|
|
440
|
+
|
|
441
|
+
// save the component in the cache
|
|
442
|
+
component_cache.set(targetPage.id, mod.default)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// kick off requests for the current page
|
|
446
|
+
loadData(page, variables)
|
|
447
|
+
|
|
448
|
+
// if we haven't loaded the component yet, suspend and do so
|
|
449
|
+
if (!component_cache.has(page.id)) {
|
|
450
|
+
throw loadComponent(page)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
loadData,
|
|
455
|
+
loadComponent,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function RouterContextProvider({
|
|
460
|
+
children,
|
|
461
|
+
client,
|
|
462
|
+
cache,
|
|
463
|
+
artifact_cache,
|
|
464
|
+
component_cache,
|
|
465
|
+
data_cache,
|
|
466
|
+
ssr_signals,
|
|
467
|
+
last_variables,
|
|
468
|
+
session: ssrSession = {},
|
|
469
|
+
}: {
|
|
470
|
+
children: React.ReactNode
|
|
471
|
+
client: HoudiniClient
|
|
472
|
+
cache: Cache
|
|
473
|
+
artifact_cache: SuspenseCache<QueryArtifact>
|
|
474
|
+
component_cache: SuspenseCache<PageComponent>
|
|
475
|
+
data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
476
|
+
ssr_signals: PendingCache
|
|
477
|
+
last_variables: LRUCache<GraphQLVariables>
|
|
478
|
+
session?: App.Session
|
|
479
|
+
}) {
|
|
480
|
+
// the session is top level state
|
|
481
|
+
// on the server, we can just use
|
|
482
|
+
const [session, setSession] = React.useState<App.Session>(ssrSession)
|
|
483
|
+
|
|
484
|
+
// if we detect an event that contains a new session value
|
|
485
|
+
const handleNewSession = React.useCallback((event: CustomEvent<App.Session>) => {
|
|
486
|
+
setSession(event.detail)
|
|
487
|
+
}, [])
|
|
488
|
+
|
|
489
|
+
React.useEffect(() => {
|
|
490
|
+
// @ts-ignore
|
|
491
|
+
window.addEventListener('_houdini_session_', handleNewSession)
|
|
492
|
+
|
|
493
|
+
// cleanup this component
|
|
494
|
+
return () => {
|
|
495
|
+
// @ts-ignore
|
|
496
|
+
window.removeEventListener('_houdini_session_', handleNewSession)
|
|
497
|
+
}
|
|
498
|
+
}, [])
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<Context.Provider
|
|
502
|
+
value={{
|
|
503
|
+
client,
|
|
504
|
+
cache,
|
|
505
|
+
artifact_cache,
|
|
506
|
+
component_cache,
|
|
507
|
+
data_cache,
|
|
508
|
+
ssr_signals,
|
|
509
|
+
last_variables,
|
|
510
|
+
session,
|
|
511
|
+
setSession: (newSession) => setSession((old) => ({ ...old, ...newSession })),
|
|
512
|
+
}}
|
|
513
|
+
>
|
|
514
|
+
{children}
|
|
515
|
+
</Context.Provider>
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
type RouterContext = {
|
|
520
|
+
client: HoudiniClient
|
|
521
|
+
cache: Cache
|
|
522
|
+
|
|
523
|
+
// We also need a cache for artifacts so that we can avoid suspending to
|
|
524
|
+
// load them if possible.
|
|
525
|
+
artifact_cache: SuspenseCache<QueryArtifact>
|
|
526
|
+
|
|
527
|
+
// We also need a cache for component references so we can avoid suspending
|
|
528
|
+
// when we load the same page multiple times
|
|
529
|
+
component_cache: SuspenseCache<PageComponent>
|
|
530
|
+
|
|
531
|
+
// Pages need a way to wait for data
|
|
532
|
+
data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
533
|
+
|
|
534
|
+
// A way to dedupe requests for a query
|
|
535
|
+
ssr_signals: PendingCache
|
|
536
|
+
|
|
537
|
+
// A way to track the last known good variables
|
|
538
|
+
last_variables: LRUCache<GraphQLVariables>
|
|
539
|
+
|
|
540
|
+
// The current session
|
|
541
|
+
session: App.Session
|
|
542
|
+
|
|
543
|
+
// a function to call that sets the client-side session singletone
|
|
544
|
+
setSession: (newSession: Partial<App.Session>) => void
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export type PendingCache = SuspenseCache<
|
|
548
|
+
Promise<void> & { resolve: () => void; reject: (message: string) => void }
|
|
549
|
+
>
|
|
550
|
+
|
|
551
|
+
const Context = React.createContext<RouterContext | null>(null)
|
|
552
|
+
|
|
553
|
+
export const useRouterContext = () => {
|
|
554
|
+
const ctx = React.useContext(Context)
|
|
555
|
+
|
|
556
|
+
if (!ctx) {
|
|
557
|
+
throw new Error('Could not find router context')
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return ctx
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function useClient() {
|
|
564
|
+
return useRouterContext().client
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function useCache() {
|
|
568
|
+
return useRouterContext().cache
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function updateLocalSession(session: App.Session) {
|
|
572
|
+
window.dispatchEvent(
|
|
573
|
+
new CustomEvent<App.Session>('_houdini_session_', {
|
|
574
|
+
bubbles: true,
|
|
575
|
+
detail: session,
|
|
576
|
+
})
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function useSession(): [App.Session, (newSession: Partial<App.Session>) => void] {
|
|
581
|
+
const ctx = useRouterContext()
|
|
582
|
+
|
|
583
|
+
// when we update the session we have to do 2 things. (1) we have to update the local state
|
|
584
|
+
// that we will use on the client (2) we have to send a request to the server so that it
|
|
585
|
+
// can update the cookie that we use for the session
|
|
586
|
+
const updateSession = (newSession: Partial<App.Session>) => {
|
|
587
|
+
// clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
|
|
588
|
+
ctx.data_cache.clear()
|
|
589
|
+
|
|
590
|
+
// update the local state
|
|
591
|
+
ctx.setSession(newSession)
|
|
592
|
+
|
|
593
|
+
// figure out the url that we will use to send values to the server
|
|
594
|
+
const auth = configFile.router?.auth
|
|
595
|
+
if (!auth) {
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const url = 'redirect' in auth ? auth.redirect : auth.url
|
|
599
|
+
|
|
600
|
+
fetch(url!, {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
body: JSON.stringify(newSession),
|
|
603
|
+
headers: {
|
|
604
|
+
'Content-Type': 'application/json',
|
|
605
|
+
Accept: 'application/json',
|
|
606
|
+
},
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return [ctx.session, updateSession]
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function useCurrentVariables(): GraphQLVariables {
|
|
614
|
+
return React.useContext(VariableContext)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const VariableContext = React.createContext<GraphQLVariables>(null)
|
|
618
|
+
|
|
619
|
+
const LocationContext = React.createContext<{
|
|
620
|
+
pathname: string
|
|
621
|
+
params: Record<string, any>
|
|
622
|
+
// a function to imperatively navigate to a url
|
|
623
|
+
goto: (url: string) => void
|
|
624
|
+
}>({
|
|
625
|
+
pathname: '',
|
|
626
|
+
params: {},
|
|
627
|
+
goto: () => {},
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>(
|
|
631
|
+
name: string
|
|
632
|
+
): [_Data | null, DocumentHandle<any, _Data, _Input>] {
|
|
633
|
+
// pull the global context values
|
|
634
|
+
const { data_cache, artifact_cache } = useRouterContext()
|
|
635
|
+
|
|
636
|
+
// load the store reference (this will suspend)
|
|
637
|
+
const store_ref = data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>
|
|
638
|
+
|
|
639
|
+
// get the live data from the store
|
|
640
|
+
const [storeValue, observer] = useDocumentStore<_Data, _Input>({
|
|
641
|
+
artifact: store_ref.artifact,
|
|
642
|
+
observer: store_ref,
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// pull out the store values we care about
|
|
646
|
+
const { data, errors } = storeValue
|
|
647
|
+
|
|
648
|
+
// if there is an error in the response we need to throw to the nearest boundary
|
|
649
|
+
if (errors && errors.length > 0) {
|
|
650
|
+
throw new Error(JSON.stringify(errors))
|
|
651
|
+
}
|
|
652
|
+
// create the handle that we will use to interact with the store
|
|
653
|
+
const handle = useDocumentHandle({
|
|
654
|
+
artifact: artifact_cache.get(name)!,
|
|
655
|
+
observer,
|
|
656
|
+
storeValue,
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// we're done
|
|
660
|
+
return [data, handle]
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function useLinkBehavior({
|
|
664
|
+
goto,
|
|
665
|
+
preload,
|
|
666
|
+
}: {
|
|
667
|
+
goto: (url: string) => void
|
|
668
|
+
preload: (url: string, which: PreloadWhichValue) => void
|
|
669
|
+
}) {
|
|
670
|
+
// always use the click handler
|
|
671
|
+
useLinkNavigation({ goto })
|
|
672
|
+
|
|
673
|
+
// only use the preload handler if the browser hasn't chosen to reduce data usage
|
|
674
|
+
// this doesn't break the rule of hooks because it will only ever have one value
|
|
675
|
+
// in the lifetime of the app
|
|
676
|
+
// @ts-ignore
|
|
677
|
+
if (!globalThis.navigator?.connection?.saveData) {
|
|
678
|
+
usePreload({ preload })
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function useLinkNavigation({ goto }: { goto: (url: string) => void }) {
|
|
683
|
+
// navigations need to be registered as transitions
|
|
684
|
+
const [pending, startTransition] = React.useTransition()
|
|
685
|
+
|
|
686
|
+
React.useEffect(() => {
|
|
687
|
+
const onClick: HTMLAnchorElement['onclick'] = (e) => {
|
|
688
|
+
if (!e.target) {
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const link = (e.target as HTMLElement | null | undefined)?.closest('a')
|
|
693
|
+
// its a link we want to handle so don't navigate like normal
|
|
694
|
+
|
|
695
|
+
// we only want to capture a "normal click" ie something that indicates a route transition
|
|
696
|
+
// in the current tab
|
|
697
|
+
// courtesy of: https://gist.github.com/devongovett/919dc0f06585bd88af053562fd7c41b7
|
|
698
|
+
if (
|
|
699
|
+
!(
|
|
700
|
+
link &&
|
|
701
|
+
link instanceof HTMLAnchorElement &&
|
|
702
|
+
link.href &&
|
|
703
|
+
(!link.target || link.target === '_self') &&
|
|
704
|
+
link.origin === location.origin &&
|
|
705
|
+
!link.hasAttribute('download') &&
|
|
706
|
+
e.button === 0 && // left clicks only
|
|
707
|
+
!e.metaKey && // open in new tab (mac)
|
|
708
|
+
!e.ctrlKey && // open in new tab (windows)
|
|
709
|
+
!e.altKey && // download
|
|
710
|
+
!e.shiftKey &&
|
|
711
|
+
!e.defaultPrevented
|
|
712
|
+
)
|
|
713
|
+
) {
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// we need to figure out the target url by looking at the href attribute
|
|
718
|
+
const target = link.attributes.getNamedItem('href')?.value
|
|
719
|
+
// make sure its a link we recognize
|
|
720
|
+
if (!target || !target.startsWith('/')) {
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// its a link we want to handle so don't navigate like normal
|
|
725
|
+
e.preventDefault()
|
|
726
|
+
e.stopPropagation()
|
|
727
|
+
|
|
728
|
+
// go to the next route as a low priority update
|
|
729
|
+
startTransition(() => {
|
|
730
|
+
goto(target)
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
window.addEventListener('click', onClick)
|
|
735
|
+
return () => {
|
|
736
|
+
window.removeEventListener('click', onClick!)
|
|
737
|
+
}
|
|
738
|
+
}, [])
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function usePreload({ preload }: { preload: (url: string, which: PreloadWhichValue) => void }) {
|
|
742
|
+
const timeoutRef: React.MutableRefObject<NodeJS.Timeout | null> = React.useRef(null)
|
|
743
|
+
|
|
744
|
+
// if the mouse pauses on an element for 20ms then we register it as a hover
|
|
745
|
+
// this avoids that annoying double tap on mobile when the click captures the hover
|
|
746
|
+
React.useEffect(() => {
|
|
747
|
+
const mouseMove: HTMLAnchorElement['onmousemove'] = (e) => {
|
|
748
|
+
const target = e.target
|
|
749
|
+
if (!(target instanceof HTMLElement)) {
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const anchor = target.closest('a')
|
|
754
|
+
if (!anchor) {
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// if the anchor doesn't allow for preloading, don't do anything
|
|
759
|
+
let preloadWhichRaw = anchor.attributes.getNamedItem('data-houdini-preload')?.value
|
|
760
|
+
let preloadWhich: PreloadWhichValue =
|
|
761
|
+
!preloadWhichRaw || preloadWhichRaw === 'true'
|
|
762
|
+
? 'page'
|
|
763
|
+
: (preloadWhichRaw as PreloadWhichValue)
|
|
764
|
+
|
|
765
|
+
// validate the preload option
|
|
766
|
+
if (!PreloadWhich[preloadWhich]) {
|
|
767
|
+
console.log(
|
|
768
|
+
`invalid preload value "${preloadWhich}" must be "${PreloadWhich.component}", "${PreloadWhich.data}", or "${PreloadWhich.page}"`
|
|
769
|
+
)
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// if we already have a timeout, remove it
|
|
774
|
+
if (timeoutRef.current) {
|
|
775
|
+
clearTimeout(timeoutRef.current)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// set the new timeout to track _this_ anchor
|
|
779
|
+
timeoutRef.current = setTimeout(() => {
|
|
780
|
+
const url = anchor.attributes.getNamedItem('href')?.value
|
|
781
|
+
if (!url) {
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
preload(url, preloadWhich)
|
|
785
|
+
}, 20)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// register/cleanup the event handler
|
|
789
|
+
document.addEventListener('mousemove', mouseMove)
|
|
790
|
+
return () => {
|
|
791
|
+
document.removeEventListener('mousemove', mouseMove)
|
|
792
|
+
}
|
|
793
|
+
}, [])
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export type RouterCache = {
|
|
797
|
+
artifact_cache: SuspenseCache<QueryArtifact>
|
|
798
|
+
component_cache: SuspenseCache<PageComponent>
|
|
799
|
+
data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
800
|
+
last_variables: LRUCache<GraphQLVariables>
|
|
801
|
+
ssr_signals: PendingCache
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function router_cache({
|
|
805
|
+
pending_queries = [],
|
|
806
|
+
artifacts = {},
|
|
807
|
+
components = {},
|
|
808
|
+
initialData = {},
|
|
809
|
+
initialVariables = {},
|
|
810
|
+
initialArtifacts = {},
|
|
811
|
+
}: {
|
|
812
|
+
pending_queries?: string[]
|
|
813
|
+
artifacts?: Record<string, QueryArtifact>
|
|
814
|
+
components?: Record<string, PageComponent>
|
|
815
|
+
initialData?: Record<string, DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
816
|
+
initialVariables?: Record<string, GraphQLVariables>
|
|
817
|
+
initialArtifacts?: Record<string, QueryArtifact>
|
|
818
|
+
} = {}): RouterCache {
|
|
819
|
+
const result: RouterCache = {
|
|
820
|
+
artifact_cache: suspense_cache(initialArtifacts),
|
|
821
|
+
component_cache: suspense_cache(),
|
|
822
|
+
data_cache: suspense_cache(initialData),
|
|
823
|
+
ssr_signals: suspense_cache(),
|
|
824
|
+
last_variables: suspense_cache(),
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// we need to fill each query with an externally resolvable promise
|
|
828
|
+
for (const query of pending_queries) {
|
|
829
|
+
result.ssr_signals.set(query, signal_promise())
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
for (const [name, artifact] of Object.entries(artifacts)) {
|
|
833
|
+
result.artifact_cache.set(name, artifact)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
for (const [name, component] of Object.entries(components)) {
|
|
837
|
+
result.component_cache.set(name, component)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
for (const [name, variables] of Object.entries(initialVariables)) {
|
|
841
|
+
result.last_variables.set(name, variables)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return result
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
|
|
848
|
+
|
|
849
|
+
export function PageContextProvider({
|
|
850
|
+
keys,
|
|
851
|
+
children,
|
|
852
|
+
}: {
|
|
853
|
+
keys: string[]
|
|
854
|
+
children: React.ReactNode
|
|
855
|
+
}) {
|
|
856
|
+
const location = useLocation()
|
|
857
|
+
const params = Object.fromEntries(
|
|
858
|
+
Object.entries(location.params).filter(([key]) => keys.includes(key))
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
return <PageContext.Provider value={{ params }}>{children}</PageContext.Provider>
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export function useRoute<PageProps extends { Params: {} }>(): RouteProp<PageProps['Params']> {
|
|
865
|
+
return useContext(PageContext)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export type RouteProp<Params> = {
|
|
869
|
+
params: Params
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// a signal promise is a promise is used to send signals by having listeners attach
|
|
873
|
+
// actions to the then()
|
|
874
|
+
function signal_promise(): Promise<void> & { resolve: () => void; reject: () => void } {
|
|
875
|
+
let resolve: () => void = () => {}
|
|
876
|
+
let reject: () => void = () => {}
|
|
877
|
+
const promise = new Promise<void>((res, rej) => {
|
|
878
|
+
resolve = res
|
|
879
|
+
reject = rej
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
...promise,
|
|
884
|
+
resolve,
|
|
885
|
+
reject,
|
|
886
|
+
}
|
|
887
|
+
}
|