gantry-web 0.3.5 → 0.4.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/src/app.tsx CHANGED
@@ -1,201 +1,368 @@
1
- /// <reference path="../types/index.d.ts" />
2
- import { StrictMode, useEffect, type FC, type ReactNode } from "react";
3
- import { createRoot } from "react-dom/client";
4
- import { TitleBar, type TitleBarProps } from "./TitleBar";
5
- import { ResizeFrame } from "./ResizeFrame";
6
- import { installZoomGuard } from "./zoom";
7
- import { connect, ready } from "./socket";
8
- import { useRoute, redirect } from "./router";
9
- import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
10
-
11
- /** The shape of a page module (a pages/<name>/<name>.tsx file). */
12
- export interface GantryPageModule {
13
- default: FC;
14
- /** export const chrome = false to hide the TitleBar on this page. */
15
- chrome?: boolean;
16
- /**
17
- * Which layouts/ wrap this page: a name ("compact"), several nested
18
- * outermost-first (["main", "compact"]), false for none, true to
19
- * force "main" even on a chromeless page. Default: "main" if it
20
- * exists (chromeless pages default to none).
21
- */
22
- layout?: boolean | string | string[];
23
- /** export const route = "/custom" to override the derived route. */
24
- route?: string;
25
- }
26
-
27
- export interface GantryPage {
28
- key: string;
29
- route: string;
30
- mod: GantryPageModule;
31
- }
32
-
33
- /**
34
- * The generated virtual:gantry-app module: the page/component/layout
35
- * registry the Vite plugin derives from the app's folders. The
36
- * synthesized .gantry/main.tsx imports it and hands it to createApp.
37
- * gantry-web itself must never import the virtual module: esbuild
38
- * cannot resolve virtual ids, so that import would force the package
39
- * out of dep prebundling - and serving it as raw node_modules source
40
- * lets Vite double-instantiate modules (?v=hash vs bare URLs), which
41
- * split the router's subscriber set and broke navigation.
42
- */
43
- export interface GantryAppModule {
44
- pages: GantryPage[];
45
- components: Record<string, { default: FC }>;
46
- /** Layouts by short name: layouts/main/main.tsx -> "main". */
47
- layouts: Record<string, { default: FC<{ children?: ReactNode }> }>;
48
- /** The root app.tsx module (default export: CreateAppOptions), or null. */
49
- appConfig: { default: CreateAppOptions } | null;
50
- singlePage?: boolean;
51
- }
52
-
53
- // The registry handed to createApp; module-level so match/layoutsFor
54
- // read it without threading props.
55
- let reg: GantryAppModule;
56
-
57
- export interface CreateAppOptions {
58
- /** Title shown in the TitleBar (default: none). */
59
- title?: ReactNode;
60
- /**
61
- * TitleBar customization: title placement (titleAlign), your own
62
- * controls in the left/right slots (back/forward buttons, menus),
63
- * heights and reserves. The usual home for this is the optional
64
- * app.tsx at the app root, default-exporting CreateAppOptions.
65
- */
66
- titleBar?: Partial<TitleBarProps>;
67
- /** Extra Tea components beyond the paired components/ folders. */
68
- components?: ComponentRegistry;
69
- /** Binding prefix when the Go side changed it from "gantry". */
70
- prefix?: string;
71
- /** Websocket URL override (default ws://<host>/gantry/ws). */
72
- socketURL?: string;
73
- /** Hide the TitleBar on every page (pages can also export chrome = false). */
74
- chrome?: boolean;
75
- }
76
-
77
- function keyClass(key: string): string {
78
- return "gantry-" + key.replace(/[^a-zA-Z0-9]+/g, "-");
79
- }
80
-
81
- function routeOf(p: GantryPage): string {
82
- return p.mod.route ?? p.route;
83
- }
84
-
85
- function match(path: string): GantryPage | undefined {
86
- const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
87
- return reg.pages.find((p) => routeOf(p) === clean) ?? reg.pages.find((p) => routeOf(p) === "/");
88
- }
89
-
90
- function PageHost({ page }: { page: GantryPage }) {
91
- useEffect(() => {
92
- ready(page.key);
93
- }, [page.key]);
94
- const Page = page.mod.default;
95
- return (
96
- <div className={"gantry-page " + keyClass(page.key)}>
97
- <Page />
98
- </div>
99
- );
100
- }
101
-
102
- // layoutsFor resolves a page's layout selection to component functions,
103
- // outermost first.
104
- //
105
- // (nothing) -> "main" if it exists (chromeless pages skip)
106
- // layout = false -> none
107
- // layout = "compact" -> that one
108
- // layout = ["main","compact"] -> nested: <Main><Compact><Page/>...
109
- function layoutsFor(page: GantryPage, chrome: boolean): FC<{ children?: ReactNode }>[] {
110
- const sel = page.mod.layout;
111
- let names: string[];
112
- if (sel === false) {
113
- names = [];
114
- } else if (typeof sel === "string") {
115
- names = [sel];
116
- } else if (Array.isArray(sel)) {
117
- names = sel;
118
- } else if (sel === true) {
119
- names = reg.layouts.main ? ["main"] : [];
120
- } else {
121
- // Default: the "main" layout on normal pages; chromeless pages
122
- // (widgets, popups) are their own surfaces and skip it.
123
- names = chrome && reg.layouts.main ? ["main"] : [];
124
- }
125
- const out: FC<{ children?: ReactNode }>[] = [];
126
- for (const n of names) {
127
- const mod = reg.layouts[n];
128
- if (mod?.default) {
129
- out.push(mod.default);
130
- } else {
131
- console.warn(`gantry: page ${page.key} wants unknown layout "${n}" (have: ${Object.keys(reg.layouts).join(", ") || "none"})`);
132
- }
133
- }
134
- return out;
135
- }
136
-
137
- function AppRoot({ options }: { options: CreateAppOptions }) {
138
- const path = useRoute();
139
- const page = match(path);
140
- // A URL whose page no longer exists (deleted during dev, stale
141
- // bookmark) falls back to the index page - normalize the address so
142
- // Links light up correctly.
143
- const target = page ? routeOf(page) : null;
144
- const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
145
- useEffect(() => {
146
- if (target !== null && target !== clean) {
147
- redirect(target);
148
- }
149
- }, [target, clean]);
150
- if (!page) {
151
- return <div className="gantry-app">No pages found - add pages/index/index.tsx</div>;
152
- }
153
- const chrome = options.chrome !== false && page.mod.chrome !== false;
154
- let content = <PageHost page={page} />;
155
- for (const Layout of layoutsFor(page, chrome).reverse()) {
156
- content = <Layout>{content}</Layout>;
157
- }
158
- return (
159
- <div className="gantry-app">
160
- <ResizeFrame prefix={options.prefix} />
161
- {chrome && <TitleBar title={options.title} prefix={options.prefix} {...options.titleBar} />}
162
- {content}
163
- </div>
164
- );
165
- }
166
-
167
- /**
168
- * createApp boots a Gantry frontend: zoom guard, websocket, component
169
- * registry, TitleBar, the root layout (layout.tsx at the app root, if
170
- * present), and the page router (single-page mode when only pages/index
171
- * exists). The synthesized .gantry/main.tsx calls this with the
172
- * virtual:gantry-app module (`import * as app from "virtual:gantry-app"`);
173
- * an app only touches it to pass options.
174
- */
175
- export function createApp(app: GantryAppModule, options: CreateAppOptions = {}): void {
176
- reg = app;
177
- // The optional root app.tsx wins over the synthesized defaults, so
178
- // apps customize everything without owning the entry file.
179
- if (reg.appConfig?.default) {
180
- options = { ...options, ...reg.appConfig.default };
181
- }
182
- installZoomGuard();
183
- connect(options.socketURL);
184
-
185
- const registry: ComponentRegistry = {};
186
- for (const [key, mod] of Object.entries(reg.components)) {
187
- if (typeof mod.default === "function") {
188
- registry[key] = mod.default as FC<TeaComponentProps>;
189
- }
190
- }
191
- Object.assign(registry, options.components);
192
- setRegistry(registry);
193
-
194
- const container = document.getElementById("root");
195
- if (!container) throw new Error("gantry: no #root element");
196
- createRoot(container).render(
197
- <StrictMode>
198
- <AppRoot options={options} />
199
- </StrictMode>,
200
- );
201
- }
1
+ /// <reference path="../types/index.d.ts" />
2
+ import { Component, StrictMode, useEffect, type ErrorInfo as ReactErrorInfo, type FC, type ReactNode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { TitleBar, type TitleBarProps } from "./TitleBar";
5
+ import { ResizeFrame } from "./ResizeFrame";
6
+ import { installZoomGuard } from "./zoom";
7
+ import { connect, ready, callGo } from "./socket";
8
+ import { useRoute, redirect, ParamsContext, type RouteParams } from "./router";
9
+ import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
10
+ import { installErrorHandlers, reportError, useGantryErrors, dismissNotice, clearFatal, setDevMode, type ErrorHandlingOptions, type GantryErrorInfo } from "./errors";
11
+ import { ErrorScreen, type ErrorScreenProps } from "./ErrorScreen";
12
+ import { fetchEnv, useMode } from "./env";
13
+
14
+ /** The shape of a page module (a pages/<name>/<name>.tsx file). */
15
+ export interface GantryPageModule {
16
+ default: FC;
17
+ /** export const chrome = false to hide the TitleBar on this page. */
18
+ chrome?: boolean;
19
+ /**
20
+ * Which layouts/ wrap this page: a name ("compact"), several nested
21
+ * outermost-first (["main", "compact"]), false for none, true to
22
+ * force "main" even on a chromeless page. Default: "main" if it
23
+ * exists (chromeless pages default to none).
24
+ */
25
+ layout?: boolean | string | string[];
26
+ /** export const route = "/custom" to override the derived route. */
27
+ route?: string;
28
+ }
29
+
30
+ export interface GantryPage {
31
+ key: string;
32
+ route: string;
33
+ mod: GantryPageModule;
34
+ }
35
+
36
+ /**
37
+ * The generated virtual:gantry-app module: the page/component/layout
38
+ * registry the Vite plugin derives from the app's folders. The
39
+ * synthesized .gantry/main.tsx imports it and hands it to createApp.
40
+ * gantry-web itself must never import the virtual module: esbuild
41
+ * cannot resolve virtual ids, so that import would force the package
42
+ * out of dep prebundling - and serving it as raw node_modules source
43
+ * lets Vite double-instantiate modules (?v=hash vs bare URLs), which
44
+ * split the router's subscriber set and broke navigation.
45
+ */
46
+ export interface GantryAppModule {
47
+ pages: GantryPage[];
48
+ components: Record<string, { default: FC }>;
49
+ /** Layouts by short name: layouts/main/main.tsx -> "main". */
50
+ layouts: Record<string, { default: FC<{ children?: ReactNode }> }>;
51
+ /** The root app.tsx module (default export: CreateAppOptions), or null. */
52
+ appConfig: { default: CreateAppOptions } | null;
53
+ singlePage?: boolean;
54
+ }
55
+
56
+ // The registry handed to createApp; module-level so match/layoutsFor
57
+ // read it without threading props.
58
+ let reg: GantryAppModule;
59
+
60
+ export interface CreateAppOptions {
61
+ /** Title shown in the TitleBar (default: none). */
62
+ title?: ReactNode;
63
+ /**
64
+ * TitleBar customization: title placement (titleAlign), your own
65
+ * controls in the left/right slots (back/forward buttons, menus),
66
+ * heights and reserves. The usual home for this is the optional
67
+ * app.tsx at the app root, default-exporting CreateAppOptions.
68
+ */
69
+ titleBar?: Partial<TitleBarProps>;
70
+ /** Extra Tea components beyond the paired components/ folders. */
71
+ components?: ComponentRegistry;
72
+ /** Binding prefix when the Go side changed it from "gantry". */
73
+ prefix?: string;
74
+ /** Websocket URL override (default ws://<host>/gantry/ws). */
75
+ socketURL?: string;
76
+ /** Hide the TitleBar on every page (pages can also export chrome = false). */
77
+ chrome?: boolean;
78
+ /**
79
+ * Crash detection and interception - on by default. A React render
80
+ * crash shows a full-screen error (full detail in development, a
81
+ * friendly card in production) instead of a white screen; Go panics
82
+ * and unhandled JS errors show dismissible notices in development.
83
+ * Replace the UI (screen), intercept (onError), or turn it off.
84
+ */
85
+ errors?: ErrorHandlingOptions & { screen?: FC<ErrorScreenProps> };
86
+ }
87
+
88
+ function keyClass(key: string): string {
89
+ return "gantry-" + key.replace(/[^a-zA-Z0-9]+/g, "-");
90
+ }
91
+
92
+ function routeOf(p: GantryPage): string {
93
+ return p.mod.route ?? p.route;
94
+ }
95
+
96
+ // A route compiles to typed segments: a literal must match exactly, a
97
+ // [param] captures one segment, a [...rest] catch-all captures one or
98
+ // more trailing segments.
99
+ type Seg =
100
+ | { kind: "lit"; value: string }
101
+ | { kind: "param"; name: string }
102
+ | { kind: "catchall"; name: string };
103
+
104
+ function routeIsDynamic(route: string): boolean {
105
+ return route.includes("[");
106
+ }
107
+
108
+ function cleanPath(path: string): string {
109
+ return path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
110
+ }
111
+
112
+ function splitPath(path: string): string[] {
113
+ return path === "/" ? [] : path.replace(/^\//, "").split("/");
114
+ }
115
+
116
+ function compileRoute(route: string): Seg[] {
117
+ return splitPath(route).map((s): Seg => {
118
+ const cm = s.match(/^\[\.\.\.(.+)\]$/);
119
+ if (cm) return { kind: "catchall", name: cm[1] };
120
+ const pm = s.match(/^\[(.+)\]$/);
121
+ if (pm) return { kind: "param", name: pm[1] };
122
+ return { kind: "lit", value: s };
123
+ });
124
+ }
125
+
126
+ // specificity ranks candidate dynamic routes so the most concrete wins:
127
+ // literals beat params beat catch-alls, segment by segment.
128
+ function specificity(segs: Seg[]): number {
129
+ let score = 0;
130
+ for (const s of segs) {
131
+ score += s.kind === "lit" ? 100 : s.kind === "param" ? 10 : 1;
132
+ }
133
+ return score;
134
+ }
135
+
136
+ function matchSegs(segs: Seg[], parts: string[]): RouteParams | null {
137
+ const params: RouteParams = {};
138
+ for (let i = 0; i < segs.length; i++) {
139
+ const s = segs[i];
140
+ if (s.kind === "catchall") {
141
+ const rest = parts.slice(i);
142
+ if (rest.length === 0) return null; // [...x] needs at least one segment
143
+ params[s.name] = rest;
144
+ return params;
145
+ }
146
+ if (i >= parts.length || parts[i] === "") return null;
147
+ if (s.kind === "param") {
148
+ params[s.name] = parts[i];
149
+ } else if (parts[i] !== s.value) {
150
+ return null;
151
+ }
152
+ }
153
+ return parts.length === segs.length ? params : null;
154
+ }
155
+
156
+ export interface RouteMatch {
157
+ page: GantryPage;
158
+ params: RouteParams;
159
+ }
160
+
161
+ /**
162
+ * match resolves a pathname to a page. An exact static route wins; then
163
+ * dynamic routes are tried most-specific first ([id] and [...slug]).
164
+ * Returns null when nothing matches - the caller falls back to the index.
165
+ */
166
+ function match(path: string): RouteMatch | null {
167
+ const clean = cleanPath(path);
168
+ const exact = reg.pages.find((p) => !routeIsDynamic(routeOf(p)) && routeOf(p) === clean);
169
+ if (exact) return { page: exact, params: {} };
170
+
171
+ const parts = splitPath(clean);
172
+ const dynamic = reg.pages
173
+ .filter((p) => routeIsDynamic(routeOf(p)))
174
+ .map((p) => ({ p, segs: compileRoute(routeOf(p)) }))
175
+ .sort((a, b) => specificity(b.segs) - specificity(a.segs));
176
+ for (const { p, segs } of dynamic) {
177
+ const params = matchSegs(segs, parts);
178
+ if (params) return { page: p, params };
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function PageHost({ page, params }: { page: GantryPage; params: RouteParams }) {
184
+ // Re-announce when the page key OR the concrete params change: the same
185
+ // dynamic page key ("pages/.../[id]") is reused across /1, /2, ... so a
186
+ // param change is still a navigation the Go side must hear about.
187
+ const paramsKey = JSON.stringify(params);
188
+ useEffect(() => {
189
+ ready(page.key, params);
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, [page.key, paramsKey]);
192
+ const Page = page.mod.default;
193
+ return (
194
+ <ParamsContext.Provider value={params}>
195
+ <div className={"gantry-page " + keyClass(page.key)}>
196
+ <Page />
197
+ </div>
198
+ </ParamsContext.Provider>
199
+ );
200
+ }
201
+
202
+ // layoutsFor resolves a page's layout selection to component functions,
203
+ // outermost first.
204
+ //
205
+ // (nothing) -> "main" if it exists (chromeless pages skip)
206
+ // layout = false -> none
207
+ // layout = "compact" -> that one
208
+ // layout = ["main","compact"] -> nested: <Main><Compact><Page/>...
209
+ function layoutsFor(page: GantryPage, chrome: boolean): FC<{ children?: ReactNode }>[] {
210
+ const sel = page.mod.layout;
211
+ let names: string[];
212
+ if (sel === false) {
213
+ names = [];
214
+ } else if (typeof sel === "string") {
215
+ names = [sel];
216
+ } else if (Array.isArray(sel)) {
217
+ names = sel;
218
+ } else if (sel === true) {
219
+ names = reg.layouts.main ? ["main"] : [];
220
+ } else {
221
+ // Default: the "main" layout on normal pages; chromeless pages
222
+ // (widgets, popups) are their own surfaces and skip it.
223
+ names = chrome && reg.layouts.main ? ["main"] : [];
224
+ }
225
+ const out: FC<{ children?: ReactNode }>[] = [];
226
+ for (const n of names) {
227
+ const mod = reg.layouts[n];
228
+ if (mod?.default) {
229
+ out.push(mod.default);
230
+ } else {
231
+ console.warn(`gantry: page ${page.key} wants unknown layout "${n}" (have: ${Object.keys(reg.layouts).join(", ") || "none"})`);
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+
237
+ // ErrorBoundary catches render crashes below it. It wraps the page
238
+ // content only - ResizeFrame and TitleBar stay outside, so a crashed
239
+ // page keeps its drag and close controls.
240
+ class ErrorBoundary extends Component<{ children: ReactNode }, { crashed: boolean }> {
241
+ state = { crashed: false };
242
+ static getDerivedStateFromError() {
243
+ return { crashed: true };
244
+ }
245
+ componentDidCatch(error: Error, info: ReactErrorInfo) {
246
+ reportError({
247
+ kind: "react-render",
248
+ code: "react.render",
249
+ message: error.message,
250
+ stack: error.stack,
251
+ componentStack: info.componentStack ?? undefined,
252
+ time: new Date().toISOString(),
253
+ origin: "js",
254
+ });
255
+ }
256
+ render() {
257
+ // The fatal overlay renders from the store (ErrorHost); the crashed
258
+ // subtree just goes away.
259
+ return this.state.crashed ? null : this.props.children;
260
+ }
261
+ }
262
+
263
+ // ErrorHost renders the (default or custom) error UI from the store:
264
+ // the fatal overlay and the notice banners.
265
+ function ErrorHost({ options }: { options: CreateAppOptions }) {
266
+ const { fatal, notices } = useGantryErrors();
267
+ const mode = useMode();
268
+ if (options.errors?.enabled === false) return null;
269
+ const Screen = options.errors?.screen ?? ErrorScreen;
270
+ return (
271
+ <>
272
+ {fatal && <Screen error={fatal} mode={mode} variant="fatal" onDismiss={clearFatal} />}
273
+ {notices.map((e, i) => (
274
+ // Newest first: the CSS shows only the first banner, dismissing
275
+ // it reveals the next.
276
+ <Screen key={i} error={e} mode={mode} variant="notice" onDismiss={() => dismissNotice(i)} />
277
+ )).reverse()}
278
+ </>
279
+ );
280
+ }
281
+
282
+ function AppRoot({ options }: { options: CreateAppOptions }) {
283
+ const path = useRoute();
284
+ const result = match(path);
285
+ const indexPage = reg.pages.find((p) => routeOf(p) === "/");
286
+ // A URL that matches no page (deleted during dev, stale bookmark) falls
287
+ // back to the index page - normalize the address so Links light up. A
288
+ // dynamic match is NOT a fallback: its URL is already canonical, so we
289
+ // must never redirect it back to the [id] pattern.
290
+ const matched = result !== null;
291
+ useEffect(() => {
292
+ if (!matched && indexPage) {
293
+ redirect("/");
294
+ }
295
+ }, [matched, indexPage]);
296
+ const page = result?.page ?? indexPage;
297
+ if (!page) {
298
+ return <div className="gantry-app">No pages found - add pages/index/index.tsx</div>;
299
+ }
300
+ const params = result?.params ?? {};
301
+ const chrome = options.chrome !== false && page.mod.chrome !== false;
302
+ let content = <PageHost page={page} params={params} />;
303
+ for (const Layout of layoutsFor(page, chrome).reverse()) {
304
+ content = <Layout>{content}</Layout>;
305
+ }
306
+ // The boundary wraps the page and layouts only: a render crash takes
307
+ // the content down, never the window chrome - the error overlay
308
+ // appears and the TitleBar keeps drag/close working.
309
+ return (
310
+ <div className="gantry-app">
311
+ <ResizeFrame prefix={options.prefix} />
312
+ {chrome && <TitleBar title={options.title} prefix={options.prefix} {...options.titleBar} />}
313
+ <ErrorBoundary>{content}</ErrorBoundary>
314
+ <ErrorHost options={options} />
315
+ </div>
316
+ );
317
+ }
318
+
319
+ /**
320
+ * createApp boots a Gantry frontend: zoom guard, websocket, component
321
+ * registry, TitleBar, the root layout (layout.tsx at the app root, if
322
+ * present), and the page router (single-page mode when only pages/index
323
+ * exists). The synthesized .gantry/main.tsx calls this with the
324
+ * virtual:gantry-app module (`import * as app from "virtual:gantry-app"`);
325
+ * an app only touches it to pass options.
326
+ */
327
+ export function createApp(app: GantryAppModule, options: CreateAppOptions = {}): void {
328
+ reg = app;
329
+ // The optional root app.tsx wins over the synthesized defaults, so
330
+ // apps customize everything without owning the entry file.
331
+ if (reg.appConfig?.default) {
332
+ options = { ...options, ...reg.appConfig.default };
333
+ }
334
+ installZoomGuard();
335
+ installErrorHandlers(options.errors ?? {});
336
+ connect(options.socketURL);
337
+ // Resolve the mode for the error UI (dev shows full detail), and
338
+ // surface a crash from the previous run if Go recorded one.
339
+ if (options.errors?.enabled !== false) {
340
+ fetchEnv()
341
+ .then((env) => setDevMode(env.mode === "development"))
342
+ .catch(() => {});
343
+ callGo("gantry", "errors")
344
+ .then((list) => {
345
+ const errs = (list ?? []) as (Omit<GantryErrorInfo, "origin"> & { kind: string })[];
346
+ const crash = errs.filter((e) => e.kind === "process-crash").at(-1);
347
+ if (crash) reportError({ ...crash, origin: "go" });
348
+ })
349
+ .catch(() => {});
350
+ }
351
+
352
+ const registry: ComponentRegistry = {};
353
+ for (const [key, mod] of Object.entries(reg.components)) {
354
+ if (typeof mod.default === "function") {
355
+ registry[key] = mod.default as FC<TeaComponentProps>;
356
+ }
357
+ }
358
+ Object.assign(registry, options.components);
359
+ setRegistry(registry);
360
+
361
+ const container = document.getElementById("root");
362
+ if (!container) throw new Error("gantry: no #root element");
363
+ createRoot(container).render(
364
+ <StrictMode>
365
+ <AppRoot options={options} />
366
+ </StrictMode>,
367
+ );
368
+ }