catalyst-core 1.0.1 → 1.0.2-canary.1
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/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/scripts/build.js +3 -1
- package/dist/scripts/preflight.js +81 -1
- package/dist/scripts/serve.js +8 -1
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +24 -6
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +121 -0
- package/package.json +3 -6
- package/test/scripts/preflight.test.ts +100 -2
- package/test/server/manifestCache.test.ts +13 -0
- package/vitest.config.ts +4 -0
package/dist/errors/registry.js
CHANGED
|
@@ -22,6 +22,7 @@ export const ERROR_CODES = {
|
|
|
22
22
|
PREFLIGHT_GET_ROUTES_NOT_FUNCTION: "PREFLIGHT-019",
|
|
23
23
|
PREFLIGHT_CUSTOM_DOCUMENT_MISSING: "PREFLIGHT-020",
|
|
24
24
|
PREFLIGHT_CUSTOM_DOCUMENT_NOT_FUNCTION: "PREFLIGHT-021",
|
|
25
|
+
PREFLIGHT_BUILD_STALE: "PREFLIGHT-022",
|
|
25
26
|
|
|
26
27
|
PROCESS_SERVER_INIT_FAILED: "PROCESS-001",
|
|
27
28
|
PROCESS_USER_HOOK_FAILED: "PROCESS-002",
|
|
@@ -204,6 +205,13 @@ export const ERROR_DEFINITIONS = {
|
|
|
204
205
|
defaultDetails: "document was found but is not a function/component.",
|
|
205
206
|
suggestedAction: "Ensure document is exported as a React component (function).",
|
|
206
207
|
},
|
|
208
|
+
[ERROR_CODES.PREFLIGHT_BUILD_STALE]: {
|
|
209
|
+
category: "PREFLIGHT",
|
|
210
|
+
defaultMessage: "The build is stale: config/config.json changed since it was built",
|
|
211
|
+
defaultDetails:
|
|
212
|
+
"PUBLIC_STATIC_ASSET_URL, PUBLIC_STATIC_ASSET_PATH and the CLIENT_ENV_VARIABLES values are inlined into the bundles at build time, so serving this build would use the old values.",
|
|
213
|
+
suggestedAction: "Run `npm run build` again, then `npm run serve`.",
|
|
214
|
+
},
|
|
207
215
|
|
|
208
216
|
[ERROR_CODES.PROCESS_SERVER_INIT_FAILED]: {
|
|
209
217
|
category: "PROCESS",
|
package/dist/errors-index.json
CHANGED
|
@@ -216,6 +216,13 @@
|
|
|
216
216
|
"suggestedAction": "Ensure document is exported as a React component (function).",
|
|
217
217
|
"docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/PREFLIGHT/PREFLIGHT-021.md"
|
|
218
218
|
},
|
|
219
|
+
"PREFLIGHT-022": {
|
|
220
|
+
"category": "PREFLIGHT",
|
|
221
|
+
"message": "The build is stale: config/config.json changed since it was built",
|
|
222
|
+
"details": "PUBLIC_STATIC_ASSET_URL, PUBLIC_STATIC_ASSET_PATH and the CLIENT_ENV_VARIABLES values are inlined into the bundles at build time, so serving this build would use the old values.",
|
|
223
|
+
"suggestedAction": "Run `npm run build` again, then `npm run serve`.",
|
|
224
|
+
"docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/PREFLIGHT/PREFLIGHT-022.md"
|
|
225
|
+
},
|
|
219
226
|
"PROCESS-001": {
|
|
220
227
|
"category": "PROCESS",
|
|
221
228
|
"message": "preServerInit threw an error during server startup",
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { RouterDataProvider, useCurrentRouteData, useRouterData, } from "./web-router/components/RouterDataProvider.jsx";
|
|
1
|
+
export { RouterDataProvider, useCurrentRouteData, useRouterData, useRouterState, } from "./web-router/components/RouterDataProvider.jsx";
|
|
2
2
|
export { MetaTag } from "./web-router/components/MetaTag.jsx";
|
|
3
3
|
export { split, hydrationReady } from "./web-router/components/Split.jsx";
|
|
4
4
|
export { Head } from "./server/renderer/document/Head.jsx";
|
package/dist/index.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { RouterDataProvider, useCurrentRouteData, useRouterData } from "./web-router/components/RouterDataProvider.jsx";
|
|
1
|
+
export { RouterDataProvider, useCurrentRouteData, useRouterData, useRouterState } from "./web-router/components/RouterDataProvider.jsx";
|
|
2
2
|
export { MetaTag } from "./web-router/components/MetaTag.jsx";
|
|
3
3
|
export { split, hydrationReady } from "./web-router/components/Split.jsx";
|
|
4
4
|
export { Head } from "./server/renderer/document/Head.jsx";
|
package/dist/scripts/build.js
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname } from "path"
|
|
|
6
6
|
import { readFileSync, existsSync, rmSync } from "fs"
|
|
7
7
|
import { createRequire } from "module"
|
|
8
8
|
import { wrapForeignError, formatError } from "../errors/index.js"
|
|
9
|
-
import { runStaticPreflightOrExit } from "./preflight.js"
|
|
9
|
+
import { runStaticPreflightOrExit, writeBuildInfo } from "./preflight.js"
|
|
10
10
|
|
|
11
11
|
// Fail fast on a misconfigured app (missing/invalid config.json, package.json,
|
|
12
12
|
// or moduleAliases) with a coded, doc-linked error — must run before the raw
|
|
@@ -112,6 +112,8 @@ async function build() {
|
|
|
112
112
|
|
|
113
113
|
console.log("✅ Server and client builds completed!")
|
|
114
114
|
|
|
115
|
+
writeBuildInfo(process.env.PWD, configJSON, argumentsObject)
|
|
116
|
+
|
|
115
117
|
await runBuildStep(["./dist/scripts/generateOfflineManifest.js"], {
|
|
116
118
|
...spawnBase,
|
|
117
119
|
env: baseEnv,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "fs"
|
|
1
|
+
import { readFileSync, writeFileSync } from "fs"
|
|
2
2
|
import path from "path"
|
|
3
3
|
import {
|
|
4
4
|
validateConfigFile,
|
|
@@ -109,3 +109,83 @@ export function runStaticPreflightOrExit(appDir = process.env.PWD || process.cwd
|
|
|
109
109
|
)
|
|
110
110
|
process.exit(1)
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
// Records the config values a build was made with, so `serve` can refuse a
|
|
114
|
+
// build that no longer matches config/config.json (issue #251).
|
|
115
|
+
const BUILD_INFO_FILE = ".catalyst-build.json"
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Config values Vite inlines into the bundles at build time (see
|
|
119
|
+
* getClientEnvVariables in vite.config.js). KEY=value CLI arguments win over
|
|
120
|
+
* config.json, and objects are stored as JSON, as in loadEnvironmentVariables.
|
|
121
|
+
* @param {Record<string, unknown>} config - parsed config/config.json
|
|
122
|
+
* @param {Record<string, string>} [args] - KEY=value CLI arguments
|
|
123
|
+
*/
|
|
124
|
+
export function getInlinedConfig(config, args = {}) {
|
|
125
|
+
const values = { ...config, ...args }
|
|
126
|
+
let clientVars = values.CLIENT_ENV_VARIABLES ?? []
|
|
127
|
+
if (typeof clientVars === "string") clientVars = JSON.parse(clientVars)
|
|
128
|
+
const keys = ["PUBLIC_STATIC_ASSET_URL", "PUBLIC_STATIC_ASSET_PATH", ...clientVars]
|
|
129
|
+
return Object.fromEntries(
|
|
130
|
+
keys.map((key) => [key, typeof values[key] === "object" ? JSON.stringify(values[key]) : values[key]])
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildInfoPath(appDir, config, args) {
|
|
135
|
+
return path.join(appDir, args.BUILD_OUTPUT_PATH || config.BUILD_OUTPUT_PATH || "build", BUILD_INFO_FILE)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} appDir
|
|
140
|
+
* @param {Record<string, unknown>} config
|
|
141
|
+
* @param {Record<string, string>} [args]
|
|
142
|
+
*/
|
|
143
|
+
export function writeBuildInfo(appDir, config, args = {}) {
|
|
144
|
+
writeFileSync(
|
|
145
|
+
buildInfoPath(appDir, config, args),
|
|
146
|
+
JSON.stringify(getInlinedConfig(config, args), null, 4)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compare the values a build was made with against the current config.
|
|
152
|
+
* Anything that stops the comparison (a missing or unreadable build info
|
|
153
|
+
* file, e.g. from an older catalyst-core, an unparseable CLIENT_ENV_VARIABLES)
|
|
154
|
+
* only warns, so the build still serves.
|
|
155
|
+
* @param {string} [appDir]
|
|
156
|
+
* @param {Record<string, string>} [args]
|
|
157
|
+
* @returns {import("../errors/index.js").CatalystError | null}
|
|
158
|
+
*/
|
|
159
|
+
export function checkBuildMatchesConfig(appDir = process.env.PWD || process.cwd(), args = {}) {
|
|
160
|
+
const skip = (reason) => {
|
|
161
|
+
console.warn(`Skipping the build/config check: ${reason}`)
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// runStaticPreflightOrExit has already checked config.json.
|
|
166
|
+
const config = JSON.parse(readFileSync(path.join(appDir, "config", "config.json"), "utf8"))
|
|
167
|
+
const infoPath = buildInfoPath(appDir, config, args)
|
|
168
|
+
const info = readJson(infoPath)
|
|
169
|
+
if (info.error || !info.value || typeof info.value !== "object" || Array.isArray(info.value)) {
|
|
170
|
+
return skip(`${infoPath} is missing or invalid. Rebuild to enable it.`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let current
|
|
174
|
+
try {
|
|
175
|
+
current = getInlinedConfig(config, args)
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return skip(`could not read CLIENT_ENV_VARIABLES: ${e.message}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const keys = new Set([...Object.keys(info.value), ...Object.keys(current)])
|
|
181
|
+
const changed = [...keys].filter((key) => info.value[key] !== current[key])
|
|
182
|
+
if (changed.length === 0) return null
|
|
183
|
+
|
|
184
|
+
const show = (value) => (value === undefined ? "unset" : JSON.stringify(value))
|
|
185
|
+
const lines = changed.map(
|
|
186
|
+
(key) => `${key}: built with ${show(info.value[key])}, now ${show(current[key])}`
|
|
187
|
+
)
|
|
188
|
+
return createError(ERROR_CODES.PREFLIGHT_BUILD_STALE, {
|
|
189
|
+
details: `These values were inlined into the build and differ from config/config.json:\n ${lines.join("\n ")}`,
|
|
190
|
+
})
|
|
191
|
+
}
|
package/dist/scripts/serve.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "path"
|
|
2
2
|
import { spawnSync } from "child_process"
|
|
3
3
|
import { arrayToObject, resolveOutputMode } from "./scriptUtils.js"
|
|
4
|
-
import { runStaticPreflightOrExit } from "./preflight.js"
|
|
4
|
+
import { runStaticPreflightOrExit, checkBuildMatchesConfig } from "./preflight.js"
|
|
5
|
+
import { formatError } from "../errors/index.js"
|
|
5
6
|
import { fileURLToPath } from "url"
|
|
6
7
|
import { dirname } from "path"
|
|
7
8
|
import { readFileSync } from "fs"
|
|
@@ -23,6 +24,12 @@ function startProd() {
|
|
|
23
24
|
const outputMode = resolveOutputMode(process.argv)
|
|
24
25
|
const dirname = path.resolve(__dirname, "../../")
|
|
25
26
|
|
|
27
|
+
const staleBuild = checkBuildMatchesConfig(undefined, argumentsObject)
|
|
28
|
+
if (staleBuild) {
|
|
29
|
+
console.error(formatError(staleBuild, outputMode))
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
// Read package.json
|
|
27
34
|
const packageJson = JSON.parse(readFileSync(path.join(process.env.PWD, "package.json"), "utf-8"))
|
|
28
35
|
const { name } = packageJson
|
|
@@ -8,14 +8,3 @@ import React from "react";
|
|
|
8
8
|
* @param {object} children - contains any child elements defined within the component
|
|
9
9
|
*/
|
|
10
10
|
export declare function Body(props: any): React.JSX.Element;
|
|
11
|
-
export declare namespace Body {
|
|
12
|
-
var propTypes: {
|
|
13
|
-
initialState: any;
|
|
14
|
-
jsx: any;
|
|
15
|
-
statusCode: any;
|
|
16
|
-
fetcherData: any;
|
|
17
|
-
children: any;
|
|
18
|
-
safeArea: any;
|
|
19
|
-
nativeWebView: any;
|
|
20
|
-
};
|
|
21
|
-
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import PropTypes from "prop-types";
|
|
3
2
|
const DEFAULT_SAFE_AREA = {
|
|
4
3
|
top: 0,
|
|
5
4
|
right: 0,
|
|
@@ -51,12 +50,3 @@ export function Body(props) {
|
|
|
51
50
|
{children}
|
|
52
51
|
</body>;
|
|
53
52
|
}
|
|
54
|
-
Body.propTypes = {
|
|
55
|
-
initialState: PropTypes.object,
|
|
56
|
-
jsx: PropTypes.any,
|
|
57
|
-
statusCode: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
|
58
|
-
fetcherData: PropTypes.object,
|
|
59
|
-
children: PropTypes.node,
|
|
60
|
-
safeArea: PropTypes.object,
|
|
61
|
-
nativeWebView: PropTypes.bool
|
|
62
|
-
};
|
|
@@ -5,16 +5,3 @@ import React from "react";
|
|
|
5
5
|
* Route-cached deferred CSS is inlined here on repeat visits; new deferred CSS still appends after body.
|
|
6
6
|
*/
|
|
7
7
|
export declare function Head(props: any): React.JSX.Element;
|
|
8
|
-
export declare namespace Head {
|
|
9
|
-
var propTypes: {
|
|
10
|
-
isBot: any;
|
|
11
|
-
inlineCss: any;
|
|
12
|
-
deferredRouteInlineCss: any;
|
|
13
|
-
jsScripts: any;
|
|
14
|
-
criticalPreloadLinks: any;
|
|
15
|
-
deferredPreloadLinks: any;
|
|
16
|
-
metaTags: any;
|
|
17
|
-
publicAssetPath: any;
|
|
18
|
-
children: any;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import PropTypes from "prop-types";
|
|
3
2
|
import FastRefresh from "../../../vite/FastRefresh.jsx";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -49,14 +48,3 @@ export function Head(props) {
|
|
|
49
48
|
{children}
|
|
50
49
|
</head>;
|
|
51
50
|
}
|
|
52
|
-
Head.propTypes = {
|
|
53
|
-
isBot: PropTypes.bool,
|
|
54
|
-
inlineCss: PropTypes.string,
|
|
55
|
-
deferredRouteInlineCss: PropTypes.string,
|
|
56
|
-
jsScripts: PropTypes.array,
|
|
57
|
-
criticalPreloadLinks: PropTypes.array,
|
|
58
|
-
deferredPreloadLinks: PropTypes.array,
|
|
59
|
-
metaTags: PropTypes.array,
|
|
60
|
-
publicAssetPath: PropTypes.string,
|
|
61
|
-
children: PropTypes.node
|
|
62
|
-
};
|
|
@@ -74,7 +74,7 @@ const createClientConfig = async () => {
|
|
|
74
74
|
return undefined
|
|
75
75
|
}
|
|
76
76
|
if (
|
|
77
|
-
/[\\/]node_modules[\\/](react|react-dom|scheduler
|
|
77
|
+
/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(
|
|
78
78
|
id
|
|
79
79
|
)
|
|
80
80
|
) {
|
package/dist/vite/vite.config.js
CHANGED
|
@@ -51,15 +51,27 @@ function scssModulesPlugin() {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Automatic runtime so the output imports react/jsx-(dev-)runtime: plugin-react
|
|
55
|
+
// only adds Fast Refresh to .js files that import it. Classic createElement
|
|
56
|
+
// output leaves the module without an HMR boundary, forcing full page reloads.
|
|
54
57
|
function jsxInJsPlugin() {
|
|
58
|
+
let isProduction = process.env.NODE_ENV === "production"
|
|
59
|
+
|
|
55
60
|
return {
|
|
56
61
|
name: "vite-plugin-jsx-in-js",
|
|
57
62
|
enforce: "pre",
|
|
63
|
+
configResolved(config) {
|
|
64
|
+
isProduction = config.isProduction
|
|
65
|
+
},
|
|
58
66
|
async transform(code, id) {
|
|
59
67
|
if (!id.endsWith(".js")) return null
|
|
60
68
|
if (id.includes("node_modules")) return null
|
|
61
69
|
if (!/</.test(code)) return null
|
|
62
|
-
return transformWithEsbuild(code, id, {
|
|
70
|
+
return transformWithEsbuild(code, id, {
|
|
71
|
+
loader: "jsx",
|
|
72
|
+
jsx: "automatic",
|
|
73
|
+
jsxDev: !isProduction,
|
|
74
|
+
})
|
|
63
75
|
},
|
|
64
76
|
}
|
|
65
77
|
}
|
|
@@ -266,9 +278,7 @@ const browserOptimizeDeps = [
|
|
|
266
278
|
"redux",
|
|
267
279
|
"redux-thunk",
|
|
268
280
|
"axios",
|
|
269
|
-
"react-helmet-async",
|
|
270
281
|
"react-google-recaptcha",
|
|
271
|
-
"react-fast-compare",
|
|
272
282
|
"history",
|
|
273
283
|
"lottie-web",
|
|
274
284
|
// Pre-bundle alongside React so esbuild marks react/react-router as external
|
|
@@ -295,7 +305,6 @@ const browserOptimizeDeps = [
|
|
|
295
305
|
// first navigation that pulls them in doesn't trigger a full-page reload.
|
|
296
306
|
"react-loadable-visibility",
|
|
297
307
|
"react-detect-offline",
|
|
298
|
-
"react-side-effect",
|
|
299
308
|
"react-async-script",
|
|
300
309
|
"normalize.css",
|
|
301
310
|
]
|
|
@@ -357,7 +366,6 @@ export const sharedViteConfig = {
|
|
|
357
366
|
optimizeDeps: {
|
|
358
367
|
include: [
|
|
359
368
|
"invariant",
|
|
360
|
-
"react-fast-compare",
|
|
361
369
|
"shallowequal",
|
|
362
370
|
"prop-types",
|
|
363
371
|
"redux-thunk",
|
|
@@ -430,7 +438,17 @@ export const sharedViteConfig = {
|
|
|
430
438
|
path.join(process.env.src_path, "src/**/*.{js,jsx,ts,tsx}"),
|
|
431
439
|
],
|
|
432
440
|
include: browserOptimizeDeps,
|
|
433
|
-
|
|
441
|
+
// catalyst-core/webmcp (and its shim subpath) must share the SAME
|
|
442
|
+
// pre-bundled module instance as catalyst-core's main entry:
|
|
443
|
+
// WebMcpProvider reads router state through a React context
|
|
444
|
+
// (OneMgRouterContext, via useRouterState()) that RouterDataProvider
|
|
445
|
+
// populates. If esbuild's dep scanner pre-bundles the subpath as its
|
|
446
|
+
// own separate optimized chunk, that chunk gets its own
|
|
447
|
+
// `createContext()` call — a second, unlinked context instance that
|
|
448
|
+
// WebMcpProvider reads from empty, throwing "must be used within a
|
|
449
|
+
// RouterDataProvider" even though one genuinely is an ancestor.
|
|
450
|
+
// Same rationale as the existing ClientRouter exclusion below.
|
|
451
|
+
exclude: ["catalyst-core/router/ClientRouter", "catalyst-core/webmcp", "catalyst-core/webmcp/shim"],
|
|
434
452
|
esbuildOptions: {
|
|
435
453
|
format: "esm",
|
|
436
454
|
target: "node2022",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import React, { useContext, useState, useEffect } from "react";
|
|
2
|
-
import { HelmetProvider, Helmet } from "react-helmet-async";
|
|
3
2
|
import { OneMgRouterContext } from "../context.jsx";
|
|
4
3
|
import { useRouterData } from "./RouterDataProvider.jsx";
|
|
5
|
-
import {
|
|
4
|
+
import { getMetaData } from "../utils/metaDataUtils.jsx";
|
|
6
5
|
import { useLocation } from "react-router";
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -14,19 +13,13 @@ export const MetaTag = () => {
|
|
|
14
13
|
} = useContext(OneMgRouterContext);
|
|
15
14
|
const routeData = useRouterData();
|
|
16
15
|
const location = useLocation();
|
|
17
|
-
const [metaTags, setMetaTags] = useState([
|
|
16
|
+
const [metaTags, setMetaTags] = useState([]);
|
|
18
17
|
useEffect(() => {
|
|
19
18
|
const mergedMetaTags = getMetaData(matchedRoutes, routeData);
|
|
20
|
-
|
|
21
|
-
setMetaTags(mergedMetaTags);
|
|
22
|
-
}
|
|
23
|
-
return () => deleteHeadTagsByDataAttribute("catalyst");
|
|
19
|
+
setMetaTags(Array.isArray(mergedMetaTags) ? mergedMetaTags : []);
|
|
24
20
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
21
|
}, [location]);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
</HelmetProvider>;
|
|
30
|
-
}
|
|
31
|
-
return <></>;
|
|
22
|
+
|
|
23
|
+
// React 19 hoists document metadata rendered by components into <head>.
|
|
24
|
+
return <>{metaTags}</>;
|
|
32
25
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
-
import { describe, expect, it, vi
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
3
|
import { render, waitFor, act } from "@testing-library/react"
|
|
4
|
-
import { MemoryRouter, Routes, Route, useNavigate } from "react-router"
|
|
4
|
+
import { MemoryRouter, Routes, Route, useLocation, useNavigate } from "react-router"
|
|
5
5
|
import { MetaTag } from "./MetaTag.jsx"
|
|
6
6
|
import { OneMgRouterContext } from "../context.jsx"
|
|
7
7
|
import { RouterContext } from "./RouterDataProvider.jsx"
|
|
@@ -26,10 +26,6 @@ function renderMetaTag({ matchedRoutes = [], routerData = {} } = {}) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
describe("MetaTag", () => {
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
document.head.innerHTML = ""
|
|
31
|
-
})
|
|
32
|
-
|
|
33
29
|
it("renders without crashing when there are no matched routes", () => {
|
|
34
30
|
expect(() => renderMetaTag()).not.toThrow()
|
|
35
31
|
})
|
|
@@ -48,53 +44,64 @@ describe("MetaTag", () => {
|
|
|
48
44
|
await waitFor(() => {
|
|
49
45
|
expect(document.head.querySelector('meta[name="description"]')).not.toBeNull()
|
|
50
46
|
})
|
|
51
|
-
expect(document.head.querySelector('meta[name="description"]').getAttribute("content")).toBe(
|
|
52
|
-
"hello"
|
|
53
|
-
)
|
|
47
|
+
expect(document.head.querySelector('meta[name="description"]').getAttribute("content")).toBe("hello")
|
|
54
48
|
})
|
|
55
49
|
|
|
56
|
-
it("
|
|
50
|
+
it("renders no metadata when no route provides setMetaData", () => {
|
|
57
51
|
renderMetaTag({ matchedRoutes: [{ route: { component: {} } }] })
|
|
58
|
-
|
|
59
|
-
// even with the single empty placeholder <meta> from useState.
|
|
60
|
-
expect(document.querySelector("head")).not.toBeNull()
|
|
52
|
+
expect(document.head.querySelector("[data-catalyst]")).toBeNull()
|
|
61
53
|
})
|
|
62
54
|
|
|
63
|
-
it("
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
55
|
+
it("clears previous metadata when navigating to a route without setMetaData", async () => {
|
|
56
|
+
const routeWithMetadata = [
|
|
57
|
+
{
|
|
58
|
+
route: {
|
|
59
|
+
component: {
|
|
60
|
+
setMetaData: () => [<meta key="d" name="description" content="with metadata" />],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
function RouteMetadata() {
|
|
67
|
+
const location = useLocation()
|
|
68
|
+
const matchedRoutes = location.pathname === "/with-metadata" ? routeWithMetadata : []
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<OneMgRouterContext.Provider value={{ matchedRoutes }}>
|
|
72
|
+
<RouterContext.Provider value={{}}>
|
|
73
|
+
<MetaTag />
|
|
74
|
+
</RouterContext.Provider>
|
|
75
|
+
</OneMgRouterContext.Provider>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
73
78
|
|
|
74
79
|
function NavButton() {
|
|
75
80
|
const navigate = useNavigate()
|
|
76
81
|
return (
|
|
77
|
-
<button type="button" onClick={() => navigate("/
|
|
82
|
+
<button type="button" onClick={() => navigate("/without-metadata")}>
|
|
78
83
|
go
|
|
79
84
|
</button>
|
|
80
85
|
)
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
render(
|
|
84
|
-
<MemoryRouter initialEntries={["/
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
<NavButton />
|
|
88
|
-
<MetaTag />
|
|
89
|
-
</RouterContext.Provider>
|
|
90
|
-
</OneMgRouterContext.Provider>
|
|
89
|
+
<MemoryRouter initialEntries={["/with-metadata"]}>
|
|
90
|
+
<NavButton />
|
|
91
|
+
<RouteMetadata />
|
|
91
92
|
</MemoryRouter>
|
|
92
93
|
)
|
|
93
|
-
|
|
94
|
+
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
expect(document.head.querySelector('meta[name="description"]')).not.toBeNull()
|
|
97
|
+
})
|
|
94
98
|
|
|
95
99
|
await act(async () => {
|
|
96
100
|
document.querySelector("button").click()
|
|
97
101
|
})
|
|
98
|
-
|
|
102
|
+
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(document.head.querySelector('meta[name="description"]')).toBeNull()
|
|
105
|
+
})
|
|
99
106
|
})
|
|
100
107
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import type { Location, NavigateFunction, Params, RouteObject } from "react-router";
|
|
2
|
+
import type { Location, NavigateFunction, Params, RouteMatch, RouteObject } from "react-router";
|
|
3
3
|
/**
|
|
4
4
|
* Data a route fetcher produced, as exposed by useCurrentRouteData / useRouterData.
|
|
5
5
|
*/
|
|
@@ -94,3 +94,27 @@ export declare const useCurrentRouteData: () => RouteData;
|
|
|
94
94
|
* @throws If used outside RouterDataProvider Context
|
|
95
95
|
*/
|
|
96
96
|
export declare const useRouterData: () => RoutesData;
|
|
97
|
+
/**
|
|
98
|
+
* Router state (location, params, navigate, the matched-route chain) read
|
|
99
|
+
* through OneMgRouterContext rather than react-router's own hooks directly.
|
|
100
|
+
*
|
|
101
|
+
* Exists so a `catalyst-core/*` SUBPATH package (e.g. `catalyst-core/webmcp`)
|
|
102
|
+
* can read router state without importing react-router itself. A subpath is
|
|
103
|
+
* a separate module resolution root from the app's own react-router import
|
|
104
|
+
* under Vite's dev SSR (react-router is deliberately excluded from
|
|
105
|
+
* vite.config.js's `dedupe`), so two `useLocation()` calls made from two
|
|
106
|
+
* different entry points can land on two different react-router module
|
|
107
|
+
* instances with two different, mutually invisible RouterContexts — the
|
|
108
|
+
* hook throws "may be used only in the context of a <Router> component"
|
|
109
|
+
* even though a <Router> genuinely is an ancestor. Going through this
|
|
110
|
+
* context instead relies only on `react`'s context identity, and `react`
|
|
111
|
+
* (unlike react-router) IS deduped.
|
|
112
|
+
*
|
|
113
|
+
* @throws If used outside RouterDataProvider Context
|
|
114
|
+
*/
|
|
115
|
+
export declare const useRouterState: () => {
|
|
116
|
+
location: Location;
|
|
117
|
+
params: Params;
|
|
118
|
+
navigate: NavigateFunction;
|
|
119
|
+
matchedRoutes: RouteMatch<string, RouteObject>[];
|
|
120
|
+
};
|
|
@@ -270,7 +270,10 @@ export const RouterDataProvider = ({
|
|
|
270
270
|
return <OneMgRouterContext.Provider value={{
|
|
271
271
|
matchedRoutes,
|
|
272
272
|
refetchData,
|
|
273
|
-
clear
|
|
273
|
+
clear,
|
|
274
|
+
location,
|
|
275
|
+
params,
|
|
276
|
+
navigate
|
|
274
277
|
}}>
|
|
275
278
|
<RouterContext.Provider value={{
|
|
276
279
|
...routeData,
|
|
@@ -331,3 +334,34 @@ export const useRouterData = () => {
|
|
|
331
334
|
}
|
|
332
335
|
return context;
|
|
333
336
|
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Router state (location, params, navigate, the matched-route chain) read
|
|
340
|
+
* through OneMgRouterContext rather than react-router's own hooks directly.
|
|
341
|
+
*
|
|
342
|
+
* Exists so a `catalyst-core/*` SUBPATH package (e.g. `catalyst-core/webmcp`)
|
|
343
|
+
* can read router state without importing react-router itself. A subpath is
|
|
344
|
+
* a separate module resolution root from the app's own react-router import
|
|
345
|
+
* under Vite's dev SSR (react-router is deliberately excluded from
|
|
346
|
+
* vite.config.js's `dedupe`), so two `useLocation()` calls made from two
|
|
347
|
+
* different entry points can land on two different react-router module
|
|
348
|
+
* instances with two different, mutually invisible RouterContexts — the
|
|
349
|
+
* hook throws "may be used only in the context of a <Router> component"
|
|
350
|
+
* even though a <Router> genuinely is an ancestor. Going through this
|
|
351
|
+
* context instead relies only on `react`'s context identity, and `react`
|
|
352
|
+
* (unlike react-router) IS deduped.
|
|
353
|
+
*
|
|
354
|
+
* @throws If used outside RouterDataProvider Context
|
|
355
|
+
*/
|
|
356
|
+
export const useRouterState = () => {
|
|
357
|
+
const context = useContext(OneMgRouterContext);
|
|
358
|
+
if (context.location === undefined) {
|
|
359
|
+
throw new Error("useRouterState must be used within a RouterDataProvider");
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
location: context.location,
|
|
363
|
+
params: context.params || {},
|
|
364
|
+
navigate: context.navigate,
|
|
365
|
+
matchedRoutes: context.matchedRoutes || []
|
|
366
|
+
};
|
|
367
|
+
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RouterDataProvider,
|
|
7
7
|
useRouterData,
|
|
8
8
|
useCurrentRouteData,
|
|
9
|
+
useRouterState,
|
|
9
10
|
serverDataFetcher,
|
|
10
11
|
} from "./RouterDataProvider.jsx"
|
|
11
12
|
|
|
@@ -308,3 +309,46 @@ describe("useCurrentRouteData", () => {
|
|
|
308
309
|
await waitFor(() => expect(screen.getByTestId("ready")).toBeInTheDocument())
|
|
309
310
|
})
|
|
310
311
|
})
|
|
312
|
+
|
|
313
|
+
describe("useRouterState", () => {
|
|
314
|
+
function StateProbe() {
|
|
315
|
+
const { location, params, navigate, matchedRoutes } = useRouterState()
|
|
316
|
+
return (
|
|
317
|
+
<div
|
|
318
|
+
data-testid="state"
|
|
319
|
+
data-path={location.pathname}
|
|
320
|
+
data-params={JSON.stringify(params)}
|
|
321
|
+
data-nav={typeof navigate}
|
|
322
|
+
data-matched={Array.isArray(matchedRoutes) ? "array" : "other"}
|
|
323
|
+
/>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
it("exposes the router location, params, navigate and matched routes under a RouterDataProvider", async () => {
|
|
328
|
+
render(
|
|
329
|
+
<MemoryRouter initialEntries={["/items/42"]}>
|
|
330
|
+
<Routes>
|
|
331
|
+
<Route
|
|
332
|
+
path="/items/:id"
|
|
333
|
+
element={
|
|
334
|
+
<RouterDataProvider initialState={{}} config={{}}>
|
|
335
|
+
<StateProbe />
|
|
336
|
+
</RouterDataProvider>
|
|
337
|
+
}
|
|
338
|
+
/>
|
|
339
|
+
</Routes>
|
|
340
|
+
</MemoryRouter>
|
|
341
|
+
)
|
|
342
|
+
const el = await screen.findByTestId("state")
|
|
343
|
+
expect(el.dataset.path).toBe("/items/42")
|
|
344
|
+
expect(JSON.parse(el.dataset.params)).toEqual({ id: "42" })
|
|
345
|
+
expect(el.dataset.nav).toBe("function")
|
|
346
|
+
expect(el.dataset.matched).toBe("array")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("throws a clear error when used outside a RouterDataProvider", () => {
|
|
350
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
351
|
+
expect(() => render(<StateProbe />)).toThrow(/useRouterState must be used within a RouterDataProvider/)
|
|
352
|
+
spy.mockRestore()
|
|
353
|
+
})
|
|
354
|
+
})
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import type { RouteMatch, RouteObject } from "react-router";
|
|
1
|
+
import type { Location, NavigateFunction, Params, RouteMatch, RouteObject } from "react-router";
|
|
2
2
|
/**
|
|
3
3
|
* Internal context shared among the router components. `matchedRoutes` is the
|
|
4
4
|
* match chain for the current location; the two callbacks are supplied by
|
|
5
5
|
* RouterDataProvider and consumed by useCurrentRouteData.
|
|
6
|
+
*
|
|
7
|
+
* `location`/`params`/`navigate` are the same values RouterDataProvider
|
|
8
|
+
* already reads via react-router's own hooks (useLocation/useParams/
|
|
9
|
+
* useNavigate) — republished here so a `catalyst-core/*` SUBPATH package
|
|
10
|
+
* (loaded through a separate module resolution than the app's own
|
|
11
|
+
* react-router import under Vite's dev SSR — `react-router` is deliberately
|
|
12
|
+
* excluded from vite.config.js's `dedupe`, see that file's comment) can read
|
|
13
|
+
* router state through ONE shared React context (identity guaranteed by
|
|
14
|
+
* `react`, which IS deduped) instead of calling react-router's hooks itself
|
|
15
|
+
* and risking a second, unlinked `react-router` module instance whose
|
|
16
|
+
* internal context a subpath's own hook calls can't see.
|
|
6
17
|
*/
|
|
7
18
|
export interface OneMgRouterContextValue {
|
|
8
19
|
matchedRoutes?: RouteMatch<string, RouteObject>[];
|
|
9
20
|
refetchData?: (route: RouteObject, routeKey: string) => (args?: Record<string, any>) => void | Promise<void>;
|
|
10
21
|
clear?: (routeKey: string) => (wait?: number) => void;
|
|
22
|
+
location?: Location;
|
|
23
|
+
params?: Params;
|
|
24
|
+
navigate?: NavigateFunction;
|
|
11
25
|
}
|
|
12
26
|
/**
|
|
13
27
|
* @description This is internal context made for sharing data among router components
|