houdini-react 2.0.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "houdini-react",
3
- "version": "2.0.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.0",
85
- "houdini-react-darwin-arm64": "2.0.0",
86
- "houdini-react-linux-x64": "2.0.0",
87
- "houdini-react-linux-arm64": "2.0.0",
88
- "houdini-react-win32-x64": "2.0.0",
89
- "houdini-react-win32-arm64": "2.0.0",
90
- "houdini-react-wasm": "2.0.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.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 to useMutationFormStatus(),
44
- // the no-prop-drilling ergonomic of React's useFormStatus (which only tracks function-action
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 } {
@@ -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, signal completion (the error is visible via
305
- // useQueryResult reading observer.state.errors) and clean up
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
- reject(err)
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
- export const Is404Context = React.createContext(false)
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
- // Mutable ref passed from the server renderer so that a synchronous RoutingError
77
- // or redirect() can propagate the correct HTTP status/location before streaming.
78
- export const StatusContext = React.createContext<{ status: number; location?: string } | null>(null)
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<{
package/vite/transform.js CHANGED
@@ -17,7 +17,7 @@ async function transform_file(page, cfRows, opts = {}) {
17
17
  if (!isJSX && !page.filepath.endsWith(".ts") && !page.filepath.endsWith(".js")) {
18
18
  return { code: page.content, map: page.map };
19
19
  }
20
- const script = parseJS(page.content, isJSX ? { plugins: ["jsx"] } : {});
20
+ const script = parseJS(page.content, isJSX ? { plugins: ["jsx"] } : {}, page.filepath);
21
21
  if (opts.stripHeaders) {
22
22
  strip_named_export(script, "headers");
23
23
  }