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.
Files changed (41) hide show
  1. package/dist/errors/registry.js +8 -0
  2. package/dist/errors-index.json +7 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.jsx +1 -1
  5. package/dist/scripts/build.js +3 -1
  6. package/dist/scripts/preflight.js +81 -1
  7. package/dist/scripts/serve.js +8 -1
  8. package/dist/server/renderer/document/Body.d.ts +0 -11
  9. package/dist/server/renderer/document/Body.jsx +0 -10
  10. package/dist/server/renderer/document/Head.d.ts +0 -13
  11. package/dist/server/renderer/document/Head.jsx +0 -12
  12. package/dist/vite/vite.config.client.js +1 -1
  13. package/dist/vite/vite.config.js +24 -6
  14. package/dist/web-router/components/MetaTag.jsx +6 -13
  15. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  16. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  17. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  18. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  19. package/dist/web-router/context.d.ts +15 -1
  20. package/dist/web-router/context.jsx +11 -0
  21. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  22. package/dist/webmcp/declarative.js +207 -0
  23. package/dist/webmcp/declarative.test.js +122 -0
  24. package/dist/webmcp/errors.js +59 -0
  25. package/dist/webmcp/errors.test.js +47 -0
  26. package/dist/webmcp/index.js +40 -0
  27. package/dist/webmcp/native.js +37 -0
  28. package/dist/webmcp/native.test.js +41 -0
  29. package/dist/webmcp/pageInfo.js +119 -0
  30. package/dist/webmcp/pageInfo.test.js +85 -0
  31. package/dist/webmcp/registry.dom.test.js +175 -0
  32. package/dist/webmcp/registry.js +540 -0
  33. package/dist/webmcp/registry.test.js +228 -0
  34. package/dist/webmcp/routeContext.js +11 -0
  35. package/dist/webmcp/shim-entry.js +8 -0
  36. package/dist/webmcp/shim.js +149 -0
  37. package/dist/webmcp/useTool.js +121 -0
  38. package/package.json +3 -6
  39. package/test/scripts/preflight.test.ts +100 -2
  40. package/test/server/manifestCache.test.ts +13 -0
  41. package/vitest.config.ts +4 -0
@@ -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",
@@ -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";
@@ -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
+ }
@@ -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|react-fast-compare|react-side-effect|react-helmet-async)[\\/]/.test(
77
+ /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(
78
78
  id
79
79
  )
80
80
  ) {
@@ -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, { loader: "jsx" })
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
- exclude: ["catalyst-core/router/ClientRouter"],
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 { deleteHeadTagsByDataAttribute, getMetaData } from "../utils/metaDataUtils.jsx";
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([<meta key={0}></meta>]);
16
+ const [metaTags, setMetaTags] = useState([]);
18
17
  useEffect(() => {
19
18
  const mergedMetaTags = getMetaData(matchedRoutes, routeData);
20
- if (Array.isArray(mergedMetaTags) && mergedMetaTags.length > 0) {
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
- if (Array.isArray(metaTags) && metaTags.length >= 0) {
27
- return <HelmetProvider>
28
- <Helmet>{metaTags}</Helmet>
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, afterEach } from "vitest"
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("keeps the placeholder meta tag when no route provides setMetaData", () => {
50
+ it("renders no metadata when no route provides setMetaData", () => {
57
51
  renderMetaTag({ matchedRoutes: [{ route: { component: {} } }] })
58
- // Component should still render (Helmet/HelmetProvider tree),
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("re-runs setMetaData and clears previous tags when the location changes", async () => {
64
- // A rerender() with a NEW <MemoryRouter initialEntries> does not
65
- // navigate an already-mounted router -- initialEntries is only
66
- // read on first mount. Confirmed directly: that approach left
67
- // setMetaData called once even after "changing" the path.
68
- // Navigating within one mounted router (via a real useNavigate
69
- // call, triggered here through a button) is what actually
70
- // changes useLocation() and re-fires MetaTag's effect.
71
- const setMetaData = vi.fn(() => [<meta key="d" name="description" content="v1" />])
72
- const matchedRoutes = [{ route: { component: { setMetaData } } }]
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("/b")}>
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={["/a"]}>
85
- <OneMgRouterContext.Provider value={{ matchedRoutes }}>
86
- <RouterContext.Provider value={{}}>
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
- await waitFor(() => expect(setMetaData).toHaveBeenCalledTimes(1))
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
- await waitFor(() => expect(setMetaData).toHaveBeenCalledTimes(2))
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