houdini-react 2.0.0-next.33 → 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 +8 -8
- package/postInstall.js +1 -1
- package/runtime/hooks/recycleNodesInto.ts +53 -0
- package/runtime/hooks/useDeepCompareEffect.ts +5 -3
- package/runtime/hooks/useDocumentStore.ts +37 -1
- package/runtime/hooks/useDocumentSubscription.ts +4 -1
- package/runtime/hooks/useFragment.ts +45 -37
- package/runtime/routing/Router.tsx +14 -7
- package/vite/index.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "houdini-react",
|
|
3
|
-
"version": "2.0.0-next.
|
|
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.
|
|
85
|
-
"houdini-react-darwin-arm64": "2.0.0-next.
|
|
86
|
-
"houdini-react-linux-x64": "2.0.0-next.
|
|
87
|
-
"houdini-react-linux-arm64": "2.0.0-next.
|
|
88
|
-
"houdini-react-win32-x64": "2.0.0-next.
|
|
89
|
-
"houdini-react-win32-arm64": "2.0.0-next.
|
|
90
|
-
"houdini-react-wasm": "2.0.0-next.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
26
|
-
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
341
|
+
.catch((err) => {
|
|
342
|
+
ssr_signals.delete(id)
|
|
343
|
+
reject(err)
|
|
344
|
+
})
|
|
339
345
|
})
|
|
340
346
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
-
|
|
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)
|
package/vite/index.js
CHANGED
|
@@ -91,6 +91,9 @@ function index_default(ctx) {
|
|
|
91
91
|
}
|
|
92
92
|
return id.substring(id.indexOf("virtual:houdini"));
|
|
93
93
|
},
|
|
94
|
+
hotUpdate() {
|
|
95
|
+
cfCache = null;
|
|
96
|
+
},
|
|
94
97
|
async transform(code, filepath) {
|
|
95
98
|
filepath = path.posixify(filepath);
|
|
96
99
|
if (filepath.startsWith("/src/")) {
|
|
@@ -153,7 +156,14 @@ function index_default(ctx) {
|
|
|
153
156
|
const parsedPath = arg ? path.parse(arg) : "";
|
|
154
157
|
const pageName = parsedPath ? parsedPath.name : "";
|
|
155
158
|
if (which === "pages") {
|
|
156
|
-
|
|
159
|
+
let page = manifest.pages[pageName];
|
|
160
|
+
if (!page) {
|
|
161
|
+
try {
|
|
162
|
+
manifest = await load_manifest({ config: ctx.config });
|
|
163
|
+
page = manifest.pages[pageName];
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
}
|
|
157
167
|
if (!page) {
|
|
158
168
|
throw new Error("unknown page" + pageName);
|
|
159
169
|
}
|