@mostra/react 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +16 -2
- package/dist/form.d.ts +20 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +259 -0
- package/dist/form.js.map +1 -0
- package/dist/hooks-Dh_l8KqJ.js +2737 -0
- package/dist/hooks-Dh_l8KqJ.js.map +1 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/dist/mount-Bpg0M5ln.js +516 -0
- package/dist/mount-Bpg0M5ln.js.map +1 -0
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +52 -0
- package/dist/router.js.map +1 -0
- package/package.json +117 -3
- package/src/form.ts +326 -0
- package/src/hooks.ts +530 -0
- package/src/index.ts +75 -0
- package/src/mount.ts +78 -0
- package/src/router.ts +84 -0
- package/src/runtime/boot.ts +306 -0
- package/src/runtime/bridge-limits.ts +30 -0
- package/src/runtime/errors.ts +53 -0
- package/src/runtime/journal.ts +219 -0
- package/src/runtime/server-client.ts +745 -0
- package/src/runtime/store.ts +1641 -0
- package/src/runtime/transport.ts +342 -0
- package/src/runtime/verify-probe.ts +67 -0
- package/src/server-hooks.ts +291 -0
- package/src/server-reference.ts +54 -0
- package/src/shell-bridge.ts +212 -0
- package/src/storage.ts +165 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { i as reportPage, n as prepareArtifact, r as renderArtifact } from "./mount-Bpg0M5ln.js";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { Outlet, createBrowserRouter, createHashRouter, createMemoryRouter } from "react-router";
|
|
5
|
+
import { RouterProvider } from "react-router/dom";
|
|
6
|
+
//#region src/router.ts
|
|
7
|
+
/** The history mode for a delivery target (D57, D111). */
|
|
8
|
+
function historyMode(target) {
|
|
9
|
+
if (target === "static") return "hash";
|
|
10
|
+
if (target === "mcp-app") return "memory";
|
|
11
|
+
return "browser";
|
|
12
|
+
}
|
|
13
|
+
/** The route tree: the pages, inside the layout when there is one. */
|
|
14
|
+
function routeTree(routes, layout) {
|
|
15
|
+
const pages = routes.map((route) => ({
|
|
16
|
+
path: route.path,
|
|
17
|
+
element: createElement(route.page)
|
|
18
|
+
}));
|
|
19
|
+
if (!layout) return pages;
|
|
20
|
+
return [{
|
|
21
|
+
element: createElement(layout, null, createElement(Outlet)),
|
|
22
|
+
children: pages
|
|
23
|
+
}];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mounts a `pages/` artifact (D57). The generated entry calls it; artifact code doesn't need to.
|
|
27
|
+
*/
|
|
28
|
+
async function mountRoutes(options) {
|
|
29
|
+
const runtime = await prepareArtifact();
|
|
30
|
+
const tree = routeTree(options.routes, options.layout);
|
|
31
|
+
const mode = historyMode(runtime.target);
|
|
32
|
+
const router = mode === "hash" ? createHashRouter(tree) : mode === "memory" ? createMemoryRouter(tree) : createBrowserRouter(tree);
|
|
33
|
+
const pathOf = () => {
|
|
34
|
+
const location = router.state.location;
|
|
35
|
+
return `${location.pathname}${location.search}${location.hash}`;
|
|
36
|
+
};
|
|
37
|
+
let last = "";
|
|
38
|
+
const report = () => {
|
|
39
|
+
const path = pathOf();
|
|
40
|
+
if (path === last) return;
|
|
41
|
+
last = path;
|
|
42
|
+
reportPage(runtime, path, document.title);
|
|
43
|
+
};
|
|
44
|
+
router.subscribe(report);
|
|
45
|
+
renderArtifact(runtime, createElement(RouterProvider, { router }));
|
|
46
|
+
report();
|
|
47
|
+
document.documentElement.dataset["mostraHistory"] = mode;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { historyMode, mountRoutes, routeTree };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","names":[],"sources":["../src/router.ts"],"sourcesContent":["\"use client\";\n\n/**\n * `pages/` routing on React Router (D57): the generated entry hands the routes, and the entry's\n * layout when it has one, to {@link mountRoutes}. The history follows the delivery target (D111):\n * browser history behind Mostra's gateway (deep links work, D98), hash routing in a static export\n * (any static host serves it), memory routing inside an MCP App (its frame has no URL of its own).\n * Every navigation is reported to the shell, so comments record their page (WP-20).\n *\n * Artifact code navigates with React Router's own `<Link to>` and `useNavigate`.\n */\nimport type { DeliveryTarget } from \"@mostra/protocol\";\nimport { type ComponentType, createElement, type ReactNode } from \"react\";\nimport {\n createBrowserRouter,\n createHashRouter,\n createMemoryRouter,\n Outlet,\n type RouteObject,\n} from \"react-router\";\nimport { RouterProvider } from \"react-router/dom\";\nimport { prepareArtifact, renderArtifact, reportPage } from \"./mount.ts\";\n\n/** One route of a `pages/` artifact: its React Router path and its page. */\nexport interface PageRouteModule {\n readonly path: string;\n readonly page: ComponentType;\n}\n\n/** How a router keeps its location: in the address bar, in its hash, or in memory. */\nexport type HistoryMode = \"browser\" | \"hash\" | \"memory\";\n\n/** The history mode for a delivery target (D57, D111). */\nexport function historyMode(target: DeliveryTarget): HistoryMode {\n if (target === \"static\") return \"hash\";\n if (target === \"mcp-app\") return \"memory\";\n return \"browser\";\n}\n\n/** The route tree: the pages, inside the layout when there is one. */\nexport function routeTree(\n routes: readonly PageRouteModule[],\n layout?: ComponentType<{ children?: ReactNode }>,\n): RouteObject[] {\n const pages: RouteObject[] = routes.map((route) => ({\n path: route.path,\n element: createElement(route.page),\n }));\n if (!layout) return pages;\n return [{ element: createElement(layout, null, createElement(Outlet)), children: pages }];\n}\n\n/**\n * Mounts a `pages/` artifact (D57). The generated entry calls it; artifact code doesn't need to.\n */\nexport async function mountRoutes(options: {\n readonly routes: readonly PageRouteModule[];\n readonly layout?: ComponentType<{ children?: ReactNode }>;\n}): Promise<void> {\n const runtime = await prepareArtifact();\n const tree = routeTree(options.routes, options.layout);\n const mode = historyMode(runtime.target);\n const router =\n mode === \"hash\"\n ? createHashRouter(tree)\n : mode === \"memory\"\n ? createMemoryRouter(tree)\n : createBrowserRouter(tree);\n const pathOf = (): string => {\n const location = router.state.location;\n return `${location.pathname}${location.search}${location.hash}`;\n };\n let last = \"\";\n const report = (): void => {\n const path = pathOf();\n if (path === last) return;\n last = path;\n reportPage(runtime, path, document.title);\n };\n router.subscribe(report);\n renderArtifact(runtime, createElement(RouterProvider, { router }));\n report();\n document.documentElement.dataset[\"mostraHistory\"] = mode;\n}\n"],"mappings":";;;;;;;AAiCA,SAAgB,YAAY,QAAqC;CAC/D,IAAI,WAAW,UAAU,OAAO;CAChC,IAAI,WAAW,WAAW,OAAO;CACjC,OAAO;AACT;;AAGA,SAAgB,UACd,QACA,QACe;CACf,MAAM,QAAuB,OAAO,KAAK,WAAW;EAClD,MAAM,MAAM;EACZ,SAAS,cAAc,MAAM,IAAI;CACnC,EAAE;CACF,IAAI,CAAC,QAAQ,OAAO;CACpB,OAAO,CAAC;EAAE,SAAS,cAAc,QAAQ,MAAM,cAAc,MAAM,CAAC;EAAG,UAAU;CAAM,CAAC;AAC1F;;;;AAKA,eAAsB,YAAY,SAGhB;CAChB,MAAM,UAAU,MAAM,gBAAgB;CACtC,MAAM,OAAO,UAAU,QAAQ,QAAQ,QAAQ,MAAM;CACrD,MAAM,OAAO,YAAY,QAAQ,MAAM;CACvC,MAAM,SACJ,SAAS,SACL,iBAAiB,IAAI,IACrB,SAAS,WACP,mBAAmB,IAAI,IACvB,oBAAoB,IAAI;CAChC,MAAM,eAAuB;EAC3B,MAAM,WAAW,OAAO,MAAM;EAC9B,OAAO,GAAG,SAAS,WAAW,SAAS,SAAS,SAAS;CAC3D;CACA,IAAI,OAAO;CACX,MAAM,eAAqB;EACzB,MAAM,OAAO,OAAO;EACpB,IAAI,SAAS,MAAM;EACnB,OAAO;EACP,WAAW,SAAS,MAAM,SAAS,KAAK;CAC1C;CACA,OAAO,UAAU,MAAM;CACvB,eAAe,SAAS,cAAc,gBAAgB,EAAE,OAAO,CAAC,CAAC;CACjE,OAAO;CACP,SAAS,gBAAgB,QAAQ,mBAAmB;AACtD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,120 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostra/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mostra client runtime for artifact authors: fields, submit, signals, choices, window.storage, routing and the shell bridge.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "The Mostra Authors",
|
|
7
|
+
"homepage": "https://github.com/mostra-dev/mostra/tree/main/packages/react#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/mostra-dev/mostra.git",
|
|
11
|
+
"directory": "packages/react"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/mostra-dev/mostra/issues",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"@mostra/source": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./form": {
|
|
23
|
+
"@mostra/source": "./src/form.ts",
|
|
24
|
+
"types": "./dist/form.d.ts",
|
|
25
|
+
"default": "./dist/form.js"
|
|
26
|
+
},
|
|
27
|
+
"./router": {
|
|
28
|
+
"@mostra/source": "./src/router.ts",
|
|
29
|
+
"types": "./dist/router.d.ts",
|
|
30
|
+
"default": "./dist/router.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"!src/**/*.test.ts"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=24"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@mostra/protocol": "0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@mostra/tools": "0.0.0",
|
|
51
|
+
"@size-limit/preset-small-lib": "14.1.0",
|
|
52
|
+
"@types/node": "24.19.1",
|
|
53
|
+
"@types/react": "19.3.0",
|
|
54
|
+
"@types/react-dom": "19.3.0",
|
|
55
|
+
"@vitest/browser-playwright": "5.0.3",
|
|
56
|
+
"playwright": "1.64.0",
|
|
57
|
+
"react": "19.3.0",
|
|
58
|
+
"react-dom": "19.3.0",
|
|
59
|
+
"react-hook-form": "7.89.0",
|
|
60
|
+
"react-router": "8.4.0",
|
|
61
|
+
"size-limit": "14.1.0",
|
|
62
|
+
"tsdown": "0.23.0",
|
|
63
|
+
"typescript": "7.0.2",
|
|
64
|
+
"vitest": "5.0.3"
|
|
65
|
+
},
|
|
66
|
+
"keywords": [
|
|
67
|
+
"mostra",
|
|
68
|
+
"react",
|
|
69
|
+
"artifact",
|
|
70
|
+
"agent"
|
|
71
|
+
],
|
|
72
|
+
"size-limit": [
|
|
73
|
+
{
|
|
74
|
+
"name": "@mostra/react whole entry, zod included (every version embeds it)",
|
|
75
|
+
"path": "dist/index.js",
|
|
76
|
+
"import": "*",
|
|
77
|
+
"ignore": [
|
|
78
|
+
"react",
|
|
79
|
+
"react-dom",
|
|
80
|
+
"react-router",
|
|
81
|
+
"react-hook-form"
|
|
82
|
+
],
|
|
83
|
+
"limit": "53.5 kB"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "@mostra/react whole entry, zod excluded",
|
|
87
|
+
"path": "dist/index.js",
|
|
88
|
+
"import": "*",
|
|
89
|
+
"ignore": [
|
|
90
|
+
"react",
|
|
91
|
+
"react-dom",
|
|
92
|
+
"react-router",
|
|
93
|
+
"react-hook-form",
|
|
94
|
+
"zod"
|
|
95
|
+
],
|
|
96
|
+
"limit": "31 kB"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"peerDependencies": {
|
|
100
|
+
"react": "^19.3.0",
|
|
101
|
+
"react-dom": "^19.3.0",
|
|
102
|
+
"react-router": "^8.4.0",
|
|
103
|
+
"react-hook-form": "^7.89.0"
|
|
104
|
+
},
|
|
105
|
+
"peerDependenciesMeta": {
|
|
106
|
+
"react-router": {
|
|
107
|
+
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"react-hook-form": {
|
|
110
|
+
"optional": true
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"scripts": {
|
|
114
|
+
"build": "tsdown",
|
|
115
|
+
"typecheck": "tsc -b",
|
|
116
|
+
"test": "vitest run",
|
|
117
|
+
"api": "mostra-tools api",
|
|
118
|
+
"size": "size-limit"
|
|
119
|
+
}
|
|
6
120
|
}
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `@mostra/react/form`: react-hook-form on Mostra fields (D52, D26). `useForm` is react-hook-form's
|
|
5
|
+
* own, with one more option, `id`: every answer persists as the Mostra field `<id>.<path>` (a
|
|
6
|
+
* field array as one answer, `<id>.items`), so a large form saves as the viewer types, resets
|
|
7
|
+
* included, survives reloads and other tabs, converges with other writers, and arrives in
|
|
8
|
+
* results nested under `id`. Its `required` rules and validation errors become the shell's
|
|
9
|
+
* blockers.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { useForm } from "@mostra/react/form";
|
|
13
|
+
*
|
|
14
|
+
* const form = useForm({ id: "intake", defaultValues: { name: "", team: "" }, labels: { name: "Your name" } });
|
|
15
|
+
* <input {...form.register("name", { required: true })} />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
19
|
+
import {
|
|
20
|
+
type FieldPath,
|
|
21
|
+
type FieldValues,
|
|
22
|
+
get,
|
|
23
|
+
type RegisterOptions,
|
|
24
|
+
set as setPath,
|
|
25
|
+
type UseFormProps,
|
|
26
|
+
type UseFormReturn,
|
|
27
|
+
useForm as useHookForm,
|
|
28
|
+
} from "react-hook-form";
|
|
29
|
+
import { useStore } from "./hooks.ts";
|
|
30
|
+
|
|
31
|
+
/** Whether two JSON values are the same. */
|
|
32
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
33
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
import { fieldKey } from "./runtime/store.ts";
|
|
37
|
+
|
|
38
|
+
/** Whether a value is a plain object, whose keys the form's values nest by. */
|
|
39
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
41
|
+
const proto = Object.getPrototypeOf(value);
|
|
42
|
+
return proto === Object.prototype || proto === null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A form's answers as Mostra fields (Claude review finding 1): one field per leaf, where a leaf is
|
|
47
|
+
* any value that isn't a plain object, arrays included. A field array is one answer, so a row
|
|
48
|
+
* removed is gone from that answer, never left behind as a field of its own, and `items` and
|
|
49
|
+
* `items.1` never both exist.
|
|
50
|
+
*/
|
|
51
|
+
function leaves(
|
|
52
|
+
values: unknown,
|
|
53
|
+
path = "",
|
|
54
|
+
out = new Map<string, unknown>(),
|
|
55
|
+
): Map<string, unknown> {
|
|
56
|
+
if (isPlainObject(values)) {
|
|
57
|
+
for (const [key, value] of Object.entries(values)) {
|
|
58
|
+
leaves(value, path === "" ? key : `${path}.${key}`, out);
|
|
59
|
+
}
|
|
60
|
+
} else if (path !== "") {
|
|
61
|
+
out.set(path, values);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The leaf a registered name lives in: the name itself, or the array it is inside. */
|
|
67
|
+
function leafOf(values: unknown, name: string): string {
|
|
68
|
+
const parts = name.split(".");
|
|
69
|
+
let current: unknown = values;
|
|
70
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
71
|
+
if (Array.isArray(current)) return parts.slice(0, index).join(".");
|
|
72
|
+
if (!isPlainObject(current)) return name;
|
|
73
|
+
current = current[parts[index] as string];
|
|
74
|
+
}
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options of {@link useForm}: react-hook-form's, and the form's field ID. */
|
|
79
|
+
export interface MostraFormOptions<TFieldValues extends FieldValues>
|
|
80
|
+
extends Omit<UseFormProps<TFieldValues>, "values"> {
|
|
81
|
+
/** The field ID every field of the form is persisted under: `<id>.<name>` (D52). */
|
|
82
|
+
readonly id: string;
|
|
83
|
+
/** Labels for results and the shell's blockers, by field name. */
|
|
84
|
+
readonly labels?: Readonly<Partial<Record<FieldPath<TFieldValues>, string>>>;
|
|
85
|
+
/** `viewer` (default): the viewer's draft. `artifact`: shared state (D63). */
|
|
86
|
+
readonly scope?: "viewer" | "artifact";
|
|
87
|
+
/** Field names kept out of events and logs (D52), still delivered with the submission. */
|
|
88
|
+
readonly sensitive?: readonly FieldPath<TFieldValues>[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* react-hook-form's `useForm`, persisted to Mostra fields under `id` (D52). Everything else is
|
|
93
|
+
* react-hook-form's own: `register`, `handleSubmit`, `formState`, resolvers.
|
|
94
|
+
*/
|
|
95
|
+
export function useForm<TFieldValues extends FieldValues = FieldValues>(
|
|
96
|
+
options: MostraFormOptions<TFieldValues>,
|
|
97
|
+
): UseFormReturn<TFieldValues> {
|
|
98
|
+
const store = useStore();
|
|
99
|
+
const { id, labels, scope = "viewer", sensitive, ...hookOptions } = options;
|
|
100
|
+
const prefix = `${id}.`;
|
|
101
|
+
const version = useSyncExternalStore(store.subscribe, store.version, store.version);
|
|
102
|
+
|
|
103
|
+
// The form starts from its answers as Mostra has them, over its defaults. After that, every
|
|
104
|
+
// answer follows the store: the store's value of a field is the viewer's own unsaved edit while
|
|
105
|
+
// there is one, else the gateway's, so another writer's change reaches the input once the
|
|
106
|
+
// viewer's edit landed, and never overwrites it before (GPT review finding 5).
|
|
107
|
+
const [defaults] = useState(() => hookOptions.defaultValues);
|
|
108
|
+
const defaultOf = useCallback((leaf: string): unknown => get(defaults ?? {}, leaf), [defaults]);
|
|
109
|
+
const storedValue = useCallback(
|
|
110
|
+
(leaf: string): unknown => store.value(fieldKey(scope, `${prefix}${leaf}`), defaultOf(leaf)),
|
|
111
|
+
[store, scope, prefix, defaultOf],
|
|
112
|
+
);
|
|
113
|
+
const stored = useCallback(
|
|
114
|
+
(leaf: string): boolean =>
|
|
115
|
+
store.value(fieldKey(scope, `${prefix}${leaf}`), undefined) !== undefined,
|
|
116
|
+
[store, scope, prefix],
|
|
117
|
+
);
|
|
118
|
+
const [initial] = useState(() => {
|
|
119
|
+
const base =
|
|
120
|
+
typeof defaults === "object" && defaults !== null
|
|
121
|
+
? structuredClone(defaults as TFieldValues)
|
|
122
|
+
: ({} as TFieldValues);
|
|
123
|
+
for (const fieldId of store.ids(scope)) {
|
|
124
|
+
if (!fieldId.startsWith(prefix)) continue;
|
|
125
|
+
setPath(base, fieldId.slice(prefix.length), store.value(fieldKey(scope, fieldId), undefined));
|
|
126
|
+
}
|
|
127
|
+
return base;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const form = useHookForm<TFieldValues>({ ...hookOptions, defaultValues: initial as never });
|
|
131
|
+
|
|
132
|
+
// Labels usually come as an object literal: the same labels are the same registration.
|
|
133
|
+
const labelsKey = JSON.stringify(labels ?? {});
|
|
134
|
+
const stableLabels = useMemo(
|
|
135
|
+
() => JSON.parse(labelsKey) as MostraFormOptions<TFieldValues>["labels"],
|
|
136
|
+
[labelsKey],
|
|
137
|
+
);
|
|
138
|
+
const sensitiveNames = useMemo(() => new Set<string>(sensitive ?? []), [sensitive]);
|
|
139
|
+
|
|
140
|
+
// Every change saves the answers that differ from what Mostra holds, leaf by leaf: a typed
|
|
141
|
+
// value, a row appended or removed, and a `reset()` alike, so what the viewer sees is what a
|
|
142
|
+
// submit sends (Claude review findings 1 and 2). An answer still at its default and never
|
|
143
|
+
// stored stays unstored: a default is written only before a submit (D110).
|
|
144
|
+
const save = useCallback(
|
|
145
|
+
(values: unknown): void => {
|
|
146
|
+
const current = leaves(values);
|
|
147
|
+
for (const [leaf, value] of current) {
|
|
148
|
+
const key = fieldKey(scope, `${prefix}${leaf}`);
|
|
149
|
+
if (!store.writable(key)) continue;
|
|
150
|
+
if (stored(leaf) ? sameJson(value, storedValue(leaf)) : sameJson(value, defaultOf(leaf))) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const label = stableLabels?.[leaf as FieldPath<TFieldValues>];
|
|
154
|
+
store.set(scope, `${prefix}${leaf}`, value, {
|
|
155
|
+
...(label !== undefined ? { label } : {}),
|
|
156
|
+
...(sensitiveNames.has(leaf) ? { sensitive: true } : {}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// A stored answer the form doesn't have is left alone: it may be an answer an earlier
|
|
160
|
+
// version asked for, which is kept and delivered as not carried over (D42).
|
|
161
|
+
},
|
|
162
|
+
[store, scope, prefix, stored, storedValue, defaultOf, stableLabels, sensitiveNames],
|
|
163
|
+
);
|
|
164
|
+
useEffect(
|
|
165
|
+
() =>
|
|
166
|
+
form.subscribe({
|
|
167
|
+
formState: { values: true },
|
|
168
|
+
callback: ({ values }) => save(values),
|
|
169
|
+
}),
|
|
170
|
+
[form, save],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Answers that changed in the store (another writer, another tab, a snapshot) reach the form,
|
|
174
|
+
// leaf by leaf; the store's own value coming back isn't saved again (`save` finds no change).
|
|
175
|
+
const { getValues, setValue } = form;
|
|
176
|
+
// `version` stands for the store's state, which the effect reads through `store`.
|
|
177
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: as above.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
// Only the form's own answers: a stored answer it doesn't have (an earlier version's) stays
|
|
180
|
+
// out of its values.
|
|
181
|
+
const current = leaves(getValues());
|
|
182
|
+
for (const [leaf, value] of current) {
|
|
183
|
+
const next = storedValue(leaf);
|
|
184
|
+
if (sameJson(next, value)) continue;
|
|
185
|
+
setValue(leaf as FieldPath<TFieldValues>, next as never, {
|
|
186
|
+
shouldDirty: false,
|
|
187
|
+
shouldTouch: false,
|
|
188
|
+
shouldValidate: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}, [version, store, scope, prefix, storedValue, getValues, setValue]);
|
|
192
|
+
|
|
193
|
+
// Registered fields become Mostra registrations of the leaves they live in, so the shell lists
|
|
194
|
+
// their blockers. Only mounted ones: a field whose input left the page, or that `unregister`
|
|
195
|
+
// removed, is released, so it neither blocks a submit nor has its default written (D110, GPT
|
|
196
|
+
// review finding 11). A field inside a field array registers its array: its own rules run in
|
|
197
|
+
// react-hook-form, whose errors block the submit (D106).
|
|
198
|
+
const rules = useRef(new Map<string, boolean>());
|
|
199
|
+
const mounted = useRef(new Map<string, { release: () => void; signature: string }>());
|
|
200
|
+
// Bumped when a field is released: the render that follows lets its registration go.
|
|
201
|
+
const [, setReleased] = useState(0);
|
|
202
|
+
const release = useCallback((name: string): void => {
|
|
203
|
+
rules.current.delete(name);
|
|
204
|
+
// Inside React's commit (a ref callback): the store hears of it after, never re-entering it.
|
|
205
|
+
queueMicrotask(() => setReleased((count) => count + 1));
|
|
206
|
+
}, []);
|
|
207
|
+
const { register, unregister } = form;
|
|
208
|
+
// One ref callback per field, so React calls it on mount and unmount only, not every render.
|
|
209
|
+
const refs = useRef(
|
|
210
|
+
new Map<string, { latest: (element: unknown) => void; ref: (element: unknown) => void }>(),
|
|
211
|
+
);
|
|
212
|
+
const wrapped = useMemo(
|
|
213
|
+
() =>
|
|
214
|
+
((name: FieldPath<TFieldValues>, ruleOptions?: RegisterOptions<TFieldValues>) => {
|
|
215
|
+
const required = ruleOptions?.required !== undefined && ruleOptions.required !== false;
|
|
216
|
+
rules.current.set(name, required);
|
|
217
|
+
const registered = register(name, ruleOptions);
|
|
218
|
+
let entry = refs.current.get(name);
|
|
219
|
+
if (!entry) {
|
|
220
|
+
const created = {
|
|
221
|
+
latest: registered.ref as (element: unknown) => void,
|
|
222
|
+
ref: (element: unknown): void => {
|
|
223
|
+
created.latest(element);
|
|
224
|
+
if (element === null) release(name);
|
|
225
|
+
else rules.current.set(name, rules.current.get(name) ?? required);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
entry = created;
|
|
229
|
+
refs.current.set(name, entry);
|
|
230
|
+
}
|
|
231
|
+
entry.latest = registered.ref as (element: unknown) => void;
|
|
232
|
+
return { ...registered, ref: entry.ref };
|
|
233
|
+
}) as UseFormReturn<TFieldValues>["register"],
|
|
234
|
+
[register, release],
|
|
235
|
+
);
|
|
236
|
+
const wrappedUnregister = useMemo(
|
|
237
|
+
() =>
|
|
238
|
+
((name?: FieldPath<TFieldValues> | readonly FieldPath<TFieldValues>[], options?: never) => {
|
|
239
|
+
const names =
|
|
240
|
+
name === undefined ? [...rules.current.keys()] : Array.isArray(name) ? name : [name];
|
|
241
|
+
for (const each of names) release(each as string);
|
|
242
|
+
unregister(name as never, options);
|
|
243
|
+
}) as UseFormReturn<TFieldValues>["unregister"],
|
|
244
|
+
[unregister, release],
|
|
245
|
+
);
|
|
246
|
+
const errors = form.formState.errors;
|
|
247
|
+
// Registered on the first render that names a field, updated only when a rule, label or error
|
|
248
|
+
// changes, and released when no mounted field lives in the leaf: re-registering on every
|
|
249
|
+
// render would never settle.
|
|
250
|
+
const sync = useCallback(
|
|
251
|
+
(errorOf: (name: string) => unknown): void => {
|
|
252
|
+
const values = getValues();
|
|
253
|
+
const byLeaf = new Map<string, { required: boolean; message?: string }>();
|
|
254
|
+
for (const [name, required] of rules.current) {
|
|
255
|
+
const leaf = leafOf(values, name);
|
|
256
|
+
const failure = errorOf(name) as { message?: unknown } | undefined;
|
|
257
|
+
const message =
|
|
258
|
+
failure === undefined
|
|
259
|
+
? undefined
|
|
260
|
+
: typeof failure.message === "string" && failure.message !== ""
|
|
261
|
+
? failure.message
|
|
262
|
+
: required
|
|
263
|
+
? "This answer is required."
|
|
264
|
+
: "Invalid value.";
|
|
265
|
+
const entry = byLeaf.get(leaf) ?? { required: false };
|
|
266
|
+
// A required rule inside an array is react-hook-form's to check, not the leaf's.
|
|
267
|
+
if (leaf === name && required) entry.required = true;
|
|
268
|
+
if (entry.message === undefined && message !== undefined) entry.message = message;
|
|
269
|
+
byLeaf.set(leaf, entry);
|
|
270
|
+
}
|
|
271
|
+
for (const [leaf, { required, message }] of byLeaf) {
|
|
272
|
+
const fieldId = `${prefix}${leaf}`;
|
|
273
|
+
const label = stableLabels?.[leaf as FieldPath<TFieldValues>];
|
|
274
|
+
const registration = {
|
|
275
|
+
id: fieldId,
|
|
276
|
+
scope,
|
|
277
|
+
hook: "useForm",
|
|
278
|
+
initial: defaultOf(leaf),
|
|
279
|
+
...(label !== undefined ? { label } : {}),
|
|
280
|
+
...(required ? { required } : {}),
|
|
281
|
+
...(sensitiveNames.has(leaf) ? { sensitive: true } : {}),
|
|
282
|
+
...(message !== undefined ? { error: message } : {}),
|
|
283
|
+
};
|
|
284
|
+
const signature = JSON.stringify([label, required, sensitiveNames.has(leaf), message]);
|
|
285
|
+
const current = mounted.current.get(fieldId);
|
|
286
|
+
if (!current) {
|
|
287
|
+
mounted.current.set(fieldId, { release: store.register(registration), signature });
|
|
288
|
+
if (message !== undefined) store.updateRegistration(registration);
|
|
289
|
+
} else if (current.signature !== signature) {
|
|
290
|
+
current.signature = signature;
|
|
291
|
+
store.updateRegistration(registration);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const [fieldId, entry] of mounted.current) {
|
|
295
|
+
if (byLeaf.has(fieldId.slice(prefix.length))) continue;
|
|
296
|
+
entry.release();
|
|
297
|
+
mounted.current.delete(fieldId);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
[prefix, scope, stableLabels, sensitiveNames, store, defaultOf, getValues],
|
|
301
|
+
);
|
|
302
|
+
useEffect(() => sync((name) => get(errors, name)));
|
|
303
|
+
// react-hook-form validates on submit unless told otherwise; before either channel's submit
|
|
304
|
+
// it validates now, so its errors are the submit's blockers (D106: the client checks), and
|
|
305
|
+
// every answer the viewer sees is saved first.
|
|
306
|
+
const { trigger, getFieldState } = form;
|
|
307
|
+
useEffect(
|
|
308
|
+
() =>
|
|
309
|
+
store.beforeSubmit(async () => {
|
|
310
|
+
save(getValues());
|
|
311
|
+
await trigger();
|
|
312
|
+
sync((name) => getFieldState(name as FieldPath<TFieldValues>).error);
|
|
313
|
+
}),
|
|
314
|
+
[store, trigger, getFieldState, sync, save, getValues],
|
|
315
|
+
);
|
|
316
|
+
useEffect(
|
|
317
|
+
() => () => {
|
|
318
|
+
for (const entry of mounted.current.values()) entry.release();
|
|
319
|
+
mounted.current.clear();
|
|
320
|
+
},
|
|
321
|
+
[],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// A new object each render: react-hook-form renews `formState`'s proxy every render.
|
|
325
|
+
return { ...form, register: wrapped, unregister: wrappedUnregister };
|
|
326
|
+
}
|