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.
- package/package.json +8 -8
- package/postInstall.js +1 -1
- package/runtime/Link.tsx +24 -55
- package/runtime/hooks/useDocumentHandle.ts +9 -4
- package/runtime/hooks/useFragment.ts +169 -1
- package/runtime/hooks/useFragmentHandle.ts +84 -3
- package/runtime/hooks/useSubscription.ts +1 -1
- package/runtime/hooks/useSubscriptionHandle.ts +10 -6
- package/runtime/hydration.tsx +2 -2
- package/runtime/index.tsx +2 -2
- package/runtime/manifest.ts +1 -1
- package/runtime/mock.ts +9 -0
- package/runtime/resolve-href.ts +155 -5
- package/runtime/routes.ts +93 -0
- package/runtime/routing/Router.tsx +113 -45
- package/runtime/testing.tsx +163 -0
- package/vite/index.js +11 -3
- package/vite/strip-headers.d.ts +1 -0
- package/vite/strip-headers.js +35 -0
- package/vite/transform.d.ts +3 -1
- package/vite/transform.js +18 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @refresh reset
|
|
2
|
+
import { getCurrentConfig } from '$houdini/runtime/config'
|
|
3
|
+
import { Cache } from 'houdini/runtime/cache'
|
|
4
|
+
import { DataSource } from 'houdini/runtime/types'
|
|
5
|
+
import { HoudiniClient } from '$houdini/runtime/client'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
|
|
8
|
+
import { scalarMarshalers, serializeSearch } from './resolve-href.js'
|
|
9
|
+
import { Router as RouterImpl, RouterContextProvider, router_cache } from './routing/index.js'
|
|
10
|
+
import manifest from './manifest.js'
|
|
11
|
+
|
|
12
|
+
type MockValue =
|
|
13
|
+
| Record<string, unknown>
|
|
14
|
+
| ((vars: any) => Record<string, unknown>)
|
|
15
|
+
| AsyncIterable<Record<string, unknown>>
|
|
16
|
+
| ((vars: any) => AsyncIterable<Record<string, unknown>>)
|
|
17
|
+
|
|
18
|
+
// buildMockPath turns a route pattern, its params, and an optional search object into
|
|
19
|
+
// the concrete path a test wants to render. The typed createMock wrapper calls this so
|
|
20
|
+
// that param substitution (and its missing-param error) and search serialization both
|
|
21
|
+
// happen before the path reaches _createMock.
|
|
22
|
+
export function buildMockPath(
|
|
23
|
+
pattern: string,
|
|
24
|
+
params: Record<string, string>,
|
|
25
|
+
search?: Record<string, unknown>
|
|
26
|
+
): string {
|
|
27
|
+
if (!search) {
|
|
28
|
+
return buildURL(pattern, params)
|
|
29
|
+
}
|
|
30
|
+
// marshal custom-scalar search values the same way <Link> does, so tests exercise
|
|
31
|
+
// the real serialization path
|
|
32
|
+
const m = manifest as any
|
|
33
|
+
const page = m.pages[m.pagesByUrl[pattern]] as
|
|
34
|
+
| { searchParams?: ReadonlyArray<{ name: string; type: string }> }
|
|
35
|
+
| undefined
|
|
36
|
+
const marshalers = scalarMarshalers(page?.searchParams, getCurrentConfig()?.scalars)
|
|
37
|
+
return buildURL(pattern, params) + serializeSearch(search, marshalers)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function _createMock({
|
|
41
|
+
path,
|
|
42
|
+
data: mocks,
|
|
43
|
+
}: {
|
|
44
|
+
path: string
|
|
45
|
+
data: Record<string, MockValue>
|
|
46
|
+
}): React.ComponentType<{}> {
|
|
47
|
+
// the route patterns are anchored to the path, so strip any search string
|
|
48
|
+
// before matching
|
|
49
|
+
const pathname = path.split('?')[0]
|
|
50
|
+
|
|
51
|
+
// Validate required mocks up-front at the call site so the error points
|
|
52
|
+
// directly to the createMock() call rather than surfacing during rendering.
|
|
53
|
+
const pages = manifest.pages as Record<
|
|
54
|
+
string,
|
|
55
|
+
{ pattern: RegExp; documents: Record<string, unknown> }
|
|
56
|
+
>
|
|
57
|
+
const page = Object.values(pages).find((p) => p.pattern.test(pathname))
|
|
58
|
+
if (page) {
|
|
59
|
+
const missing = Object.keys(page.documents).filter((name) => !(name in mocks))
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`createMock: missing mock data for ${missing.map((n) => `"${n}"`).join(', ')} on route "${pathname}". Add ${missing.length === 1 ? 'it' : 'them'} to the data object passed to createMock.`
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const cache = new Cache(getCurrentConfig())
|
|
68
|
+
|
|
69
|
+
const mockPlugin = () => ({
|
|
70
|
+
network(ctx: any, { resolve }: any) {
|
|
71
|
+
const mock = mocks[ctx.artifact.name]
|
|
72
|
+
if (mock === undefined) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`createMock: "${ctx.artifact.name}" fired but was not in data. Add it to the data object passed to createMock.`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (ctx.artifact.kind === 'HoudiniSubscription') {
|
|
79
|
+
const iterable =
|
|
80
|
+
typeof mock === 'function'
|
|
81
|
+
? (mock as (v: any) => AsyncIterable<Record<string, unknown>>)(
|
|
82
|
+
ctx.variables ?? {}
|
|
83
|
+
)
|
|
84
|
+
: (mock as AsyncIterable<Record<string, unknown>>)
|
|
85
|
+
const iterator = iterable[Symbol.asyncIterator]()
|
|
86
|
+
ctx.abortController.signal.addEventListener(
|
|
87
|
+
'abort',
|
|
88
|
+
() => {
|
|
89
|
+
iterator.return?.()
|
|
90
|
+
},
|
|
91
|
+
{ once: true }
|
|
92
|
+
)
|
|
93
|
+
;(async () => {
|
|
94
|
+
for await (const data of { [Symbol.asyncIterator]: () => iterator }) {
|
|
95
|
+
if (ctx.abortController.signal.aborted) break
|
|
96
|
+
resolve(ctx, {
|
|
97
|
+
data,
|
|
98
|
+
errors: null,
|
|
99
|
+
fetching: false,
|
|
100
|
+
variables: null,
|
|
101
|
+
source: DataSource.Network,
|
|
102
|
+
partial: false,
|
|
103
|
+
stale: false,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const data = typeof mock === 'function' ? mock(ctx.variables ?? {}) : mock
|
|
111
|
+
resolve(ctx, {
|
|
112
|
+
data,
|
|
113
|
+
errors: null,
|
|
114
|
+
fetching: false,
|
|
115
|
+
variables: null,
|
|
116
|
+
source: DataSource.Network,
|
|
117
|
+
partial: false,
|
|
118
|
+
stale: false,
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Pass the same fresh cache to the client so the cache policy plugin doesn't
|
|
124
|
+
// reach into the global cacheRef singleton and return stale data from previous tests.
|
|
125
|
+
const mockClient = new HoudiniClient({
|
|
126
|
+
url: 'http://localhost/graphql',
|
|
127
|
+
plugins: [mockPlugin as any],
|
|
128
|
+
cache,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return function MockApp() {
|
|
132
|
+
const cachesRef = React.useRef<ReturnType<typeof router_cache> | null>(null)
|
|
133
|
+
if (cachesRef.current === null) {
|
|
134
|
+
cachesRef.current = router_cache()
|
|
135
|
+
}
|
|
136
|
+
const caches = cachesRef.current
|
|
137
|
+
return (
|
|
138
|
+
<RouterContextProvider
|
|
139
|
+
client={mockClient}
|
|
140
|
+
cache={cache}
|
|
141
|
+
artifact_cache={caches.artifact_cache}
|
|
142
|
+
component_cache={caches.component_cache}
|
|
143
|
+
data_cache={caches.data_cache}
|
|
144
|
+
ssr_signals={caches.ssr_signals}
|
|
145
|
+
last_variables={caches.last_variables}
|
|
146
|
+
>
|
|
147
|
+
<React.Suspense fallback={null}>
|
|
148
|
+
<RouterImpl manifest={manifest} initialURL={path} assetPrefix="" />
|
|
149
|
+
</React.Suspense>
|
|
150
|
+
</RouterContextProvider>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildURL(pattern: string, params: Record<string, string>): string {
|
|
156
|
+
return pattern.replace(/\[([^\]]+)\]/g, (_, key) => {
|
|
157
|
+
const value = params[key]
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
throw new Error(`createMock: missing param "${key}" for pattern "${pattern}"`)
|
|
160
|
+
}
|
|
161
|
+
return String(value)
|
|
162
|
+
})
|
|
163
|
+
}
|
package/vite/index.js
CHANGED
|
@@ -146,7 +146,7 @@ function index_default(ctx) {
|
|
|
146
146
|
hotUpdate() {
|
|
147
147
|
cfCache = null;
|
|
148
148
|
},
|
|
149
|
-
async transform(code, filepath) {
|
|
149
|
+
async transform(code, filepath, options) {
|
|
150
150
|
filepath = path.posixify(filepath);
|
|
151
151
|
if (filepath.startsWith("/src/")) {
|
|
152
152
|
filepath = path.join(process.cwd(), filepath);
|
|
@@ -154,6 +154,7 @@ function index_default(ctx) {
|
|
|
154
154
|
if (!ctx.config.includeFile(filepath)) {
|
|
155
155
|
return;
|
|
156
156
|
}
|
|
157
|
+
const stripHeaders = !options?.ssr && /(?:^|\/)\+(?:page|layout)\.[jt]sx?$/.test(filepath);
|
|
157
158
|
if (cfCache === null) {
|
|
158
159
|
try {
|
|
159
160
|
cfCache = ctx.db.all(
|
|
@@ -170,7 +171,8 @@ function index_default(ctx) {
|
|
|
170
171
|
filepath: path.posixify(filepath),
|
|
171
172
|
watch_file: this.addWatchFile.bind(this)
|
|
172
173
|
},
|
|
173
|
-
cfCache
|
|
174
|
+
cfCache,
|
|
175
|
+
{ stripHeaders }
|
|
174
176
|
);
|
|
175
177
|
},
|
|
176
178
|
async closeBundle() {
|
|
@@ -284,9 +286,15 @@ mount_static_app(App, manifest)
|
|
|
284
286
|
next();
|
|
285
287
|
return;
|
|
286
288
|
}
|
|
287
|
-
const
|
|
289
|
+
const manifest_module = await server.ssrLoadModule(
|
|
288
290
|
path.join(plugin_dir(ctx.config, "houdini-react"), "runtime", "manifest.ts")
|
|
289
291
|
);
|
|
292
|
+
const router_manifest = manifest_module.default;
|
|
293
|
+
for (const id of Object.keys(manifest_module.route_headers ?? {})) {
|
|
294
|
+
if (router_manifest.pages[id]) {
|
|
295
|
+
router_manifest.pages[id].headers = manifest_module.route_headers[id];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
290
298
|
const { createServerAdapter } = await server.ssrLoadModule(
|
|
291
299
|
adapter_config_path(ctx.config)
|
|
292
300
|
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function strip_named_export(script: any, name: string): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function strip_named_export(script, name) {
|
|
2
|
+
const body = script.body;
|
|
3
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
4
|
+
const node = body[i];
|
|
5
|
+
if (node.type !== "ExportNamedDeclaration") {
|
|
6
|
+
continue;
|
|
7
|
+
}
|
|
8
|
+
if (node.declaration) {
|
|
9
|
+
const decl = node.declaration;
|
|
10
|
+
if (decl.type === "FunctionDeclaration" && decl.id?.name === name) {
|
|
11
|
+
body.splice(i, 1);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (decl.type === "VariableDeclaration") {
|
|
15
|
+
decl.declarations = decl.declarations.filter(
|
|
16
|
+
(d) => !(d.id?.type === "Identifier" && d.id.name === name)
|
|
17
|
+
);
|
|
18
|
+
if (decl.declarations.length === 0) {
|
|
19
|
+
body.splice(i, 1);
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (node.specifiers?.length) {
|
|
26
|
+
node.specifiers = node.specifiers.filter((s) => s.exported?.name !== name);
|
|
27
|
+
if (node.specifiers.length === 0) {
|
|
28
|
+
body.splice(i, 1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
strip_named_export
|
|
35
|
+
};
|
package/vite/transform.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ export type ComponentFieldRow = {
|
|
|
5
5
|
field: string;
|
|
6
6
|
fragment: string;
|
|
7
7
|
};
|
|
8
|
-
export declare function transform_file(page: TransformPage, cfRows: ComponentFieldRow[]
|
|
8
|
+
export declare function transform_file(page: TransformPage, cfRows: ComponentFieldRow[], opts?: {
|
|
9
|
+
stripHeaders?: boolean;
|
|
10
|
+
}): Promise<{
|
|
9
11
|
code: string;
|
|
10
12
|
map?: SourceMapInput;
|
|
11
13
|
}>;
|
package/vite/transform.js
CHANGED
|
@@ -10,13 +10,17 @@ import {
|
|
|
10
10
|
} from "houdini";
|
|
11
11
|
import { componentField_unit_path, houdini_root } from "houdini/router/conventions";
|
|
12
12
|
import * as recast from "recast";
|
|
13
|
+
import { strip_named_export } from "./strip-headers.js";
|
|
13
14
|
const AST = recast.types.builders;
|
|
14
|
-
async function transform_file(page, cfRows) {
|
|
15
|
+
async function transform_file(page, cfRows, opts = {}) {
|
|
15
16
|
const isJSX = page.filepath.endsWith(".tsx") || page.filepath.endsWith(".jsx");
|
|
16
17
|
if (!isJSX && !page.filepath.endsWith(".ts") && !page.filepath.endsWith(".js")) {
|
|
17
18
|
return { code: page.content, map: page.map };
|
|
18
19
|
}
|
|
19
20
|
const script = parseJS(page.content, isJSX ? { plugins: ["jsx"] } : {});
|
|
21
|
+
if (opts.stripHeaders) {
|
|
22
|
+
strip_named_export(script, "headers");
|
|
23
|
+
}
|
|
20
24
|
const cfMap = {};
|
|
21
25
|
for (const row of cfRows) {
|
|
22
26
|
if (row.type && row.field && row.fragment) {
|
|
@@ -29,9 +33,9 @@ async function transform_file(page, cfRows) {
|
|
|
29
33
|
tag({ node, artifact, parsedDocument }) {
|
|
30
34
|
const { id: artifactRef } = artifact_import({ page, script, artifact });
|
|
31
35
|
const properties = [AST.objectProperty(AST.stringLiteral("artifact"), artifactRef)];
|
|
32
|
-
if (is_paginated(parsedDocument)) {
|
|
36
|
+
if (is_paginated(parsedDocument) || is_refetchable(parsedDocument)) {
|
|
33
37
|
if (artifact.kind !== ArtifactKind.Query) {
|
|
34
|
-
const refetchName = artifact.name + "_Pagination_Query";
|
|
38
|
+
const refetchName = artifact.name + (is_paginated(parsedDocument) ? "_Pagination_Query" : "_Refetch_Query");
|
|
35
39
|
const { id: refetchRef } = artifact_import({
|
|
36
40
|
page,
|
|
37
41
|
script,
|
|
@@ -85,6 +89,17 @@ function is_paginated(doc) {
|
|
|
85
89
|
});
|
|
86
90
|
return paginated;
|
|
87
91
|
}
|
|
92
|
+
function is_refetchable(doc) {
|
|
93
|
+
let refetchable = false;
|
|
94
|
+
graphql.visit(doc, {
|
|
95
|
+
Directive(node) {
|
|
96
|
+
if (node.name.value === "refetchable") {
|
|
97
|
+
refetchable = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return refetchable;
|
|
102
|
+
}
|
|
88
103
|
export {
|
|
89
104
|
transform_file
|
|
90
105
|
};
|