houdini-react 2.0.1 → 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 +81 -0
- package/runtime/hooks/useDocumentHandle.ts +11 -0
- package/runtime/hooks/useMutationForm.tsx +4 -4
- package/runtime/hydration.tsx +21 -0
- package/runtime/index.tsx +1 -0
- package/runtime/routing/Router.tsx +540 -95
- package/runtime/routing/cache.ts +50 -4
- package/runtime/routing/errors.tsx +46 -21
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'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { GraphQLError } from 'houdini/runtime'
|
|
2
|
+
import { createContext } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { Goto } from './routes.js'
|
|
5
|
+
import type { RouterContext } from './routing/Router.js'
|
|
6
|
+
|
|
7
|
+
// All of the runtime's React contexts are defined in this module on purpose.
|
|
8
|
+
//
|
|
9
|
+
// A React context is a module-level singleton: provider and consumer must hold
|
|
10
|
+
// the *same* object returned by createContext(). When a context is created
|
|
11
|
+
// inside a module that also exports components/hooks (like Router.tsx), Vite's
|
|
12
|
+
// dev server re-evaluates that module on an HMR update (for example when codegen
|
|
13
|
+
// rewrites a neighboring generated unit, or when react-refresh falls back to a
|
|
14
|
+
// full module re-run). Each re-evaluation mints a brand-new context object, and
|
|
15
|
+
// if a consumer rebinds to the new one while the still-mounted provider holds
|
|
16
|
+
// the old one, useContext() reads a context the provider never populated and
|
|
17
|
+
// throws "Could not find router context".
|
|
18
|
+
//
|
|
19
|
+
// This module imports only `react` at runtime (everything else is `import type`,
|
|
20
|
+
// erased at build time), so it is a pure leaf in the dependency graph. Route
|
|
21
|
+
// edits never re-evaluate it, so these context objects keep a stable identity
|
|
22
|
+
// across granular HMR updates. We deliberately avoid a globalThis registry to
|
|
23
|
+
// pin identity: multiple independent Router contexts can legitimately coexist in
|
|
24
|
+
// one process (for example across Astro islands), and a global singleton would
|
|
25
|
+
// wrongly collapse them into one.
|
|
26
|
+
|
|
27
|
+
export const RouterContextObject = createContext<RouterContext | null>(null)
|
|
28
|
+
|
|
29
|
+
export const LocationContext = createContext<{
|
|
30
|
+
pathname: string
|
|
31
|
+
params: Record<string, any>
|
|
32
|
+
// the parsed query string of the current url (declared search params coerced to
|
|
33
|
+
// their scalar type, other keys raw; repeated keys are arrays).
|
|
34
|
+
search: Record<string, any>
|
|
35
|
+
// a function to imperatively navigate to a url
|
|
36
|
+
goto: Goto
|
|
37
|
+
}>({
|
|
38
|
+
pathname: '',
|
|
39
|
+
params: {},
|
|
40
|
+
search: {},
|
|
41
|
+
goto: () => {},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export const Is404Context = createContext(false)
|
|
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
|
+
|
|
68
|
+
export const PageContext = createContext<{ params: Record<string, any> }>({ params: {} })
|
|
69
|
+
|
|
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)
|
|
77
|
+
|
|
78
|
+
// FormStatusContext carries the nearest <Form>'s pending state to useMutationFormStatus(),
|
|
79
|
+
// the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
|
|
80
|
+
// submissions, so it can't see our forms).
|
|
81
|
+
export const FormStatusContext = createContext<{ pending: boolean }>({ pending: false })
|
|
@@ -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,
|
|
@@ -3,6 +3,7 @@ import type { MutationArtifact, GraphQLObject, GraphQLVariables } from 'houdini/
|
|
|
3
3
|
import { coerceFormData, interpolateRedirect } from 'houdini/runtime'
|
|
4
4
|
import React from 'react'
|
|
5
5
|
|
|
6
|
+
import { FormStatusContext } from '../contexts.js'
|
|
6
7
|
import { useSession, useRoute, useFormResult, useFormToken } from '../routing/Router.js'
|
|
7
8
|
import { useDocumentStore } from './useDocumentStore.js'
|
|
8
9
|
|
|
@@ -40,10 +41,9 @@ export type MutationForm<_Result = any> = {
|
|
|
40
41
|
pending: boolean
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
// FormStatusContext carries the nearest <Form>'s pending state
|
|
44
|
-
// the no-prop-drilling ergonomic of React's useFormStatus (which
|
|
45
|
-
// submissions, so it can't see our forms).
|
|
46
|
-
const FormStatusContext = React.createContext<{ pending: boolean }>({ pending: false })
|
|
44
|
+
// FormStatusContext (defined in ../contexts.js) carries the nearest <Form>'s pending state
|
|
45
|
+
// to useMutationFormStatus(), the no-prop-drilling ergonomic of React's useFormStatus (which
|
|
46
|
+
// only tracks function-action submissions, so it can't see our forms).
|
|
47
47
|
|
|
48
48
|
/** useMutationFormStatus reads the pending state of the nearest <Form> from a child. */
|
|
49
49
|
export function useMutationFormStatus(): { pending: boolean } {
|
package/runtime/hydration.tsx
CHANGED
|
@@ -26,6 +26,7 @@ declare global {
|
|
|
26
26
|
__houdini__auth_url__?: string | null
|
|
27
27
|
__houdini__pending_artifacts__?: Record<string, QueryArtifact>
|
|
28
28
|
__houdini__pending_data__?: Record<string, any>
|
|
29
|
+
__houdini__pending_errors__?: Record<string, any>
|
|
29
30
|
__houdini__pending_variables__?: Record<string, GraphQLVariables>
|
|
30
31
|
__houdini__pending_cache__?: any[]
|
|
31
32
|
__houdini__nav_caches__?: RouterCache
|
|
@@ -127,6 +128,26 @@ export function hydrate_page(
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// an @loading query that errored streams its errors here (its data is null, so it has no
|
|
132
|
+
// pending_data entry above). build a store that already carries the errors so the page's
|
|
133
|
+
// useQueryResult throws to the error boundary instead of hanging on the loading frame.
|
|
134
|
+
for (const [artifactName, errors] of Object.entries(window.__houdini__pending_errors__ ?? {})) {
|
|
135
|
+
const artifact = window.__houdini__pending_artifacts__?.[artifactName]
|
|
136
|
+
if (!artifact) {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
initialArtifacts[artifactName] = artifact
|
|
140
|
+
const variables = window.__houdini__pending_variables__?.[artifactName]
|
|
141
|
+
const observer = window.__houdini__client__!.observe({
|
|
142
|
+
artifact,
|
|
143
|
+
cache: window.__houdini__cache__,
|
|
144
|
+
initialVariables: variables,
|
|
145
|
+
})
|
|
146
|
+
observer.update((state) => ({ ...state, fetching: false, errors }))
|
|
147
|
+
initialData[artifactName] = observer
|
|
148
|
+
}
|
|
149
|
+
window.__houdini__pending_errors__ = {}
|
|
150
|
+
|
|
130
151
|
if (!window.__houdini__nav_caches__) {
|
|
131
152
|
window.__houdini__nav_caches__ = router_cache({
|
|
132
153
|
pending_queries: pendingQueries,
|
package/runtime/index.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GraphQLObject, GraphQLVariables } from 'houdini/runtime'
|
|
1
|
+
import type { GraphQLError, GraphQLObject, GraphQLVariables } from 'houdini/runtime'
|
|
2
2
|
import type { QueryArtifact } from 'houdini/runtime'
|
|
3
3
|
import type { Cache } from 'houdini/runtime/cache'
|
|
4
4
|
import type { DocumentStore, HoudiniClient } from 'houdini/runtime/client'
|
|
@@ -13,6 +13,15 @@ import type { RouterManifest, RouterPageManifest } from 'houdini/router/types'
|
|
|
13
13
|
import React from 'react'
|
|
14
14
|
import { useContext, useEffect } from 'react'
|
|
15
15
|
|
|
16
|
+
import {
|
|
17
|
+
RouterContextObject as Context,
|
|
18
|
+
LocationContext,
|
|
19
|
+
Is404Context,
|
|
20
|
+
NavigationContext,
|
|
21
|
+
PageContext,
|
|
22
|
+
PendingURLContext,
|
|
23
|
+
} from '../contexts.js'
|
|
24
|
+
import { escapeScriptTag } from '../escape.js'
|
|
16
25
|
import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
|
|
17
26
|
import type { Goto } from '../routes.js'
|
|
18
27
|
import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
|
|
@@ -20,7 +29,11 @@ import { useDocumentStore } from '../hooks/useDocumentStore.js'
|
|
|
20
29
|
import { type SuspenseCache, suspense_cache } from './cache.js'
|
|
21
30
|
import { GraphQLErrors, RoutingError, StatusContext } from './errors.js'
|
|
22
31
|
|
|
23
|
-
type PageComponent = React.ComponentType<{
|
|
32
|
+
type PageComponent = React.ComponentType<{
|
|
33
|
+
url: string
|
|
34
|
+
showLoading?: boolean
|
|
35
|
+
children?: React.ReactNode
|
|
36
|
+
}>
|
|
24
37
|
|
|
25
38
|
const PreloadWhich = {
|
|
26
39
|
component: 'component',
|
|
@@ -30,6 +43,86 @@ const PreloadWhich = {
|
|
|
30
43
|
|
|
31
44
|
type PreloadWhichValue = (typeof PreloadWhich)[keyof typeof PreloadWhich]
|
|
32
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
|
+
|
|
33
126
|
/**
|
|
34
127
|
* Router is the top level entry point for the filesystem-based router.
|
|
35
128
|
* It is responsible for loading various page sources (including API fetches) and
|
|
@@ -57,6 +150,20 @@ export function Router({
|
|
|
57
150
|
return initialURL || window.location.pathname + window.location.search
|
|
58
151
|
})
|
|
59
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
|
+
|
|
60
167
|
// find the matching page for the current route. find_match also hands back the parsed
|
|
61
168
|
// query string (declared search params coerced, UI-only keys raw). custom-scalar route
|
|
62
169
|
// and search params arrive in their url transport form, so we unmarshal them once here
|
|
@@ -90,8 +197,35 @@ export function Router({
|
|
|
90
197
|
injectToStream,
|
|
91
198
|
})
|
|
92
199
|
// if we get this far, it's safe to load the component
|
|
93
|
-
const { component_cache, data_cache, ssr_signals } = useRouterContext()
|
|
200
|
+
const { component_cache, data_cache, artifact_cache, ssr_signals } = useRouterContext()
|
|
94
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
|
+
})
|
|
95
229
|
|
|
96
230
|
// if we got this far then we're past suspense
|
|
97
231
|
|
|
@@ -106,13 +240,96 @@ export function Router({
|
|
|
106
240
|
}
|
|
107
241
|
}, [currentURL])
|
|
108
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
|
+
|
|
109
322
|
// when we first mount we should start listening to the back button
|
|
110
323
|
React.useEffect(() => {
|
|
111
324
|
if (!globalThis.window) {
|
|
112
325
|
return
|
|
113
326
|
}
|
|
114
327
|
const onChange = (_evt: PopStateEvent) => {
|
|
115
|
-
|
|
328
|
+
const url = window.location.pathname + window.location.search
|
|
329
|
+
setPendingURL(url)
|
|
330
|
+
startNavigation(() => {
|
|
331
|
+
setCurrentURL(url)
|
|
332
|
+
})
|
|
116
333
|
}
|
|
117
334
|
window.addEventListener('popstate', onChange)
|
|
118
335
|
return () => {
|
|
@@ -120,34 +337,72 @@ export function Router({
|
|
|
120
337
|
}
|
|
121
338
|
}, [])
|
|
122
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
|
+
|
|
123
362
|
// the function to call to navigate. accepts either a ready-made url string or a
|
|
124
363
|
// typed target { to, params, search } that is assembled (and custom scalars
|
|
125
364
|
// marshaled) exactly the way <Link> builds its href. The typed surface is the
|
|
126
365
|
// shared Goto contract; the implementation takes the loose runtime shape.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
406
|
|
|
152
407
|
// links are powered using anchor tags that we intercept and handle ourselves
|
|
153
408
|
useLinkBehavior({
|
|
@@ -182,31 +437,71 @@ export function Router({
|
|
|
182
437
|
},
|
|
183
438
|
})
|
|
184
439
|
|
|
185
|
-
//
|
|
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
|
+
|
|
186
459
|
// render the component embedded in the necessary context so it can orchestrate
|
|
187
460
|
// its needs
|
|
188
461
|
return (
|
|
189
|
-
<
|
|
190
|
-
value={
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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>
|
|
207
492
|
)
|
|
208
493
|
}
|
|
209
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
|
+
|
|
210
505
|
// internal accessor for the raw location context. the public surface is useRoute, which
|
|
211
506
|
// layers the per-route param/search types on top of this. not re-exported from the package
|
|
212
507
|
// index, so it isn't part of the public API.
|
|
@@ -285,6 +580,70 @@ function usePageData({
|
|
|
285
580
|
? data_cache.get(artifact.name)!
|
|
286
581
|
: client.observe({ artifact, cache })
|
|
287
582
|
|
|
583
|
+
// surface a query error to the client. an @loading query streams its result while the
|
|
584
|
+
// document is still open, so on the initial SSR load the client is parked on the loading
|
|
585
|
+
// frame waiting for this query's pending signal to resolve. unlike the success path the
|
|
586
|
+
// store carries no data we can hydrate from the cache (errors aren't cache data), so we
|
|
587
|
+
// stream the errors directly and rebuild an erroring store on the client. without this the
|
|
588
|
+
// pending signal never resolves and the page hangs on the loading state instead of reaching
|
|
589
|
+
// the nearest error boundary.
|
|
590
|
+
function stream_error(errors: GraphQLError[]) {
|
|
591
|
+
injectToStream?.(`
|
|
592
|
+
<script>
|
|
593
|
+
{
|
|
594
|
+
const artifactName = ${escapeScriptTag(JSON.stringify(artifact.name))}
|
|
595
|
+
const errors = ${escapeScriptTag(JSON.stringify(errors))}
|
|
596
|
+
const variables = ${escapeScriptTag(JSON.stringify(observer.state.variables))}
|
|
597
|
+
const artifact = ${escapeScriptTag(JSON.stringify(artifact))}
|
|
598
|
+
|
|
599
|
+
// build a document store that already carries the errors so useQueryResult
|
|
600
|
+
// throws to the nearest boundary the moment it reads the store
|
|
601
|
+
const __houdini__error_store__ = (knownArtifact) => {
|
|
602
|
+
const store = window.__houdini__client__.observe({
|
|
603
|
+
artifact: knownArtifact ?? artifact,
|
|
604
|
+
cache: window.__houdini__cache__,
|
|
605
|
+
initialVariables: variables,
|
|
606
|
+
})
|
|
607
|
+
store.update((state) => ({ ...state, fetching: false, errors }))
|
|
608
|
+
return store
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// if the client has already hydrated (a client-side navigation to an @loading
|
|
612
|
+
// route) push the error store straight into the live caches and release the signal
|
|
613
|
+
if (window.__houdini__nav_caches__) {
|
|
614
|
+
const caches = window.__houdini__nav_caches__
|
|
615
|
+
caches.data_cache.set(
|
|
616
|
+
artifactName,
|
|
617
|
+
__houdini__error_store__(caches.artifact_cache.get(artifactName))
|
|
618
|
+
)
|
|
619
|
+
if (caches.ssr_signals.has(artifactName)) {
|
|
620
|
+
caches.ssr_signals.get(artifactName).resolve()
|
|
621
|
+
caches.ssr_signals.delete(artifactName)
|
|
622
|
+
}
|
|
623
|
+
} else {
|
|
624
|
+
// otherwise the page module hasn't run yet (the common @loading case): stash the
|
|
625
|
+
// error so hydrate_page can build the error store when it sets up the caches
|
|
626
|
+
const pendingArtifacts = (window.__houdini__pending_artifacts__ =
|
|
627
|
+
window.__houdini__pending_artifacts__ || {})
|
|
628
|
+
pendingArtifacts[artifactName] = artifact
|
|
629
|
+
const pendingVariables = (window.__houdini__pending_variables__ =
|
|
630
|
+
window.__houdini__pending_variables__ || {})
|
|
631
|
+
pendingVariables[artifactName] = variables
|
|
632
|
+
const pendingErrors = (window.__houdini__pending_errors__ =
|
|
633
|
+
window.__houdini__pending_errors__ || {})
|
|
634
|
+
pendingErrors[artifactName] = errors
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
</script>
|
|
638
|
+
`)
|
|
639
|
+
}
|
|
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
|
+
|
|
288
647
|
// store the observer immediately so useQueryResult can access it
|
|
289
648
|
// during SSR rendering before the fetch resolves
|
|
290
649
|
let resolve: () => void = () => {}
|
|
@@ -299,11 +658,22 @@ function usePageData({
|
|
|
299
658
|
session,
|
|
300
659
|
})
|
|
301
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
|
+
|
|
302
669
|
data_cache.set(id, observer)
|
|
303
670
|
|
|
304
|
-
// if there is an error,
|
|
305
|
-
//
|
|
671
|
+
// if there is an error, stream it to the client (so an @loading query
|
|
672
|
+
// reaches the error boundary instead of hanging on the loading state) and
|
|
673
|
+
// clean up. on the client data_cache.set above is enough for useQueryResult
|
|
674
|
+
// to read the errors and throw; the stream is what carries them across SSR.
|
|
306
675
|
if (observer.state.errors && observer.state.errors.length > 0) {
|
|
676
|
+
stream_error(observer.state.errors)
|
|
307
677
|
ssr_signals.delete(id)
|
|
308
678
|
resolve()
|
|
309
679
|
return
|
|
@@ -422,7 +792,19 @@ function usePageData({
|
|
|
422
792
|
if (err?.name === 'AbortError') {
|
|
423
793
|
return
|
|
424
794
|
}
|
|
425
|
-
|
|
795
|
+
// same stale-generation rule as the success path
|
|
796
|
+
if (data_cache.generation !== generation) {
|
|
797
|
+
resolve()
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
// a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
|
|
801
|
+
// seed it as a synthetic GraphQL error on the store and stream it to the client,
|
|
802
|
+
// otherwise an @loading query that rejects hangs on the loading state
|
|
803
|
+
const errors = [{ message: err?.message ?? String(err) }]
|
|
804
|
+
observer.update((state) => ({ ...state, fetching: false, errors }))
|
|
805
|
+
data_cache.set(id, observer)
|
|
806
|
+
stream_error(errors)
|
|
807
|
+
resolve()
|
|
426
808
|
})
|
|
427
809
|
})
|
|
428
810
|
|
|
@@ -568,16 +950,26 @@ export function RouterContextProvider({
|
|
|
568
950
|
// if we detect an event that contains a new session value. The detail carries the subtree
|
|
569
951
|
// and whether to merge it into the current session (an @session(merge:) upsert) or replace
|
|
570
952
|
// it wholesale; a legacy plain-session detail is treated as a replace.
|
|
571
|
-
const handleNewSession = React.useCallback(
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
+
)
|
|
581
973
|
|
|
582
974
|
React.useEffect(() => {
|
|
583
975
|
window.addEventListener(HOUDINI_SESSION_EVENT, handleNewSession)
|
|
@@ -611,7 +1003,7 @@ export function RouterContextProvider({
|
|
|
611
1003
|
)
|
|
612
1004
|
}
|
|
613
1005
|
|
|
614
|
-
type RouterContext = {
|
|
1006
|
+
export type RouterContext = {
|
|
615
1007
|
client: HoudiniClient
|
|
616
1008
|
cache: Cache
|
|
617
1009
|
|
|
@@ -662,8 +1054,6 @@ export type PendingCache = SuspenseCache<
|
|
|
662
1054
|
Promise<void> & { resolve: () => void; reject: (message: string) => void }
|
|
663
1055
|
>
|
|
664
1056
|
|
|
665
|
-
const Context = React.createContext<RouterContext | null>(null)
|
|
666
|
-
|
|
667
1057
|
export const useRouterContext = () => {
|
|
668
1058
|
const ctx = React.useContext(Context)
|
|
669
1059
|
|
|
@@ -705,6 +1095,21 @@ export function updateLocalSession(session: App.Session, merge = false) {
|
|
|
705
1095
|
)
|
|
706
1096
|
}
|
|
707
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
|
+
|
|
708
1113
|
export function useSession(): [
|
|
709
1114
|
App.Session,
|
|
710
1115
|
(newSession: Partial<App.Session> | null) => Promise<void>,
|
|
@@ -717,9 +1122,8 @@ export function useSession(): [
|
|
|
717
1122
|
// log out — clearing the local session and deleting the cookie. It's awaitable so callers
|
|
718
1123
|
// can wait for the cookie to settle before navigating.
|
|
719
1124
|
const updateSession = async (newSession: Partial<App.Session> | null) => {
|
|
720
|
-
//
|
|
721
|
-
ctx
|
|
722
|
-
ctx.ssr_signals.clear()
|
|
1125
|
+
// drop everything derived from the previous session so queries refetch with the new one
|
|
1126
|
+
invalidate_session_caches(ctx)
|
|
723
1127
|
|
|
724
1128
|
if (newSession === null) {
|
|
725
1129
|
ctx.clearSession()
|
|
@@ -740,29 +1144,66 @@ export function useSession(): [
|
|
|
740
1144
|
return [ctx.session, updateSession]
|
|
741
1145
|
}
|
|
742
1146
|
|
|
743
|
-
const LocationContext = React.createContext<{
|
|
744
|
-
pathname: string
|
|
745
|
-
params: Record<string, any>
|
|
746
|
-
// the parsed query string of the current url (declared search params coerced to
|
|
747
|
-
// their scalar type, other keys raw; repeated keys are arrays).
|
|
748
|
-
search: Record<string, any>
|
|
749
|
-
// a function to imperatively navigate to a url
|
|
750
|
-
goto: Goto
|
|
751
|
-
}>({
|
|
752
|
-
pathname: '',
|
|
753
|
-
params: {},
|
|
754
|
-
search: {},
|
|
755
|
-
goto: () => {},
|
|
756
|
-
})
|
|
757
|
-
|
|
758
1147
|
export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>(
|
|
759
1148
|
name: string
|
|
760
1149
|
): [_Data | null, DocumentHandle<any, _Data, _Input>] {
|
|
761
1150
|
// pull the global context values
|
|
762
|
-
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>)
|
|
1187
|
+
|
|
1188
|
+
React.useEffect(() => {
|
|
1189
|
+
last_store.current = store_ref
|
|
1190
|
+
last_vars.current = (last_variables.get(name) as GraphQLVariables | null) ?? null
|
|
1191
|
+
})
|
|
763
1192
|
|
|
764
|
-
|
|
765
|
-
|
|
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])
|
|
766
1207
|
|
|
767
1208
|
// get the live data from the store
|
|
768
1209
|
const [storeValue, observer] = useDocumentStore<_Data, _Input>({
|
|
@@ -809,9 +1250,6 @@ function useLinkBehavior({
|
|
|
809
1250
|
}
|
|
810
1251
|
|
|
811
1252
|
function useLinkNavigation({ goto }: { goto: Goto }) {
|
|
812
|
-
// navigations need to be registered as transitions
|
|
813
|
-
const [_pending, startTransition] = React.useTransition()
|
|
814
|
-
|
|
815
1253
|
React.useEffect(() => {
|
|
816
1254
|
const onClick: HTMLAnchorElement['onclick'] = (e) => {
|
|
817
1255
|
if (!e.target) {
|
|
@@ -860,10 +1298,11 @@ function useLinkNavigation({ goto }: { goto: Goto }) {
|
|
|
860
1298
|
e.preventDefault()
|
|
861
1299
|
e.stopPropagation()
|
|
862
1300
|
|
|
863
|
-
//
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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)
|
|
867
1306
|
}
|
|
868
1307
|
|
|
869
1308
|
window.addEventListener('click', onClick)
|
|
@@ -958,7 +1397,14 @@ export function router_cache({
|
|
|
958
1397
|
const result: RouterCache = {
|
|
959
1398
|
artifact_cache: suspense_cache(initialArtifacts),
|
|
960
1399
|
component_cache: suspense_cache(),
|
|
961
|
-
|
|
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
|
+
}),
|
|
962
1408
|
ssr_signals: suspense_cache(),
|
|
963
1409
|
last_variables: suspense_cache(),
|
|
964
1410
|
}
|
|
@@ -1018,7 +1464,8 @@ class NotFoundLayoutBoundary extends React.Component<
|
|
|
1018
1464
|
}
|
|
1019
1465
|
}
|
|
1020
1466
|
|
|
1021
|
-
|
|
1467
|
+
// re-exported (defined in ../contexts.js) so existing `routing` barrel consumers keep working
|
|
1468
|
+
export { Is404Context }
|
|
1022
1469
|
|
|
1023
1470
|
export function NotFoundGate({ children }: { children: React.ReactNode }) {
|
|
1024
1471
|
const is404 = React.useContext(Is404Context)
|
|
@@ -1028,8 +1475,6 @@ export function NotFoundGate({ children }: { children: React.ReactNode }) {
|
|
|
1028
1475
|
return <>{children}</>
|
|
1029
1476
|
}
|
|
1030
1477
|
|
|
1031
|
-
const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
|
|
1032
|
-
|
|
1033
1478
|
export function PageContextProvider({
|
|
1034
1479
|
keys,
|
|
1035
1480
|
children,
|
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,6 +1,8 @@
|
|
|
1
1
|
import type { GraphQLError } from 'houdini/runtime'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
|
+
import { LocationContext, StatusContext } from '../contexts.js'
|
|
5
|
+
|
|
4
6
|
export class GraphQLErrors extends Error {
|
|
5
7
|
graphqlErrors: GraphQLError[]
|
|
6
8
|
|
|
@@ -73,9 +75,9 @@ export function redirect(status: 300 | 301 | 302 | 303 | 307 | 308, location: st
|
|
|
73
75
|
throw new RedirectError(status, location)
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
export
|
|
78
|
+
// StatusContext is defined in ../contexts.js (a dependency-only leaf module) so its
|
|
79
|
+
// identity survives Vite HMR re-evaluation; re-exported here to keep the existing surface.
|
|
80
|
+
export { StatusContext }
|
|
79
81
|
|
|
80
82
|
type HoudiniErrorBoundaryProps = {
|
|
81
83
|
errorView: React.ComponentType<{
|
|
@@ -88,33 +90,67 @@ type HoudiniErrorBoundaryProps = {
|
|
|
88
90
|
type HoudiniErrorBoundaryState = {
|
|
89
91
|
hasError: boolean
|
|
90
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
|
|
96
|
+
}
|
|
97
|
+
|
|
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} />
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
|
|
94
|
-
HoudiniErrorBoundaryProps,
|
|
107
|
+
class ErrorBoundary extends React.Component<
|
|
108
|
+
HoudiniErrorBoundaryProps & { resetKey: string },
|
|
95
109
|
HoudiniErrorBoundaryState
|
|
96
110
|
> {
|
|
97
111
|
static contextType = StatusContext
|
|
98
112
|
declare context: React.ContextType<typeof StatusContext>
|
|
99
113
|
|
|
100
114
|
constructor(
|
|
101
|
-
props: HoudiniErrorBoundaryProps,
|
|
115
|
+
props: HoudiniErrorBoundaryProps & { resetKey: string },
|
|
102
116
|
context: React.ContextType<typeof StatusContext>
|
|
103
117
|
) {
|
|
104
118
|
super(props, context)
|
|
105
119
|
// Second-pass SSR: statusRef is pre-set to an error status by on_render after the first
|
|
106
|
-
// 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.
|
|
107
124
|
if (typeof window === 'undefined' && context && context.status >= 400) {
|
|
108
125
|
this.state = {
|
|
109
126
|
hasError: true,
|
|
110
|
-
errors: [new RoutingError(context.status)],
|
|
127
|
+
errors: context.errors ?? [new RoutingError(context.status)],
|
|
128
|
+
resetKey: props.resetKey,
|
|
111
129
|
}
|
|
112
130
|
} else {
|
|
113
|
-
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 }
|
|
114
149
|
}
|
|
150
|
+
return null
|
|
115
151
|
}
|
|
116
152
|
|
|
117
|
-
static getDerivedStateFromError(error: unknown): HoudiniErrorBoundaryState {
|
|
153
|
+
static getDerivedStateFromError(error: unknown): Partial<HoudiniErrorBoundaryState> {
|
|
118
154
|
if (error instanceof GraphQLErrors) {
|
|
119
155
|
return { hasError: true, errors: error.graphqlErrors }
|
|
120
156
|
}
|
|
@@ -124,17 +160,6 @@ export class HoudiniErrorBoundary extends React.Component<
|
|
|
124
160
|
}
|
|
125
161
|
}
|
|
126
162
|
|
|
127
|
-
componentDidCatch(error: Error): void {
|
|
128
|
-
if (this.context) {
|
|
129
|
-
if (error instanceof RoutingError) {
|
|
130
|
-
this.context.status = error.status
|
|
131
|
-
} else if (error instanceof RedirectError) {
|
|
132
|
-
this.context.status = error.status
|
|
133
|
-
this.context.location = error.location
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
163
|
render() {
|
|
139
164
|
if (this.state.hasError) {
|
|
140
165
|
const ErrorView = this.props.errorView
|