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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "houdini-react",
3
- "version": "2.1.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.1.2",
86
- "houdini-react-darwin-arm64": "2.1.2",
87
- "houdini-react-linux-x64": "2.1.2",
88
- "houdini-react-linux-arm64": "2.1.2",
89
- "houdini-react-win32-x64": "2.1.2",
90
- "houdini-react-win32-arm64": "2.1.2",
91
- "houdini-react-wasm": "2.1.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.1.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 { useClient } from '../routing/index.js'
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
- const promiseCache = createLRUCache<QuerySuspenseUnit>()
22
- type QuerySuspenseUnit<
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
- const client = useClient()
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
- client.observe<_Data, _Input>({
53
- artifact,
54
- initialValue: (suspenseValue?.resolved?.data ?? {}) as _Data,
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
- const storeValue = React.useSyncExternalStore(subscribe, () => box.current)
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
- // when we unmount, we need to clean up
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.cleanup()
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
- let reject: (reason?: any) => void = () => {}
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
- promiseCache.delete(identifier)
150
- reject(err)
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
@@ -66,6 +66,7 @@ export function Router({
66
66
  session={session}
67
67
  formResult={formResult}
68
68
  formToken={formToken}
69
+ injectToStream={injectToStream}
69
70
  >
70
71
  <RouterImpl
71
72
  initialURL={initialURL}
@@ -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(): [