houdini-react 2.0.0-go.2 → 2.0.0-go.20

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.
@@ -0,0 +1,882 @@
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
+ // now that we have the artifact, we can load the query too
407
+ load_query({ id: artifact.name, artifact, variables })
408
+ })
409
+ .catch((err) => {
410
+ // TODO: handle error
411
+ console.log(err)
412
+ })
413
+ }
414
+
415
+ // we need to make sure that every artifact we found is loaded
416
+ // or else we need to load the query
417
+ for (const artifact of Object.values(found_artifacts)) {
418
+ // if we don't have the query, load it
419
+ if (!data_cache.has(artifact.name)) {
420
+ load_query({ id: artifact.name, artifact, variables })
421
+ }
422
+ }
423
+ }
424
+
425
+ // if we don't have the component then we need to load it, save it in the cache, and
426
+ // then suspend with a promise that will resolve once its in cache
427
+ async function loadComponent(targetPage: RouterPageManifest<ComponentType>) {
428
+ // if we already have the component, don't do anything
429
+ if (component_cache.has(targetPage.id)) {
430
+ return
431
+ }
432
+
433
+ // load the component and then save it in the cache
434
+ const mod = await targetPage.component()
435
+
436
+ // save the component in the cache
437
+ component_cache.set(targetPage.id, mod.default)
438
+ }
439
+
440
+ // kick off requests for the current page
441
+ loadData(page, variables)
442
+
443
+ // if we haven't loaded the component yet, suspend and do so
444
+ if (!component_cache.has(page.id)) {
445
+ throw loadComponent(page)
446
+ }
447
+
448
+ return {
449
+ loadData,
450
+ loadComponent,
451
+ }
452
+ }
453
+
454
+ export function RouterContextProvider({
455
+ children,
456
+ client,
457
+ cache,
458
+ artifact_cache,
459
+ component_cache,
460
+ data_cache,
461
+ ssr_signals,
462
+ last_variables,
463
+ session: ssrSession = {},
464
+ }: {
465
+ children: React.ReactNode
466
+ client: HoudiniClient
467
+ cache: Cache
468
+ artifact_cache: SuspenseCache<QueryArtifact>
469
+ component_cache: SuspenseCache<PageComponent>
470
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
471
+ ssr_signals: PendingCache
472
+ last_variables: LRUCache<GraphQLVariables>
473
+ session?: App.Session
474
+ }) {
475
+ // the session is top level state
476
+ // on the server, we can just use
477
+ const [session, setSession] = React.useState<App.Session>(ssrSession)
478
+
479
+ // if we detect an event that contains a new session value
480
+ const handleNewSession = React.useCallback((event: CustomEvent<App.Session>) => {
481
+ setSession(event.detail)
482
+ }, [])
483
+
484
+ React.useEffect(() => {
485
+ // @ts-ignore
486
+ window.addEventListener('_houdini_session_', handleNewSession)
487
+
488
+ // cleanup this component
489
+ return () => {
490
+ // @ts-ignore
491
+ window.removeEventListener('_houdini_session_', handleNewSession)
492
+ }
493
+ }, [])
494
+
495
+ return (
496
+ <Context.Provider
497
+ value={{
498
+ client,
499
+ cache,
500
+ artifact_cache,
501
+ component_cache,
502
+ data_cache,
503
+ ssr_signals,
504
+ last_variables,
505
+ session,
506
+ setSession: (newSession) => setSession((old) => ({ ...old, ...newSession })),
507
+ }}
508
+ >
509
+ {children}
510
+ </Context.Provider>
511
+ )
512
+ }
513
+
514
+ type RouterContext = {
515
+ client: HoudiniClient
516
+ cache: Cache
517
+
518
+ // We also need a cache for artifacts so that we can avoid suspending to
519
+ // load them if possible.
520
+ artifact_cache: SuspenseCache<QueryArtifact>
521
+
522
+ // We also need a cache for component references so we can avoid suspending
523
+ // when we load the same page multiple times
524
+ component_cache: SuspenseCache<PageComponent>
525
+
526
+ // Pages need a way to wait for data
527
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
528
+
529
+ // A way to dedupe requests for a query
530
+ ssr_signals: PendingCache
531
+
532
+ // A way to track the last known good variables
533
+ last_variables: LRUCache<GraphQLVariables>
534
+
535
+ // The current session
536
+ session: App.Session
537
+
538
+ // a function to call that sets the client-side session singletone
539
+ setSession: (newSession: Partial<App.Session>) => void
540
+ }
541
+
542
+ export type PendingCache = SuspenseCache<
543
+ Promise<void> & { resolve: () => void; reject: (message: string) => void }
544
+ >
545
+
546
+ const Context = React.createContext<RouterContext | null>(null)
547
+
548
+ export const useRouterContext = () => {
549
+ const ctx = React.useContext(Context)
550
+
551
+ if (!ctx) {
552
+ throw new Error('Could not find router context')
553
+ }
554
+
555
+ return ctx
556
+ }
557
+
558
+ export function useClient() {
559
+ return useRouterContext().client
560
+ }
561
+
562
+ export function useCache() {
563
+ return useRouterContext().cache
564
+ }
565
+
566
+ export function updateLocalSession(session: App.Session) {
567
+ window.dispatchEvent(
568
+ new CustomEvent<App.Session>('_houdini_session_', {
569
+ bubbles: true,
570
+ detail: session,
571
+ })
572
+ )
573
+ }
574
+
575
+ export function useSession(): [App.Session, (newSession: Partial<App.Session>) => void] {
576
+ const ctx = useRouterContext()
577
+
578
+ // when we update the session we have to do 2 things. (1) we have to update the local state
579
+ // that we will use on the client (2) we have to send a request to the server so that it
580
+ // can update the cookie that we use for the session
581
+ const updateSession = (newSession: Partial<App.Session>) => {
582
+ // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
583
+ ctx.data_cache.clear()
584
+
585
+ // update the local state
586
+ ctx.setSession(newSession)
587
+
588
+ // figure out the url that we will use to send values to the server
589
+ const auth = configFile.router?.auth
590
+ if (!auth) {
591
+ return
592
+ }
593
+ const url = 'redirect' in auth ? auth.redirect : auth.url
594
+
595
+ fetch(url!, {
596
+ method: 'POST',
597
+ body: JSON.stringify(newSession),
598
+ headers: {
599
+ 'Content-Type': 'application/json',
600
+ Accept: 'application/json',
601
+ },
602
+ })
603
+ }
604
+
605
+ return [ctx.session, updateSession]
606
+ }
607
+
608
+ export function useCurrentVariables(): GraphQLVariables {
609
+ return React.useContext(VariableContext)
610
+ }
611
+
612
+ const VariableContext = React.createContext<GraphQLVariables>(null)
613
+
614
+ const LocationContext = React.createContext<{
615
+ pathname: string
616
+ params: Record<string, any>
617
+ // a function to imperatively navigate to a url
618
+ goto: (url: string) => void
619
+ }>({
620
+ pathname: '',
621
+ params: {},
622
+ goto: () => {},
623
+ })
624
+
625
+ export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>(
626
+ name: string
627
+ ): [_Data | null, DocumentHandle<any, _Data, _Input>] {
628
+ // pull the global context values
629
+ const { data_cache, artifact_cache } = useRouterContext()
630
+
631
+ // load the store reference (this will suspend)
632
+ const store_ref = data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>
633
+
634
+ // get the live data from the store
635
+ const [storeValue, observer] = useDocumentStore<_Data, _Input>({
636
+ artifact: store_ref.artifact,
637
+ observer: store_ref,
638
+ })
639
+
640
+ // pull out the store values we care about
641
+ const { data, errors } = storeValue
642
+
643
+ // if there is an error in the response we need to throw to the nearest boundary
644
+ if (errors && errors.length > 0) {
645
+ throw new Error(JSON.stringify(errors))
646
+ }
647
+ // create the handle that we will use to interact with the store
648
+ const handle = useDocumentHandle({
649
+ artifact: artifact_cache.get(name)!,
650
+ observer,
651
+ storeValue,
652
+ })
653
+
654
+ // we're done
655
+ return [data, handle]
656
+ }
657
+
658
+ function useLinkBehavior({
659
+ goto,
660
+ preload,
661
+ }: {
662
+ goto: (url: string) => void
663
+ preload: (url: string, which: PreloadWhichValue) => void
664
+ }) {
665
+ // always use the click handler
666
+ useLinkNavigation({ goto })
667
+
668
+ // only use the preload handler if the browser hasn't chosen to reduce data usage
669
+ // this doesn't break the rule of hooks because it will only ever have one value
670
+ // in the lifetime of the app
671
+ // @ts-ignore
672
+ if (!globalThis.navigator?.connection?.saveData) {
673
+ usePreload({ preload })
674
+ }
675
+ }
676
+
677
+ function useLinkNavigation({ goto }: { goto: (url: string) => void }) {
678
+ // navigations need to be registered as transitions
679
+ const [pending, startTransition] = React.useTransition()
680
+
681
+ React.useEffect(() => {
682
+ const onClick: HTMLAnchorElement['onclick'] = (e) => {
683
+ if (!e.target) {
684
+ return
685
+ }
686
+
687
+ const link = (e.target as HTMLElement | null | undefined)?.closest('a')
688
+ // its a link we want to handle so don't navigate like normal
689
+
690
+ // we only want to capture a "normal click" ie something that indicates a route transition
691
+ // in the current tab
692
+ // courtesy of: https://gist.github.com/devongovett/919dc0f06585bd88af053562fd7c41b7
693
+ if (
694
+ !(
695
+ link &&
696
+ link instanceof HTMLAnchorElement &&
697
+ link.href &&
698
+ (!link.target || link.target === '_self') &&
699
+ link.origin === location.origin &&
700
+ !link.hasAttribute('download') &&
701
+ e.button === 0 && // left clicks only
702
+ !e.metaKey && // open in new tab (mac)
703
+ !e.ctrlKey && // open in new tab (windows)
704
+ !e.altKey && // download
705
+ !e.shiftKey &&
706
+ !e.defaultPrevented
707
+ )
708
+ ) {
709
+ return
710
+ }
711
+
712
+ // we need to figure out the target url by looking at the href attribute
713
+ const target = link.attributes.getNamedItem('href')?.value
714
+ // make sure its a link we recognize
715
+ if (!target || !target.startsWith('/')) {
716
+ return
717
+ }
718
+
719
+ // its a link we want to handle so don't navigate like normal
720
+ e.preventDefault()
721
+ e.stopPropagation()
722
+
723
+ // go to the next route as a low priority update
724
+ startTransition(() => {
725
+ goto(target)
726
+ })
727
+ }
728
+
729
+ window.addEventListener('click', onClick)
730
+ return () => {
731
+ window.removeEventListener('click', onClick!)
732
+ }
733
+ }, [])
734
+ }
735
+
736
+ function usePreload({ preload }: { preload: (url: string, which: PreloadWhichValue) => void }) {
737
+ const timeoutRef: React.MutableRefObject<NodeJS.Timeout | null> = React.useRef(null)
738
+
739
+ // if the mouse pauses on an element for 20ms then we register it as a hover
740
+ // this avoids that annoying double tap on mobile when the click captures the hover
741
+ React.useEffect(() => {
742
+ const mouseMove: HTMLAnchorElement['onmousemove'] = (e) => {
743
+ const target = e.target
744
+ if (!(target instanceof HTMLElement)) {
745
+ return
746
+ }
747
+
748
+ const anchor = target.closest('a')
749
+ if (!anchor) {
750
+ return
751
+ }
752
+
753
+ // if the anchor doesn't allow for preloading, don't do anything
754
+ let preloadWhichRaw = anchor.attributes.getNamedItem('data-houdini-preload')?.value
755
+ let preloadWhich: PreloadWhichValue =
756
+ !preloadWhichRaw || preloadWhichRaw === 'true'
757
+ ? 'page'
758
+ : (preloadWhichRaw as PreloadWhichValue)
759
+
760
+ // validate the preload option
761
+ if (!PreloadWhich[preloadWhich]) {
762
+ console.log(
763
+ `invalid preload value "${preloadWhich}" must be "${PreloadWhich.component}", "${PreloadWhich.data}", or "${PreloadWhich.page}"`
764
+ )
765
+ return
766
+ }
767
+
768
+ // if we already have a timeout, remove it
769
+ if (timeoutRef.current) {
770
+ clearTimeout(timeoutRef.current)
771
+ }
772
+
773
+ // set the new timeout to track _this_ anchor
774
+ timeoutRef.current = setTimeout(() => {
775
+ const url = anchor.attributes.getNamedItem('href')?.value
776
+ if (!url) {
777
+ return
778
+ }
779
+ preload(url, preloadWhich)
780
+ }, 20)
781
+ }
782
+
783
+ // register/cleanup the event handler
784
+ document.addEventListener('mousemove', mouseMove)
785
+ return () => {
786
+ document.removeEventListener('mousemove', mouseMove)
787
+ }
788
+ }, [])
789
+ }
790
+
791
+ export type RouterCache = {
792
+ artifact_cache: SuspenseCache<QueryArtifact>
793
+ component_cache: SuspenseCache<PageComponent>
794
+ data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
795
+ last_variables: LRUCache<GraphQLVariables>
796
+ ssr_signals: PendingCache
797
+ }
798
+
799
+ export function router_cache({
800
+ pending_queries = [],
801
+ artifacts = {},
802
+ components = {},
803
+ initialData = {},
804
+ initialVariables = {},
805
+ initialArtifacts = {},
806
+ }: {
807
+ pending_queries?: string[]
808
+ artifacts?: Record<string, QueryArtifact>
809
+ components?: Record<string, PageComponent>
810
+ initialData?: Record<string, DocumentStore<GraphQLObject, GraphQLVariables>>
811
+ initialVariables?: Record<string, GraphQLVariables>
812
+ initialArtifacts?: Record<string, QueryArtifact>
813
+ } = {}): RouterCache {
814
+ const result: RouterCache = {
815
+ artifact_cache: suspense_cache(initialArtifacts),
816
+ component_cache: suspense_cache(),
817
+ data_cache: suspense_cache(initialData),
818
+ ssr_signals: suspense_cache(),
819
+ last_variables: suspense_cache(),
820
+ }
821
+
822
+ // we need to fill each query with an externally resolvable promise
823
+ for (const query of pending_queries) {
824
+ result.ssr_signals.set(query, signal_promise())
825
+ }
826
+
827
+ for (const [name, artifact] of Object.entries(artifacts)) {
828
+ result.artifact_cache.set(name, artifact)
829
+ }
830
+
831
+ for (const [name, component] of Object.entries(components)) {
832
+ result.component_cache.set(name, component)
833
+ }
834
+
835
+ for (const [name, variables] of Object.entries(initialVariables)) {
836
+ result.last_variables.set(name, variables)
837
+ }
838
+
839
+ return result
840
+ }
841
+
842
+ const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
843
+
844
+ export function PageContextProvider({
845
+ keys,
846
+ children,
847
+ }: {
848
+ keys: string[]
849
+ children: React.ReactNode
850
+ }) {
851
+ const location = useLocation()
852
+ const params = Object.fromEntries(
853
+ Object.entries(location.params).filter(([key]) => keys.includes(key))
854
+ )
855
+
856
+ return <PageContext.Provider value={{ params }}>{children}</PageContext.Provider>
857
+ }
858
+
859
+ export function useRoute<PageProps extends { Params: {} }>(): RouteProp<PageProps['Params']> {
860
+ return useContext(PageContext)
861
+ }
862
+
863
+ export type RouteProp<Params> = {
864
+ params: Params
865
+ }
866
+
867
+ // a signal promise is a promise is used to send signals by having listeners attach
868
+ // actions to the then()
869
+ function signal_promise(): Promise<void> & { resolve: () => void; reject: () => void } {
870
+ let resolve: () => void = () => {}
871
+ let reject: () => void = () => {}
872
+ const promise = new Promise<void>((res, rej) => {
873
+ resolve = res
874
+ reject = rej
875
+ })
876
+
877
+ return {
878
+ ...promise,
879
+ resolve,
880
+ reject,
881
+ }
882
+ }