houdini-react 2.0.2 → 2.1.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/package.json +8 -8
- package/postInstall.js +1 -1
- package/runtime/contexts.ts +30 -3
- package/runtime/hooks/useDocumentHandle.ts +11 -0
- package/runtime/index.tsx +1 -0
- package/runtime/routing/Router.tsx +458 -70
- package/runtime/routing/cache.ts +50 -4
- package/runtime/routing/errors.tsx +42 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "houdini-react",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "The React plugin for houdini",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -81,13 +81,13 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"optionalDependencies": {
|
|
84
|
-
"houdini-react-darwin-x64": "2.0
|
|
85
|
-
"houdini-react-darwin-arm64": "2.0
|
|
86
|
-
"houdini-react-linux-x64": "2.0
|
|
87
|
-
"houdini-react-linux-arm64": "2.0
|
|
88
|
-
"houdini-react-win32-x64": "2.0
|
|
89
|
-
"houdini-react-win32-arm64": "2.0
|
|
90
|
-
"houdini-react-wasm": "2.0
|
|
84
|
+
"houdini-react-darwin-x64": "2.1.0",
|
|
85
|
+
"houdini-react-darwin-arm64": "2.1.0",
|
|
86
|
+
"houdini-react-linux-x64": "2.1.0",
|
|
87
|
+
"houdini-react-linux-arm64": "2.1.0",
|
|
88
|
+
"houdini-react-win32-x64": "2.1.0",
|
|
89
|
+
"houdini-react-win32-arm64": "2.1.0",
|
|
90
|
+
"houdini-react-wasm": "2.1.0"
|
|
91
91
|
},
|
|
92
92
|
"scripts": {
|
|
93
93
|
"compile": "scripts build-go",
|
package/postInstall.js
CHANGED
|
@@ -5,7 +5,7 @@ const https = require('https')
|
|
|
5
5
|
const child_process = require('child_process')
|
|
6
6
|
|
|
7
7
|
// Adjust the version you want to install. You can also make this dynamic.
|
|
8
|
-
const BINARY_DISTRIBUTION_VERSION = '2.0
|
|
8
|
+
const BINARY_DISTRIBUTION_VERSION = '2.1.0'
|
|
9
9
|
|
|
10
10
|
// Windows binaries end with .exe so we need to special case them.
|
|
11
11
|
const binaryName = process.platform === 'win32' ? 'houdini-react.exe' : 'houdini-react'
|
package/runtime/contexts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GraphQLError } from 'houdini/runtime'
|
|
1
2
|
import { createContext } from 'react'
|
|
2
3
|
|
|
3
4
|
import type { Goto } from './routes.js'
|
|
@@ -42,11 +43,37 @@ export const LocationContext = createContext<{
|
|
|
42
43
|
|
|
43
44
|
export const Is404Context = createContext(false)
|
|
44
45
|
|
|
46
|
+
// PendingURLContext (internal) carries the raw navigation target. Unlike
|
|
47
|
+
// NavigationContext.to it is NOT nulled when the router considers itself idle, so it
|
|
48
|
+
// reads the same in every render lane — the transition lane renders with isPending
|
|
49
|
+
// false, which would hide the target from it. useQueryResult uses this to tell whether
|
|
50
|
+
// the render it is part of is the destination of an in-flight navigation (suspend on a
|
|
51
|
+
// missing store) or the still-visible previous page (keep rendering the store it has).
|
|
52
|
+
export const PendingURLContext = createContext<string | null>(null)
|
|
53
|
+
|
|
54
|
+
// NavigationContext carries the router's in-flight navigation, if any: `pending` is true
|
|
55
|
+
// from the moment a navigation starts until the destination renders its actual content
|
|
56
|
+
// (it stays true while the @loading state shows), and `to` is the destination url while
|
|
57
|
+
// pending. Read through useNavigation().
|
|
58
|
+
export const NavigationContext = createContext<{
|
|
59
|
+
pending: boolean
|
|
60
|
+
to: string | null
|
|
61
|
+
goto: Goto
|
|
62
|
+
}>({
|
|
63
|
+
pending: false,
|
|
64
|
+
to: null,
|
|
65
|
+
goto: () => {},
|
|
66
|
+
})
|
|
67
|
+
|
|
45
68
|
export const PageContext = createContext<{ params: Record<string, any> }>({ params: {} })
|
|
46
69
|
|
|
47
|
-
// Mutable ref passed from the server renderer
|
|
48
|
-
//
|
|
49
|
-
|
|
70
|
+
// Mutable ref passed from the server renderer. It carries the HTTP status/location for
|
|
71
|
+
// the response and, when the first SSR render pass threw (error boundaries don't run
|
|
72
|
+
// during SSR), the errors the boundary should render on the second pass.
|
|
73
|
+
export const StatusContext = createContext<{
|
|
74
|
+
status: number
|
|
75
|
+
errors?: Array<Error | GraphQLError>
|
|
76
|
+
} | null>(null)
|
|
50
77
|
|
|
51
78
|
// FormStatusContext carries the nearest <Form>'s pending state to useMutationFormStatus(),
|
|
52
79
|
// the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
|
|
@@ -103,6 +103,15 @@ export function useDocumentHandle<
|
|
|
103
103
|
...args?.variables,
|
|
104
104
|
},
|
|
105
105
|
session,
|
|
106
|
+
// handle-driven sends (pagination, manual refetch) must not republish the
|
|
107
|
+
// document's @loading state — the skeleton is for router-driven loads. The
|
|
108
|
+
// page keeps its current data while the request is in flight; callers track
|
|
109
|
+
// progress through loadNextPending/loadPreviousPending instead. (matches
|
|
110
|
+
// useQueryHandle/useFragmentHandle and the svelte pagination stores)
|
|
111
|
+
stuff: {
|
|
112
|
+
silenceLoading: true,
|
|
113
|
+
...(args as { stuff?: App.Stuff } | undefined)?.stuff,
|
|
114
|
+
},
|
|
106
115
|
})
|
|
107
116
|
}
|
|
108
117
|
|
|
@@ -141,6 +150,7 @@ export function useDocumentHandle<
|
|
|
141
150
|
applyUpdates: updates,
|
|
142
151
|
},
|
|
143
152
|
session,
|
|
153
|
+
stuff: { silenceLoading: true, ...args?.stuff },
|
|
144
154
|
})
|
|
145
155
|
},
|
|
146
156
|
getSession: async () => session,
|
|
@@ -172,6 +182,7 @@ export function useDocumentHandle<
|
|
|
172
182
|
applyUpdates: updates,
|
|
173
183
|
...args?.cacheParams,
|
|
174
184
|
},
|
|
185
|
+
stuff: { silenceLoading: true, ...args?.stuff },
|
|
175
186
|
})
|
|
176
187
|
},
|
|
177
188
|
getSession: async () => session,
|
package/runtime/index.tsx
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
RouterContextObject as Context,
|
|
18
18
|
LocationContext,
|
|
19
19
|
Is404Context,
|
|
20
|
+
NavigationContext,
|
|
20
21
|
PageContext,
|
|
22
|
+
PendingURLContext,
|
|
21
23
|
} from '../contexts.js'
|
|
22
24
|
import { escapeScriptTag } from '../escape.js'
|
|
23
25
|
import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
|
|
@@ -27,7 +29,11 @@ import { useDocumentStore } from '../hooks/useDocumentStore.js'
|
|
|
27
29
|
import { type SuspenseCache, suspense_cache } from './cache.js'
|
|
28
30
|
import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
|
|
29
31
|
|
|
30
|
-
type PageComponent = React.ComponentType<{
|
|
32
|
+
type PageComponent = React.ComponentType<{
|
|
33
|
+
url: string
|
|
34
|
+
showLoading?: boolean
|
|
35
|
+
children?: React.ReactNode
|
|
36
|
+
}>
|
|
31
37
|
|
|
32
38
|
const PreloadWhich = {
|
|
33
39
|
component: 'component',
|
|
@@ -37,6 +43,86 @@ const PreloadWhich = {
|
|
|
37
43
|
|
|
38
44
|
type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
|
|
39
45
|
type ComponentType = any
|
|
46
|
+
|
|
47
|
+
// useLoadingState decides whether to show the route's @loading state during navigation.
|
|
48
|
+
// `active` is the navigation transition's pending flag. The state flips on only once
|
|
49
|
+
// `active` has been pending for at least `loadingDelay` ms (so fast navigations never
|
|
50
|
+
// show it). Once shown it stays on until BOTH of these are true, so a response landing
|
|
51
|
+
// just after the delay doesn't cause a skeleton flicker:
|
|
52
|
+
// - it has been visible for at least `minDuration` ms
|
|
53
|
+
// - the target page's data has landed (`waitForData` resolves)
|
|
54
|
+
// A navigation that starts while the state is already showing re-arms the minDuration
|
|
55
|
+
// clock, so the new destination's data can't flash in right as the original hold expires
|
|
56
|
+
// — `target` (the destination url) is a dependency for exactly that reason: a second
|
|
57
|
+
// navigation can start without `active` ever flipping (the first transition is still
|
|
58
|
+
// pending), and the target change is what re-runs the effect.
|
|
59
|
+
// Note that `active` flips false as soon as the loading frame commits (the frame doesn't
|
|
60
|
+
// suspend, so the transition finishes with it on screen) — which is why the hide side
|
|
61
|
+
// waits on the data explicitly instead of trusting `active`.
|
|
62
|
+
function useLoadingState({
|
|
63
|
+
active,
|
|
64
|
+
target,
|
|
65
|
+
loadingDelay,
|
|
66
|
+
minDuration,
|
|
67
|
+
waitForData,
|
|
68
|
+
}: {
|
|
69
|
+
active: boolean
|
|
70
|
+
target: string | null
|
|
71
|
+
loadingDelay: number
|
|
72
|
+
minDuration: number
|
|
73
|
+
waitForData: () => Promise<void>
|
|
74
|
+
}): boolean {
|
|
75
|
+
const [show, setShow] = React.useState(false)
|
|
76
|
+
const shownAt = React.useRef<number | null>(null)
|
|
77
|
+
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
if (active) {
|
|
80
|
+
// already showing — a new navigation is starting while the loading state is
|
|
81
|
+
// up. Keep it up, but re-arm the minimum-duration clock: measured from the
|
|
82
|
+
// first show, the hold could expire right as this navigation's data lands,
|
|
83
|
+
// flashing the freshly-loaded content in and out of the loading state.
|
|
84
|
+
if (show) {
|
|
85
|
+
shownAt.current = performance.now()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
// wait out the delay; if we're still pending, switch the loading state on
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
shownAt.current = performance.now()
|
|
91
|
+
setShow(true)
|
|
92
|
+
}, loadingDelay)
|
|
93
|
+
return () => clearTimeout(timeout)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// navigation finished. if we never showed the loading state, there's nothing to do
|
|
97
|
+
if (!show) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
// otherwise wait for the page's data, then keep the loading state up until it has
|
|
101
|
+
// been visible for at least minDuration. waiting on the data here means the page
|
|
102
|
+
// never mounts with its query still missing (which would re-suspend it into the
|
|
103
|
+
// Suspense fallback and briefly double-render the frame).
|
|
104
|
+
let cancelled = false
|
|
105
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
106
|
+
waitForData().then(() => {
|
|
107
|
+
if (cancelled) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const elapsed = performance.now() - (shownAt.current ?? performance.now())
|
|
111
|
+
const remaining = Math.max(0, minDuration - elapsed)
|
|
112
|
+
timeout = setTimeout(() => {
|
|
113
|
+
shownAt.current = null
|
|
114
|
+
setShow(false)
|
|
115
|
+
}, remaining)
|
|
116
|
+
})
|
|
117
|
+
return () => {
|
|
118
|
+
cancelled = true
|
|
119
|
+
clearTimeout(timeout)
|
|
120
|
+
}
|
|
121
|
+
}, [active, show, target, loadingDelay, minDuration])
|
|
122
|
+
|
|
123
|
+
return show
|
|
124
|
+
}
|
|
125
|
+
|
|
40
126
|
/**
|
|
41
127
|
* Router is the top level entry point for the filesystem-based router.
|
|
42
128
|
* It is responsible for loading various page sources (including API fetches) and
|
|
@@ -64,6 +150,20 @@ export function Router({
|
|
|
64
150
|
return initialURL || window.location.pathname + window.location.search
|
|
65
151
|
})
|
|
66
152
|
|
|
153
|
+
// Navigation runs inside a transition so React keeps the previously-rendered route
|
|
154
|
+
// on screen while the next one loads (instead of immediately falling back to the
|
|
155
|
+
// loading state). If the transition stays pending longer than `loadingDelay`, we
|
|
156
|
+
// surface the route's @loading frame (see showLoading → the entry's page slot);
|
|
157
|
+
// fast navigations never show it, and once shown it stays up for `minDuration`.
|
|
158
|
+
const [isNavigating, startNavigation] = React.useTransition()
|
|
159
|
+
|
|
160
|
+
// pendingURL tracks the navigation target *urgently* (outside the transition), so a
|
|
161
|
+
// render that happens while the transition is still pending can tell whether it is
|
|
162
|
+
// looking at the destination (transition lane: currentURL === pendingURL) or at the
|
|
163
|
+
// still-committed previous route (urgent lane: currentURL lags behind). The loading
|
|
164
|
+
// frame is only ever shown on the destination — see the showLoading prop below.
|
|
165
|
+
const [pendingURL, setPendingURL] = React.useState<string | null>(null)
|
|
166
|
+
|
|
67
167
|
// find the matching page for the current route. find_match also hands back the parsed
|
|
68
168
|
// query string (declared search params coerced, UI-only keys raw). custom-scalar route
|
|
69
169
|
// and search params arrive in their url transport form, so we unmarshal them once here
|
|
@@ -97,8 +197,35 @@ export function Router({
|
|
|
97
197
|
injectToStream,
|
|
98
198
|
})
|
|
99
199
|
// if we get this far, it's safe to load the component
|
|
100
|
-
const { component_cache, data_cache, ssr_signals } = useRouterContext()
|
|
200
|
+
const { component_cache, data_cache, artifact_cache, ssr_signals } = useRouterContext()
|
|
101
201
|
const PageComponent = component_cache.get(targetPage.id)!
|
|
202
|
+
const [session] = useSession()
|
|
203
|
+
|
|
204
|
+
// decide whether the entry should render its @loading frame instead of the page.
|
|
205
|
+
// values come from the router config (bundled client-side; they're UI timing, not
|
|
206
|
+
// secrets), clamped so a bad value can't schedule a negative timeout.
|
|
207
|
+
// The machine only arms when the *destination* can actually render a frame (one of
|
|
208
|
+
// its documents is @loading): a frameless destination just holds the previous page
|
|
209
|
+
// for the whole transition, and arming the invisible state anyway would keep
|
|
210
|
+
// useNavigation().pending true for up to minDuration after the content committed.
|
|
211
|
+
const routerConfig = getCurrentConfig()?.router ?? {}
|
|
212
|
+
const [pendingPage] = pendingURL !== null ? find_match(manifest, pendingURL) : [null]
|
|
213
|
+
const destinationHasFrame = pendingPage
|
|
214
|
+
? Object.values(pendingPage.documents).some((document) => document.loading)
|
|
215
|
+
: false
|
|
216
|
+
const showLoading = useLoadingState({
|
|
217
|
+
active: isNavigating && destinationHasFrame,
|
|
218
|
+
target: pendingURL,
|
|
219
|
+
loadingDelay: Math.max(0, routerConfig.loadingDelay ?? 200),
|
|
220
|
+
minDuration: Math.max(0, routerConfig.minDuration ?? 400),
|
|
221
|
+
// resolves once every query of the page being rendered has a store in data_cache
|
|
222
|
+
// (including error stores — load_query seeds those too, so an errored query
|
|
223
|
+
// releases the loading state instead of pinning it)
|
|
224
|
+
waitForData: () =>
|
|
225
|
+
Promise.all(
|
|
226
|
+
Object.keys(targetPage.documents).map((name) => data_cache.waitFor(name))
|
|
227
|
+
).then(() => {}),
|
|
228
|
+
})
|
|
102
229
|
|
|
103
230
|
// if we got this far then we're past suspense
|
|
104
231
|
|
|
@@ -113,13 +240,96 @@ export function Router({
|
|
|
113
240
|
}
|
|
114
241
|
}, [currentURL])
|
|
115
242
|
|
|
243
|
+
// (dev only) hot-swap regenerated query artifacts. The router loads artifacts through
|
|
244
|
+
// dynamic import and keeps them — and the DocumentStores built from them — in caches
|
|
245
|
+
// keyed by document name, so vite's module-level HMR can never reach them: after
|
|
246
|
+
// codegen rewrites an artifact, the caches keep serving the old document and the page
|
|
247
|
+
// renders stale data. The compiler emits a custom event listing the changed artifact
|
|
248
|
+
// modules (see flushClientUpdates in houdini's vite plugin); re-import each one fresh,
|
|
249
|
+
// swap it into the artifact cache, evict the stale store, and reload the current
|
|
250
|
+
// page's data. useQueryResult keeps the committed store on screen while the
|
|
251
|
+
// replacement loads (the same keep-last-store path as an abandoned navigation), so
|
|
252
|
+
// the swap doesn't flash a loading state.
|
|
253
|
+
const [, forceRender] = React.useReducer((n: number) => n + 1, 0)
|
|
254
|
+
// the listener is registered once but must act on the latest render's page/variables
|
|
255
|
+
// (and loadData's closure over the current session)
|
|
256
|
+
const hmr = React.useRef({ page: targetPage, variables, loadData })
|
|
257
|
+
hmr.current = { page: targetPage, variables, loadData }
|
|
258
|
+
React.useEffect(() => {
|
|
259
|
+
// dev only: vite statically replaces both tokens in production builds
|
|
260
|
+
// (env.DEV → false, hot → undefined), so the whole listener is dead-code
|
|
261
|
+
// eliminated. The casts erase to the literal `import.meta.env.DEV` /
|
|
262
|
+
// `import.meta.hot` tokens — don't alias import.meta or add optional
|
|
263
|
+
// chaining, or the replacement (and vite's hot-context injection) stops
|
|
264
|
+
// matching.
|
|
265
|
+
if (!(import.meta as unknown as { env: { DEV?: boolean } }).env.DEV) {
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
const hot = (import.meta as { hot?: any }).hot
|
|
269
|
+
if (!hot) {
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
const onArtifactUpdate = async ({
|
|
273
|
+
artifacts,
|
|
274
|
+
}: {
|
|
275
|
+
artifacts: Array<{ name: string; url: string }>
|
|
276
|
+
}) => {
|
|
277
|
+
let stale = false
|
|
278
|
+
for (const { name, url } of artifacts) {
|
|
279
|
+
// only documents this client has loaded can be stale
|
|
280
|
+
if (!artifact_cache.has(name)) {
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
// bust the browser's module cache — re-importing the bare url would just
|
|
284
|
+
// return the module we already have
|
|
285
|
+
let artifact: QueryArtifact | undefined
|
|
286
|
+
try {
|
|
287
|
+
const bust = `${url.includes('?') ? '&' : '?'}t=${Date.now()}`
|
|
288
|
+
artifact = (await import(/* @vite-ignore */ `${url}${bust}`))?.default
|
|
289
|
+
} catch {
|
|
290
|
+
// the module failed to load (e.g. the document was deleted) — leave the
|
|
291
|
+
// caches alone rather than evicting a store we can't replace
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
if (!artifact) {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
artifact_cache.set(name, artifact)
|
|
298
|
+
// evict the store built from the old artifact so the reload below creates a
|
|
299
|
+
// replacement. deliberately no cleanup(): mounted components keep rendering
|
|
300
|
+
// the old store until the new one lands (see SuspenseCache's delete() note)
|
|
301
|
+
data_cache.delete(name)
|
|
302
|
+
ssr_signals.delete(name)
|
|
303
|
+
stale = true
|
|
304
|
+
}
|
|
305
|
+
if (!stale) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
const { page, variables, loadData } = hmr.current
|
|
309
|
+
// the reload honors each query's cache policy: an edit that doesn't change
|
|
310
|
+
// what the document asks for (or asks for data the cache already holds)
|
|
311
|
+
// resolves instantly from cache; anything the old selection didn't cover
|
|
312
|
+
// misses and goes to the network
|
|
313
|
+
loadData(page, variables)
|
|
314
|
+
// re-render so useQueryResult sees the eviction and subscribes to the
|
|
315
|
+
// replacement — deleting a cache entry doesn't notify anyone on its own
|
|
316
|
+
forceRender()
|
|
317
|
+
}
|
|
318
|
+
hot.on('houdini:artifact-update', onArtifactUpdate)
|
|
319
|
+
return () => hot.off?.('houdini:artifact-update', onArtifactUpdate)
|
|
320
|
+
}, [])
|
|
321
|
+
|
|
116
322
|
// when we first mount we should start listening to the back button
|
|
117
323
|
React.useEffect(() => {
|
|
118
324
|
if (!globalThis.window) {
|
|
119
325
|
return
|
|
120
326
|
}
|
|
121
327
|
const onChange = (_evt: PopStateEvent) => {
|
|
122
|
-
|
|
328
|
+
const url = window.location.pathname + window.location.search
|
|
329
|
+
setPendingURL(url)
|
|
330
|
+
startNavigation(() => {
|
|
331
|
+
setCurrentURL(url)
|
|
332
|
+
})
|
|
123
333
|
}
|
|
124
334
|
window.addEventListener('popstate', onChange)
|
|
125
335
|
return () => {
|
|
@@ -127,34 +337,72 @@ export function Router({
|
|
|
127
337
|
}
|
|
128
338
|
}, [])
|
|
129
339
|
|
|
340
|
+
// On navigation (but not the initial mount), re-fire the route's already-cached
|
|
341
|
+
// queries so each one's cache policy is honored (stale-while-revalidate). The
|
|
342
|
+
// observers are kept, so a query whose variables are unchanged and whose policy
|
|
343
|
+
// allows a cache read resolves without a loading frame; queries whose variables
|
|
344
|
+
// changed (or aren't cached) are evicted + reloaded by loadData with their loading
|
|
345
|
+
// state. We reuse the variables already unmarshaled for this render rather than
|
|
346
|
+
// re-parsing the URL. This also covers back/forward (popstate), which bypasses goto.
|
|
347
|
+
// The guard compares URLs (not a boolean) so Strict Mode's double effect invocation
|
|
348
|
+
// on mount doesn't slip past it and revalidate the initial page.
|
|
349
|
+
const lastRevalidatedURL = React.useRef(currentURL)
|
|
350
|
+
React.useEffect(() => {
|
|
351
|
+
if (lastRevalidatedURL.current === currentURL) {
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
lastRevalidatedURL.current = currentURL
|
|
355
|
+
for (const name of Object.keys(targetPage.documents)) {
|
|
356
|
+
if (data_cache.has(name)) {
|
|
357
|
+
data_cache.get(name).send({ variables, session })
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}, [currentURL])
|
|
361
|
+
|
|
130
362
|
// the function to call to navigate. accepts either a ready-made url string or a
|
|
131
363
|
// typed target { to, params, search } that is assembled (and custom scalars
|
|
132
364
|
// marshaled) exactly the way <Link> builds its href. The typed surface is the
|
|
133
365
|
// shared Goto contract; the implementation takes the loose runtime shape.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
366
|
+
// Referentially stable (everything it closes over is): it ships in the memoized
|
|
367
|
+
// NavigationContext value and anchors the link listener effect, so a new identity
|
|
368
|
+
// per render would re-render every useNavigation consumer and re-attach listeners.
|
|
369
|
+
const goto = React.useCallback(
|
|
370
|
+
(
|
|
371
|
+
target:
|
|
372
|
+
| string
|
|
373
|
+
| {
|
|
374
|
+
to: string
|
|
375
|
+
params?: Record<string, unknown>
|
|
376
|
+
search?: Record<string, unknown>
|
|
377
|
+
}
|
|
378
|
+
) => {
|
|
379
|
+
const url =
|
|
380
|
+
typeof target === 'string'
|
|
381
|
+
? target
|
|
382
|
+
: buildHref(
|
|
383
|
+
target.to,
|
|
384
|
+
manifest.pages[manifest.pagesByUrl[target.to]],
|
|
385
|
+
getCurrentConfig()?.scalars,
|
|
386
|
+
target.params,
|
|
387
|
+
target.search
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
// We intentionally don't blanket-clear the data cache on navigation (that would
|
|
391
|
+
// force every query back through its loading state). Observers and their data
|
|
392
|
+
// survive, and per-query revalidation is handled by the navigation effect above
|
|
393
|
+
// (which honors each query's cache policy). A real session change still clears
|
|
394
|
+
// the cache (updateSession / the session event listener).
|
|
395
|
+
|
|
396
|
+
// track the destination urgently (so in-flight renders can identify it) and
|
|
397
|
+
// perform the navigation inside a transition so React keeps the current route on
|
|
398
|
+
// screen until the next one is ready (or until showLoading swaps in the frame).
|
|
399
|
+
setPendingURL(url)
|
|
400
|
+
startNavigation(() => {
|
|
401
|
+
setCurrentURL(url)
|
|
402
|
+
})
|
|
403
|
+
},
|
|
404
|
+
[manifest]
|
|
405
|
+
) as Goto
|
|
158
406
|
|
|
159
407
|
// links are powered using anchor tags that we intercept and handle ourselves
|
|
160
408
|
useLinkBehavior({
|
|
@@ -189,31 +437,71 @@ export function Router({
|
|
|
189
437
|
},
|
|
190
438
|
})
|
|
191
439
|
|
|
192
|
-
//
|
|
440
|
+
// The loading frame only renders on the destination of the navigation: while a
|
|
441
|
+
// transition is pending, the committed tree still has the previous currentURL
|
|
442
|
+
// (pendingURL differs), so an urgent re-render of it — e.g. the showLoading flip —
|
|
443
|
+
// keeps showing the previous page instead of swapping it for its own frame. The
|
|
444
|
+
// destination (transition lane, where currentURL === pendingURL) renders the frame,
|
|
445
|
+
// which doesn't suspend, so the transition commits as soon as the rest of the entry
|
|
446
|
+
// (component, artifacts, layout data) is renderable.
|
|
447
|
+
const showFrame = showLoading && currentURL === pendingURL
|
|
448
|
+
|
|
449
|
+
// the public pending-navigation surface (useNavigation). A navigation counts as
|
|
450
|
+
// pending until the destination shows its actual content: the transition can commit
|
|
451
|
+
// with the @loading frame on screen (isNavigating flips false then), so the loading
|
|
452
|
+
// state extends it. Memoized so consumers only re-render when it actually changes.
|
|
453
|
+
const navigating = isNavigating || showLoading
|
|
454
|
+
const navigation = React.useMemo(
|
|
455
|
+
() => ({ pending: navigating, to: navigating ? pendingURL : null, goto }),
|
|
456
|
+
[navigating, pendingURL, goto]
|
|
457
|
+
)
|
|
458
|
+
|
|
193
459
|
// render the component embedded in the necessary context so it can orchestrate
|
|
194
460
|
// its needs
|
|
195
461
|
return (
|
|
196
|
-
<
|
|
197
|
-
value={
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
462
|
+
<PendingURLContext.Provider value={pendingURL}>
|
|
463
|
+
<NavigationContext.Provider value={navigation}>
|
|
464
|
+
<LocationContext.Provider
|
|
465
|
+
value={{
|
|
466
|
+
pathname: currentURL,
|
|
467
|
+
goto,
|
|
468
|
+
params: variables ?? {},
|
|
469
|
+
search,
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
<Is404Context.Provider value={is404}>
|
|
473
|
+
{is404 ? (
|
|
474
|
+
<NotFoundLayoutBoundary key={targetPage.id}>
|
|
475
|
+
<PageComponent
|
|
476
|
+
url={currentURL}
|
|
477
|
+
showLoading={showFrame}
|
|
478
|
+
key={targetPage.id + '__404'}
|
|
479
|
+
/>
|
|
480
|
+
</NotFoundLayoutBoundary>
|
|
481
|
+
) : (
|
|
482
|
+
<PageComponent
|
|
483
|
+
url={currentURL}
|
|
484
|
+
showLoading={showFrame}
|
|
485
|
+
key={targetPage.id}
|
|
486
|
+
/>
|
|
487
|
+
)}
|
|
488
|
+
</Is404Context.Provider>
|
|
489
|
+
</LocationContext.Provider>
|
|
490
|
+
</NavigationContext.Provider>
|
|
491
|
+
</PendingURLContext.Provider>
|
|
214
492
|
)
|
|
215
493
|
}
|
|
216
494
|
|
|
495
|
+
// useNavigation exposes the router's in-flight navigation. `pending` is true from the
|
|
496
|
+
// moment a navigation starts until the destination renders its actual content — it stays
|
|
497
|
+
// true while the destination's @loading state is showing — and `to` carries the
|
|
498
|
+
// destination url while pending (null when idle). It also carries `goto` — the same
|
|
499
|
+
// navigate function useRoute exposes — so navigation chrome (progress bars, nav menus,
|
|
500
|
+
// per-link spinners) only needs this one hook.
|
|
501
|
+
export function useNavigation(): { pending: boolean; to: string | null; goto: Goto } {
|
|
502
|
+
return useContext(NavigationContext)
|
|
503
|
+
}
|
|
504
|
+
|
|
217
505
|
// internal accessor for the raw location context. the public surface is useRoute, which
|
|
218
506
|
// layers the per-route param/search types on top of this. not re-exported from the package
|
|
219
507
|
// index, so it isn't part of the public API.
|
|
@@ -350,6 +638,12 @@ function usePageData({
|
|
|
350
638
|
`)
|
|
351
639
|
}
|
|
352
640
|
|
|
641
|
+
// remember which cache generation this send belongs to: if the cache is
|
|
642
|
+
// invalidated while the request is in flight (a session change clears it), the
|
|
643
|
+
// result was fetched under state that no longer applies and must not be
|
|
644
|
+
// re-inserted — the invalidation already triggered a fresh send
|
|
645
|
+
const generation = data_cache.generation
|
|
646
|
+
|
|
353
647
|
// store the observer immediately so useQueryResult can access it
|
|
354
648
|
// during SSR rendering before the fetch resolves
|
|
355
649
|
let resolve: () => void = () => {}
|
|
@@ -364,6 +658,14 @@ function usePageData({
|
|
|
364
658
|
session,
|
|
365
659
|
})
|
|
366
660
|
.then(async () => {
|
|
661
|
+
// a stale-generation result: release the signal so nothing hangs, but
|
|
662
|
+
// don't put the observer (or its stream scripts) anywhere
|
|
663
|
+
if (data_cache.generation !== generation) {
|
|
664
|
+
ssr_signals.delete(id)
|
|
665
|
+
resolve()
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
|
|
367
669
|
data_cache.set(id, observer)
|
|
368
670
|
|
|
369
671
|
// if there is an error, stream it to the client (so an @loading query
|
|
@@ -490,6 +792,11 @@ function usePageData({
|
|
|
490
792
|
if (err?.name === 'AbortError') {
|
|
491
793
|
return
|
|
492
794
|
}
|
|
795
|
+
// same stale-generation rule as the success path
|
|
796
|
+
if (data_cache.generation !== generation) {
|
|
797
|
+
resolve()
|
|
798
|
+
return
|
|
799
|
+
}
|
|
493
800
|
// a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
|
|
494
801
|
// seed it as a synthetic GraphQL error on the store and stream it to the client,
|
|
495
802
|
// otherwise an @loading query that rejects hangs on the loading state
|
|
@@ -643,16 +950,26 @@ export function RouterContextProvider({
|
|
|
643
950
|
// if we detect an event that contains a new session value. The detail carries the subtree
|
|
644
951
|
// and whether to merge it into the current session (an @session(merge:) upsert) or replace
|
|
645
952
|
// it wholesale; a legacy plain-session detail is treated as a replace.
|
|
646
|
-
const handleNewSession = React.useCallback(
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
953
|
+
const handleNewSession = React.useCallback(
|
|
954
|
+
(event: Event) => {
|
|
955
|
+
const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
|
|
956
|
+
const isWrapped =
|
|
957
|
+
detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
|
|
958
|
+
const next = (
|
|
959
|
+
isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
|
|
960
|
+
) as App.Session
|
|
961
|
+
const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
|
|
962
|
+
|
|
963
|
+
// a new session invalidates every cached query result, exactly like updateSession():
|
|
964
|
+
// navigation no longer clears the data cache, so without this an event-driven
|
|
965
|
+
// session change (updateLocalSession) would keep serving results fetched under the
|
|
966
|
+
// old session
|
|
967
|
+
invalidate_session_caches({ data_cache, ssr_signals, last_variables })
|
|
968
|
+
|
|
969
|
+
setSession((prev) => (merge ? { ...prev, ...next } : next))
|
|
970
|
+
},
|
|
971
|
+
[data_cache, ssr_signals, last_variables]
|
|
972
|
+
)
|
|
656
973
|
|
|
657
974
|
React.useEffect(() => {
|
|
658
975
|
window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
|
|
@@ -778,6 +1095,21 @@ export function updateLocalSession(session: App.Session, merge = false) {
|
|
|
778
1095
|
)
|
|
779
1096
|
}
|
|
780
1097
|
|
|
1098
|
+
// invalidate_session_caches drops everything derived from the previous session: query
|
|
1099
|
+
// results, in-flight signal bookkeeping, and the last-sent variables (so useQueryResult's
|
|
1100
|
+
// keep-last-store check can't match a store fetched under the old session — the page
|
|
1101
|
+
// suspends into its loading state and refetches). One helper shared by both session-change
|
|
1102
|
+
// paths (updateSession and the HOUDINI_SESSION_EVENT listener) so they can't drift.
|
|
1103
|
+
function invalidate_session_caches(caches: {
|
|
1104
|
+
data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
1105
|
+
ssr_signals: PendingCache
|
|
1106
|
+
last_variables: LRUCache<GraphQLVariables>
|
|
1107
|
+
}) {
|
|
1108
|
+
caches.data_cache.clear()
|
|
1109
|
+
caches.ssr_signals.clear()
|
|
1110
|
+
caches.last_variables.clear()
|
|
1111
|
+
}
|
|
1112
|
+
|
|
781
1113
|
export function useSession(): [
|
|
782
1114
|
App.Session,
|
|
783
1115
|
(newSession: Partial<App.Session> | null) => Promise<void>,
|
|
@@ -790,9 +1122,8 @@ export function useSession(): [
|
|
|
790
1122
|
// log out — clearing the local session and deleting the cookie. It's awaitable so callers
|
|
791
1123
|
// can wait for the cookie to settle before navigating.
|
|
792
1124
|
const updateSession = async (newSession: Partial<App.Session> | null) => {
|
|
793
|
-
//
|
|
794
|
-
ctx
|
|
795
|
-
ctx.ssr_signals.clear()
|
|
1125
|
+
// drop everything derived from the previous session so queries refetch with the new one
|
|
1126
|
+
invalidate_session_caches(ctx)
|
|
796
1127
|
|
|
797
1128
|
if (newSession === null) {
|
|
798
1129
|
ctx.clearSession()
|
|
@@ -817,10 +1148,62 @@ export function useQueryResult<_Data extends GraphQLObject, _Input extends Graph
|
|
|
817
1148
|
name: string
|
|
818
1149
|
): [_Data | null, DocumentHandle<any, _Data, _Input>] {
|
|
819
1150
|
// pull the global context values
|
|
820
|
-
const { data_cache, artifact_cache } = useRouterContext()
|
|
1151
|
+
const { data_cache, artifact_cache, last_variables } = useRouterContext()
|
|
1152
|
+
const { pathname } = useLocationContext()
|
|
1153
|
+
const pendingURL = React.useContext(PendingURLContext)
|
|
1154
|
+
// the store (and the router-level variables it was sent with) from this component's
|
|
1155
|
+
// last COMMIT. Written in an effect, never during render: the ref object is shared
|
|
1156
|
+
// between a fiber and its work-in-progress alternate, so a render-phase write from a
|
|
1157
|
+
// transition lane that never commits would leak the wrong store into the committed
|
|
1158
|
+
// tree's next render.
|
|
1159
|
+
const last_store = React.useRef<DocumentStore<_Data, _Input> | null>(null)
|
|
1160
|
+
const last_vars = React.useRef<GraphQLVariables | null>(null)
|
|
1161
|
+
const [, bumpStore] = React.useReducer((n: number) => n + 1, 0)
|
|
1162
|
+
|
|
1163
|
+
// Decide which store to render. data_cache.get suspends when the store is missing —
|
|
1164
|
+
// the right behavior for a first render or for the navigation destination (a
|
|
1165
|
+
// transition waits on it, or the page's @loading boundary catches it). But the router
|
|
1166
|
+
// evicts a document mid-navigation to reload it with new variables, and a re-render
|
|
1167
|
+
// of the still-visible previous page must NOT re-suspend into its own loading state —
|
|
1168
|
+
// it keeps rendering the store from its last commit and the in-flight navigation
|
|
1169
|
+
// swaps the tree when the replacement resolves. Two cases keep the last store:
|
|
1170
|
+
// - the render's URL lags the navigation target (pendingURL is lane-independent —
|
|
1171
|
+
// see PendingURLContext). A lagging render always prefers its committed store,
|
|
1172
|
+
// even when the cache already holds a replacement for different variables.
|
|
1173
|
+
// - the store is missing but the router's current variables for this document
|
|
1174
|
+
// still match the ones the committed store was sent with: the eviction belongs
|
|
1175
|
+
// to a navigation that was abandoned (e.g. goto back to the committed URL), so
|
|
1176
|
+
// this render should keep its data while the document reloads.
|
|
1177
|
+
// The bump effect re-renders once a replacement lands, so a kept-stale commit always
|
|
1178
|
+
// converges to the fresh store.
|
|
1179
|
+
const missing = !data_cache.has(name)
|
|
1180
|
+
const lagging = pendingURL !== null && pathname !== pendingURL
|
|
1181
|
+
const matches_render =
|
|
1182
|
+
last_vars.current !== null && deepEquals(last_vars.current, last_variables.get(name))
|
|
1183
|
+
const store_ref =
|
|
1184
|
+
last_store.current && (lagging || (missing && matches_render))
|
|
1185
|
+
? last_store.current
|
|
1186
|
+
: (data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>)
|
|
821
1187
|
|
|
822
|
-
|
|
823
|
-
|
|
1188
|
+
React.useEffect(() => {
|
|
1189
|
+
last_store.current = store_ref
|
|
1190
|
+
last_vars.current = (last_variables.get(name) as GraphQLVariables | null) ?? null
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
React.useEffect(() => {
|
|
1194
|
+
if (!missing) {
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
let cancelled = false
|
|
1198
|
+
data_cache.waitFor(name).then(() => {
|
|
1199
|
+
if (!cancelled) {
|
|
1200
|
+
bumpStore()
|
|
1201
|
+
}
|
|
1202
|
+
})
|
|
1203
|
+
return () => {
|
|
1204
|
+
cancelled = true
|
|
1205
|
+
}
|
|
1206
|
+
}, [missing, name])
|
|
824
1207
|
|
|
825
1208
|
// get the live data from the store
|
|
826
1209
|
const [storeValue, observer] = useDocumentStore<_Data, _Input>({
|
|
@@ -867,9 +1250,6 @@ function useLinkBehavior({
|
|
|
867
1250
|
}
|
|
868
1251
|
|
|
869
1252
|
function useLinkNavigation({ goto }: { goto: Goto }) {
|
|
870
|
-
// navigations need to be registered as transitions
|
|
871
|
-
const [_pending, startTransition] = React.useTransition()
|
|
872
|
-
|
|
873
1253
|
React.useEffect(() => {
|
|
874
1254
|
const onClick: HTMLAnchorElement['onclick'] = (e) => {
|
|
875
1255
|
if (!e.target) {
|
|
@@ -918,10 +1298,11 @@ function useLinkNavigation({ goto }: { goto: Goto }) {
|
|
|
918
1298
|
e.preventDefault()
|
|
919
1299
|
e.stopPropagation()
|
|
920
1300
|
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1301
|
+
// goto() runs the URL update in its own transition and tracks the pending
|
|
1302
|
+
// destination urgently. Don't wrap the call in another transition here: that
|
|
1303
|
+
// would drag the urgent bookkeeping (pendingURL) into the transition lane,
|
|
1304
|
+
// and the committed tree couldn't tell where the navigation is headed.
|
|
1305
|
+
goto(target)
|
|
925
1306
|
}
|
|
926
1307
|
|
|
927
1308
|
window.addEventListener('click', onClick)
|
|
@@ -1016,7 +1397,14 @@ export function router_cache({
|
|
|
1016
1397
|
const result: RouterCache = {
|
|
1017
1398
|
artifact_cache: suspense_cache(initialArtifacts),
|
|
1018
1399
|
component_cache: suspense_cache(),
|
|
1019
|
-
|
|
1400
|
+
// observers accumulate across navigations now that goto doesn't clear the cache,
|
|
1401
|
+
// so the LRU capacity limit is reachable on long sessions. When it silently evicts
|
|
1402
|
+
// a store, let the store's plugins release whatever they hold. Best-effort: a
|
|
1403
|
+
// cleanup that rejects must not become an unhandled rejection (which kills a node
|
|
1404
|
+
// process outright).
|
|
1405
|
+
data_cache: suspense_cache(initialData, (store) => {
|
|
1406
|
+
store.cleanup().catch(() => {})
|
|
1407
|
+
}),
|
|
1020
1408
|
ssr_signals: suspense_cache(),
|
|
1021
1409
|
last_variables: suspense_cache(),
|
|
1022
1410
|
}
|
package/runtime/routing/cache.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// is thrown that resolves when a value is passed to set()
|
|
4
4
|
import { LRUCache } from 'houdini/runtime'
|
|
5
5
|
|
|
6
|
-
export function suspense_cache<T>(
|
|
7
|
-
|
|
6
|
+
export function suspense_cache<T>(
|
|
7
|
+
initialData?: Record<string, T>,
|
|
8
|
+
dispose?: (value: T) => void
|
|
9
|
+
): SuspenseCache<T> {
|
|
10
|
+
const cache = new SuspenseCache<T>(dispose)
|
|
8
11
|
|
|
9
12
|
for (const [key, value] of Object.entries(initialData ?? {})) {
|
|
10
13
|
cache.set(key, value)
|
|
@@ -18,6 +21,26 @@ export class SuspenseCache<_Data> extends LRUCache<_Data> {
|
|
|
18
21
|
// that means we need a place to put our callbacks
|
|
19
22
|
#callbacks: Map<string, { resolve: () => void; reject: () => void }[]> = new Map()
|
|
20
23
|
|
|
24
|
+
// called when a value is silently evicted by the LRU capacity limit so it can
|
|
25
|
+
// release any resources it holds (e.g. a DocumentStore's plugin state). Explicit
|
|
26
|
+
// delete()/clear() don't dispose: their values may still be rendered by a mounted
|
|
27
|
+
// component that is about to re-suspend and receive a replacement.
|
|
28
|
+
#dispose?: (value: _Data) => void
|
|
29
|
+
|
|
30
|
+
constructor(dispose?: (value: _Data) => void) {
|
|
31
|
+
super()
|
|
32
|
+
this.#dispose = dispose
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// bumped by clear() so async work started before an invalidation can tell its results
|
|
36
|
+
// are stale (see load_query, which drops a completed send from a previous generation
|
|
37
|
+
// instead of re-inserting data fetched under an old session)
|
|
38
|
+
#generation = 0
|
|
39
|
+
|
|
40
|
+
get generation(): number {
|
|
41
|
+
return this.#generation
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
get(key: string): _Data {
|
|
22
45
|
// if there is a value, use that
|
|
23
46
|
if (super.has(key)) {
|
|
@@ -26,17 +49,40 @@ export class SuspenseCache<_Data> extends LRUCache<_Data> {
|
|
|
26
49
|
|
|
27
50
|
// we don't have a value, so we need to throw a promise
|
|
28
51
|
// that resolves when a value is passed to set()
|
|
29
|
-
throw
|
|
52
|
+
throw this.waitFor(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// waitFor resolves when the key holds a value — immediately if it already does,
|
|
56
|
+
// otherwise when set() is next called for it. The non-throwing counterpart to get()
|
|
57
|
+
// for code that isn't rendering (so it can't suspend).
|
|
58
|
+
waitFor(key: string): Promise<void> {
|
|
59
|
+
if (super.has(key)) {
|
|
60
|
+
return Promise.resolve()
|
|
61
|
+
}
|
|
62
|
+
return new Promise<void>((resolve, reject) => {
|
|
30
63
|
this.#subscribe(key, resolve, reject)
|
|
31
64
|
})
|
|
32
65
|
}
|
|
33
66
|
|
|
34
67
|
override clear() {
|
|
35
68
|
super.clear()
|
|
36
|
-
this.#
|
|
69
|
+
this.#generation++
|
|
70
|
+
// deliberately NOT clearing #callbacks: a waitFor()/get() subscriber is waiting
|
|
71
|
+
// for the key to hold a value, and after an invalidation the router refetches and
|
|
72
|
+
// set()s it again — the pre-clear subscribers must resolve then (with the fresh
|
|
73
|
+
// value) instead of hanging forever on a promise nothing can settle.
|
|
37
74
|
}
|
|
38
75
|
|
|
39
76
|
set(key: string, value: _Data) {
|
|
77
|
+
// inserting a new key at capacity silently evicts the least-recently-used entry —
|
|
78
|
+
// hand it to the dispose hook first so it can clean up after itself
|
|
79
|
+
if (this.#dispose && !super.has(key) && this.size() >= this._capacity) {
|
|
80
|
+
const oldest = this._map.keys().next()
|
|
81
|
+
if (!oldest.done) {
|
|
82
|
+
this.#dispose(this._map.get(oldest.value)!)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
40
86
|
// perform the set like normal
|
|
41
87
|
super.set(key, value)
|
|
42
88
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GraphQLError } from 'houdini/runtime'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
|
-
import { StatusContext } from '../contexts.js'
|
|
4
|
+
import { LocationContext, StatusContext } from '../contexts.js'
|
|
5
5
|
|
|
6
6
|
export class GraphQLErrors extends Error {
|
|
7
7
|
graphqlErrors: GraphQLError[]
|
|
@@ -90,33 +90,67 @@ type HoudiniErrorBoundaryProps = {
|
|
|
90
90
|
type HoudiniErrorBoundaryState = {
|
|
91
91
|
hasError: boolean
|
|
92
92
|
errors: Array<Error | GraphQLError>
|
|
93
|
+
// the resetKey (current pathname) the boundary last rendered for — a caught error is
|
|
94
|
+
// cleared when it changes (see getDerivedStateFromProps)
|
|
95
|
+
resetKey: string
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
// HoudiniErrorBoundary wraps the class boundary so it can read the current pathname:
|
|
99
|
+
// route boundaries persist across same-route navigations (they aren't remounted per URL
|
|
100
|
+
// anymore), so the boundary has to clear a caught error itself when the URL changes —
|
|
101
|
+
// otherwise navigating away from an errored page would keep rendering the error view.
|
|
102
|
+
export function HoudiniErrorBoundary(props: HoudiniErrorBoundaryProps) {
|
|
103
|
+
const { pathname } = React.useContext(LocationContext)
|
|
104
|
+
return <ErrorBoundary resetKey={pathname} {...props} />
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class ErrorBoundary extends React.Component<
|
|
108
|
+
HoudiniErrorBoundaryProps & { resetKey: string },
|
|
97
109
|
HoudiniErrorBoundaryState
|
|
98
110
|
> {
|
|
99
111
|
static contextType = StatusContext
|
|
100
112
|
declare context: React.ContextType<typeof StatusContext>
|
|
101
113
|
|
|
102
114
|
constructor(
|
|
103
|
-
props: HoudiniErrorBoundaryProps,
|
|
115
|
+
props: HoudiniErrorBoundaryProps & { resetKey: string },
|
|
104
116
|
context: React.ContextType<typeof StatusContext>
|
|
105
117
|
) {
|
|
106
118
|
super(props, context)
|
|
107
119
|
// Second-pass SSR: statusRef is pre-set to an error status by on_render after the first
|
|
108
|
-
// render threw. Start in error state immediately so children never render (and never
|
|
120
|
+
// render threw. Start in error state immediately so children never render (and never
|
|
121
|
+
// throw). The ref carries the actual thrown errors when it has them (e.g. GraphQL
|
|
122
|
+
// errors from a query); a bare status (a routing error / unmatched URL) renders as a
|
|
123
|
+
// RoutingError so the view can branch on the status code.
|
|
109
124
|
if (typeof window === 'undefined' && context && context.status >= 400) {
|
|
110
125
|
this.state = {
|
|
111
126
|
hasError: true,
|
|
112
|
-
errors: [new RoutingError(context.status)],
|
|
127
|
+
errors: context.errors ?? [new RoutingError(context.status)],
|
|
128
|
+
resetKey: props.resetKey,
|
|
113
129
|
}
|
|
114
130
|
} else {
|
|
115
|
-
this.state = { hasError: false, errors: [] }
|
|
131
|
+
this.state = { hasError: false, errors: [], resetKey: props.resetKey }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// a navigation is a retry: clear a caught error the moment a render arrives for a new
|
|
136
|
+
// URL. Doing this in derived state (not componentDidUpdate + setState) matters: the
|
|
137
|
+
// reset happens inside whatever render is already in flight, so when the router
|
|
138
|
+
// navigates away from an errored route the children render — and suspend — inside the
|
|
139
|
+
// router's own transition. isNavigating/showLoading account for the wait, the error
|
|
140
|
+
// view stays on screen until the destination is renderable, and the destination's
|
|
141
|
+
// @loading frame can show. If the retry throws again the boundary re-catches, and
|
|
142
|
+
// since resetKey is unchanged then, it can't loop.
|
|
143
|
+
static getDerivedStateFromProps(
|
|
144
|
+
props: HoudiniErrorBoundaryProps & { resetKey: string },
|
|
145
|
+
state: HoudiniErrorBoundaryState
|
|
146
|
+
): Partial<HoudiniErrorBoundaryState> | null {
|
|
147
|
+
if (props.resetKey !== state.resetKey) {
|
|
148
|
+
return { hasError: false, errors: [], resetKey: props.resetKey }
|
|
116
149
|
}
|
|
150
|
+
return null
|
|
117
151
|
}
|
|
118
152
|
|
|
119
|
-
static getDerivedStateFromError(error: unknown): HoudiniErrorBoundaryState {
|
|
153
|
+
static getDerivedStateFromError(error: unknown): Partial<HoudiniErrorBoundaryState> {
|
|
120
154
|
if (error instanceof GraphQLErrors) {
|
|
121
155
|
return { hasError: true, errors: error.graphqlErrors }
|
|
122
156
|
}
|
|
@@ -126,17 +160,6 @@ export class HoudiniErrorBoundary extends React.Component<
|
|
|
126
160
|
}
|
|
127
161
|
}
|
|
128
162
|
|
|
129
|
-
componentDidCatch(error: Error): void {
|
|
130
|
-
if (this.context) {
|
|
131
|
-
if (error instanceof RoutingError) {
|
|
132
|
-
this.context.status = error.status
|
|
133
|
-
} else if (error instanceof RedirectError) {
|
|
134
|
-
this.context.status = error.status
|
|
135
|
-
this.context.location = error.location
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
163
|
render() {
|
|
141
164
|
if (this.state.hasError) {
|
|
142
165
|
const ErrorView = this.props.errorView
|