houdini-react 2.1.2 → 2.2.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/hooks/observerRefs.ts +51 -0
- package/runtime/hooks/suspenseCache.ts +58 -0
- package/runtime/hooks/useQueryHandle.ts +103 -27
- package/runtime/index.tsx +1 -0
- package/runtime/routing/Router.tsx +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "houdini-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "The React plugin for houdini",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -82,13 +82,13 @@
|
|
|
82
82
|
}
|
|
83
83
|
},
|
|
84
84
|
"optionalDependencies": {
|
|
85
|
-
"houdini-react-darwin-x64": "2.
|
|
86
|
-
"houdini-react-darwin-arm64": "2.
|
|
87
|
-
"houdini-react-linux-x64": "2.
|
|
88
|
-
"houdini-react-linux-arm64": "2.
|
|
89
|
-
"houdini-react-win32-x64": "2.
|
|
90
|
-
"houdini-react-win32-arm64": "2.
|
|
91
|
-
"houdini-react-wasm": "2.
|
|
85
|
+
"houdini-react-darwin-x64": "2.2.0",
|
|
86
|
+
"houdini-react-darwin-arm64": "2.2.0",
|
|
87
|
+
"houdini-react-linux-x64": "2.2.0",
|
|
88
|
+
"houdini-react-linux-arm64": "2.2.0",
|
|
89
|
+
"houdini-react-win32-x64": "2.2.0",
|
|
90
|
+
"houdini-react-win32-arm64": "2.2.0",
|
|
91
|
+
"houdini-react-wasm": "2.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
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.2.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,51 @@
|
|
|
1
|
+
// Committed components hold a reference on their document store: two components
|
|
2
|
+
// rendering the same query+variables share one store through the suspense cache, so the
|
|
3
|
+
// store can only tear down when the LAST holder unmounts. The count is backed by a real
|
|
4
|
+
// (no-op) subscription because the underlying store runs its plugin cleanups (dropping
|
|
5
|
+
// the cache subscription) whenever its subscriber count touches zero — which react's
|
|
6
|
+
// strict-mode effect replay does to the component's own subscription.
|
|
7
|
+
|
|
8
|
+
// the only thing retain/release need from a store is subscribe
|
|
9
|
+
type Holdable = {
|
|
10
|
+
subscribe: (fn: (value: any) => void) => () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const observerRefs = new WeakMap<Holdable, { count: number; hold: () => void }>()
|
|
14
|
+
|
|
15
|
+
export function retainObserver(observer: Holdable) {
|
|
16
|
+
const entry = observerRefs.get(observer)
|
|
17
|
+
if (entry) {
|
|
18
|
+
entry.count++
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
observerRefs.set(observer, { count: 1, hold: observer.subscribe(() => {}) })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function releaseObserver(observer: Holdable) {
|
|
25
|
+
const entry = observerRefs.get(observer)
|
|
26
|
+
if (!entry) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
entry.count--
|
|
30
|
+
if (entry.count > 0) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
// defer the disposal a tick: strict mode releases and synchronously re-retains, and
|
|
34
|
+
// dropping the hold at zero immediately would tear the store down mid-replay
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
const current = observerRefs.get(observer)
|
|
37
|
+
if (!current || current.count > 0) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
observerRefs.delete(observer)
|
|
41
|
+
// dropping the hold makes the store's own last-unsubscriber teardown run the
|
|
42
|
+
// plugin cleanups (including the cache unsubscribe)
|
|
43
|
+
current.hold()
|
|
44
|
+
}, 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// whether any committed component currently holds the store (used to decide if an
|
|
48
|
+
// evicted suspense unit's store was abandoned and needs explicit disposal)
|
|
49
|
+
export function isObserverRetained(observer: Holdable): boolean {
|
|
50
|
+
return observerRefs.has(observer)
|
|
51
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createLRUCache } from 'houdini/runtime'
|
|
2
|
+
import type { GraphQLObject, GraphQLVariables, QueryArtifact, LRUCache } from 'houdini/runtime'
|
|
3
|
+
import type { Cache } from 'houdini/runtime/cache'
|
|
4
|
+
import type { DocumentStore } from 'houdini/runtime/client'
|
|
5
|
+
|
|
6
|
+
import { isObserverRetained } from './observerRefs.js'
|
|
7
|
+
import type { DocumentHandle } from './useDocumentHandle.js'
|
|
8
|
+
|
|
9
|
+
export type QuerySuspenseUnit<
|
|
10
|
+
_Data extends GraphQLObject = GraphQLObject,
|
|
11
|
+
_Input extends GraphQLVariables = GraphQLVariables,
|
|
12
|
+
> = {
|
|
13
|
+
resolve: () => void
|
|
14
|
+
resolved?: DocumentHandle<QueryArtifact, _Data, _Input>
|
|
15
|
+
// a failed fetch parks its error here (and resolves the thenable): the suspense
|
|
16
|
+
// protocol retries the render on resolution, and the retry throws this to the
|
|
17
|
+
// nearest error boundary. rejecting the thenable instead would make react retry a
|
|
18
|
+
// render that starts a brand new fetch — an error loop.
|
|
19
|
+
rejected?: unknown
|
|
20
|
+
then: (val: any) => any
|
|
21
|
+
// the store that started the fetch. suspending discards the component instance that
|
|
22
|
+
// created it, so the retry render has to pick this store back up — the cache
|
|
23
|
+
// subscription created by the fetch belongs to it, and a fresh store would never
|
|
24
|
+
// hear about later cache updates (a mutation write, a list operation)
|
|
25
|
+
observer: DocumentStore<GraphQLObject, GraphQLVariables>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// suspense state is scoped to the Cache instance: the browser has exactly one so the
|
|
29
|
+
// scoping is invisible there, but on the server the cache is created per request — and
|
|
30
|
+
// suspense units carry resolved query data, which can be session-dependent. a module-wide
|
|
31
|
+
// cache would let one request's render serve another user's data.
|
|
32
|
+
const promiseCaches = new WeakMap<Cache, LRUCache<QuerySuspenseUnit>>()
|
|
33
|
+
|
|
34
|
+
export function promiseCacheFor(cache: Cache): LRUCache<QuerySuspenseUnit> {
|
|
35
|
+
let result = promiseCaches.get(cache)
|
|
36
|
+
if (!result) {
|
|
37
|
+
result = createLRUCache<QuerySuspenseUnit>(1000, (unit) => {
|
|
38
|
+
// a unit leaving the cache whose store no committed component ever picked up is
|
|
39
|
+
// an abandoned suspense (suspended, then unmounted before commit) or an errored
|
|
40
|
+
// fetch — dispose the store so its cache subscription doesn't outlive it.
|
|
41
|
+
// retained stores are governed by their holders instead.
|
|
42
|
+
if (!isObserverRetained(unit.observer)) {
|
|
43
|
+
unit.observer.cleanup()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
promiseCaches.set(cache, result)
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// a session change invalidates every cached query result. the router clears its own
|
|
52
|
+
// caches (data_cache et al); this is the same sweep for useQuery's suspense state, so a
|
|
53
|
+
// session-dependent useQuery refetches instead of serving data fetched under the old
|
|
54
|
+
// session. clearing evicts every unit (disposing unretained stores); mounted components
|
|
55
|
+
// re-render off the session context, miss the cache, and re-suspend into a fresh fetch.
|
|
56
|
+
export function invalidateSuspenseCache(cache: Cache) {
|
|
57
|
+
promiseCaches.get(cache)?.clear()
|
|
58
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { createLRUCache } from 'houdini/runtime'
|
|
2
1
|
import type { GraphQLObject, CachePolicies, QueryArtifact, GraphQLVariables } from 'houdini/runtime'
|
|
2
|
+
import type { DocumentStore } from 'houdini/runtime/client'
|
|
3
3
|
import React from 'react'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { GraphQLErrors, useRouterContext } from '../routing/index.js'
|
|
6
|
+
import { releaseObserver, retainObserver } from './observerRefs.js'
|
|
7
|
+
import { promiseCacheFor, type QuerySuspenseUnit } from './suspenseCache.js'
|
|
6
8
|
import type { DocumentHandle } from './useDocumentHandle.js'
|
|
7
9
|
import { useDocumentHandle } from './useDocumentHandle.js'
|
|
8
10
|
import { useIsMountedRef } from './useIsMounted.js'
|
|
@@ -17,16 +19,9 @@ import { useIsMountedRef } from './useIsMounted.js'
|
|
|
17
19
|
// - If we have a cached promise that's been resolved, we should return that value
|
|
18
20
|
//
|
|
19
21
|
// When the Component unmounts, we need to remove the entry from the cache (so we can load again)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
_Data extends GraphQLObject = GraphQLObject,
|
|
24
|
-
_Input extends GraphQLVariables = GraphQLVariables,
|
|
25
|
-
> = {
|
|
26
|
-
resolve: () => void
|
|
27
|
-
resolved?: DocumentHandle<QueryArtifact, _Data, _Input>
|
|
28
|
-
then: (val: any) => any
|
|
29
|
-
}
|
|
22
|
+
//
|
|
23
|
+
// The suspense state itself lives in suspenseCache.ts, scoped per Cache instance (i.e.
|
|
24
|
+
// per request on the server) and invalidated on session changes.
|
|
30
25
|
|
|
31
26
|
export function useQueryHandle<
|
|
32
27
|
_Artifact extends QueryArtifact,
|
|
@@ -37,22 +32,45 @@ export function useQueryHandle<
|
|
|
37
32
|
variables: any = null,
|
|
38
33
|
config: UseQueryConfig = {}
|
|
39
34
|
): any {
|
|
35
|
+
// the client, the per-request cache (a singleton in the browser), and — during a
|
|
36
|
+
// server render — the stream injector all come from the router context
|
|
37
|
+
const { client, cache, injectToStream } = useRouterContext()
|
|
38
|
+
|
|
39
|
+
// suspense state lives on the per-request cache so SSR requests can't see each other's
|
|
40
|
+
const promiseCache = promiseCacheFor(cache)
|
|
41
|
+
|
|
40
42
|
// figure out the identifier so we know what to look for
|
|
41
43
|
const identifier = queryIdentifier({ artifact, variables, config })
|
|
42
44
|
|
|
43
45
|
// see if we have an entry in the cache for the identifier
|
|
44
46
|
const suspenseValue = promiseCache.get(identifier)
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
// a failed fetch: surface the error to the nearest boundary, and drop the unit so a
|
|
49
|
+
// later mount (e.g. navigating back after the error boundary took over) retries
|
|
50
|
+
// instead of re-throwing the stale error forever
|
|
51
|
+
if (suspenseValue?.rejected) {
|
|
52
|
+
promiseCache.delete(identifier)
|
|
53
|
+
throw suspenseValue.rejected
|
|
54
|
+
}
|
|
47
55
|
|
|
48
56
|
const isMountedRef = useIsMountedRef()
|
|
49
57
|
|
|
50
|
-
// hold onto an observer we'll use
|
|
58
|
+
// hold onto an observer we'll use. if a fetch for this identifier already started, we
|
|
59
|
+
// have to reuse the store that started it: suspending threw away the component
|
|
60
|
+
// instance that created it, and the cache subscription set up by that fetch belongs
|
|
61
|
+
// to that store — a fresh one would render fine but never hear about later cache
|
|
62
|
+
// updates. the initial value has to stay null (not an empty object) until the
|
|
63
|
+
// suspense promise resolves: every "do we have data yet" check below is a truthiness
|
|
64
|
+
// check, and a truthy empty object makes a re-render that happens mid-flight (eg a
|
|
65
|
+
// parent state update) commit the component with empty data instead of re-throwing
|
|
66
|
+
// the pending promise.
|
|
51
67
|
const [observer] = React.useState(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
() =>
|
|
69
|
+
(suspenseValue?.observer as DocumentStore<_Data, _Input> | undefined) ??
|
|
70
|
+
client.observe<_Data, _Input>({
|
|
71
|
+
artifact,
|
|
72
|
+
initialValue: (suspenseValue?.resolved?.data ?? null) as _Data,
|
|
73
|
+
})
|
|
56
74
|
)
|
|
57
75
|
|
|
58
76
|
// a ref flag we'll enable before throwing so that we don't update while suspend
|
|
@@ -74,8 +92,14 @@ export function useQueryHandle<
|
|
|
74
92
|
[observer, isMountedRef.current]
|
|
75
93
|
)
|
|
76
94
|
|
|
77
|
-
// get a safe reference to the cache
|
|
78
|
-
|
|
95
|
+
// get a safe reference to the cache. the server snapshot is what lets this hook render
|
|
96
|
+
// during SSR at all: without it react throws before the fetch ever starts and the whole
|
|
97
|
+
// subtree falls back to client rendering.
|
|
98
|
+
const storeValue = React.useSyncExternalStore(
|
|
99
|
+
subscribe,
|
|
100
|
+
() => box.current,
|
|
101
|
+
() => box.current
|
|
102
|
+
)
|
|
79
103
|
|
|
80
104
|
// compute the imperative handle for this artifact
|
|
81
105
|
const handle = useDocumentHandle<_Artifact, _Data, _Input>({
|
|
@@ -92,13 +116,24 @@ export function useQueryHandle<
|
|
|
92
116
|
}
|
|
93
117
|
}, [identifier])
|
|
94
118
|
|
|
95
|
-
//
|
|
119
|
+
// a committed component holds a reference on its store; the store tears down when
|
|
120
|
+
// the last holder unmounts (see observerRefs.ts)
|
|
96
121
|
React.useEffect(() => {
|
|
122
|
+
retainObserver(observer)
|
|
97
123
|
return () => {
|
|
98
|
-
observer
|
|
124
|
+
releaseObserver(observer)
|
|
99
125
|
}
|
|
100
126
|
}, [observer])
|
|
101
127
|
|
|
128
|
+
// suspenseTracker mutes store notifications while we're suspended. on the initial
|
|
129
|
+
// mount the flag dies with the discarded pre-commit instance, but when a committed
|
|
130
|
+
// instance re-suspends (its variables changed) the flag flips on its own ref and
|
|
131
|
+
// nothing else clears it — the subscription would stay muted forever. effects only
|
|
132
|
+
// run for committed (non-throwing) renders, so this is the spot to unmute.
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
suspenseTracker.current = false
|
|
135
|
+
})
|
|
136
|
+
|
|
102
137
|
// if the promise has resolved, let's use that for our first render
|
|
103
138
|
const result = storeValue.data
|
|
104
139
|
|
|
@@ -106,17 +141,18 @@ export function useQueryHandle<
|
|
|
106
141
|
// we are going to cache the promise and then throw it
|
|
107
142
|
// when it resolves the cached value will be updated
|
|
108
143
|
// and it will be picked up in the next render
|
|
144
|
+
// note: the thenable only ever resolves — failures park on suspenseUnit.rejected
|
|
145
|
+
// and resolve, so the retry render throws them (see the rejected check above)
|
|
109
146
|
let resolve: () => void = () => {}
|
|
110
|
-
|
|
111
|
-
const loadPromise = new Promise<void>((res, rej) => {
|
|
147
|
+
const loadPromise = new Promise<void>((res) => {
|
|
112
148
|
resolve = res
|
|
113
|
-
reject = rej
|
|
114
149
|
})
|
|
115
150
|
|
|
116
151
|
const suspenseUnit: QuerySuspenseUnit<_Data, _Input> = {
|
|
117
152
|
// biome-ignore lint/suspicious/noThenProperty: suspense protocol requires a thenable
|
|
118
153
|
then: loadPromise.then.bind(loadPromise),
|
|
119
154
|
resolve,
|
|
155
|
+
observer: observer as unknown as DocumentStore<GraphQLObject, GraphQLVariables>,
|
|
120
156
|
// @ts-expect-error
|
|
121
157
|
variables,
|
|
122
158
|
}
|
|
@@ -135,6 +171,18 @@ export function useQueryHandle<
|
|
|
135
171
|
},
|
|
136
172
|
})
|
|
137
173
|
.then((value) => {
|
|
174
|
+
// a graphql error must reach the error boundary, not resolve the suspense
|
|
175
|
+
// with null data (the component would crash reading its fields). same error
|
|
176
|
+
// shape route queries throw, so +error boundaries see the graphql errors.
|
|
177
|
+
// park it on the unit and resolve — the retry render throws it (see the
|
|
178
|
+
// rejected check above; rejecting the thenable would make react retry into
|
|
179
|
+
// a brand new fetch, an error loop)
|
|
180
|
+
if (value.errors && value.errors.length > 0) {
|
|
181
|
+
suspenseUnit.rejected = new GraphQLErrors(value.errors)
|
|
182
|
+
suspenseUnit.resolve()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
138
186
|
// the final value
|
|
139
187
|
suspenseUnit.resolved = {
|
|
140
188
|
...handle,
|
|
@@ -143,11 +191,39 @@ export function useQueryHandle<
|
|
|
143
191
|
artifact,
|
|
144
192
|
} as unknown as DocumentHandle<QueryArtifact, _Data, _Input>
|
|
145
193
|
|
|
194
|
+
// on the server, ship the resolved cache snapshot to the browser the same way
|
|
195
|
+
// route queries stream theirs: hydration then serves this query straight from
|
|
196
|
+
// the cache instead of refetching over the network. (a query that resolves
|
|
197
|
+
// before the shell flushes doesn't need this — its data rides the initial
|
|
198
|
+
// cache snapshot the server embeds in the document — and the injector wrapper
|
|
199
|
+
// no-ops in that window.)
|
|
200
|
+
if (!globalThis.window) {
|
|
201
|
+
injectToStream?.(`
|
|
202
|
+
<script>
|
|
203
|
+
{
|
|
204
|
+
const __houdini__snapshot__ = ${cache.serialize()}
|
|
205
|
+
if (window.__houdini__cache__) {
|
|
206
|
+
// hydrate into a fresh layer and merge it down, rather than clobbering the
|
|
207
|
+
// shared hydration layer (which would drop everything hydrated before it)
|
|
208
|
+
const __houdini__layer__ = window.__houdini__cache__.hydrate(__houdini__snapshot__)
|
|
209
|
+
if (__houdini__layer__) {
|
|
210
|
+
window.__houdini__cache__._internal_unstable.storage.resolveLayer(__houdini__layer__.id)
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
(window.__houdini__pending_cache__ = window.__houdini__pending_cache__ || []).push(__houdini__snapshot__)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
</script>
|
|
217
|
+
`)
|
|
218
|
+
}
|
|
219
|
+
|
|
146
220
|
suspenseUnit.resolve()
|
|
147
221
|
})
|
|
148
222
|
.catch((err) => {
|
|
149
|
-
|
|
150
|
-
|
|
223
|
+
// same protocol as graphql errors: park and resolve so the retry render
|
|
224
|
+
// throws to the boundary instead of starting a fresh fetch
|
|
225
|
+
suspenseUnit.rejected = err
|
|
226
|
+
suspenseUnit.resolve()
|
|
151
227
|
})
|
|
152
228
|
suspenseTracker.current = true
|
|
153
229
|
throw suspenseUnit
|
package/runtime/index.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { escapeScriptTag } from '../escape.js'
|
|
24
24
|
import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
|
|
25
25
|
import type { Goto } from '../routes.js'
|
|
26
|
+
import { invalidateSuspenseCache } from '../hooks/suspenseCache.js'
|
|
26
27
|
import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
|
|
27
28
|
import { useDocumentStore } from '../hooks/useDocumentStore.js'
|
|
28
29
|
import { type SuspenseCache, suspense_cache } from './cache.js'
|
|
@@ -931,6 +932,7 @@ export function RouterContextProvider({
|
|
|
931
932
|
session: ssrSession = {},
|
|
932
933
|
formResult = null,
|
|
933
934
|
formToken = null,
|
|
935
|
+
injectToStream,
|
|
934
936
|
}: {
|
|
935
937
|
children: React.ReactNode
|
|
936
938
|
client: HoudiniClient
|
|
@@ -943,6 +945,7 @@ export function RouterContextProvider({
|
|
|
943
945
|
session?: App.Session
|
|
944
946
|
formResult?: FormResult | null
|
|
945
947
|
formToken?: string | null
|
|
948
|
+
injectToStream?: (chunk: string) => void
|
|
946
949
|
}) {
|
|
947
950
|
// the session is top level state
|
|
948
951
|
// on the server, we can just use
|
|
@@ -974,12 +977,12 @@ export function RouterContextProvider({
|
|
|
974
977
|
// navigation no longer clears the data cache, so without this an event-driven
|
|
975
978
|
// session change (updateLocalSession) would keep serving results fetched under the
|
|
976
979
|
// old session
|
|
977
|
-
invalidate_session_caches({ data_cache, ssr_signals, last_variables })
|
|
980
|
+
invalidate_session_caches({ cache, data_cache, ssr_signals, last_variables })
|
|
978
981
|
|
|
979
982
|
latestSession.current = merge ? { ...latestSession.current, ...next } : next
|
|
980
983
|
setSession(latestSession.current)
|
|
981
984
|
},
|
|
982
|
-
[data_cache, ssr_signals, last_variables]
|
|
985
|
+
[cache, data_cache, ssr_signals, last_variables]
|
|
983
986
|
)
|
|
984
987
|
|
|
985
988
|
React.useEffect(() => {
|
|
@@ -1017,6 +1020,7 @@ export function RouterContextProvider({
|
|
|
1017
1020
|
},
|
|
1018
1021
|
formResult,
|
|
1019
1022
|
formToken,
|
|
1023
|
+
injectToStream,
|
|
1020
1024
|
}}
|
|
1021
1025
|
>
|
|
1022
1026
|
{children}
|
|
@@ -1071,6 +1075,11 @@ export type RouterContext = {
|
|
|
1071
1075
|
// the session-bound CSRF token forms render in their hidden field (always present from a
|
|
1072
1076
|
// server render; null only when there is no server, e.g. a static export).
|
|
1073
1077
|
formToken: string | null
|
|
1078
|
+
|
|
1079
|
+
// present only during a server render: appends a chunk to the response stream. queries
|
|
1080
|
+
// that resolve after the shell flushes use this to ship their cache snapshot to the
|
|
1081
|
+
// browser (route queries via load_query, component-level useQuery via useQueryHandle).
|
|
1082
|
+
injectToStream?: (chunk: string) => void
|
|
1074
1083
|
}
|
|
1075
1084
|
|
|
1076
1085
|
// FormResult mirrors the server's injected shape: a no-JS submission's result keyed by
|
|
@@ -1128,6 +1137,7 @@ export function updateLocalSession(session: App.Session, merge = false) {
|
|
|
1128
1137
|
// suspends into its loading state and refetches). One helper shared by both session-change
|
|
1129
1138
|
// paths (updateSession and the HOUDINI_SESSION_EVENT listener) so they can't drift.
|
|
1130
1139
|
function invalidate_session_caches(caches: {
|
|
1140
|
+
cache: Cache
|
|
1131
1141
|
data_cache: SuspenseCache<DocumentStore<GraphQLObject, GraphQLVariables>>
|
|
1132
1142
|
ssr_signals: PendingCache
|
|
1133
1143
|
last_variables: LRUCache<GraphQLVariables>
|
|
@@ -1135,6 +1145,14 @@ function invalidate_session_caches(caches: {
|
|
|
1135
1145
|
caches.data_cache.clear()
|
|
1136
1146
|
caches.ssr_signals.clear()
|
|
1137
1147
|
caches.last_variables.clear()
|
|
1148
|
+
// useQuery's suspense state is keyed off the Cache instance and holds resolved query
|
|
1149
|
+
// data too — sweep it as well so session-dependent useQuery components refetch
|
|
1150
|
+
invalidateSuspenseCache(caches.cache)
|
|
1151
|
+
// the normalized cache still holds values fetched under the old session. mark it all
|
|
1152
|
+
// stale (not clear — the data can render while revalidating) so a refetch whose
|
|
1153
|
+
// variables didn't change with the session still continues to the network instead of
|
|
1154
|
+
// being served the old session's value from cache.
|
|
1155
|
+
caches.cache.markTypeStale()
|
|
1138
1156
|
}
|
|
1139
1157
|
|
|
1140
1158
|
export function useSession(): [
|