houdini-react 2.0.1 → 2.0.2
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 +54 -0
- package/runtime/hooks/useMutationForm.tsx +4 -4
- package/runtime/hydration.tsx +21 -0
- package/runtime/routing/Router.tsx +82 -25
- package/runtime/routing/errors.tsx +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "houdini-react",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
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.0.2",
|
|
85
|
+
"houdini-react-darwin-arm64": "2.0.2",
|
|
86
|
+
"houdini-react-linux-x64": "2.0.2",
|
|
87
|
+
"houdini-react-linux-arm64": "2.0.2",
|
|
88
|
+
"houdini-react-win32-x64": "2.0.2",
|
|
89
|
+
"houdini-react-win32-arm64": "2.0.2",
|
|
90
|
+
"houdini-react-wasm": "2.0.2"
|
|
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.0.2'
|
|
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,54 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { Goto } from './routes.js'
|
|
4
|
+
import type { RouterContext } from './routing/Router.js'
|
|
5
|
+
|
|
6
|
+
// All of the runtime's React contexts are defined in this module on purpose.
|
|
7
|
+
//
|
|
8
|
+
// A React context is a module-level singleton: provider and consumer must hold
|
|
9
|
+
// the *same* object returned by createContext(). When a context is created
|
|
10
|
+
// inside a module that also exports components/hooks (like Router.tsx), Vite's
|
|
11
|
+
// dev server re-evaluates that module on an HMR update (for example when codegen
|
|
12
|
+
// rewrites a neighboring generated unit, or when react-refresh falls back to a
|
|
13
|
+
// full module re-run). Each re-evaluation mints a brand-new context object, and
|
|
14
|
+
// if a consumer rebinds to the new one while the still-mounted provider holds
|
|
15
|
+
// the old one, useContext() reads a context the provider never populated and
|
|
16
|
+
// throws "Could not find router context".
|
|
17
|
+
//
|
|
18
|
+
// This module imports only `react` at runtime (everything else is `import type`,
|
|
19
|
+
// erased at build time), so it is a pure leaf in the dependency graph. Route
|
|
20
|
+
// edits never re-evaluate it, so these context objects keep a stable identity
|
|
21
|
+
// across granular HMR updates. We deliberately avoid a globalThis registry to
|
|
22
|
+
// pin identity: multiple independent Router contexts can legitimately coexist in
|
|
23
|
+
// one process (for example across Astro islands), and a global singleton would
|
|
24
|
+
// wrongly collapse them into one.
|
|
25
|
+
|
|
26
|
+
export const RouterContextObject = createContext<RouterContext | null>(null)
|
|
27
|
+
|
|
28
|
+
export const LocationContext = createContext<{
|
|
29
|
+
pathname: string
|
|
30
|
+
params: Record<string, any>
|
|
31
|
+
// the parsed query string of the current url (declared search params coerced to
|
|
32
|
+
// their scalar type, other keys raw; repeated keys are arrays).
|
|
33
|
+
search: Record<string, any>
|
|
34
|
+
// a function to imperatively navigate to a url
|
|
35
|
+
goto: Goto
|
|
36
|
+
}>({
|
|
37
|
+
pathname: '',
|
|
38
|
+
params: {},
|
|
39
|
+
search: {},
|
|
40
|
+
goto: () => {},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const Is404Context = createContext(false)
|
|
44
|
+
|
|
45
|
+
export const PageContext = createContext<{ params: Record<string, any> }>({ params: {} })
|
|
46
|
+
|
|
47
|
+
// Mutable ref passed from the server renderer so that a synchronous RoutingError
|
|
48
|
+
// or redirect() can propagate the correct HTTP status/location before streaming.
|
|
49
|
+
export const StatusContext = createContext<{ status: number; location?: string } | null>(null)
|
|
50
|
+
|
|
51
|
+
// FormStatusContext carries the nearest <Form>'s pending state to useMutationFormStatus(),
|
|
52
|
+
// the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
|
|
53
|
+
// submissions, so it can't see our forms).
|
|
54
|
+
export const FormStatusContext = createContext<{ pending: boolean }>({ pending: false })
|
|
@@ -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,
|
|
@@ -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,13 @@ 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
|
+
PageContext,
|
|
21
|
+
} from '../contexts.js'
|
|
22
|
+
import { escapeScriptTag } from '../escape.js'
|
|
16
23
|
import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
|
|
17
24
|
import type { Goto } from '../routes.js'
|
|
18
25
|
import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
|
|
@@ -285,6 +292,64 @@ function usePageData({
|
|
|
285
292
|
? data_cache.get(artifact.name)!
|
|
286
293
|
: client.observe({ artifact, cache })
|
|
287
294
|
|
|
295
|
+
// surface a query error to the client. an @loading query streams its result while the
|
|
296
|
+
// document is still open, so on the initial SSR load the client is parked on the loading
|
|
297
|
+
// frame waiting for this query's pending signal to resolve. unlike the success path the
|
|
298
|
+
// store carries no data we can hydrate from the cache (errors aren't cache data), so we
|
|
299
|
+
// stream the errors directly and rebuild an erroring store on the client. without this the
|
|
300
|
+
// pending signal never resolves and the page hangs on the loading state instead of reaching
|
|
301
|
+
// the nearest error boundary.
|
|
302
|
+
function stream_error(errors: GraphQLError[]) {
|
|
303
|
+
injectToStream?.(`
|
|
304
|
+
<script>
|
|
305
|
+
{
|
|
306
|
+
const artifactName = ${escapeScriptTag(JSON.stringify(artifact.name))}
|
|
307
|
+
const errors = ${escapeScriptTag(JSON.stringify(errors))}
|
|
308
|
+
const variables = ${escapeScriptTag(JSON.stringify(observer.state.variables))}
|
|
309
|
+
const artifact = ${escapeScriptTag(JSON.stringify(artifact))}
|
|
310
|
+
|
|
311
|
+
// build a document store that already carries the errors so useQueryResult
|
|
312
|
+
// throws to the nearest boundary the moment it reads the store
|
|
313
|
+
const __houdini__error_store__ = (knownArtifact) => {
|
|
314
|
+
const store = window.__houdini__client__.observe({
|
|
315
|
+
artifact: knownArtifact ?? artifact,
|
|
316
|
+
cache: window.__houdini__cache__,
|
|
317
|
+
initialVariables: variables,
|
|
318
|
+
})
|
|
319
|
+
store.update((state) => ({ ...state, fetching: false, errors }))
|
|
320
|
+
return store
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// if the client has already hydrated (a client-side navigation to an @loading
|
|
324
|
+
// route) push the error store straight into the live caches and release the signal
|
|
325
|
+
if (window.__houdini__nav_caches__) {
|
|
326
|
+
const caches = window.__houdini__nav_caches__
|
|
327
|
+
caches.data_cache.set(
|
|
328
|
+
artifactName,
|
|
329
|
+
__houdini__error_store__(caches.artifact_cache.get(artifactName))
|
|
330
|
+
)
|
|
331
|
+
if (caches.ssr_signals.has(artifactName)) {
|
|
332
|
+
caches.ssr_signals.get(artifactName).resolve()
|
|
333
|
+
caches.ssr_signals.delete(artifactName)
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
// otherwise the page module hasn't run yet (the common @loading case): stash the
|
|
337
|
+
// error so hydrate_page can build the error store when it sets up the caches
|
|
338
|
+
const pendingArtifacts = (window.__houdini__pending_artifacts__ =
|
|
339
|
+
window.__houdini__pending_artifacts__ || {})
|
|
340
|
+
pendingArtifacts[artifactName] = artifact
|
|
341
|
+
const pendingVariables = (window.__houdini__pending_variables__ =
|
|
342
|
+
window.__houdini__pending_variables__ || {})
|
|
343
|
+
pendingVariables[artifactName] = variables
|
|
344
|
+
const pendingErrors = (window.__houdini__pending_errors__ =
|
|
345
|
+
window.__houdini__pending_errors__ || {})
|
|
346
|
+
pendingErrors[artifactName] = errors
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
</script>
|
|
350
|
+
`)
|
|
351
|
+
}
|
|
352
|
+
|
|
288
353
|
// store the observer immediately so useQueryResult can access it
|
|
289
354
|
// during SSR rendering before the fetch resolves
|
|
290
355
|
let resolve: () => void = () => {}
|
|
@@ -301,9 +366,12 @@ function usePageData({
|
|
|
301
366
|
.then(async () => {
|
|
302
367
|
data_cache.set(id, observer)
|
|
303
368
|
|
|
304
|
-
// if there is an error,
|
|
305
|
-
//
|
|
369
|
+
// if there is an error, stream it to the client (so an @loading query
|
|
370
|
+
// reaches the error boundary instead of hanging on the loading state) and
|
|
371
|
+
// clean up. on the client data_cache.set above is enough for useQueryResult
|
|
372
|
+
// to read the errors and throw; the stream is what carries them across SSR.
|
|
306
373
|
if (observer.state.errors && observer.state.errors.length > 0) {
|
|
374
|
+
stream_error(observer.state.errors)
|
|
307
375
|
ssr_signals.delete(id)
|
|
308
376
|
resolve()
|
|
309
377
|
return
|
|
@@ -422,7 +490,14 @@ function usePageData({
|
|
|
422
490
|
if (err?.name === 'AbortError') {
|
|
423
491
|
return
|
|
424
492
|
}
|
|
425
|
-
|
|
493
|
+
// a thrown error (e.g. a throwOnError plugin) never lands in observer.state, so
|
|
494
|
+
// seed it as a synthetic GraphQL error on the store and stream it to the client,
|
|
495
|
+
// otherwise an @loading query that rejects hangs on the loading state
|
|
496
|
+
const errors = [{ message: err?.message ?? String(err) }]
|
|
497
|
+
observer.update((state) => ({ ...state, fetching: false, errors }))
|
|
498
|
+
data_cache.set(id, observer)
|
|
499
|
+
stream_error(errors)
|
|
500
|
+
resolve()
|
|
426
501
|
})
|
|
427
502
|
})
|
|
428
503
|
|
|
@@ -611,7 +686,7 @@ export function RouterContextProvider({
|
|
|
611
686
|
)
|
|
612
687
|
}
|
|
613
688
|
|
|
614
|
-
type RouterContext = {
|
|
689
|
+
export type RouterContext = {
|
|
615
690
|
client: HoudiniClient
|
|
616
691
|
cache: Cache
|
|
617
692
|
|
|
@@ -662,8 +737,6 @@ export type PendingCache = SuspenseCache<
|
|
|
662
737
|
Promise<void> & { resolve: () => void; reject: (message: string) => void }
|
|
663
738
|
>
|
|
664
739
|
|
|
665
|
-
const Context = React.createContext<RouterContext | null>(null)
|
|
666
|
-
|
|
667
740
|
export const useRouterContext = () => {
|
|
668
741
|
const ctx = React.useContext(Context)
|
|
669
742
|
|
|
@@ -740,21 +813,6 @@ export function useSession(): [
|
|
|
740
813
|
return [ctx.session, updateSession]
|
|
741
814
|
}
|
|
742
815
|
|
|
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
816
|
export function useQueryResult<_Data extends GraphQLObject, _Input extends GraphQLVariables>(
|
|
759
817
|
name: string
|
|
760
818
|
): [_Data | null, DocumentHandle<any, _Data, _Input>] {
|
|
@@ -1018,7 +1076,8 @@ class NotFoundLayoutBoundary extends React.Component<
|
|
|
1018
1076
|
}
|
|
1019
1077
|
}
|
|
1020
1078
|
|
|
1021
|
-
|
|
1079
|
+
// re-exported (defined in ../contexts.js) so existing `routing` barrel consumers keep working
|
|
1080
|
+
export { Is404Context }
|
|
1022
1081
|
|
|
1023
1082
|
export function NotFoundGate({ children }: { children: React.ReactNode }) {
|
|
1024
1083
|
const is404 = React.useContext(Is404Context)
|
|
@@ -1028,8 +1087,6 @@ export function NotFoundGate({ children }: { children: React.ReactNode }) {
|
|
|
1028
1087
|
return <>{children}</>
|
|
1029
1088
|
}
|
|
1030
1089
|
|
|
1031
|
-
const PageContext = React.createContext<{ params: Record<string, any> }>({ params: {} })
|
|
1032
|
-
|
|
1033
1090
|
export function PageContextProvider({
|
|
1034
1091
|
keys,
|
|
1035
1092
|
children,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { GraphQLError } from 'houdini/runtime'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
|
+
import { 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<{
|