houdini-react 2.0.0-next.41 → 2.0.0-next.45

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.
@@ -1,14 +1,164 @@
1
+ // A marshaler turns a rich runtime value (e.g. a Date) into the transport form that
2
+ // belongs in the URL (e.g. a timestamp). Keyed by param / search-param name.
3
+ type Marshaler = (value: any) => any
4
+ type Marshalers = Record<string, Marshaler>
5
+
6
+ // scalarMarshalers builds a name→marshal map for the route's params or search params by
7
+ // looking each declared GraphQL type up in the config's custom scalars. Names without a
8
+ // custom-scalar marshal function are omitted, so callers fall back to String(value).
9
+ // Kept pure (scalars are passed in) so it can be unit-tested without the runtime config.
10
+ export function scalarMarshalers(
11
+ defs: ReadonlyArray<{ name: string; type?: string }> | undefined,
12
+ scalars: Record<string, { marshal?: Marshaler } | undefined> | undefined
13
+ ): Marshalers {
14
+ const out: Marshalers = {}
15
+ for (const def of defs ?? []) {
16
+ const marshal = def.type ? scalars?.[def.type]?.marshal : undefined
17
+ if (marshal) {
18
+ out[def.name] = marshal
19
+ }
20
+ }
21
+ return out
22
+ }
23
+
24
+ // marshalValue applies the per-key marshaler (if any) and stringifies the result.
25
+ function marshalValue(value: unknown, marshal?: Marshaler): string {
26
+ return String(marshal ? marshal(value) : value)
27
+ }
28
+
29
+ // An unmarshaler is the inverse of a Marshaler: it turns the transport form a URL carries
30
+ // back into a rich runtime value (e.g. a timestamp string into a Date).
31
+ type Unmarshaler = (value: any) => any
32
+ type Unmarshalers = Record<string, Unmarshaler>
33
+
34
+ // scalarUnmarshalers is the read-side mirror of scalarMarshalers: a name→unmarshal map for
35
+ // the declared params/search params whose GraphQL type is a custom scalar. Names without a
36
+ // custom-scalar unmarshal function are omitted (built-ins and UI-only keys are left as-is).
37
+ export function scalarUnmarshalers(
38
+ defs: ReadonlyArray<{ name: string; type?: string }> | undefined,
39
+ scalars: Record<string, { unmarshal?: Unmarshaler } | undefined> | undefined
40
+ ): Unmarshalers {
41
+ const out: Unmarshalers = {}
42
+ for (const def of defs ?? []) {
43
+ const unmarshal = def.type ? scalars?.[def.type]?.unmarshal : undefined
44
+ if (unmarshal) {
45
+ out[def.name] = unmarshal
46
+ }
47
+ }
48
+ return out
49
+ }
50
+
51
+ // decodeScalar recovers the marshaled value from the string the URL carries. marshalValue
52
+ // wrote it with String(), which dropped the type, so we JSON.parse to get numbers, booleans
53
+ // and null back, falling back to the raw string when it isn't valid JSON (e.g. a custom
54
+ // scalar that marshals to a plain string). Consequence worth documenting: a value like
55
+ // "true" or "123" always decodes to a boolean / number before it reaches unmarshal.
56
+ function decodeScalar(value: string): unknown {
57
+ try {
58
+ return JSON.parse(value)
59
+ } catch {
60
+ return value
61
+ }
62
+ }
63
+
64
+ // unmarshalScalars turns the transport values a parsed query string carries back into rich
65
+ // runtime values for every key that has a custom-scalar unmarshaler. Keys without one
66
+ // (built-ins, UI-only keys) pass through untouched; List values are unmarshaled
67
+ // element-wise. Used for both useRoute().search and the query variables the router feeds
68
+ // back into marshalInputs, so a custom-scalar search param round-trips correctly. When
69
+ // there's nothing to unmarshal the input object is returned as-is (no allocation).
70
+ export function unmarshalScalars(
71
+ values: Record<string, any>,
72
+ unmarshalers: Unmarshalers
73
+ ): Record<string, any> {
74
+ if (Object.keys(unmarshalers).length === 0) {
75
+ return values
76
+ }
77
+ const out: Record<string, any> = { ...values }
78
+ for (const [key, unmarshal] of Object.entries(unmarshalers)) {
79
+ if (!(key in out) || out[key] == null) {
80
+ continue
81
+ }
82
+ const value = out[key]
83
+ out[key] = Array.isArray(value)
84
+ ? value.map((entry) => unmarshal(decodeScalar(entry)))
85
+ : unmarshal(decodeScalar(value))
86
+ }
87
+ return out
88
+ }
89
+
90
+ // the per-route info buildHref needs to marshal: the declared param and search types.
91
+ export type RouteHrefInfo = {
92
+ params?: ReadonlyArray<{ name: string; type?: string }>
93
+ searchParams?: ReadonlyArray<{ name: string; type?: string }>
94
+ }
95
+
96
+ // buildHref assembles a full href for a route: it fills the path params and appends the
97
+ // search string, marshaling custom-scalar values in both so the URL always carries their
98
+ // transport form. This is the one place params + search become a URL, shared by <Link>
99
+ // and goto so they behave identically. `route` provides the type info (undefined for an
100
+ // external href, which is returned untouched).
101
+ export function buildHref(
102
+ to: string,
103
+ route: RouteHrefInfo | undefined,
104
+ scalars: Record<string, { marshal?: Marshaler } | undefined> | undefined,
105
+ params?: Record<string, unknown>,
106
+ search?: Record<string, unknown>
107
+ ): string {
108
+ let href =
109
+ params != null ? resolveHref(to, params, scalarMarshalers(route?.params, scalars)) : to
110
+ if (search != null) {
111
+ href += serializeSearch(search, scalarMarshalers(route?.searchParams, scalars))
112
+ }
113
+ return href
114
+ }
115
+
1
116
  export function resolveHref(
2
117
  href: string,
3
- params: Record<string, string | number | boolean>
118
+ params: Record<string, unknown>,
119
+ marshalers?: Marshalers
4
120
  ): string {
121
+ // renders a present param value through its marshaler, or undefined when absent
122
+ const render = (key: string): string | undefined => {
123
+ const val = params[key]
124
+ if (val === undefined || val === null) {
125
+ return undefined
126
+ }
127
+ return marshalValue(val, marshalers?.[key])
128
+ }
5
129
  // optional [[param]] — strip the whole /[[param]] segment when the value is absent
6
130
  href = href.replace(/\/\[\[([^\]]+)\]\]/g, (_, key: string) => {
7
- const val = params[key]
8
- return val !== undefined ? '/' + String(val) : ''
131
+ const val = render(key)
132
+ return val !== undefined ? '/' + val : ''
9
133
  })
10
134
  // rest [...slug] — substitute [..slug] with the value (or empty string when absent)
11
- href = href.replace(/\[\.\.\.([^\]]+)\]/g, (_, key: string) => String(params[key] ?? ''))
135
+ href = href.replace(/\[\.\.\.([^\]]+)\]/g, (_, key: string) => render(key) ?? '')
12
136
  // regular [param]
13
- return href.replace(/\[([^\]]+)\]/g, (_, key: string) => String(params[key] ?? key))
137
+ return href.replace(/\[([^\]]+)\]/g, (_, key: string) => render(key) ?? key)
138
+ }
139
+
140
+ // serializeSearch turns a search object into a query string (including the leading
141
+ // "?"), skipping null/undefined values and expanding arrays into repeated keys so
142
+ // they round-trip with List-typed query variables. A custom-scalar value is run
143
+ // through its marshaler so the URL holds the transport form. Returns "" when nothing
144
+ // is set.
145
+ export function serializeSearch(search: Record<string, unknown>, marshalers?: Marshalers): string {
146
+ const params = new URLSearchParams()
147
+ for (const [key, value] of Object.entries(search)) {
148
+ if (value === null || value === undefined) {
149
+ continue
150
+ }
151
+ const marshal = marshalers?.[key]
152
+ if (Array.isArray(value)) {
153
+ for (const entry of value) {
154
+ if (entry !== null && entry !== undefined) {
155
+ params.append(key, marshalValue(entry, marshal))
156
+ }
157
+ }
158
+ } else {
159
+ params.append(key, marshalValue(value, marshal))
160
+ }
161
+ }
162
+ const str = params.toString()
163
+ return str ? '?' + str : ''
14
164
  }
@@ -0,0 +1,93 @@
1
+ // this file is part of houdini's generated runtime — do not edit
2
+ //
3
+ // It is the single source of per-route typing: the types here are derived from the
4
+ // generated manifest's shape and drive <Link>, goto(), and createMock so the rules for
5
+ // "which params/search does this route accept" live in exactly one place.
6
+
7
+ // @ts-ignore
8
+ import type rawManifest from './manifest.js'
9
+ // @ts-ignore
10
+ import type { _TSType } from './manifest.js'
11
+
12
+ type _Pages = (typeof rawManifest)['pages']
13
+
14
+ // ---- route params ----
15
+
16
+ type _Param = { readonly name: string; readonly type: string; readonly optional: boolean }
17
+ type _ParamObj<Ps extends readonly _Param[]> = {
18
+ [P in Ps[number] as P['optional'] extends true ? P['name'] : never]?: _TSType<P['type']>
19
+ } & {
20
+ [P in Ps[number] as P['optional'] extends true ? never : P['name']]: _TSType<P['type']>
21
+ }
22
+
23
+ // ---- search params ----
24
+
25
+ // Search params are derived from a query's nullable variables, so every key is
26
+ // optional. A `List`-wrapped variable accepts an array (serialized as repeated keys).
27
+ type _SearchParam = {
28
+ readonly name: string
29
+ readonly type: string
30
+ readonly wrappers: readonly string[]
31
+ }
32
+ type _SearchValue<P extends _SearchParam> = 'List' extends P['wrappers'][number]
33
+ ? _TSType<P['type']>[]
34
+ : _TSType<P['type']>
35
+ // The route's nullable query variables get precise types, but search isn't limited to
36
+ // them: extra keys are allowed so the query string can also carry UI-only state that no
37
+ // query reads (a selected tab, an open modal, a client-side sort). The query consumes
38
+ // only the declared keys; the rest just ride along in the URL.
39
+ type _SearchObj<Ps extends readonly _SearchParam[]> = {
40
+ [P in Ps[number] as P['name']]?: _SearchValue<P>
41
+ } & Record<string, unknown>
42
+
43
+ // ---- public route typing ----
44
+
45
+ // All known app route URL strings — useful as a constraint for custom link wrappers.
46
+ export type RouteHrefs = _Pages[keyof _Pages] extends { readonly url: infer U extends string }
47
+ ? U
48
+ : never
49
+
50
+ // hrefs that aren't app routes: external links, mailto/tel, fragments, relative paths.
51
+ export type ExternalHref =
52
+ | `http://${string}`
53
+ | `https://${string}`
54
+ | `mailto:${string}`
55
+ | `tel:${string}`
56
+ | `blob:${string}`
57
+ | `data:${string}`
58
+ | `//${string}`
59
+ | `#${string}`
60
+ | `./${string}`
61
+ | `../${string}`
62
+
63
+ type _PageForRoute<H extends string> = Extract<_Pages[keyof _Pages], { readonly url: H }>
64
+
65
+ // params is required (and typed) when the route has dynamic segments, and absent
66
+ // otherwise. Kept a separate intersection so TS evaluates `to` independently — route
67
+ // completions still include parameterized routes even before params is filled in.
68
+ export type ParamsForRoute<H extends string> = [_PageForRoute<H>] extends [never]
69
+ ? { params?: never }
70
+ : _PageForRoute<H> extends { readonly params: readonly [] }
71
+ ? { params?: never }
72
+ : _PageForRoute<H> extends { readonly params: infer Ps extends readonly _Param[] }
73
+ ? { params: _ParamObj<Ps> }
74
+ : { params?: never }
75
+
76
+ // search is always optional and always open (any route can carry UI-only query string
77
+ // state). When the route declares nullable query variables, those keys get precise
78
+ // types; otherwise search is just an open record of serializable values.
79
+ export type SearchForRoute<H extends string> = [_PageForRoute<H>] extends [never]
80
+ ? { search?: Record<string, unknown> }
81
+ : _PageForRoute<H> extends { readonly searchParams: infer Ps extends readonly _SearchParam[] }
82
+ ? { search?: _SearchObj<Ps> }
83
+ : { search?: Record<string, unknown> }
84
+
85
+ // a typed navigation target: a known route plus its (typed) params and search. Used by
86
+ // goto(); <Link> intersects the same pieces onto its anchor props.
87
+ export type NavTarget<H extends RouteHrefs> = { to: H } & ParamsForRoute<H> & SearchForRoute<H>
88
+
89
+ // goto() accepts either a ready-made url string (escape hatch) or a typed NavTarget.
90
+ export interface Goto {
91
+ (url: string): void
92
+ <H extends RouteHrefs>(target: NavTarget<H>): void
93
+ }
@@ -12,6 +12,8 @@ import type { RouterManifest, RouterPageManifest } from 'houdini/router/types'
12
12
  import React from 'react'
13
13
  import { useContext, useEffect } from 'react'
14
14
 
15
+ import { buildHref, scalarUnmarshalers, unmarshalScalars } from '../resolve-href.js'
16
+ import type { Goto } from '../routes.js'
15
17
  import { type DocumentHandle, useDocumentHandle } from '../hooks/useDocumentHandle.js'
16
18
  import { useDocumentStore } from '../hooks/useDocumentStore.js'
17
19
  import { type SuspenseCache, suspense_cache } from './cache.js'
@@ -51,11 +53,21 @@ export function Router({
51
53
  }) {
52
54
  // the current route is just a string in state.
53
55
  const [currentURL, setCurrentURL] = React.useState(() => {
54
- return initialURL || window.location.pathname
56
+ return initialURL || window.location.pathname + window.location.search
55
57
  })
56
58
 
57
- // find the matching page for the current route
58
- const [page, variables] = find_match(configFile, manifest, currentURL)
59
+ // find the matching page for the current route. find_match also hands back the parsed
60
+ // query string (declared search params coerced, UI-only keys raw). custom-scalar route
61
+ // and search params arrive in their url transport form, so we unmarshal them once here
62
+ // — the rich values feed both the query variables (which marshalInputs re-marshals for
63
+ // the request) and useRoute()'s params/search.
64
+ const [page, rawVariables, rawSearch] = find_match(manifest, currentURL)
65
+ const unmarshalers = scalarUnmarshalers(
66
+ [...(page?.params ?? []), ...(page?.searchParams ?? [])],
67
+ getCurrentConfig()?.scalars
68
+ )
69
+ const variables = unmarshalScalars(rawVariables ?? {}, unmarshalers)
70
+ const search = unmarshalScalars(rawSearch, unmarshalers)
59
71
  const is404 = !page
60
72
  // When no exact match, find the deepest prefix-matching page to render
61
73
  // its layout chain with NotFoundGate throwing inside the appropriate boundary.
@@ -88,7 +100,7 @@ export function Router({
88
100
 
89
101
  // whenever the route changes, we need to make sure the browser's stack is up to date
90
102
  React.useEffect(() => {
91
- if (globalThis.window && window.location.pathname !== currentURL) {
103
+ if (globalThis.window && window.location.pathname + window.location.search !== currentURL) {
92
104
  window.history.pushState({}, '', currentURL)
93
105
  }
94
106
  }, [currentURL])
@@ -99,7 +111,7 @@ export function Router({
99
111
  return
100
112
  }
101
113
  const onChange = (_evt: PopStateEvent) => {
102
- setCurrentURL(window.location.pathname)
114
+ setCurrentURL(window.location.pathname + window.location.search)
103
115
  }
104
116
  window.addEventListener('popstate', onChange)
105
117
  return () => {
@@ -107,8 +119,26 @@ export function Router({
107
119
  }
108
120
  }, [])
109
121
 
110
- // the function to call to navigate to a url
111
- const goto = (url: string) => {
122
+ // the function to call to navigate. accepts either a ready-made url string or a
123
+ // typed target { to, params, search } that is assembled (and custom scalars
124
+ // marshaled) exactly the way <Link> builds its href. The typed surface is the
125
+ // shared Goto contract; the implementation takes the loose runtime shape.
126
+ const goto = ((
127
+ target:
128
+ | string
129
+ | { to: string; params?: Record<string, unknown>; search?: Record<string, unknown> }
130
+ ) => {
131
+ const url =
132
+ typeof target === 'string'
133
+ ? target
134
+ : buildHref(
135
+ target.to,
136
+ manifest.pages[manifest.pagesByUrl[target.to]],
137
+ getCurrentConfig()?.scalars,
138
+ target.params,
139
+ target.search
140
+ )
141
+
112
142
  // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
113
143
  data_cache.clear()
114
144
  // clear pending signals so the next render starts fresh load_query calls
@@ -116,7 +146,7 @@ export function Router({
116
146
 
117
147
  // perform the navigation
118
148
  setCurrentURL(url)
119
- }
149
+ }) as Goto
120
150
 
121
151
  // links are powered using anchor tags that we intercept and handle ourselves
122
152
  useLinkBehavior({
@@ -125,10 +155,19 @@ export function Router({
125
155
  // there are 2 things that we could preload: the page component and the data
126
156
 
127
157
  // look for the matching route information
128
- const [page, variables] = find_match(configFile, manifest, url)
158
+ const [page, rawVariables] = find_match(manifest, url)
129
159
  if (!page) {
130
160
  return
131
161
  }
162
+ // unmarshal any custom-scalar route/search params so the preloaded query
163
+ // marshals them the same way the rendered one does
164
+ const variables = unmarshalScalars(
165
+ rawVariables ?? {},
166
+ scalarUnmarshalers(
167
+ [...page.params, ...page.searchParams],
168
+ getCurrentConfig()?.scalars
169
+ )
170
+ )
132
171
 
133
172
  // load the page component if necessary
134
173
  if (['page', 'component'].includes(which)) {
@@ -146,33 +185,34 @@ export function Router({
146
185
  // render the component embedded in the necessary context so it can orchestrate
147
186
  // its needs
148
187
  return (
149
- <VariableContext.Provider value={variables}>
150
- <LocationContext.Provider
151
- value={{
152
- pathname: currentURL,
153
- goto,
154
- params: variables ?? {},
155
- }}
156
- >
157
- <Is404Context.Provider value={is404}>
158
- {is404 ? (
159
- <NotFoundLayoutBoundary key={targetPage.id}>
160
- <PageComponent url={currentURL} key={targetPage.id + '__404'} />
161
- </NotFoundLayoutBoundary>
162
- ) : (
163
- <PageComponent url={currentURL} key={targetPage.id} />
164
- )}
165
- </Is404Context.Provider>
166
- </LocationContext.Provider>
167
- </VariableContext.Provider>
188
+ <LocationContext.Provider
189
+ value={{
190
+ pathname: currentURL,
191
+ goto,
192
+ params: variables ?? {},
193
+ search,
194
+ }}
195
+ >
196
+ <Is404Context.Provider value={is404}>
197
+ {is404 ? (
198
+ <NotFoundLayoutBoundary key={targetPage.id}>
199
+ <PageComponent url={currentURL} key={targetPage.id + '__404'} />
200
+ </NotFoundLayoutBoundary>
201
+ ) : (
202
+ <PageComponent url={currentURL} key={targetPage.id} />
203
+ )}
204
+ </Is404Context.Provider>
205
+ </LocationContext.Provider>
168
206
  )
169
207
  }
170
208
 
171
- // export the location information in context
172
- export const useLocation = () => useContext(LocationContext)
209
+ // internal accessor for the raw location context. the public surface is useRoute, which
210
+ // layers the per-route param/search types on top of this. not re-exported from the package
211
+ // index, so it isn't part of the public API.
212
+ export const useLocationContext = () => useContext(LocationContext)
173
213
 
174
214
  export const ClientRedirect = ({ to }: { to: string }) => {
175
- const { goto } = useLocation()
215
+ const { goto } = useLocationContext()
176
216
  useEffect(() => {
177
217
  goto(to)
178
218
  }, [to])
@@ -632,20 +672,18 @@ export function useSession(): [App.Session, (newSession: Partial<App.Session>) =
632
672
  return [ctx.session, updateSession]
633
673
  }
634
674
 
635
- export function useCurrentVariables(): GraphQLVariables {
636
- return React.useContext(VariableContext)
637
- }
638
-
639
- const VariableContext = React.createContext<GraphQLVariables>(null)
640
-
641
675
  const LocationContext = React.createContext<{
642
676
  pathname: string
643
677
  params: Record<string, any>
678
+ // the parsed query string of the current url (declared search params coerced to
679
+ // their scalar type, other keys raw; repeated keys are arrays).
680
+ search: Record<string, any>
644
681
  // a function to imperatively navigate to a url
645
- goto: (url: string) => void
682
+ goto: Goto
646
683
  }>({
647
684
  pathname: '',
648
685
  params: {},
686
+ search: {},
649
687
  goto: () => {},
650
688
  })
651
689
 
@@ -686,7 +724,7 @@ function useLinkBehavior({
686
724
  goto,
687
725
  preload,
688
726
  }: {
689
- goto: (url: string) => void
727
+ goto: Goto
690
728
  preload: (url: string, which: PreloadWhichValue) => void
691
729
  }) {
692
730
  // always use the click handler
@@ -702,7 +740,7 @@ function useLinkBehavior({
702
740
  }
703
741
  }
704
742
 
705
- function useLinkNavigation({ goto }: { goto: (url: string) => void }) {
743
+ function useLinkNavigation({ goto }: { goto: Goto }) {
706
744
  // navigations need to be registered as transitions
707
745
  const [_pending, startTransition] = React.useTransition()
708
746
 
@@ -925,7 +963,7 @@ export function PageContextProvider({
925
963
  keys: string[]
926
964
  children: React.ReactNode
927
965
  }) {
928
- const location = useLocation()
966
+ const location = useLocationContext()
929
967
  const params = Object.fromEntries(
930
968
  Object.entries(location.params).filter(([key]) => keys.includes(key))
931
969
  )
@@ -933,12 +971,42 @@ export function PageContextProvider({
933
971
  return <PageContext.Provider value={{ params }}>{children}</PageContext.Provider>
934
972
  }
935
973
 
936
- export function useRoute<PageProps extends { Params: {} }>(): RouteProp<PageProps['Params']> {
937
- return useContext(PageContext)
974
+ // useRoute is the single hook for reading the current route: the route's params (scoped to
975
+ // this route's path segments) and search, both typed when a generated PageRoute/LayoutRoute
976
+ // is supplied, plus the current pathname and goto. This replaces the old useLocation —
977
+ // params/search live here rather than on the component props. Unlike a conventional
978
+ // useLocation it also carries params and goto, which is why it's named for the route.
979
+ export function useRoute<
980
+ // the default leaves params/search empty (not a loose record) so that reading them
981
+ // without passing the route's generated PageRoute type is a compile error, while
982
+ // pathname and goto stay available for navigation-only code.
983
+ _Route extends { params: any; search: any } = { params: {}; search: {} },
984
+ >(): {
985
+ pathname: string
986
+ params: _Route['params']
987
+ search: _Route['search']
988
+ goto: Goto
989
+ } {
990
+ const location = useLocationContext()
991
+ const route = useContext(PageContext)
992
+ return {
993
+ pathname: location.pathname,
994
+ params: route.params as _Route['params'],
995
+ search: location.search as _Route['search'],
996
+ goto: location.goto,
997
+ }
938
998
  }
939
999
 
940
- export type RouteProp<Params> = {
941
- params: Params
1000
+ // A route shape for useRoute. A route's generated PageRoute/LayoutRoute already satisfies the
1001
+ // `{ params; search }` shape, so the common case is useRoute<PageRoute>(). For a route-agnostic
1002
+ // component (e.g. a reusable paginator that assumes its route exposes `after`/`first` search
1003
+ // params) there's no single generated type to pass — use GenericRoute to type the axis you
1004
+ // depend on and leave the other `never` (which falls back to a loose record). Search comes
1005
+ // first since that's the usual reason to reach for it, so a search-only component writes
1006
+ // GenericRoute<{ ... }> and a params-only one writes GenericRoute<never, { ... }>.
1007
+ export type GenericRoute<Search = never, Params = never> = {
1008
+ params: [Params] extends [never] ? Record<string, any> : Params
1009
+ search: [Search] extends [never] ? Record<string, any> : Search
942
1010
  }
943
1011
 
944
1012
  // a signal promise is a promise is used to send signals by having listeners attach