houdini-react 2.0.0-next.34 → 2.0.0-next.35

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-next.34",
3
+ "version": "2.0.0-next.35",
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-next.34",
85
- "houdini-react-darwin-arm64": "2.0.0-next.34",
86
- "houdini-react-linux-x64": "2.0.0-next.34",
87
- "houdini-react-linux-arm64": "2.0.0-next.34",
88
- "houdini-react-win32-x64": "2.0.0-next.34",
89
- "houdini-react-win32-arm64": "2.0.0-next.34",
90
- "houdini-react-wasm": "2.0.0-next.34"
84
+ "houdini-react-darwin-x64": "2.0.0-next.35",
85
+ "houdini-react-darwin-arm64": "2.0.0-next.35",
86
+ "houdini-react-linux-x64": "2.0.0-next.35",
87
+ "houdini-react-linux-arm64": "2.0.0-next.35",
88
+ "houdini-react-win32-x64": "2.0.0-next.35",
89
+ "houdini-react-win32-arm64": "2.0.0-next.35",
90
+ "houdini-react-wasm": "2.0.0-next.35"
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-next.34'
8
+ const BINARY_DISTRIBUTION_VERSION = '2.0.0-next.35'
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,53 @@
1
+ /**
2
+ * Walk `next` and, wherever a sub-tree is deeply equal to the corresponding
3
+ * sub-tree in `prev`, substitute `prev`'s reference. This preserves object
4
+ * identity for unchanged branches so React.memo can bail out on re-renders
5
+ * when a cache write touches unrelated parts of the graph.
6
+ *
7
+ * Arrays are reconciled by index. When lengths differ the array itself gets a
8
+ * new reference, but individual matching elements are still recycled so that
9
+ * items from the previous render keep their identities.
10
+ */
11
+ export function recycleNodesInto<T>(prev: T | null | undefined, next: T): T {
12
+ if (Object.is(prev, next)) return prev as T
13
+
14
+ if (next === null || typeof next !== 'object') return next
15
+ if (prev === null || prev === undefined || typeof prev !== 'object') return next
16
+
17
+ if (Array.isArray(next)) {
18
+ if (!Array.isArray(prev)) return next
19
+
20
+ const nextLen = next.length
21
+ const prevLen = (prev as unknown[]).length
22
+ const minLen = Math.min(prevLen, nextLen)
23
+
24
+ let changed = false
25
+ const result: unknown[] = new Array(nextLen)
26
+
27
+ for (let i = 0; i < minLen; i++) {
28
+ result[i] = recycleNodesInto((prev as unknown[])[i], next[i])
29
+ if (result[i] !== (prev as unknown[])[i]) changed = true
30
+ }
31
+ for (let i = minLen; i < nextLen; i++) {
32
+ result[i] = next[i]
33
+ }
34
+
35
+ if (!changed && prevLen === nextLen) return prev as T
36
+ return result as unknown as T
37
+ }
38
+
39
+ const prevObj = prev as Record<string, unknown>
40
+ const nextObj = next as Record<string, unknown>
41
+ const nextKeys = Object.keys(nextObj)
42
+ const prevKeyCount = Object.keys(prevObj).length
43
+
44
+ let changed = prevKeyCount !== nextKeys.length
45
+ const result: Record<string, unknown> = {}
46
+
47
+ for (const key of nextKeys) {
48
+ result[key] = recycleNodesInto(prevObj[key], nextObj[key])
49
+ if (result[key] !== prevObj[key]) changed = true
50
+ }
51
+
52
+ return changed ? (result as T) : (prev as T)
53
+ }
@@ -36,14 +36,16 @@ function isPrimitive(val: unknown) {
36
36
  */
37
37
  export function useDeepCompareMemoize<T>(value: T) {
38
38
  const ref = React.useRef<T>(value)
39
- const signalRef = React.useRef<number>(0)
40
39
 
41
40
  if (!deepEquals(value, ref.current)) {
42
41
  ref.current = value
43
- signalRef.current += 1
44
42
  }
45
43
 
46
- return React.useMemo(() => ref.current, [])
44
+ // Return ref.current directly so React.useEffect sees a new reference only
45
+ // when the deep value actually changes — matching the original use-deep-compare-effect.
46
+ // The useMemo(() => ref.current, []) wrapper that was here previously was incorrect:
47
+ // it froze the returned value at mount time, preventing effects from ever re-firing.
48
+ return ref.current
47
49
  }
48
50
 
49
51
  function useDeepCompareEffect(
@@ -9,6 +9,7 @@ import * as React from 'react'
9
9
 
10
10
  import { useClient } from '../routing/index.js'
11
11
  import { useIsMountedRef } from './useIsMounted.js'
12
+ import { recycleNodesInto } from './recycleNodesInto.js'
12
13
 
13
14
  export type UseDocumentStoreParams<
14
15
  _Artifact extends DocumentArtifact,
@@ -17,6 +18,10 @@ export type UseDocumentStoreParams<
17
18
  > = {
18
19
  artifact: _Artifact
19
20
  observer?: DocumentStore<_Data, _Input>
21
+ // Optional synchronous seed for box.current. When provided, box.current is updated
22
+ // during render so useSyncExternalStore's snapshot is immediately correct (e.g. on
23
+ // fragment parent change). Must be memoized by the caller — tracked by reference.
24
+ initialState?: QueryResult<_Data, _Input>
20
25
  } & Partial<ObserveParams<_Data, DocumentArtifact, _Input>>
21
26
 
22
27
  export function useDocumentStore<
@@ -26,6 +31,7 @@ export function useDocumentStore<
26
31
  >({
27
32
  artifact,
28
33
  observer: obs,
34
+ initialState,
29
35
  ...observeParams
30
36
  }: UseDocumentStoreParams<_Artifact, _Data, _Input>): [
31
37
  QueryResult<_Data, _Input>,
@@ -52,11 +58,41 @@ export function useDocumentStore<
52
58
  setObserver(obs)
53
59
  }
54
60
 
61
+ // Relay-style synchronous seeding: when initialState changes (i.e., the fragment
62
+ // parent changed), update box.current immediately during this render so
63
+ // useSyncExternalStore's getSnapshot returns the correct data without waiting for
64
+ // the subscription effect to fire. Tracked by reference — if provided, callers
65
+ // must memoize initialState to avoid spurious reseeds on every render.
66
+ const prevInitialStateRef = React.useRef<QueryResult<_Data, _Input> | undefined>(undefined)
67
+ if (initialState !== undefined && initialState !== prevInitialStateRef.current) {
68
+ prevInitialStateRef.current = initialState
69
+ box.current = initialState
70
+ }
71
+
55
72
  // the function that registers a new subscription for the observer
56
73
  const subscribe: any = React.useCallback(
57
74
  (fn: () => void) => {
58
75
  return observer.subscribe((val) => {
59
- box.current = val
76
+ const prev = box.current
77
+ // Preserve object identity for unchanged subtrees so React.memo on
78
+ // fragment components can bail out when their data wasn't touched.
79
+ const stableData = recycleNodesInto(prev?.data, val.data)
80
+ const next = stableData === val.data ? val : { ...val, data: stableData }
81
+
82
+ // Skip the re-render entirely if the new state is semantically identical
83
+ // to what React already has (e.g. an idempotent cache write).
84
+ if (
85
+ next === prev ||
86
+ (stableData === prev?.data &&
87
+ val.fetching === prev?.fetching &&
88
+ val.errors === prev?.errors &&
89
+ val.source === prev?.source &&
90
+ val.stale === prev?.stale)
91
+ ) {
92
+ return
93
+ }
94
+
95
+ box.current = next
60
96
  if (isMountedRef.current) {
61
97
  fn()
62
98
  }
@@ -18,15 +18,17 @@ export function useDocumentSubscription<
18
18
  artifact,
19
19
  variables,
20
20
  send,
21
+ initialState,
21
22
  disabled,
22
23
  ...observeParams
23
24
  }: UseDocumentStoreParams<_Artifact, _Data, _Input> & {
24
25
  variables: _Input
25
26
  disabled?: boolean
26
- send?: Partial<SendParams>
27
+ send?: Partial<Omit<SendParams, 'initialState'>>
27
28
  }): [QueryResult<_Data, _Input> & { parent?: string | null }, DocumentStore<_Data, _Input>] {
28
29
  const [storeValue, observer] = useDocumentStore<_Data, _Input>({
29
30
  artifact,
31
+ initialState,
30
32
  ...observeParams,
31
33
  })
32
34
 
@@ -42,6 +44,7 @@ export function useDocumentSubscription<
42
44
  // TODO: metadata
43
45
  metadata: {},
44
46
  ...send,
47
+ initialState,
45
48
  })
46
49
  }
47
50
 
@@ -1,6 +1,10 @@
1
- import { deepEquals } from 'houdini/runtime'
2
1
  import { fragmentKey } from 'houdini/runtime'
3
- import type { GraphQLObject, GraphQLVariables, FragmentArtifact } from 'houdini/runtime'
2
+ import type {
3
+ GraphQLObject,
4
+ GraphQLVariables,
5
+ FragmentArtifact,
6
+ QueryResult,
7
+ } from 'houdini/runtime'
4
8
  import * as React from 'react'
5
9
 
6
10
  import { useRouterContext } from '../routing/index.js'
@@ -15,33 +19,54 @@ export function useFragment<
15
19
  document: { artifact: FragmentArtifact }
16
20
  ): _Data | null {
17
21
  const { cache } = useRouterContext()
18
-
19
- // get the fragment reference info
20
22
  const { parent, variables, loading } = fragmentReference<_Data, _Input, _ReferenceType>(
21
23
  reference,
22
24
  document
23
25
  )
24
26
 
25
- // if we got this far then we are safe to use the fields on the object
26
- let cachedValue = reference as _Data | null
27
+ // Read from cache whenever the parent or loading state changes. The parent
28
+ // path uniquely identifies which cache record this fragment is bound to, so
29
+ // variables are excluded from the dep array — they are forwarded to
30
+ // observer.send() separately and don't affect which record we read.
31
+ // biome-ignore lint/correctness/useExhaustiveDependencies: variables intentionally excluded
32
+ const cachedValue = React.useMemo(() => {
33
+ if (reference && parent) {
34
+ return cache.read({
35
+ selection: document.artifact.selection,
36
+ parent,
37
+ variables,
38
+ loading,
39
+ }).data as _Data
40
+ }
41
+ return reference as _Data | null
42
+ }, [parent, loading])
27
43
 
28
- // on the client, we want to ensure that we apply masking to the initial value by
29
- // loading the value from cache
30
- if (reference && parent) {
31
- cachedValue = cache.read({
32
- selection: document.artifact.selection,
33
- parent,
34
- variables,
35
- loading,
36
- }).data as _Data
37
- }
44
+ // Stable initialState derived from cachedValue. useDocumentStore uses this to
45
+ // seed box.current synchronously during render when the parent changes, so
46
+ // storeValue.data is immediately correct without waiting for the subscription
47
+ // effect to fire. Must be memoized (reference-stable) so the store doesn't
48
+ // re-seed on every render.
49
+ // biome-ignore lint/correctness/useExhaustiveDependencies: variables changes don't require re-seeding
50
+ const initialState = React.useMemo(
51
+ (): QueryResult<_Data, _Input> | undefined =>
52
+ cachedValue !== null
53
+ ? {
54
+ data: cachedValue,
55
+ errors: null,
56
+ fetching: false,
57
+ partial: false,
58
+ stale: false,
59
+ source: null,
60
+ variables: variables ?? null,
61
+ }
62
+ : undefined,
63
+ [cachedValue]
64
+ )
38
65
 
39
- // we're ready to setup the live document
40
66
  const [storeValue] = useDocumentSubscription<FragmentArtifact, _Data, _Input>({
41
67
  artifact: document.artifact,
42
68
  variables,
43
69
  initialValue: cachedValue,
44
- // dont subscribe to anything if we are loading
45
70
  disabled: loading,
46
71
  send: {
47
72
  stuff: {
@@ -49,27 +74,10 @@ export function useFragment<
49
74
  },
50
75
  setup: true,
51
76
  },
77
+ initialState,
52
78
  })
53
79
 
54
- // the parent has changed, we need to use initialValue for this render
55
- // if we don't, then there is a very brief flash where we will show the old data
56
- // before the store has had a chance to update
57
- const lastReference = React.useRef<{ parent: string; variables: _Input } | null>(null)
58
- return React.useMemo(() => {
59
- // if the parent reference has changed we need to always prefer the cached value
60
- const parentChange =
61
- storeValue.parent !== parent ||
62
- !deepEquals({ parent, variables }, lastReference.current)
63
- if (parentChange) {
64
- // make sure we keep track of the last reference we used
65
- lastReference.current = { parent, variables: { ...variables } }
66
-
67
- // and use the cached value
68
- return cachedValue
69
- }
70
-
71
- return storeValue.data
72
- }, [variables, parent, storeValue.parent, storeValue.data, cachedValue])
80
+ return storeValue.data
73
81
  }
74
82
 
75
83
  export function fragmentReference<_Data extends GraphQLObject, _Input, _ReferenceType extends {}>(
@@ -73,7 +73,7 @@ export function Router({
73
73
  injectToStream,
74
74
  })
75
75
  // if we get this far, it's safe to load the component
76
- const { component_cache, data_cache } = useRouterContext()
76
+ const { component_cache, data_cache, ssr_signals } = useRouterContext()
77
77
  const PageComponent = component_cache.get(page.id)!
78
78
 
79
79
  // if we got this far then we're past suspense
@@ -107,6 +107,8 @@ export function Router({
107
107
  const goto = (url: string) => {
108
108
  // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
109
109
  data_cache.clear()
110
+ // clear pending signals so the next render starts fresh load_query calls
111
+ ssr_signals.clear()
110
112
 
111
113
  // perform the navigation
112
114
  setCurrentURL(url)
@@ -333,17 +335,20 @@ function usePageData({
333
335
  </script>
334
336
  `)
335
337
 
338
+ ssr_signals.delete(id)
336
339
  resolve()
337
340
  })
338
- .catch(reject)
341
+ .catch((err) => {
342
+ ssr_signals.delete(id)
343
+ reject(err)
344
+ })
339
345
  })
340
346
 
341
- // if we are on the server, we need to save a signal that we can use to
342
- // communicate with the client when we're done
347
+ // register the pending signal on both client and server so that concurrent React renders
348
+ // (concurrent mode / strict mode) that call load_query before data_cache is populated
349
+ // find the existing signal and don't create a duplicate observer+send
343
350
  const resolvable = { ...promise, resolve, reject }
344
- if (!globalThis.window) {
345
- ssr_signals.set(id, resolvable)
346
- }
351
+ ssr_signals.set(id, resolvable)
347
352
 
348
353
  // we're done
349
354
  return resolvable
@@ -378,6 +383,7 @@ function usePageData({
378
383
  // before we can compare we need to only look at the variables that the artifact cares about
379
384
  if (Object.keys(usedVariables ?? {}).length > 0 && !deepEquals(last, usedVariables)) {
380
385
  data_cache.delete(artifact)
386
+ ssr_signals.delete(artifact)
381
387
  }
382
388
  }
383
389
 
@@ -583,6 +589,7 @@ export function useSession(): [App.Session, (newSession: Partial<App.Session>) =
583
589
  const updateSession = (newSession: Partial<App.Session>) => {
584
590
  // clear the data cache so that we refetch queries with the new session (will force a cache-lookup)
585
591
  ctx.data_cache.clear()
592
+ ctx.ssr_signals.clear()
586
593
 
587
594
  // update the local state
588
595
  ctx.setSession(newSession)