houdini-react 2.0.0-next.9 → 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.
- package/bin/houdini-react +173 -0
- package/package.json +63 -39
- package/postInstall.js +360 -0
- package/runtime/Link.tsx +50 -0
- package/runtime/client.ts +5 -0
- package/runtime/clientPlugin.ts +17 -0
- package/runtime/componentFields.ts +79 -0
- package/runtime/escape.ts +15 -0
- package/runtime/hooks/index.ts +17 -0
- package/runtime/hooks/recycleNodesInto.ts +60 -0
- package/runtime/hooks/useDeepCompareEffect.ts +93 -0
- package/runtime/hooks/useDocumentHandle.ts +243 -0
- package/runtime/hooks/useDocumentStore.ts +112 -0
- package/runtime/hooks/useDocumentSubscription.ts +65 -0
- package/runtime/hooks/useFragment.ts +269 -0
- package/runtime/hooks/useFragmentHandle.ts +274 -0
- package/runtime/hooks/useIsMounted.ts +14 -0
- package/runtime/hooks/useLogoutForm.tsx +60 -0
- package/runtime/hooks/useMutation.ts +80 -0
- package/runtime/hooks/useMutationForm.tsx +163 -0
- package/runtime/hooks/useQuery.ts +12 -0
- package/runtime/hooks/useQueryHandle.ts +194 -0
- package/runtime/hooks/useSubscription.ts +12 -0
- package/runtime/hooks/useSubscriptionHandle.ts +42 -0
- package/runtime/hydration.tsx +186 -0
- package/runtime/index.tsx +77 -0
- package/runtime/login.ts +22 -0
- package/runtime/manifest.ts +6 -0
- package/runtime/mock.ts +9 -0
- package/runtime/package.json +1 -0
- package/runtime/resolve-href.ts +151 -0
- package/runtime/routes.ts +93 -0
- package/runtime/routing/Router.tsx +1101 -0
- package/runtime/routing/cache.ts +57 -0
- package/runtime/routing/errors.tsx +145 -0
- package/runtime/routing/index.ts +17 -0
- package/runtime/testing.tsx +163 -0
- package/runtime/tsconfig.json +39 -0
- package/server/index.d.ts +1 -0
- package/server/index.js +4 -0
- package/server/react-streaming.d.js +0 -0
- package/vite/index.d.ts +3 -0
- package/vite/index.js +397 -0
- package/vite/strip-headers.d.ts +1 -0
- package/vite/strip-headers.js +35 -0
- package/vite/transform.d.ts +13 -0
- package/vite/transform.js +105 -0
- package/README.md +0 -36
- package/build/plugin/codegen/entries/documentWrappers.d.ts +0 -6
- package/build/plugin/codegen/entries/fallbacks.d.ts +0 -5
- package/build/plugin/codegen/entries/index.d.ts +0 -16
- package/build/plugin/codegen/entries/pages.d.ts +0 -2
- package/build/plugin/codegen/index.d.ts +0 -17
- package/build/plugin/codegen/manifest.d.ts +0 -5
- package/build/plugin/codegen/render.d.ts +0 -7
- package/build/plugin/codegen/router.d.ts +0 -7
- package/build/plugin/codegen/typeRoot.d.ts +0 -5
- package/build/plugin/config.d.ts +0 -4
- package/build/plugin/dedent.d.ts +0 -1
- package/build/plugin/extract.d.ts +0 -6
- package/build/plugin/index.d.ts +0 -5
- package/build/plugin/state.d.ts +0 -3
- package/build/plugin/transform.d.ts +0 -6
- package/build/plugin/vite.d.ts +0 -27
- package/build/plugin-cjs/index.js +0 -90119
- package/build/plugin-cjs/package.json +0 -1
- package/build/plugin-esm/index.js +0 -90115
- package/build/runtime/client.d.ts +0 -3
- package/build/runtime/clientPlugin.d.ts +0 -3
- package/build/runtime/componentFields.d.ts +0 -9
- package/build/runtime/hooks/index.d.ts +0 -8
- package/build/runtime/hooks/useDeepCompareEffect.d.ts +0 -35
- package/build/runtime/hooks/useDocumentHandle.d.ts +0 -36
- package/build/runtime/hooks/useDocumentStore.d.ts +0 -11
- package/build/runtime/hooks/useDocumentSubscription.d.ts +0 -11
- package/build/runtime/hooks/useFragment.d.ts +0 -16
- package/build/runtime/hooks/useFragmentHandle.d.ts +0 -8
- package/build/runtime/hooks/useIsMounted.d.ts +0 -3
- package/build/runtime/hooks/useMutation.d.ts +0 -14
- package/build/runtime/hooks/useQuery.d.ts +0 -5
- package/build/runtime/hooks/useQueryHandle.d.ts +0 -10
- package/build/runtime/hooks/useSubscription.d.ts +0 -4
- package/build/runtime/hooks/useSubscriptionHandle.d.ts +0 -25
- package/build/runtime/index.d.ts +0 -14
- package/build/runtime/manifest.d.ts +0 -3
- package/build/runtime/routing/Router.d.ts +0 -62
- package/build/runtime/routing/cache.d.ts +0 -7
- package/build/runtime/routing/hooks.d.ts +0 -40
- package/build/runtime/routing/index.d.ts +0 -3
- package/build/runtime-cjs/client.d.ts +0 -3
- package/build/runtime-cjs/client.js +0 -25
- package/build/runtime-cjs/clientPlugin.d.ts +0 -3
- package/build/runtime-cjs/clientPlugin.js +0 -37
- package/build/runtime-cjs/componentFields.d.ts +0 -9
- package/build/runtime-cjs/componentFields.js +0 -83
- package/build/runtime-cjs/hooks/index.d.ts +0 -8
- package/build/runtime-cjs/hooks/index.js +0 -45
- package/build/runtime-cjs/hooks/useDeepCompareEffect.d.ts +0 -35
- package/build/runtime-cjs/hooks/useDeepCompareEffect.js +0 -76
- package/build/runtime-cjs/hooks/useDocumentHandle.d.ts +0 -36
- package/build/runtime-cjs/hooks/useDocumentHandle.js +0 -177
- package/build/runtime-cjs/hooks/useDocumentStore.d.ts +0 -11
- package/build/runtime-cjs/hooks/useDocumentStore.js +0 -76
- package/build/runtime-cjs/hooks/useDocumentSubscription.d.ts +0 -11
- package/build/runtime-cjs/hooks/useDocumentSubscription.js +0 -76
- package/build/runtime-cjs/hooks/useFragment.d.ts +0 -16
- package/build/runtime-cjs/hooks/useFragment.js +0 -102
- package/build/runtime-cjs/hooks/useFragmentHandle.d.ts +0 -8
- package/build/runtime-cjs/hooks/useFragmentHandle.js +0 -47
- package/build/runtime-cjs/hooks/useIsMounted.d.ts +0 -3
- package/build/runtime-cjs/hooks/useIsMounted.js +0 -38
- package/build/runtime-cjs/hooks/useMutation.d.ts +0 -14
- package/build/runtime-cjs/hooks/useMutation.js +0 -67
- package/build/runtime-cjs/hooks/useQuery.d.ts +0 -5
- package/build/runtime-cjs/hooks/useQuery.js +0 -32
- package/build/runtime-cjs/hooks/useQueryHandle.d.ts +0 -10
- package/build/runtime-cjs/hooks/useQueryHandle.js +0 -131
- package/build/runtime-cjs/hooks/useSubscription.d.ts +0 -4
- package/build/runtime-cjs/hooks/useSubscription.js +0 -32
- package/build/runtime-cjs/hooks/useSubscriptionHandle.d.ts +0 -25
- package/build/runtime-cjs/hooks/useSubscriptionHandle.js +0 -42
- package/build/runtime-cjs/index.d.ts +0 -14
- package/build/runtime-cjs/index.js +0 -88
- package/build/runtime-cjs/manifest.d.ts +0 -3
- package/build/runtime-cjs/manifest.js +0 -25
- package/build/runtime-cjs/package.json +0 -1
- package/build/runtime-cjs/routing/Router.d.ts +0 -62
- package/build/runtime-cjs/routing/Router.js +0 -540
- package/build/runtime-cjs/routing/cache.d.ts +0 -7
- package/build/runtime-cjs/routing/cache.js +0 -61
- package/build/runtime-cjs/routing/hooks.d.ts +0 -40
- package/build/runtime-cjs/routing/hooks.js +0 -93
- package/build/runtime-cjs/routing/index.d.ts +0 -3
- package/build/runtime-cjs/routing/index.js +0 -33
- package/build/runtime-esm/client.d.ts +0 -3
- package/build/runtime-esm/client.js +0 -5
- package/build/runtime-esm/clientPlugin.d.ts +0 -3
- package/build/runtime-esm/clientPlugin.js +0 -17
- package/build/runtime-esm/componentFields.d.ts +0 -9
- package/build/runtime-esm/componentFields.js +0 -59
- package/build/runtime-esm/hooks/index.d.ts +0 -8
- package/build/runtime-esm/hooks/index.js +0 -15
- package/build/runtime-esm/hooks/useDeepCompareEffect.d.ts +0 -35
- package/build/runtime-esm/hooks/useDeepCompareEffect.js +0 -41
- package/build/runtime-esm/hooks/useDocumentHandle.d.ts +0 -36
- package/build/runtime-esm/hooks/useDocumentHandle.js +0 -143
- package/build/runtime-esm/hooks/useDocumentStore.d.ts +0 -11
- package/build/runtime-esm/hooks/useDocumentStore.js +0 -42
- package/build/runtime-esm/hooks/useDocumentSubscription.d.ts +0 -11
- package/build/runtime-esm/hooks/useDocumentSubscription.js +0 -42
- package/build/runtime-esm/hooks/useFragment.d.ts +0 -16
- package/build/runtime-esm/hooks/useFragment.js +0 -67
- package/build/runtime-esm/hooks/useFragmentHandle.d.ts +0 -8
- package/build/runtime-esm/hooks/useFragmentHandle.js +0 -23
- package/build/runtime-esm/hooks/useIsMounted.d.ts +0 -3
- package/build/runtime-esm/hooks/useIsMounted.js +0 -14
- package/build/runtime-esm/hooks/useMutation.d.ts +0 -14
- package/build/runtime-esm/hooks/useMutation.js +0 -42
- package/build/runtime-esm/hooks/useQuery.d.ts +0 -5
- package/build/runtime-esm/hooks/useQuery.js +0 -8
- package/build/runtime-esm/hooks/useQueryHandle.d.ts +0 -10
- package/build/runtime-esm/hooks/useQueryHandle.js +0 -97
- package/build/runtime-esm/hooks/useSubscription.d.ts +0 -4
- package/build/runtime-esm/hooks/useSubscription.js +0 -8
- package/build/runtime-esm/hooks/useSubscriptionHandle.d.ts +0 -25
- package/build/runtime-esm/hooks/useSubscriptionHandle.js +0 -18
- package/build/runtime-esm/index.d.ts +0 -14
- package/build/runtime-esm/index.js +0 -48
- package/build/runtime-esm/manifest.d.ts +0 -3
- package/build/runtime-esm/manifest.js +0 -5
- package/build/runtime-esm/routing/Router.d.ts +0 -62
- package/build/runtime-esm/routing/Router.js +0 -499
- package/build/runtime-esm/routing/cache.d.ts +0 -7
- package/build/runtime-esm/routing/cache.js +0 -36
- package/build/runtime-esm/routing/hooks.d.ts +0 -40
- package/build/runtime-esm/routing/hooks.js +0 -53
- package/build/runtime-esm/routing/index.d.ts +0 -3
- package/build/runtime-esm/routing/index.js +0 -6
- package/build/server/index.d.ts +0 -1
- package/build/server-cjs/index.js +0 -28
- package/build/server-cjs/package.json +0 -1
- package/build/server-esm/index.js +0 -4
- package/build/server-esm/package.json +0 -1
- /package/{build/plugin-esm → server}/package.json +0 -0
- /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
|
+
}
|