houdini-react 2.0.0-next.9 → 2.0.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.
Files changed (185) hide show
  1. package/bin/houdini-react +173 -0
  2. package/package.json +63 -39
  3. package/postInstall.js +360 -0
  4. package/runtime/Link.tsx +50 -0
  5. package/runtime/client.ts +5 -0
  6. package/runtime/clientPlugin.ts +17 -0
  7. package/runtime/componentFields.ts +79 -0
  8. package/runtime/escape.ts +15 -0
  9. package/runtime/hooks/index.ts +17 -0
  10. package/runtime/hooks/recycleNodesInto.ts +60 -0
  11. package/runtime/hooks/useDeepCompareEffect.ts +93 -0
  12. package/runtime/hooks/useDocumentHandle.ts +243 -0
  13. package/runtime/hooks/useDocumentStore.ts +112 -0
  14. package/runtime/hooks/useDocumentSubscription.ts +65 -0
  15. package/runtime/hooks/useFragment.ts +269 -0
  16. package/runtime/hooks/useFragmentHandle.ts +274 -0
  17. package/runtime/hooks/useIsMounted.ts +14 -0
  18. package/runtime/hooks/useLogoutForm.tsx +60 -0
  19. package/runtime/hooks/useMutation.ts +80 -0
  20. package/runtime/hooks/useMutationForm.tsx +163 -0
  21. package/runtime/hooks/useQuery.ts +12 -0
  22. package/runtime/hooks/useQueryHandle.ts +194 -0
  23. package/runtime/hooks/useSubscription.ts +12 -0
  24. package/runtime/hooks/useSubscriptionHandle.ts +42 -0
  25. package/runtime/hydration.tsx +186 -0
  26. package/runtime/index.tsx +77 -0
  27. package/runtime/login.ts +22 -0
  28. package/runtime/manifest.ts +6 -0
  29. package/runtime/mock.ts +9 -0
  30. package/runtime/package.json +1 -0
  31. package/runtime/resolve-href.ts +151 -0
  32. package/runtime/routes.ts +93 -0
  33. package/runtime/routing/Router.tsx +1101 -0
  34. package/runtime/routing/cache.ts +57 -0
  35. package/runtime/routing/errors.tsx +145 -0
  36. package/runtime/routing/index.ts +17 -0
  37. package/runtime/testing.tsx +163 -0
  38. package/runtime/tsconfig.json +39 -0
  39. package/server/index.d.ts +1 -0
  40. package/server/index.js +4 -0
  41. package/server/react-streaming.d.js +0 -0
  42. package/vite/index.d.ts +3 -0
  43. package/vite/index.js +397 -0
  44. package/vite/strip-headers.d.ts +1 -0
  45. package/vite/strip-headers.js +35 -0
  46. package/vite/transform.d.ts +13 -0
  47. package/vite/transform.js +105 -0
  48. package/README.md +0 -36
  49. package/build/plugin/codegen/entries/documentWrappers.d.ts +0 -6
  50. package/build/plugin/codegen/entries/fallbacks.d.ts +0 -5
  51. package/build/plugin/codegen/entries/index.d.ts +0 -16
  52. package/build/plugin/codegen/entries/pages.d.ts +0 -2
  53. package/build/plugin/codegen/index.d.ts +0 -17
  54. package/build/plugin/codegen/manifest.d.ts +0 -5
  55. package/build/plugin/codegen/render.d.ts +0 -7
  56. package/build/plugin/codegen/router.d.ts +0 -7
  57. package/build/plugin/codegen/typeRoot.d.ts +0 -5
  58. package/build/plugin/config.d.ts +0 -4
  59. package/build/plugin/dedent.d.ts +0 -1
  60. package/build/plugin/extract.d.ts +0 -6
  61. package/build/plugin/index.d.ts +0 -5
  62. package/build/plugin/state.d.ts +0 -3
  63. package/build/plugin/transform.d.ts +0 -6
  64. package/build/plugin/vite.d.ts +0 -27
  65. package/build/plugin-cjs/index.js +0 -90119
  66. package/build/plugin-cjs/package.json +0 -1
  67. package/build/plugin-esm/index.js +0 -90115
  68. package/build/runtime/client.d.ts +0 -3
  69. package/build/runtime/clientPlugin.d.ts +0 -3
  70. package/build/runtime/componentFields.d.ts +0 -9
  71. package/build/runtime/hooks/index.d.ts +0 -8
  72. package/build/runtime/hooks/useDeepCompareEffect.d.ts +0 -35
  73. package/build/runtime/hooks/useDocumentHandle.d.ts +0 -36
  74. package/build/runtime/hooks/useDocumentStore.d.ts +0 -11
  75. package/build/runtime/hooks/useDocumentSubscription.d.ts +0 -11
  76. package/build/runtime/hooks/useFragment.d.ts +0 -16
  77. package/build/runtime/hooks/useFragmentHandle.d.ts +0 -8
  78. package/build/runtime/hooks/useIsMounted.d.ts +0 -3
  79. package/build/runtime/hooks/useMutation.d.ts +0 -14
  80. package/build/runtime/hooks/useQuery.d.ts +0 -5
  81. package/build/runtime/hooks/useQueryHandle.d.ts +0 -10
  82. package/build/runtime/hooks/useSubscription.d.ts +0 -4
  83. package/build/runtime/hooks/useSubscriptionHandle.d.ts +0 -25
  84. package/build/runtime/index.d.ts +0 -14
  85. package/build/runtime/manifest.d.ts +0 -3
  86. package/build/runtime/routing/Router.d.ts +0 -62
  87. package/build/runtime/routing/cache.d.ts +0 -7
  88. package/build/runtime/routing/hooks.d.ts +0 -40
  89. package/build/runtime/routing/index.d.ts +0 -3
  90. package/build/runtime-cjs/client.d.ts +0 -3
  91. package/build/runtime-cjs/client.js +0 -25
  92. package/build/runtime-cjs/clientPlugin.d.ts +0 -3
  93. package/build/runtime-cjs/clientPlugin.js +0 -37
  94. package/build/runtime-cjs/componentFields.d.ts +0 -9
  95. package/build/runtime-cjs/componentFields.js +0 -83
  96. package/build/runtime-cjs/hooks/index.d.ts +0 -8
  97. package/build/runtime-cjs/hooks/index.js +0 -45
  98. package/build/runtime-cjs/hooks/useDeepCompareEffect.d.ts +0 -35
  99. package/build/runtime-cjs/hooks/useDeepCompareEffect.js +0 -76
  100. package/build/runtime-cjs/hooks/useDocumentHandle.d.ts +0 -36
  101. package/build/runtime-cjs/hooks/useDocumentHandle.js +0 -177
  102. package/build/runtime-cjs/hooks/useDocumentStore.d.ts +0 -11
  103. package/build/runtime-cjs/hooks/useDocumentStore.js +0 -76
  104. package/build/runtime-cjs/hooks/useDocumentSubscription.d.ts +0 -11
  105. package/build/runtime-cjs/hooks/useDocumentSubscription.js +0 -76
  106. package/build/runtime-cjs/hooks/useFragment.d.ts +0 -16
  107. package/build/runtime-cjs/hooks/useFragment.js +0 -102
  108. package/build/runtime-cjs/hooks/useFragmentHandle.d.ts +0 -8
  109. package/build/runtime-cjs/hooks/useFragmentHandle.js +0 -47
  110. package/build/runtime-cjs/hooks/useIsMounted.d.ts +0 -3
  111. package/build/runtime-cjs/hooks/useIsMounted.js +0 -38
  112. package/build/runtime-cjs/hooks/useMutation.d.ts +0 -14
  113. package/build/runtime-cjs/hooks/useMutation.js +0 -67
  114. package/build/runtime-cjs/hooks/useQuery.d.ts +0 -5
  115. package/build/runtime-cjs/hooks/useQuery.js +0 -32
  116. package/build/runtime-cjs/hooks/useQueryHandle.d.ts +0 -10
  117. package/build/runtime-cjs/hooks/useQueryHandle.js +0 -131
  118. package/build/runtime-cjs/hooks/useSubscription.d.ts +0 -4
  119. package/build/runtime-cjs/hooks/useSubscription.js +0 -32
  120. package/build/runtime-cjs/hooks/useSubscriptionHandle.d.ts +0 -25
  121. package/build/runtime-cjs/hooks/useSubscriptionHandle.js +0 -42
  122. package/build/runtime-cjs/index.d.ts +0 -14
  123. package/build/runtime-cjs/index.js +0 -88
  124. package/build/runtime-cjs/manifest.d.ts +0 -3
  125. package/build/runtime-cjs/manifest.js +0 -25
  126. package/build/runtime-cjs/package.json +0 -1
  127. package/build/runtime-cjs/routing/Router.d.ts +0 -62
  128. package/build/runtime-cjs/routing/Router.js +0 -540
  129. package/build/runtime-cjs/routing/cache.d.ts +0 -7
  130. package/build/runtime-cjs/routing/cache.js +0 -61
  131. package/build/runtime-cjs/routing/hooks.d.ts +0 -40
  132. package/build/runtime-cjs/routing/hooks.js +0 -93
  133. package/build/runtime-cjs/routing/index.d.ts +0 -3
  134. package/build/runtime-cjs/routing/index.js +0 -33
  135. package/build/runtime-esm/client.d.ts +0 -3
  136. package/build/runtime-esm/client.js +0 -5
  137. package/build/runtime-esm/clientPlugin.d.ts +0 -3
  138. package/build/runtime-esm/clientPlugin.js +0 -17
  139. package/build/runtime-esm/componentFields.d.ts +0 -9
  140. package/build/runtime-esm/componentFields.js +0 -59
  141. package/build/runtime-esm/hooks/index.d.ts +0 -8
  142. package/build/runtime-esm/hooks/index.js +0 -15
  143. package/build/runtime-esm/hooks/useDeepCompareEffect.d.ts +0 -35
  144. package/build/runtime-esm/hooks/useDeepCompareEffect.js +0 -41
  145. package/build/runtime-esm/hooks/useDocumentHandle.d.ts +0 -36
  146. package/build/runtime-esm/hooks/useDocumentHandle.js +0 -143
  147. package/build/runtime-esm/hooks/useDocumentStore.d.ts +0 -11
  148. package/build/runtime-esm/hooks/useDocumentStore.js +0 -42
  149. package/build/runtime-esm/hooks/useDocumentSubscription.d.ts +0 -11
  150. package/build/runtime-esm/hooks/useDocumentSubscription.js +0 -42
  151. package/build/runtime-esm/hooks/useFragment.d.ts +0 -16
  152. package/build/runtime-esm/hooks/useFragment.js +0 -67
  153. package/build/runtime-esm/hooks/useFragmentHandle.d.ts +0 -8
  154. package/build/runtime-esm/hooks/useFragmentHandle.js +0 -23
  155. package/build/runtime-esm/hooks/useIsMounted.d.ts +0 -3
  156. package/build/runtime-esm/hooks/useIsMounted.js +0 -14
  157. package/build/runtime-esm/hooks/useMutation.d.ts +0 -14
  158. package/build/runtime-esm/hooks/useMutation.js +0 -42
  159. package/build/runtime-esm/hooks/useQuery.d.ts +0 -5
  160. package/build/runtime-esm/hooks/useQuery.js +0 -8
  161. package/build/runtime-esm/hooks/useQueryHandle.d.ts +0 -10
  162. package/build/runtime-esm/hooks/useQueryHandle.js +0 -97
  163. package/build/runtime-esm/hooks/useSubscription.d.ts +0 -4
  164. package/build/runtime-esm/hooks/useSubscription.js +0 -8
  165. package/build/runtime-esm/hooks/useSubscriptionHandle.d.ts +0 -25
  166. package/build/runtime-esm/hooks/useSubscriptionHandle.js +0 -18
  167. package/build/runtime-esm/index.d.ts +0 -14
  168. package/build/runtime-esm/index.js +0 -48
  169. package/build/runtime-esm/manifest.d.ts +0 -3
  170. package/build/runtime-esm/manifest.js +0 -5
  171. package/build/runtime-esm/routing/Router.d.ts +0 -62
  172. package/build/runtime-esm/routing/Router.js +0 -499
  173. package/build/runtime-esm/routing/cache.d.ts +0 -7
  174. package/build/runtime-esm/routing/cache.js +0 -36
  175. package/build/runtime-esm/routing/hooks.d.ts +0 -40
  176. package/build/runtime-esm/routing/hooks.js +0 -53
  177. package/build/runtime-esm/routing/index.d.ts +0 -3
  178. package/build/runtime-esm/routing/index.js +0 -6
  179. package/build/server/index.d.ts +0 -1
  180. package/build/server-cjs/index.js +0 -28
  181. package/build/server-cjs/package.json +0 -1
  182. package/build/server-esm/index.js +0 -4
  183. package/build/server-esm/package.json +0 -1
  184. /package/{build/plugin-esm → server}/package.json +0 -0
  185. /package/{build/runtime-esm → vite}/package.json +0 -0
@@ -0,0 +1,65 @@
1
+ import type {
2
+ DocumentArtifact,
3
+ GraphQLVariables,
4
+ QueryResult,
5
+ GraphQLObject,
6
+ } from 'houdini/runtime'
7
+ import type { DocumentStore, SendParams } from 'houdini/runtime/client'
8
+
9
+ import { useSession } from '../routing/Router.js'
10
+ import useDeepCompareEffect from './useDeepCompareEffect.js'
11
+ import { useDocumentStore, type UseDocumentStoreParams } from './useDocumentStore.js'
12
+
13
+ export function useDocumentSubscription<
14
+ _Artifact extends DocumentArtifact = DocumentArtifact,
15
+ _Data extends GraphQLObject = GraphQLObject,
16
+ _Input extends GraphQLVariables = GraphQLVariables,
17
+ >({
18
+ artifact,
19
+ variables,
20
+ send,
21
+ initialState,
22
+ disabled,
23
+ ...observeParams
24
+ }: UseDocumentStoreParams<_Artifact, _Data, _Input> & {
25
+ variables: _Input
26
+ disabled?: boolean
27
+ send?: Partial<Omit<SendParams, 'initialState'>>
28
+ }): [QueryResult<_Data, _Input> & { parent?: string | null }, DocumentStore<_Data, _Input>] {
29
+ const [storeValue, observer] = useDocumentStore<_Data, _Input>({
30
+ artifact,
31
+ initialState,
32
+ ...observeParams,
33
+ })
34
+
35
+ // grab the current session value
36
+ const [session] = useSession()
37
+
38
+ // whenever the variables change, we need to retrigger the query
39
+ useDeepCompareEffect(() => {
40
+ if (!disabled) {
41
+ observer.send({
42
+ variables,
43
+ session,
44
+ // TODO: metadata
45
+ metadata: {},
46
+ ...send,
47
+ initialState,
48
+ })
49
+ }
50
+
51
+ return () => {
52
+ if (!disabled) {
53
+ observer.cleanup()
54
+ }
55
+ }
56
+ }, [disabled, session, observer, variables ?? {}, send ?? {}])
57
+
58
+ return [
59
+ {
60
+ parent: send?.stuff?.parentID,
61
+ ...storeValue,
62
+ },
63
+ observer,
64
+ ]
65
+ }
@@ -0,0 +1,269 @@
1
+ import { fragmentKey } from 'houdini/runtime'
2
+ import type {
3
+ GraphQLObject,
4
+ GraphQLVariables,
5
+ FragmentArtifact,
6
+ QueryResult,
7
+ SubscriptionSpec,
8
+ } from 'houdini/runtime'
9
+ import * as React from 'react'
10
+
11
+ import { useRouterContext } from '../routing/index.js'
12
+ import { useDocumentSubscription } from './useDocumentSubscription.js'
13
+
14
+ // useFragment reads a fragment's data back out of the cache. When the fragment is marked
15
+ // @plural it is spread on a list field, so the reference is an array of fragment references
16
+ // and the hook returns an array of data (see usePluralFragment below).
17
+
18
+ // plural overloads: the reference is an array of fragment references
19
+ export function useFragment<
20
+ _Data extends GraphQLObject,
21
+ _ReferenceType extends {},
22
+ _Input extends GraphQLVariables = GraphQLVariables,
23
+ >(
24
+ reference: ReadonlyArray<_Data | { ' $fragments': _ReferenceType }>,
25
+ document: { artifact: FragmentArtifact }
26
+ ): _Data[]
27
+ export function useFragment<
28
+ _Data extends GraphQLObject,
29
+ _ReferenceType extends {},
30
+ _Input extends GraphQLVariables = GraphQLVariables,
31
+ >(
32
+ reference: ReadonlyArray<_Data | { ' $fragments': _ReferenceType }> | null,
33
+ document: { artifact: FragmentArtifact }
34
+ ): _Data[] | null
35
+
36
+ // singular overload: the reference is a single fragment reference
37
+ export function useFragment<
38
+ _Data extends GraphQLObject,
39
+ _ReferenceType extends {},
40
+ _Input extends GraphQLVariables = GraphQLVariables,
41
+ >(
42
+ reference: _Data | { ' $fragments': _ReferenceType } | null,
43
+ document: { artifact: FragmentArtifact }
44
+ ): _Data | null
45
+
46
+ export function useFragment(reference: any, document: { artifact: FragmentArtifact }): any {
47
+ const plural = Boolean(document.artifact.plural)
48
+
49
+ // a non-plural fragment given a list of references is a mistake (the fragment needs
50
+ // @plural to be read as an array)
51
+ if (!plural && Array.isArray(reference)) {
52
+ throw new Error(
53
+ `useFragment received a list of references for "${document.artifact.name}", but it is not marked @plural.`
54
+ )
55
+ }
56
+
57
+ // Both implementations run on every render so the rules of hooks are preserved; the one
58
+ // that isn't relevant is fed a null reference and becomes a no-op. Which result we return
59
+ // is keyed off the static @plural artifact flag, so a given call-site is always consistent.
60
+ const singularResult = useSingularFragment(plural ? null : reference, document)
61
+ const pluralResult = usePluralFragment(plural ? (reference ?? null) : null, document)
62
+
63
+ return plural ? pluralResult : singularResult
64
+ }
65
+
66
+ function useSingularFragment<
67
+ _Data extends GraphQLObject,
68
+ _ReferenceType extends {},
69
+ _Input extends GraphQLVariables = GraphQLVariables,
70
+ >(
71
+ reference: _Data | { ' $fragments': _ReferenceType } | null,
72
+ document: { artifact: FragmentArtifact }
73
+ ): _Data | null {
74
+ const { cache } = useRouterContext()
75
+ const { parent, variables, loading } = fragmentReference<_Data, _Input, _ReferenceType>(
76
+ reference,
77
+ document
78
+ )
79
+
80
+ // Read from cache whenever the parent or loading state changes. The parent
81
+ // path uniquely identifies which cache record this fragment is bound to, so
82
+ // variables are excluded from the dep array — they are forwarded to
83
+ // observer.send() separately and don't affect which record we read.
84
+ // biome-ignore lint/correctness/useExhaustiveDependencies: variables intentionally excluded
85
+ const cachedValue = React.useMemo(() => {
86
+ if (reference && parent) {
87
+ return cache.read({
88
+ selection: document.artifact.selection,
89
+ parent,
90
+ variables,
91
+ loading,
92
+ }).data as _Data
93
+ }
94
+ return reference as _Data | null
95
+ }, [parent, loading])
96
+
97
+ // Stable initialState derived from cachedValue. useDocumentStore uses this to
98
+ // seed box.current synchronously during render when the parent changes, so
99
+ // storeValue.data is immediately correct without waiting for the subscription
100
+ // effect to fire. Must be memoized (reference-stable) so the store doesn't
101
+ // re-seed on every render.
102
+ // biome-ignore lint/correctness/useExhaustiveDependencies: variables changes don't require re-seeding
103
+ const initialState = React.useMemo(
104
+ (): QueryResult<_Data, _Input> | undefined =>
105
+ cachedValue !== null
106
+ ? {
107
+ data: cachedValue,
108
+ errors: null,
109
+ fetching: false,
110
+ partial: false,
111
+ stale: false,
112
+ source: null,
113
+ variables: variables ?? null,
114
+ }
115
+ : undefined,
116
+ [cachedValue]
117
+ )
118
+
119
+ const [storeValue] = useDocumentSubscription<FragmentArtifact, _Data, _Input>({
120
+ artifact: document.artifact,
121
+ variables,
122
+ initialValue: cachedValue,
123
+ // no parent means there is nothing to subscribe to (eg a null reference, or this
124
+ // singular hook running in no-op mode for a @plural fragment)
125
+ disabled: loading || !parent,
126
+ send: {
127
+ stuff: {
128
+ parentID: parent,
129
+ },
130
+ setup: true,
131
+ },
132
+ initialState,
133
+ })
134
+
135
+ return storeValue.data
136
+ }
137
+
138
+ // usePluralFragment consumes a @plural fragment: the reference is an array of fragment
139
+ // references (one per item in the list the fragment was spread on). Each item is bound to
140
+ // its own cache record, so we keep the list in state and register one cache subscription
141
+ // per item inside a single effect. A subscription message carries the new value for just
142
+ // that record, so we patch it into the list in place rather than re-reading everything.
143
+ // Doing the subscriptions in one effect keeps the hook count stable regardless of how many
144
+ // items the list contains (we can't call a hook per item).
145
+ function usePluralFragment<
146
+ _Data extends GraphQLObject,
147
+ _ReferenceType extends {},
148
+ _Input extends GraphQLVariables = GraphQLVariables,
149
+ >(
150
+ references: ReadonlyArray<_Data | { ' $fragments': _ReferenceType }> | null,
151
+ document: { artifact: FragmentArtifact }
152
+ ): _Data[] | null {
153
+ const { cache } = useRouterContext()
154
+ const artifact = document.artifact
155
+
156
+ // resolve the cache record + variables for each reference in the list
157
+ // biome-ignore lint/correctness/useExhaustiveDependencies: document is a stable import
158
+ const entries = React.useMemo(
159
+ () =>
160
+ references
161
+ ? references.map((reference) =>
162
+ fragmentReference<_Data, _Input, _ReferenceType>(reference, document)
163
+ )
164
+ : null,
165
+ [references]
166
+ )
167
+
168
+ // a stable key describing which records (and variables) we are bound to, so we only
169
+ // re-seed and re-subscribe when the set of records actually changes (eg an insert/remove)
170
+ const subscriptionKey = entries
171
+ ? entries
172
+ .map((entry) => `${entry.parent}:${JSON.stringify(entry.variables ?? {})}`)
173
+ .join('|')
174
+ : ''
175
+
176
+ // read every item from the cache. used to seed the list, and to re-seed it when the set
177
+ // of records changes (membership changes don't necessarily message the existing records).
178
+ const readAll = (): _Data[] | null => {
179
+ if (!references || !entries) {
180
+ return null
181
+ }
182
+ return entries.map(({ parent, variables, loading }, i) =>
183
+ parent
184
+ ? (cache.read({ selection: artifact.selection, parent, variables, loading })
185
+ .data as _Data)
186
+ : (references[i] as _Data)
187
+ )
188
+ }
189
+
190
+ const [data, setData] = React.useState<_Data[] | null>(readAll)
191
+
192
+ // the subscriptionKey the current `data` was seeded for; starts matching the initial
193
+ // useState seed so we don't redundantly re-seed on mount
194
+ const seededKey = React.useRef(subscriptionKey)
195
+
196
+ // re-seed when the set of records changes, then subscribe to each record individually.
197
+ // onMessage carries the new value for just that record, so we patch it in place.
198
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on subscriptionKey
199
+ React.useEffect(() => {
200
+ if (seededKey.current !== subscriptionKey) {
201
+ seededKey.current = subscriptionKey
202
+ setData(readAll())
203
+ }
204
+
205
+ if (!entries) {
206
+ return
207
+ }
208
+
209
+ const specs: SubscriptionSpec[] = []
210
+ entries.forEach(({ parent, variables, loading }, index) => {
211
+ if (!parent || loading) {
212
+ return
213
+ }
214
+ const spec: SubscriptionSpec = {
215
+ rootType: artifact.rootType,
216
+ kind: artifact.kind,
217
+ selection: artifact.selection,
218
+ parentID: parent,
219
+ variables: () => variables ?? {},
220
+ onMessage: (message) => {
221
+ // only a cache write ('update') carries a new value for this record. other
222
+ // messages (eg 'refetch' from cache.refresh()) don't apply to a fragment,
223
+ // which has no refetch of its own; the refreshed value arrives as an 'update'.
224
+ if (message.kind !== 'update') {
225
+ return
226
+ }
227
+ setData((current) => {
228
+ if (!current) {
229
+ return current
230
+ }
231
+ const next = current.slice()
232
+ next[index] = message.data as _Data
233
+ return next
234
+ })
235
+ },
236
+ }
237
+ cache.subscribe(spec)
238
+ specs.push(spec)
239
+ })
240
+
241
+ return () => {
242
+ for (const spec of specs) {
243
+ cache.unsubscribe(spec)
244
+ }
245
+ }
246
+ }, [subscriptionKey])
247
+
248
+ return data
249
+ }
250
+
251
+ export function fragmentReference<_Data extends GraphQLObject, _Input, _ReferenceType extends {}>(
252
+ reference: _Data | { ' $fragments': _ReferenceType } | null,
253
+ document: { artifact: FragmentArtifact }
254
+ ): { variables: _Input; parent: string; loading: boolean } {
255
+ // @ts-expect-error: typescript can't guarantee that the fragment key is defined
256
+ // but if its not, then the fragment wasn't mixed into the right thing
257
+ // the variables for the fragment live on the initial value's $fragment key
258
+ const { variables, parent } = reference?.[fragmentKey]?.values?.[document.artifact.name] ?? {}
259
+ if (reference && fragmentKey in reference && (!variables || !parent)) {
260
+ console.warn(
261
+ `⚠️ Parent does not contain the information for this fragment. Something is wrong.
262
+ Please ensure that you have passed a record that has ${document.artifact.name} mixed into it.`
263
+ )
264
+ }
265
+ // @ts-expect-error
266
+ const loading = Boolean(reference?.[fragmentKey]?.loading)
267
+
268
+ return { variables, parent, loading }
269
+ }
@@ -0,0 +1,274 @@
1
+ import {
2
+ extractPageInfo,
3
+ cursorHandlers,
4
+ offsetHandlers,
5
+ getCurrentConfig,
6
+ entityRefetchVariables,
7
+ } from 'houdini/runtime'
8
+ import type {
9
+ GraphQLObject,
10
+ FragmentArtifact,
11
+ QueryArtifact,
12
+ GraphQLVariables,
13
+ FetchFn,
14
+ } from 'houdini/runtime'
15
+ import * as React from 'react'
16
+
17
+ import { useClient, useSession } from '../routing/Router.js'
18
+ import type { DocumentHandle } from './useDocumentHandle.js'
19
+ import { fragmentReference, useFragment } from './useFragment.js'
20
+
21
+ // useFragmentHandle is just like useFragment except it also returns an imperative handle
22
+ // that users can use to interact with the fragment
23
+ export function useFragmentHandle<
24
+ _Artifact extends FragmentArtifact,
25
+ _Data extends GraphQLObject,
26
+ _ReferenceType extends {},
27
+ _PaginationArtifact extends QueryArtifact,
28
+ _Input extends GraphQLVariables = GraphQLVariables,
29
+ >(
30
+ reference: _Data | { ' $fragments': _ReferenceType } | null,
31
+ document: { artifact: FragmentArtifact; refetchArtifact?: QueryArtifact }
32
+ ): any {
33
+ const fragmentData = useFragment<_Data, _ReferenceType, _Input>(reference, document)
34
+ const { variables, loading } = fragmentReference<_Data, _Input, _ReferenceType>(
35
+ reference,
36
+ document
37
+ )
38
+
39
+ const client = useClient()
40
+ const [session] = useSession()
41
+
42
+ const [forwardPending, setForwardPending] = React.useState(false)
43
+ const [backwardPending, setBackwardPending] = React.useState(false)
44
+
45
+ const previousCursorsRef = React.useRef<(string | null)[]>([])
46
+ const nextCursorsRef = React.useRef<(string | null)[]>([])
47
+
48
+ const refetchArtifact = document.refetchArtifact as QueryArtifact | undefined
49
+ const refetchPath = refetchArtifact?.refetch?.path
50
+
51
+ const paginationObserver = React.useMemo(() => {
52
+ if (!refetchArtifact?.refetch?.paginated) return null
53
+ return client.observe<_Data, _Input>({ artifact: refetchArtifact })
54
+ }, [refetchArtifact?.name])
55
+
56
+ const isSinglePage = refetchArtifact?.refetch?.mode === 'SinglePage'
57
+
58
+ // Subscribe to the pagination observer so the component re-renders when a new page lands.
59
+ // For SinglePage we pass disablePartial so partial cache hits (entity found but connection
60
+ // not yet fetched) are never resolved back to the observer — we go straight to the network
61
+ // and update the observer only once we have a complete page.
62
+ const subscribeToObserver = React.useCallback(
63
+ (fn: () => void) => paginationObserver?.subscribe(() => fn()) ?? (() => {}),
64
+ [paginationObserver]
65
+ )
66
+ const getObserverSnapshot = React.useCallback(
67
+ () => paginationObserver?.state.data ?? null,
68
+ [paginationObserver]
69
+ )
70
+ const paginationData = React.useSyncExternalStore(
71
+ subscribeToObserver,
72
+ getObserverSnapshot,
73
+ getObserverSnapshot
74
+ )
75
+
76
+ const paginationEntityData = React.useMemo<_Data | null>(() => {
77
+ if (!paginationData || !refetchArtifact?.selection?.fields) return null
78
+ const rootField = Object.keys(refetchArtifact.selection.fields)[0]
79
+ if (!rootField) return null
80
+ return (paginationData as any)[rootField] ?? null
81
+ }, [paginationData, refetchArtifact])
82
+
83
+ // @refetchable fragments embed the fragment in a query keyed by id (paginated: false).
84
+ // We observe that query so refetch() can swap in fresh data computed with new argument
85
+ // values, just like SinglePage pagination swaps in the latest page.
86
+ const isRefetchable = !!refetchArtifact?.refetch && !refetchArtifact.refetch.paginated
87
+
88
+ const refetchObserver = React.useMemo(() => {
89
+ if (!isRefetchable || !refetchArtifact) return null
90
+ return client.observe<_Data, _Input>({ artifact: refetchArtifact })
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [refetchArtifact?.name, isRefetchable])
93
+
94
+ const subscribeToRefetch = React.useCallback(
95
+ (fn: () => void) => refetchObserver?.subscribe(() => fn()) ?? (() => {}),
96
+ [refetchObserver]
97
+ )
98
+ const getRefetchSnapshot = React.useCallback(
99
+ () => refetchObserver?.state.data ?? null,
100
+ [refetchObserver]
101
+ )
102
+ const refetchData = React.useSyncExternalStore(
103
+ subscribeToRefetch,
104
+ getRefetchSnapshot,
105
+ getRefetchSnapshot
106
+ )
107
+
108
+ const refetchEntityData = React.useMemo<_Data | null>(() => {
109
+ if (!refetchData || !refetchArtifact?.selection?.fields) return null
110
+ const rootField = Object.keys(refetchArtifact.selection.fields)[0]
111
+ if (!rootField) return null
112
+ return (refetchData as any)[rootField] ?? null
113
+ }, [refetchData, refetchArtifact])
114
+
115
+ const displayData =
116
+ isSinglePage && paginationEntityData !== null
117
+ ? paginationEntityData
118
+ : isRefetchable && refetchEntityData !== null
119
+ ? refetchEntityData
120
+ : fragmentData
121
+
122
+ const wrapLoad = <_Result>(
123
+ setLoading: (val: boolean) => void,
124
+ fn: (value: any) => Promise<_Result>
125
+ ) => {
126
+ return async (value: any) => {
127
+ setLoading(true)
128
+ let err: Error | null = null
129
+ let result: _Result | null = null
130
+ try {
131
+ result = await fn(value)
132
+ } catch (e) {
133
+ err = e as Error
134
+ }
135
+ setLoading(false)
136
+ if (err && err.name !== 'AbortError') throw err
137
+ return result
138
+ }
139
+ }
140
+
141
+ const handle = React.useMemo(() => {
142
+ if (!refetchArtifact?.refetch?.paginated || !paginationObserver) return null
143
+
144
+ const fetchFn: FetchFn<_Data, _Input> = (args) => {
145
+ return paginationObserver.send({
146
+ ...args,
147
+ session,
148
+ stuff: { silenceLoading: true },
149
+ cacheParams: {
150
+ disableSubscriptions: true,
151
+ // Suppress partial cache hits so an in-flight forward navigation never
152
+ // briefly resolves with an entity that is missing its connection field.
153
+ // Full cache hits (partial: false) still resolve, so backward navigation
154
+ // continues to be served instantly from cache.
155
+ disablePartial: true,
156
+ },
157
+ })
158
+ }
159
+
160
+ const fetchUpdate = (args: any, updates: string[]) => {
161
+ return paginationObserver.send({
162
+ ...args,
163
+ cacheParams: {
164
+ ...args?.cacheParams,
165
+ disableSubscriptions: true,
166
+ applyUpdates: updates,
167
+ },
168
+ session,
169
+ })
170
+ }
171
+
172
+ if (refetchArtifact.refetch!.method === 'cursor') {
173
+ const handlers = cursorHandlers<_Data, _Input>({
174
+ artifact: refetchArtifact,
175
+ getState: () => displayData as _Data | null,
176
+ getVariables: () =>
177
+ (paginationObserver.state.variables ?? variables) as NonNullable<_Input>,
178
+ // no-op pagination while the parent is still in its @loading state (issue #1408)
179
+ getLoading: () => loading,
180
+ fetch: fetchFn,
181
+ fetchUpdate,
182
+ getSession: async () => session,
183
+ previousCursors: previousCursorsRef.current,
184
+ nextCursors: nextCursorsRef.current,
185
+ })
186
+
187
+ return {
188
+ loadNext: wrapLoad(setForwardPending, handlers.loadNextPage),
189
+ loadNextPending: forwardPending,
190
+ loadPrevious: wrapLoad(setBackwardPending, handlers.loadPreviousPage),
191
+ loadPreviousPending: backwardPending,
192
+ pageInfo: refetchPath
193
+ ? extractPageInfo(displayData as GraphQLObject, refetchPath)
194
+ : null,
195
+ }
196
+ }
197
+
198
+ if (refetchArtifact.refetch!.method === 'offset') {
199
+ const handlers = offsetHandlers({
200
+ artifact: refetchArtifact,
201
+ getState: () => displayData as _Data | null,
202
+ getVariables: () =>
203
+ (paginationObserver.state.variables ?? variables) as NonNullable<_Input>,
204
+ // no-op pagination while the parent is still in its @loading state (issue #1408)
205
+ getLoading: () => loading,
206
+ storeName: refetchArtifact.name,
207
+ fetch: fetchFn,
208
+ fetchUpdate: async (args: any, updates = ['append']) =>
209
+ fetchUpdate(args, updates) as any,
210
+ getSession: async () => session,
211
+ })
212
+
213
+ return {
214
+ loadNext: wrapLoad(setForwardPending, handlers.loadNextPage),
215
+ loadNextPending: forwardPending,
216
+ }
217
+ }
218
+
219
+ return null
220
+ }, [
221
+ refetchArtifact,
222
+ paginationObserver,
223
+ displayData,
224
+ session,
225
+ forwardPending,
226
+ backwardPending,
227
+ loading,
228
+ ])
229
+
230
+ // the fragment's current argument values: the initial args overlaid with everything that
231
+ // has been passed to refetch() so far. we track these explicitly rather than reading them
232
+ // back off the embedded query, whose variables also carry the synthetic id-lookup keys.
233
+ const [refetchArgs, setRefetchArgs] = React.useState<Partial<_Input>>({})
234
+
235
+ // re-run the embedded query with new argument values. the entity's id is derived from
236
+ // the fragment data (the parent reference), which always carries the visible id. we must
237
+ // NOT derive it from displayData: after a refetch that becomes the embedded query result,
238
+ // which masks the entity's id out of its selection, so reading it back would yield
239
+ // `id: undefined` and clobber the real id on a second refetch.
240
+ const refetch = React.useMemo(() => {
241
+ if (!isRefetchable || !refetchObserver || !refetchArtifact) return undefined
242
+ return (newVariables?: _Input) => {
243
+ setRefetchArgs((prev) => ({ ...prev, ...newVariables }))
244
+ const idVariables = entityRefetchVariables(
245
+ getCurrentConfig(),
246
+ refetchArtifact.refetch?.targetType,
247
+ fragmentData as Record<string, any> | null
248
+ )
249
+ return refetchObserver.send({
250
+ variables: {
251
+ ...(refetchObserver.state.variables ?? variables),
252
+ ...idVariables,
253
+ ...newVariables,
254
+ } as _Input,
255
+ // suppress loading-state placeholder data during the transition so the
256
+ // currently displayed value stays put until the fresh result arrives
257
+ stuff: { silenceLoading: true },
258
+ cacheParams: { disableSubscriptions: true, disablePartial: true },
259
+ session,
260
+ })
261
+ }
262
+ }, [isRefetchable, refetchObserver, refetchArtifact, fragmentData, variables, session])
263
+
264
+ const displayVariables = isRefetchable
265
+ ? ({ ...(variables as Record<string, any>), ...refetchArgs } as _Input)
266
+ : variables
267
+
268
+ return {
269
+ ...handle,
270
+ variables: displayVariables,
271
+ data: displayData,
272
+ refetch,
273
+ }
274
+ }
@@ -0,0 +1,14 @@
1
+ import { useRef, useEffect } from 'react'
2
+
3
+ export function useIsMountedRef(): { current: boolean } {
4
+ const isMountedRef = useRef(true)
5
+
6
+ useEffect(() => {
7
+ isMountedRef.current = true
8
+ return () => {
9
+ isMountedRef.current = false
10
+ }
11
+ }, [])
12
+
13
+ return isMountedRef
14
+ }
@@ -0,0 +1,60 @@
1
+ import { getAuthUrl } from 'houdini/runtime'
2
+ import React from 'react'
3
+
4
+ import { useSession, useRoute } from '../routing/Router.js'
5
+
6
+ export type UseLogoutFormOptions = {
7
+ // where to navigate after logout. The no-JS path 303s here from the server; the enhanced
8
+ // path navigates with goto(). Defaults to '/'.
9
+ redirectTo?: string
10
+ // enhanced-path-only side effect (a no-op without JS, the correct degradation)
11
+ onSuccess?: () => void
12
+ }
13
+
14
+ export type LogoutForm = {
15
+ // spread onto a <form>: a real string action so the native POST clears the session before
16
+ // hydration; the onSubmit intercepts after. Identical server/client → clean hydration.
17
+ form: {
18
+ action: string
19
+ method: 'post'
20
+ onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
21
+ }
22
+ // the hidden marker fields the no-JS POST needs; render inside the <form>
23
+ hidden: React.ReactNode
24
+ }
25
+
26
+ /**
27
+ * useLogoutForm renders a progressively-enhanced logout form. Without JS the native POST hits
28
+ * the auth endpoint, which deletes the session cookie and 303s to `redirectTo`. After
29
+ * hydration the onSubmit clears the session client-side (local state + cookie) and navigates.
30
+ * It mirrors `useMutationForm`, but there's no mutation — logout is just "clear the session".
31
+ */
32
+ export function useLogoutForm(opts: UseLogoutFormOptions = {}): LogoutForm {
33
+ const [, updateSession] = useSession()
34
+ const { goto } = useRoute()
35
+ const redirectTo = opts.redirectTo ?? '/'
36
+
37
+ const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
38
+ // only reached after hydration — before that the browser submits natively
39
+ event.preventDefault()
40
+ // null logs out: clears local state and deletes the cookie
41
+ await updateSession(null)
42
+ opts.onSuccess?.()
43
+ goto(redirectTo)
44
+ }
45
+
46
+ const form: LogoutForm['form'] = {
47
+ action: getAuthUrl(),
48
+ method: 'post',
49
+ onSubmit,
50
+ }
51
+
52
+ const hidden = (
53
+ <>
54
+ <input type="hidden" name="__houdini_logout" value="1" />
55
+ <input type="hidden" name="redirectTo" value={redirectTo} />
56
+ </>
57
+ )
58
+
59
+ return { form, hidden }
60
+ }