houdini-react 2.0.2 → 2.1.1
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 +9 -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 +503 -85
- package/runtime/routing/cache.ts +50 -4
- package/runtime/routing/errors.tsx +42 -19
- package/vite/index.js +30 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "houdini-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "The React plugin for houdini",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"estraverse": "^5.3.0",
|
|
35
35
|
"express": "^5.1.0",
|
|
36
36
|
"graphql-yoga": "^5.21.1",
|
|
37
|
+
"mrmime": "^2.0.1",
|
|
37
38
|
"react": "^19.2.7",
|
|
38
39
|
"react-dom": "^19.2.7",
|
|
39
40
|
"react-streaming": "^0.4.17",
|
|
@@ -81,13 +82,13 @@
|
|
|
81
82
|
}
|
|
82
83
|
},
|
|
83
84
|
"optionalDependencies": {
|
|
84
|
-
"houdini-react-darwin-x64": "2.
|
|
85
|
-
"houdini-react-darwin-arm64": "2.
|
|
86
|
-
"houdini-react-linux-x64": "2.
|
|
87
|
-
"houdini-react-linux-arm64": "2.
|
|
88
|
-
"houdini-react-win32-x64": "2.
|
|
89
|
-
"houdini-react-win32-arm64": "2.
|
|
90
|
-
"houdini-react-wasm": "2.
|
|
85
|
+
"houdini-react-darwin-x64": "2.1.1",
|
|
86
|
+
"houdini-react-darwin-arm64": "2.1.1",
|
|
87
|
+
"houdini-react-linux-x64": "2.1.1",
|
|
88
|
+
"houdini-react-linux-arm64": "2.1.1",
|
|
89
|
+
"houdini-react-win32-x64": "2.1.1",
|
|
90
|
+
"houdini-react-win32-arm64": "2.1.1",
|
|
91
|
+
"houdini-react-wasm": "2.1.1"
|
|
91
92
|
},
|
|
92
93
|
"scripts": {
|
|
93
94
|
"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.
|
|
8
|
+
const BINARY_DISTRIBUTION_VERSION = '2.1.1'
|
|
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
|
@@ -4,8 +4,7 @@ import type { Cache } from 'houdini/runtime/cache'
|
|
|
4
4
|
import type { DocumentStore, HoudiniClient } from 'houdini/runtime/client'
|
|
5
5
|
import { getCurrentConfig } from '$houdini/runtime'
|
|
6
6
|
import configFile from '$houdini/runtime/imports/config'
|
|
7
|
-
import { deepEquals } from 'houdini/runtime'
|
|
8
|
-
import type { LRUCache } from 'houdini/runtime'
|
|
7
|
+
import { deepEquals, LRUCache } from 'houdini/runtime'
|
|
9
8
|
import { marshalSelection, marshalInputs, getAuthUrl, HOUDINI_SESSION_EVENT } from 'houdini/runtime'
|
|
10
9
|
import type { HoudiniSessionEventDetail } from 'houdini/runtime'
|
|
11
10
|
import { find_match, find_prefix_match } from 'houdini/router/match'
|
|
@@ -17,7 +16,9 @@ import {
|
|
|
17
16
|
RouterContextObject as Context,
|
|
18
17
|
LocationContext,
|
|
19
18
|
Is404Context,
|
|
19
|
+
NavigationContext,
|
|
20
20
|
PageContext,
|
|
21
|
+
PendingURLContext,
|
|
21
22
|
} from '../contexts.js'
|
|
22
23
|
import { escapeScriptTag } from '../escape.js'
|
|
23
24
|
import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
|
|
@@ -27,7 +28,11 @@ import { useDocumentStore } from '../hooks/useDocumentStore.js'
|
|
|
27
28
|
import { type SuspenseCache, suspense_cache } from './cache.js'
|
|
28
29
|
import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
|
|
29
30
|
|
|
30
|
-
type PageComponent = React.ComponentType<{
|
|
31
|
+
type PageComponent = React.ComponentType<{
|
|
32
|
+
url: string
|
|
33
|
+
showLoading?: boolean
|
|
34
|
+
children?: React.ReactNode
|
|
35
|
+
}>
|
|
31
36
|
|
|
32
37
|
const PreloadWhich = {
|
|
33
38
|
component: 'component',
|
|
@@ -37,6 +42,86 @@ const PreloadWhich = {
|
|
|
37
42
|
|
|
38
43
|
type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
|
|
39
44
|
type ComponentType = any
|
|
45
|
+
|
|
46
|
+
// useLoadingState decides whether to show the route's @loading state during navigation.
|
|
47
|
+
// `active` is the navigation transition's pending flag. The state flips on only once
|
|
48
|
+
// `active` has been pending for at least `loadingDelay` ms (so fast navigations never
|
|
49
|
+
// show it). Once shown it stays on until BOTH of these are true, so a response landing
|
|
50
|
+
// just after the delay doesn't cause a skeleton flicker:
|
|
51
|
+
// - it has been visible for at least `minDuration` ms
|
|
52
|
+
// - the target page's data has landed (`waitForData` resolves)
|
|
53
|
+
// A navigation that starts while the state is already showing re-arms the minDuration
|
|
54
|
+
// clock, so the new destination's data can't flash in right as the original hold expires
|
|
55
|
+
// — `target` (the destination url) is a dependency for exactly that reason: a second
|
|
56
|
+
// navigation can start without `active` ever flipping (the first transition is still
|
|
57
|
+
// pending), and the target change is what re-runs the effect.
|
|
58
|
+
// Note that `active` flips false as soon as the loading frame commits (the frame doesn't
|
|
59
|
+
// suspend, so the transition finishes with it on screen) — which is why the hide side
|
|
60
|
+
// waits on the data explicitly instead of trusting `active`.
|
|
61
|
+
function useLoadingState({
|
|
62
|
+
active,
|
|
63
|
+
target,
|
|
64
|
+
loadingDelay,
|
|
65
|
+
minDuration,
|
|
66
|
+
waitForData,
|
|
67
|
+
}: {
|
|
68
|
+
active: boolean
|
|
69
|
+
target: string | null
|
|
70
|
+
loadingDelay: number
|
|
71
|
+
minDuration: number
|
|
72
|
+
waitForData: () => Promise<void>
|
|
73
|
+
}): boolean {
|
|
74
|
+
const [show, setShow] = React.useState(false)
|
|
75
|
+
const shownAt = React.useRef<number | null>(null)
|
|
76
|
+
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (active) {
|
|
79
|
+
// already showing — a new navigation is starting while the loading state is
|
|
80
|
+
// up. Keep it up, but re-arm the minimum-duration clock: measured from the
|
|
81
|
+
// first show, the hold could expire right as this navigation's data lands,
|
|
82
|
+
// flashing the freshly-loaded content in and out of the loading state.
|
|
83
|
+
if (show) {
|
|
84
|
+
shownAt.current = performance.now()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// wait out the delay; if we're still pending, switch the loading state on
|
|
88
|
+
const timeout = setTimeout(() => {
|
|
89
|
+
shownAt.current = performance.now()
|
|
90
|
+
setShow(true)
|
|
91
|
+
}, loadingDelay)
|
|
92
|
+
return () => clearTimeout(timeout)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// navigation finished. if we never showed the loading state, there's nothing to do
|
|
96
|
+
if (!show) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
// otherwise wait for the page's data, then keep the loading state up until it has
|
|
100
|
+
// been visible for at least minDuration. waiting on the data here means the page
|
|
101
|
+
// never mounts with its query still missing (which would re-suspend it into the
|
|
102
|
+
// Suspense fallback and briefly double-render the frame).
|
|
103
|
+
let cancelled = false
|
|
104
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
105
|
+
waitForData().then(() => {
|
|
106
|
+
if (cancelled) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const elapsed = performance.now() - (shownAt.current ?? performance.now())
|
|
110
|
+
const remaining = Math.max(0, minDuration - elapsed)
|
|
111
|
+
timeout = setTimeout(() => {
|
|
112
|
+
shownAt.current = null
|
|
113
|
+
setShow(false)
|
|
114
|
+
}, remaining)
|
|
115
|
+
})
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true
|
|
118
|
+
clearTimeout(timeout)
|
|
119
|
+
}
|
|
120
|
+
}, [active, show, target, loadingDelay, minDuration])
|
|
121
|
+
|
|
122
|
+
return show
|
|
123
|
+
}
|
|
124
|
+
|
|
40
125
|
/**
|
|
41
126
|
* Router is the top level entry point for the filesystem-based router.
|
|
42
127
|
* It is responsible for loading various page sources (including API fetches) and
|
|
@@ -64,6 +149,20 @@ export function Router({
|
|
|
64
149
|
return initialURL || window.location.pathname + window.location.search
|
|
65
150
|
})
|
|
66
151
|
|
|
152
|
+
// Navigation runs inside a transition so React keeps the previously-rendered route
|
|
153
|
+
// on screen while the next one loads (instead of immediately falling back to the
|
|
154
|
+
// loading state). If the transition stays pending longer than `loadingDelay`, we
|
|
155
|
+
// surface the route's @loading frame (see showLoading → the entry's page slot);
|
|
156
|
+
// fast navigations never show it, and once shown it stays up for `minDuration`.
|
|
157
|
+
const [isNavigating, startNavigation] = React.useTransition()
|
|
158
|
+
|
|
159
|
+
// pendingURL tracks the navigation target *urgently* (outside the transition), so a
|
|
160
|
+
// render that happens while the transition is still pending can tell whether it is
|
|
161
|
+
// looking at the destination (transition lane: currentURL === pendingURL) or at the
|
|
162
|
+
// still-committed previous route (urgent lane: currentURL lags behind). The loading
|
|
163
|
+
// frame is only ever shown on the destination — see the showLoading prop below.
|
|
164
|
+
const [pendingURL, setPendingURL] = React.useState<string | null>(null)
|
|
165
|
+
|
|
67
166
|
// find the matching page for the current route. find_match also hands back the parsed
|
|
68
167
|
// query string (declared search params coerced, UI-only keys raw). custom-scalar route
|
|
69
168
|
// and search params arrive in their url transport form, so we unmarshal them once here
|
|
@@ -97,9 +196,36 @@ export function Router({
|
|
|
97
196
|
injectToStream,
|
|
98
197
|
})
|
|
99
198
|
// if we get this far, it's safe to load the component
|
|
100
|
-
const { component_cache, data_cache, ssr_signals } =
|
|
199
|
+
const { component_cache, data_cache, artifact_cache, ssr_signals, latestSession } =
|
|
200
|
+
useRouterContext()
|
|
101
201
|
const PageComponent = component_cache.get(targetPage.id)!
|
|
102
202
|
|
|
203
|
+
// decide whether the entry should render its @loading frame instead of the page.
|
|
204
|
+
// values come from the router config (bundled client-side; they're UI timing, not
|
|
205
|
+
// secrets), clamped so a bad value can't schedule a negative timeout.
|
|
206
|
+
// The machine only arms when the *destination* can actually render a frame (one of
|
|
207
|
+
// its documents is @loading): a frameless destination just holds the previous page
|
|
208
|
+
// for the whole transition, and arming the invisible state anyway would keep
|
|
209
|
+
// useNavigation().pending true for up to minDuration after the content committed.
|
|
210
|
+
const routerConfig = getCurrentConfig()?.router ?? {}
|
|
211
|
+
const [pendingPage] = pendingURL !== null ? find_match(manifest, pendingURL) : [null]
|
|
212
|
+
const destinationHasFrame = pendingPage
|
|
213
|
+
? Object.values(pendingPage.documents).some((document) => document.loading)
|
|
214
|
+
: false
|
|
215
|
+
const showLoading = useLoadingState({
|
|
216
|
+
active: isNavigating && destinationHasFrame,
|
|
217
|
+
target: pendingURL,
|
|
218
|
+
loadingDelay: Math.max(0, routerConfig.loadingDelay ?? 200),
|
|
219
|
+
minDuration: Math.max(0, routerConfig.minDuration ?? 400),
|
|
220
|
+
// resolves once every query of the page being rendered has a store in data_cache
|
|
221
|
+
// (including error stores — load_query seeds those too, so an errored query
|
|
222
|
+
// releases the loading state instead of pinning it)
|
|
223
|
+
waitForData: () =>
|
|
224
|
+
Promise.all(
|
|
225
|
+
Object.keys(targetPage.documents).map((name) => data_cache.waitFor(name))
|
|
226
|
+
).then(() => {}),
|
|
227
|
+
})
|
|
228
|
+
|
|
103
229
|
// if we got this far then we're past suspense
|
|
104
230
|
|
|
105
231
|
//
|
|
@@ -113,13 +239,96 @@ export function Router({
|
|
|
113
239
|
}
|
|
114
240
|
}, [currentURL])
|
|
115
241
|
|
|
242
|
+
// (dev only) hot-swap regenerated query artifacts. The router loads artifacts through
|
|
243
|
+
// dynamic import and keeps them — and the DocumentStores built from them — in caches
|
|
244
|
+
// keyed by document name, so vite's module-level HMR can never reach them: after
|
|
245
|
+
// codegen rewrites an artifact, the caches keep serving the old document and the page
|
|
246
|
+
// renders stale data. The compiler emits a custom event listing the changed artifact
|
|
247
|
+
// modules (see flushClientUpdates in houdini's vite plugin); re-import each one fresh,
|
|
248
|
+
// swap it into the artifact cache, evict the stale store, and reload the current
|
|
249
|
+
// page's data. useQueryResult keeps the committed store on screen while the
|
|
250
|
+
// replacement loads (the same keep-last-store path as an abandoned navigation), so
|
|
251
|
+
// the swap doesn't flash a loading state.
|
|
252
|
+
const [, forceRender] = React.useReducer((n: number) => n + 1, 0)
|
|
253
|
+
// the listener is registered once but must act on the latest render's page/variables
|
|
254
|
+
// (and loadData's closure over the current session)
|
|
255
|
+
const hmr = React.useRef({ page: targetPage, variables, loadData })
|
|
256
|
+
hmr.current = { page: targetPage, variables, loadData }
|
|
257
|
+
React.useEffect(() => {
|
|
258
|
+
// dev only: vite statically replaces both tokens in production builds
|
|
259
|
+
// (env.DEV → false, hot → undefined), so the whole listener is dead-code
|
|
260
|
+
// eliminated. The casts erase to the literal `import.meta.env.DEV` /
|
|
261
|
+
// `import.meta.hot` tokens — don't alias import.meta or add optional
|
|
262
|
+
// chaining, or the replacement (and vite's hot-context injection) stops
|
|
263
|
+
// matching.
|
|
264
|
+
if (!(import.meta as unknown as { env: { DEV?: boolean } }).env.DEV) {
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
const hot = (import.meta as { hot?: any }).hot
|
|
268
|
+
if (!hot) {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const onArtifactUpdate = async ({
|
|
272
|
+
artifacts,
|
|
273
|
+
}: {
|
|
274
|
+
artifacts: Array<{ name: string; url: string }>
|
|
275
|
+
}) => {
|
|
276
|
+
let stale = false
|
|
277
|
+
for (const { name, url } of artifacts) {
|
|
278
|
+
// only documents this client has loaded can be stale
|
|
279
|
+
if (!artifact_cache.has(name)) {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
// bust the browser's module cache — re-importing the bare url would just
|
|
283
|
+
// return the module we already have
|
|
284
|
+
let artifact: QueryArtifact | undefined
|
|
285
|
+
try {
|
|
286
|
+
const bust = `${url.includes('?') ? '&' : '?'}t=${Date.now()}`
|
|
287
|
+
artifact = (await import(/* @vite-ignore */ `${url}${bust}`))?.default
|
|
288
|
+
} catch {
|
|
289
|
+
// the module failed to load (e.g. the document was deleted) — leave the
|
|
290
|
+
// caches alone rather than evicting a store we can't replace
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
if (!artifact) {
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
artifact_cache.set(name, artifact)
|
|
297
|
+
// evict the store built from the old artifact so the reload below creates a
|
|
298
|
+
// replacement. deliberately no cleanup(): mounted components keep rendering
|
|
299
|
+
// the old store until the new one lands (see SuspenseCache's delete() note)
|
|
300
|
+
data_cache.delete(name)
|
|
301
|
+
ssr_signals.delete(name)
|
|
302
|
+
stale = true
|
|
303
|
+
}
|
|
304
|
+
if (!stale) {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const { page, variables, loadData } = hmr.current
|
|
308
|
+
// the reload honors each query's cache policy: an edit that doesn't change
|
|
309
|
+
// what the document asks for (or asks for data the cache already holds)
|
|
310
|
+
// resolves instantly from cache; anything the old selection didn't cover
|
|
311
|
+
// misses and goes to the network
|
|
312
|
+
loadData(page, variables)
|
|
313
|
+
// re-render so useQueryResult sees the eviction and subscribes to the
|
|
314
|
+
// replacement — deleting a cache entry doesn't notify anyone on its own
|
|
315
|
+
forceRender()
|
|
316
|
+
}
|
|
317
|
+
hot.on('houdini:artifact-update', onArtifactUpdate)
|
|
318
|
+
return () => hot.off?.('houdini:artifact-update', onArtifactUpdate)
|
|
319
|
+
}, [])
|
|
320
|
+
|
|
116
321
|
// when we first mount we should start listening to the back button
|
|
117
322
|
React.useEffect(() => {
|
|
118
323
|
if (!globalThis.window) {
|
|
119
324
|
return
|
|
120
325
|
}
|
|
121
326
|
const onChange = (_evt: PopStateEvent) => {
|
|
122
|
-
|
|
327
|
+
const url = window.location.pathname + window.location.search
|
|
328
|
+
setPendingURL(url)
|
|
329
|
+
startNavigation(() => {
|
|
330
|
+
setCurrentURL(url)
|
|
331
|
+
})
|
|
123
332
|
}
|
|
124
333
|
window.addEventListener('popstate', onChange)
|
|
125
334
|
return () => {
|
|
@@ -127,34 +336,72 @@ export function Router({
|
|
|
127
336
|
}
|
|
128
337
|
}, [])
|
|
129
338
|
|
|
339
|
+
// On navigation (but not the initial mount), re-fire the route's already-cached
|
|
340
|
+
// queries so each one's cache policy is honored (stale-while-revalidate). The
|
|
341
|
+
// observers are kept, so a query whose variables are unchanged and whose policy
|
|
342
|
+
// allows a cache read resolves without a loading frame; queries whose variables
|
|
343
|
+
// changed (or aren't cached) are evicted + reloaded by loadData with their loading
|
|
344
|
+
// state. We reuse the variables already unmarshaled for this render rather than
|
|
345
|
+
// re-parsing the URL. This also covers back/forward (popstate), which bypasses goto.
|
|
346
|
+
// The guard compares URLs (not a boolean) so Strict Mode's double effect invocation
|
|
347
|
+
// on mount doesn't slip past it and revalidate the initial page.
|
|
348
|
+
const lastRevalidatedURL = React.useRef(currentURL)
|
|
349
|
+
React.useEffect(() => {
|
|
350
|
+
if (lastRevalidatedURL.current === currentURL) {
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
lastRevalidatedURL.current = currentURL
|
|
354
|
+
for (const name of Object.keys(targetPage.documents)) {
|
|
355
|
+
if (data_cache.has(name)) {
|
|
356
|
+
data_cache.get(name).send({ variables, session: latestSession.current })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}, [currentURL])
|
|
360
|
+
|
|
130
361
|
// the function to call to navigate. accepts either a ready-made url string or a
|
|
131
362
|
// typed target { to, params, search } that is assembled (and custom scalars
|
|
132
363
|
// marshaled) exactly the way <Link> builds its href. The typed surface is the
|
|
133
364
|
// 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
|
-
|
|
365
|
+
// Referentially stable (everything it closes over is): it ships in the memoized
|
|
366
|
+
// NavigationContext value and anchors the link listener effect, so a new identity
|
|
367
|
+
// per render would re-render every useNavigation consumer and re-attach listeners.
|
|
368
|
+
const goto = React.useCallback(
|
|
369
|
+
(
|
|
370
|
+
target:
|
|
371
|
+
| string
|
|
372
|
+
| {
|
|
373
|
+
to: string
|
|
374
|
+
params?: Record<string, unknown>
|
|
375
|
+
search?: Record<string, unknown>
|
|
376
|
+
}
|
|
377
|
+
) => {
|
|
378
|
+
const url =
|
|
379
|
+
typeof target === 'string'
|
|
380
|
+
? target
|
|
381
|
+
: buildHref(
|
|
382
|
+
target.to,
|
|
383
|
+
manifest.pages[manifest.pagesByUrl[target.to]],
|
|
384
|
+
getCurrentConfig()?.scalars,
|
|
385
|
+
target.params,
|
|
386
|
+
target.search
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
// We intentionally don't blanket-clear the data cache on navigation (that would
|
|
390
|
+
// force every query back through its loading state). Observers and their data
|
|
391
|
+
// survive, and per-query revalidation is handled by the navigation effect above
|
|
392
|
+
// (which honors each query's cache policy). A real session change still clears
|
|
393
|
+
// the cache (updateSession / the session event listener).
|
|
394
|
+
|
|
395
|
+
// track the destination urgently (so in-flight renders can identify it) and
|
|
396
|
+
// perform the navigation inside a transition so React keeps the current route on
|
|
397
|
+
// screen until the next one is ready (or until showLoading swaps in the frame).
|
|
398
|
+
setPendingURL(url)
|
|
399
|
+
startNavigation(() => {
|
|
400
|
+
setCurrentURL(url)
|
|
401
|
+
})
|
|
402
|
+
},
|
|
403
|
+
[manifest]
|
|
404
|
+
) as Goto
|
|
158
405
|
|
|
159
406
|
// links are powered using anchor tags that we intercept and handle ourselves
|
|
160
407
|
useLinkBehavior({
|
|
@@ -189,31 +436,71 @@ export function Router({
|
|
|
189
436
|
},
|
|
190
437
|
})
|
|
191
438
|
|
|
192
|
-
//
|
|
439
|
+
// The loading frame only renders on the destination of the navigation: while a
|
|
440
|
+
// transition is pending, the committed tree still has the previous currentURL
|
|
441
|
+
// (pendingURL differs), so an urgent re-render of it — e.g. the showLoading flip —
|
|
442
|
+
// keeps showing the previous page instead of swapping it for its own frame. The
|
|
443
|
+
// destination (transition lane, where currentURL === pendingURL) renders the frame,
|
|
444
|
+
// which doesn't suspend, so the transition commits as soon as the rest of the entry
|
|
445
|
+
// (component, artifacts, layout data) is renderable.
|
|
446
|
+
const showFrame = showLoading && currentURL === pendingURL
|
|
447
|
+
|
|
448
|
+
// the public pending-navigation surface (useNavigation). A navigation counts as
|
|
449
|
+
// pending until the destination shows its actual content: the transition can commit
|
|
450
|
+
// with the @loading frame on screen (isNavigating flips false then), so the loading
|
|
451
|
+
// state extends it. Memoized so consumers only re-render when it actually changes.
|
|
452
|
+
const navigating = isNavigating || showLoading
|
|
453
|
+
const navigation = React.useMemo(
|
|
454
|
+
() => ({ pending: navigating, to: navigating ? pendingURL : null, goto }),
|
|
455
|
+
[navigating, pendingURL, goto]
|
|
456
|
+
)
|
|
457
|
+
|
|
193
458
|
// render the component embedded in the necessary context so it can orchestrate
|
|
194
459
|
// its needs
|
|
195
460
|
return (
|
|
196
|
-
<
|
|
197
|
-
value={
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
461
|
+
<PendingURLContext.Provider value={pendingURL}>
|
|
462
|
+
<NavigationContext.Provider value={navigation}>
|
|
463
|
+
<LocationContext.Provider
|
|
464
|
+
value={{
|
|
465
|
+
pathname: currentURL,
|
|
466
|
+
goto,
|
|
467
|
+
params: variables ?? {},
|
|
468
|
+
search,
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
<Is404Context.Provider value={is404}>
|
|
472
|
+
{is404 ? (
|
|
473
|
+
<NotFoundLayoutBoundary key={targetPage.id}>
|
|
474
|
+
<PageComponent
|
|
475
|
+
url={currentURL}
|
|
476
|
+
showLoading={showFrame}
|
|
477
|
+
key={targetPage.id + '__404'}
|
|
478
|
+
/>
|
|
479
|
+
</NotFoundLayoutBoundary>
|
|
480
|
+
) : (
|
|
481
|
+
<PageComponent
|
|
482
|
+
url={currentURL}
|
|
483
|
+
showLoading={showFrame}
|
|
484
|
+
key={targetPage.id}
|
|
485
|
+
/>
|
|
486
|
+
)}
|
|
487
|
+
</Is404Context.Provider>
|
|
488
|
+
</LocationContext.Provider>
|
|
489
|
+
</NavigationContext.Provider>
|
|
490
|
+
</PendingURLContext.Provider>
|
|
214
491
|
)
|
|
215
492
|
}
|
|
216
493
|
|
|
494
|
+
// useNavigation exposes the router's in-flight navigation. `pending` is true from the
|
|
495
|
+
// moment a navigation starts until the destination renders its actual content — it stays
|
|
496
|
+
// true while the destination's @loading state is showing — and `to` carries the
|
|
497
|
+
// destination url while pending (null when idle). It also carries `goto` — the same
|
|
498
|
+
// navigate function useRoute exposes — so navigation chrome (progress bars, nav menus,
|
|
499
|
+
// per-link spinners) only needs this one hook.
|
|
500
|
+
export function useNavigation(): { pending: boolean; to: string | null; goto: Goto } {
|
|
501
|
+
return useContext(NavigationContext)
|
|
502
|
+
}
|
|
503
|
+
|
|
217
504
|
// internal accessor for the raw location context. the public surface is useRoute, which
|
|
218
505
|
// layers the per-route param/search types on top of this. not re-exported from the package
|
|
219
506
|
// index, so it isn't part of the public API.
|
|
@@ -256,11 +543,9 @@ function usePageData({
|
|
|
256
543
|
artifact_cache,
|
|
257
544
|
ssr_signals,
|
|
258
545
|
last_variables,
|
|
546
|
+
latestSession,
|
|
259
547
|
} = useRouterContext()
|
|
260
548
|
|
|
261
|
-
// grab the current session value
|
|
262
|
-
const [session] = useSession()
|
|
263
|
-
|
|
264
549
|
// the function to load a query using the cache references
|
|
265
550
|
function load_query({
|
|
266
551
|
id,
|
|
@@ -271,11 +556,12 @@ function usePageData({
|
|
|
271
556
|
artifact: QueryArtifact
|
|
272
557
|
variables: GraphQLVariables
|
|
273
558
|
}): Promise<void> {
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
559
|
+
// record the variables this document is being sent with. only the loaded document:
|
|
560
|
+
// a preload loads the *destination's* queries while another page is still rendered,
|
|
561
|
+
// so writing the whole page's documents here (the old behavior) would tag the
|
|
562
|
+
// current page's documents with the destination's variables — and never tag the
|
|
563
|
+
// preloaded document at all
|
|
564
|
+
last_variables.set(id, variables)
|
|
279
565
|
|
|
280
566
|
// TODO: AbortController on send()
|
|
281
567
|
// TODO: we can read from cache here before making an asynchronous network call
|
|
@@ -350,6 +636,12 @@ function usePageData({
|
|
|
350
636
|
`)
|
|
351
637
|
}
|
|
352
638
|
|
|
639
|
+
// remember which cache generation this send belongs to: if the cache is
|
|
640
|
+
// invalidated while the request is in flight (a session change clears it), the
|
|
641
|
+
// result was fetched under state that no longer applies and must not be
|
|
642
|
+
// re-inserted — the invalidation already triggered a fresh send
|
|
643
|
+
const generation = data_cache.generation
|
|
644
|
+
|
|
353
645
|
// store the observer immediately so useQueryResult can access it
|
|
354
646
|
// during SSR rendering before the fetch resolves
|
|
355
647
|
let resolve: () => void = () => {}
|
|
@@ -361,9 +653,20 @@ function usePageData({
|
|
|
361
653
|
observer
|
|
362
654
|
.send({
|
|
363
655
|
variables: variables,
|
|
364
|
-
session
|
|
656
|
+
// read the ref, not the render-scoped session: this send can run during a
|
|
657
|
+
// render whose committed session predates a just-written one, and it must
|
|
658
|
+
// not repopulate the invalidated caches with stale-session results
|
|
659
|
+
session: latestSession.current,
|
|
365
660
|
})
|
|
366
661
|
.then(async () => {
|
|
662
|
+
// a stale-generation result: release the signal so nothing hangs, but
|
|
663
|
+
// don't put the observer (or its stream scripts) anywhere
|
|
664
|
+
if (data_cache.generation !== generation) {
|
|
665
|
+
ssr_signals.delete(id)
|
|
666
|
+
resolve()
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
367
670
|
data_cache.set(id, observer)
|
|
368
671
|
|
|
369
672
|
// if there is an error, stream it to the client (so an @loading query
|
|
@@ -490,6 +793,11 @@ function usePageData({
|
|
|
490
793
|
if (err?.name === 'AbortError') {
|
|
491
794
|
return
|
|
492
795
|
}
|
|
796
|
+
// same stale-generation rule as the success path
|
|
797
|
+
if (data_cache.generation !== generation) {
|
|
798
|
+
resolve()
|
|
799
|
+
return
|
|
800
|
+
}
|
|
493
801
|
// a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
|
|
494
802
|
// seed it as a synthetic GraphQL error on the store and stream it to the client,
|
|
495
803
|
// otherwise an @loading query that rejects hangs on the loading state
|
|
@@ -640,19 +948,39 @@ export function RouterContextProvider({
|
|
|
640
948
|
// on the server, we can just use
|
|
641
949
|
const [session, setSession] = React.useState<App.Session>(ssrSession)
|
|
642
950
|
|
|
951
|
+
// the React state above lags behind session writes: a setSession scheduled inside a
|
|
952
|
+
// transition hasn't committed when another lane renders (e.g. the urgent isPending
|
|
953
|
+
// flip of a navigation started in the same transition), so a render-phase load_query
|
|
954
|
+
// in that window would send with the previous session and repopulate the caches the
|
|
955
|
+
// session change just invalidated — the new-session render then finds everything
|
|
956
|
+
// "cached" and never refetches. Every session write updates this ref synchronously,
|
|
957
|
+
// and query sends read it instead of the render-scoped state.
|
|
958
|
+
const latestSession = React.useRef<App.Session>(ssrSession)
|
|
959
|
+
|
|
643
960
|
// if we detect an event that contains a new session value. The detail carries the subtree
|
|
644
961
|
// and whether to merge it into the current session (an @session(merge:) upsert) or replace
|
|
645
962
|
// 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
|
-
|
|
963
|
+
const handleNewSession = React.useCallback(
|
|
964
|
+
(event: Event) => {
|
|
965
|
+
const detail = (event as CustomEvent<HoudiniSessionEventDetail | App.Session>).detail
|
|
966
|
+
const isWrapped =
|
|
967
|
+
detail && typeof detail === 'object' && 'session' in detail && 'merge' in detail
|
|
968
|
+
const next = (
|
|
969
|
+
isWrapped ? (detail as HoudiniSessionEventDetail).session : detail
|
|
970
|
+
) as App.Session
|
|
971
|
+
const merge = isWrapped && (detail as HoudiniSessionEventDetail).merge
|
|
972
|
+
|
|
973
|
+
// a new session invalidates every cached query result, exactly like updateSession():
|
|
974
|
+
// navigation no longer clears the data cache, so without this an event-driven
|
|
975
|
+
// session change (updateLocalSession) would keep serving results fetched under the
|
|
976
|
+
// old session
|
|
977
|
+
invalidate_session_caches({ data_cache, ssr_signals, last_variables })
|
|
978
|
+
|
|
979
|
+
latestSession.current = merge ? { ...latestSession.current, ...next } : next
|
|
980
|
+
setSession(latestSession.current)
|
|
981
|
+
},
|
|
982
|
+
[data_cache, ssr_signals, last_variables]
|
|
983
|
+
)
|
|
656
984
|
|
|
657
985
|
React.useEffect(() => {
|
|
658
986
|
window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
|
|
@@ -674,9 +1002,19 @@ export function RouterContextProvider({
|
|
|
674
1002
|
ssr_signals,
|
|
675
1003
|
last_variables,
|
|
676
1004
|
session,
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1005
|
+
latestSession,
|
|
1006
|
+
setSession: (newSession) => {
|
|
1007
|
+
latestSession.current = { ...latestSession.current, ...newSession }
|
|
1008
|
+
setSession(latestSession.current)
|
|
1009
|
+
},
|
|
1010
|
+
replaceSession: (next) => {
|
|
1011
|
+
latestSession.current = next
|
|
1012
|
+
setSession(next)
|
|
1013
|
+
},
|
|
1014
|
+
clearSession: () => {
|
|
1015
|
+
latestSession.current = {}
|
|
1016
|
+
setSession({})
|
|
1017
|
+
},
|
|
680
1018
|
formResult,
|
|
681
1019
|
formToken,
|
|
682
1020
|
}}
|
|
@@ -710,6 +1048,12 @@ export type RouterContext = {
|
|
|
710
1048
|
// The current session
|
|
711
1049
|
session: App.Session
|
|
712
1050
|
|
|
1051
|
+
// the most recent session value, written synchronously by every session setter. The
|
|
1052
|
+
// `session` state above only updates when React commits, which can lag the write by a
|
|
1053
|
+
// render (e.g. a setSession inside a transition) — anything that fires a request during
|
|
1054
|
+
// render (load_query) must read this instead so it can't send a stale session.
|
|
1055
|
+
latestSession: { current: App.Session }
|
|
1056
|
+
|
|
713
1057
|
// a function to call that sets the client-side session singletone
|
|
714
1058
|
setSession: (newSession: Partial<App.Session>) => void
|
|
715
1059
|
|
|
@@ -778,6 +1122,21 @@ export function updateLocalSession(session: App.Session, merge = false) {
|
|
|
778
1122
|
)
|
|
779
1123
|
}
|
|
780
1124
|
|
|
1125
|
+
// invalidate_session_caches drops everything derived from the previous session: query
|
|
1126
|
+
// results, in-flight signal bookkeeping, and the last-sent variables (so useQueryResult's
|
|
1127
|
+
// keep-last-store check can't match a store fetched under the old session — the page
|
|
1128
|
+
// suspends into its loading state and refetches). One helper shared by both session-change
|
|
1129
|
+
// paths (updateSession and the HOUDINI_SESSION_EVENT listener) so they can't drift.
|
|
1130
|
+
function invalidate_session_caches(caches: {
|
|
1131
|
+
data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
1132
|
+
ssr_signals: PendingCache
|
|
1133
|
+
last_variables: LRUCache<GraphQLVariables>
|
|
1134
|
+
}) {
|
|
1135
|
+
caches.data_cache.clear()
|
|
1136
|
+
caches.ssr_signals.clear()
|
|
1137
|
+
caches.last_variables.clear()
|
|
1138
|
+
}
|
|
1139
|
+
|
|
781
1140
|
export function useSession(): [
|
|
782
1141
|
App.Session,
|
|
783
1142
|
(newSession: Partial<App.Session> | null) => Promise<void>,
|
|
@@ -790,9 +1149,8 @@ export function useSession(): [
|
|
|
790
1149
|
// log out — clearing the local session and deleting the cookie. It's awaitable so callers
|
|
791
1150
|
// can wait for the cookie to settle before navigating.
|
|
792
1151
|
const updateSession = async (newSession: Partial<App.Session> | null) => {
|
|
793
|
-
//
|
|
794
|
-
ctx
|
|
795
|
-
ctx.ssr_signals.clear()
|
|
1152
|
+
// drop everything derived from the previous session so queries refetch with the new one
|
|
1153
|
+
invalidate_session_caches(ctx)
|
|
796
1154
|
|
|
797
1155
|
if (newSession === null) {
|
|
798
1156
|
ctx.clearSession()
|
|
@@ -817,10 +1175,62 @@ export function useQueryResult<_Data extends GraphQLObject, _Input extends Graph
|
|
|
817
1175
|
name: string
|
|
818
1176
|
): [_Data | null, DocumentHandle<any, _Data, _Input>] {
|
|
819
1177
|
// pull the global context values
|
|
820
|
-
const { data_cache, artifact_cache } = useRouterContext()
|
|
1178
|
+
const { data_cache, artifact_cache, last_variables } = useRouterContext()
|
|
1179
|
+
const { pathname } = useLocationContext()
|
|
1180
|
+
const pendingURL = React.useContext(PendingURLContext)
|
|
1181
|
+
// the store (and the router-level variables it was sent with) from this component's
|
|
1182
|
+
// last COMMIT. Written in an effect, never during render: the ref object is shared
|
|
1183
|
+
// between a fiber and its work-in-progress alternate, so a render-phase write from a
|
|
1184
|
+
// transition lane that never commits would leak the wrong store into the committed
|
|
1185
|
+
// tree's next render.
|
|
1186
|
+
const last_store = React.useRef<DocumentStore<_Data, _Input> | null>(null)
|
|
1187
|
+
const last_vars = React.useRef<GraphQLVariables | null>(null)
|
|
1188
|
+
const [, bumpStore] = React.useReducer((n: number) => n + 1, 0)
|
|
1189
|
+
|
|
1190
|
+
// Decide which store to render. data_cache.get suspends when the store is missing —
|
|
1191
|
+
// the right behavior for a first render or for the navigation destination (a
|
|
1192
|
+
// transition waits on it, or the page's @loading boundary catches it). But the router
|
|
1193
|
+
// evicts a document mid-navigation to reload it with new variables, and a re-render
|
|
1194
|
+
// of the still-visible previous page must NOT re-suspend into its own loading state —
|
|
1195
|
+
// it keeps rendering the store from its last commit and the in-flight navigation
|
|
1196
|
+
// swaps the tree when the replacement resolves. Two cases keep the last store:
|
|
1197
|
+
// - the render's URL lags the navigation target (pendingURL is lane-independent —
|
|
1198
|
+
// see PendingURLContext). A lagging render always prefers its committed store,
|
|
1199
|
+
// even when the cache already holds a replacement for different variables.
|
|
1200
|
+
// - the store is missing but the router's current variables for this document
|
|
1201
|
+
// still match the ones the committed store was sent with: the eviction belongs
|
|
1202
|
+
// to a navigation that was abandoned (e.g. goto back to the committed URL), so
|
|
1203
|
+
// this render should keep its data while the document reloads.
|
|
1204
|
+
// The bump effect re-renders once a replacement lands, so a kept-stale commit always
|
|
1205
|
+
// converges to the fresh store.
|
|
1206
|
+
const missing = !data_cache.has(name)
|
|
1207
|
+
const lagging = pendingURL !== null && pathname !== pendingURL
|
|
1208
|
+
const matches_render =
|
|
1209
|
+
last_vars.current !== null && deepEquals(last_vars.current, last_variables.get(name))
|
|
1210
|
+
const store_ref =
|
|
1211
|
+
last_store.current && (lagging || (missing && matches_render))
|
|
1212
|
+
? last_store.current
|
|
1213
|
+
: (data_cache.get(name)! as unknown as DocumentStore<_Data, _Input>)
|
|
1214
|
+
|
|
1215
|
+
React.useEffect(() => {
|
|
1216
|
+
last_store.current = store_ref
|
|
1217
|
+
last_vars.current = (last_variables.get(name) as GraphQLVariables | null) ?? null
|
|
1218
|
+
})
|
|
821
1219
|
|
|
822
|
-
|
|
823
|
-
|
|
1220
|
+
React.useEffect(() => {
|
|
1221
|
+
if (!missing) {
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
let cancelled = false
|
|
1225
|
+
data_cache.waitFor(name).then(() => {
|
|
1226
|
+
if (!cancelled) {
|
|
1227
|
+
bumpStore()
|
|
1228
|
+
}
|
|
1229
|
+
})
|
|
1230
|
+
return () => {
|
|
1231
|
+
cancelled = true
|
|
1232
|
+
}
|
|
1233
|
+
}, [missing, name])
|
|
824
1234
|
|
|
825
1235
|
// get the live data from the store
|
|
826
1236
|
const [storeValue, observer] = useDocumentStore<_Data, _Input>({
|
|
@@ -867,9 +1277,6 @@ function useLinkBehavior({
|
|
|
867
1277
|
}
|
|
868
1278
|
|
|
869
1279
|
function useLinkNavigation({ goto }: { goto: Goto }) {
|
|
870
|
-
// navigations need to be registered as transitions
|
|
871
|
-
const [_pending, startTransition] = React.useTransition()
|
|
872
|
-
|
|
873
1280
|
React.useEffect(() => {
|
|
874
1281
|
const onClick: HTMLAnchorElement['onclick'] = (e) => {
|
|
875
1282
|
if (!e.target) {
|
|
@@ -918,10 +1325,11 @@ function useLinkNavigation({ goto }: { goto: Goto }) {
|
|
|
918
1325
|
e.preventDefault()
|
|
919
1326
|
e.stopPropagation()
|
|
920
1327
|
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1328
|
+
// goto() runs the URL update in its own transition and tracks the pending
|
|
1329
|
+
// destination urgently. Don't wrap the call in another transition here: that
|
|
1330
|
+
// would drag the urgent bookkeeping (pendingURL) into the transition lane,
|
|
1331
|
+
// and the committed tree couldn't tell where the navigation is headed.
|
|
1332
|
+
goto(target)
|
|
925
1333
|
}
|
|
926
1334
|
|
|
927
1335
|
window.addEventListener('click', onClick)
|
|
@@ -1016,9 +1424,19 @@ export function router_cache({
|
|
|
1016
1424
|
const result: RouterCache = {
|
|
1017
1425
|
artifact_cache: suspense_cache(initialArtifacts),
|
|
1018
1426
|
component_cache: suspense_cache(),
|
|
1019
|
-
|
|
1427
|
+
// observers accumulate across navigations now that goto doesn't clear the cache,
|
|
1428
|
+
// so the LRU capacity limit is reachable on long sessions. When it silently evicts
|
|
1429
|
+
// a store, let the store's plugins release whatever they hold. Best-effort: a
|
|
1430
|
+
// cleanup that rejects must not become an unhandled rejection (which kills a node
|
|
1431
|
+
// process outright).
|
|
1432
|
+
data_cache: suspense_cache(initialData, (store) => {
|
|
1433
|
+
store.cleanup().catch(() => {})
|
|
1434
|
+
}),
|
|
1020
1435
|
ssr_signals: suspense_cache(),
|
|
1021
|
-
|
|
1436
|
+
// a plain LRU, NOT a suspense cache: readers look up documents that may have no
|
|
1437
|
+
// entry yet (useQueryResult reads it during render and in its commit effect, where
|
|
1438
|
+
// a suspense cache's thrown promise would land in the nearest error boundary)
|
|
1439
|
+
last_variables: new LRUCache(),
|
|
1022
1440
|
}
|
|
1023
1441
|
|
|
1024
1442
|
// we need to fill each query with an externally resolvable promise
|
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
|
package/vite/index.js
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
client_build_directory
|
|
7
7
|
} from "houdini/router/conventions";
|
|
8
8
|
import { load_manifest } from "houdini/router/manifest";
|
|
9
|
-
import {
|
|
9
|
+
import { lookup } from "mrmime";
|
|
10
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
10
11
|
import { createRequire } from "node:module";
|
|
11
12
|
import { build } from "vite";
|
|
12
13
|
import { transform_file } from "./transform.js";
|
|
@@ -287,7 +288,7 @@ mount_static_app(App, manifest)
|
|
|
287
288
|
return;
|
|
288
289
|
}
|
|
289
290
|
const url = req.url.split("?")[0];
|
|
290
|
-
if (url.startsWith("/@") || url.startsWith("/virtual:") || url.startsWith("/node_modules/") || /\.[a-z]+$/i.test(url)) {
|
|
291
|
+
if (url.startsWith("/@") || url.startsWith("/virtual:") || url.startsWith("/node_modules/") || /\.[a-z]+$/i.test(url) || lookup(url) !== void 0 || is_public_file(url, server.config.publicDir)) {
|
|
291
292
|
next();
|
|
292
293
|
return;
|
|
293
294
|
}
|
|
@@ -304,18 +305,16 @@ mount_static_app(App, manifest)
|
|
|
304
305
|
adapter_config_path(ctx.config)
|
|
305
306
|
);
|
|
306
307
|
const requestHeaders = new Headers();
|
|
307
|
-
for (const
|
|
308
|
-
requestHeaders.set(
|
|
308
|
+
for (const [name, value] of Object.entries(req.headers ?? {})) {
|
|
309
|
+
requestHeaders.set(name, Array.isArray(value) ? value.join(", ") : value ?? "");
|
|
309
310
|
}
|
|
310
311
|
const port = server.config.server.port ?? 5173;
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
req.method
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
} : void 0
|
|
318
|
-
);
|
|
312
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
313
|
+
const request = new Request(`http://localhost:${port}` + req.url, {
|
|
314
|
+
method: req.method,
|
|
315
|
+
headers: requestHeaders,
|
|
316
|
+
...hasBody ? { body: await getBody(req) } : {}
|
|
317
|
+
});
|
|
319
318
|
let documentPremable = `<script type="module" src="/@vite/client" async=""></script>`;
|
|
320
319
|
try {
|
|
321
320
|
const transformed = await server.transformIndexHtml(
|
|
@@ -353,20 +352,17 @@ mount_static_app(App, manifest)
|
|
|
353
352
|
if (res.closed) {
|
|
354
353
|
return;
|
|
355
354
|
}
|
|
356
|
-
for (const
|
|
357
|
-
|
|
355
|
+
for (const [key, value] of result.headers ?? []) {
|
|
356
|
+
if (key.toLowerCase() !== "set-cookie") {
|
|
357
|
+
res.setHeader(key, value);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const setCookies = result.headers?.getSetCookie() ?? [];
|
|
361
|
+
if (setCookies.length > 0) {
|
|
362
|
+
res.setHeader("Set-Cookie", setCookies);
|
|
358
363
|
}
|
|
359
364
|
if (result.status >= 300 && result.status < 400) {
|
|
360
|
-
res.writeHead(result.status
|
|
361
|
-
Location: result.headers.get("Location") ?? "",
|
|
362
|
-
...[...result.headers].reduce(
|
|
363
|
-
(headers, [key, value]) => ({
|
|
364
|
-
...headers,
|
|
365
|
-
[key]: value
|
|
366
|
-
}),
|
|
367
|
-
{}
|
|
368
|
-
)
|
|
369
|
-
});
|
|
365
|
+
res.writeHead(result.status);
|
|
370
366
|
} else {
|
|
371
367
|
res.write(await result.text());
|
|
372
368
|
}
|
|
@@ -380,6 +376,16 @@ mount_static_app(App, manifest)
|
|
|
380
376
|
}
|
|
381
377
|
};
|
|
382
378
|
}
|
|
379
|
+
function is_public_file(url, publicDir) {
|
|
380
|
+
if (!publicDir) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
return statSync(path.join(publicDir, decodeURIComponent(url))).isFile();
|
|
385
|
+
} catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
383
389
|
function getBody(request) {
|
|
384
390
|
return new Promise((resolve) => {
|
|
385
391
|
const bodyParts = [];
|