gantry-web 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gantry-web",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/app.tsx CHANGED
@@ -1,14 +1,58 @@
1
1
  /// <reference path="../types/index.d.ts" />
2
2
  import { StrictMode, useEffect, type FC, type ReactNode } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { pages, components as pairedComponents, layouts, appConfig, type GantryPage } from "virtual:gantry-app";
5
4
  import { TitleBar, type TitleBarProps } from "./TitleBar";
6
5
  import { ResizeFrame } from "./ResizeFrame";
7
6
  import { installZoomGuard } from "./zoom";
8
7
  import { connect, ready } from "./socket";
9
8
  import { useRoute, redirect } from "./router";
10
9
  import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
11
- import "./styles.css";
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;
12
56
 
13
57
  export interface CreateAppOptions {
14
58
  /** Title shown in the TitleBar (default: none). */
@@ -40,7 +84,7 @@ function routeOf(p: GantryPage): string {
40
84
 
41
85
  function match(path: string): GantryPage | undefined {
42
86
  const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
43
- return pages.find((p) => routeOf(p) === clean) ?? pages.find((p) => routeOf(p) === "/");
87
+ return reg.pages.find((p) => routeOf(p) === clean) ?? reg.pages.find((p) => routeOf(p) === "/");
44
88
  }
45
89
 
46
90
  function PageHost({ page }: { page: GantryPage }) {
@@ -72,19 +116,19 @@ function layoutsFor(page: GantryPage, chrome: boolean): FC<{ children?: ReactNod
72
116
  } else if (Array.isArray(sel)) {
73
117
  names = sel;
74
118
  } else if (sel === true) {
75
- names = layouts.main ? ["main"] : [];
119
+ names = reg.layouts.main ? ["main"] : [];
76
120
  } else {
77
121
  // Default: the "main" layout on normal pages; chromeless pages
78
122
  // (widgets, popups) are their own surfaces and skip it.
79
- names = chrome && layouts.main ? ["main"] : [];
123
+ names = chrome && reg.layouts.main ? ["main"] : [];
80
124
  }
81
125
  const out: FC<{ children?: ReactNode }>[] = [];
82
126
  for (const n of names) {
83
- const mod = layouts[n];
127
+ const mod = reg.layouts[n];
84
128
  if (mod?.default) {
85
129
  out.push(mod.default);
86
130
  } else {
87
- console.warn(`gantry: page ${page.key} wants unknown layout "${n}" (have: ${Object.keys(layouts).join(", ") || "none"})`);
131
+ console.warn(`gantry: page ${page.key} wants unknown layout "${n}" (have: ${Object.keys(reg.layouts).join(", ") || "none"})`);
88
132
  }
89
133
  }
90
134
  return out;
@@ -124,20 +168,22 @@ function AppRoot({ options }: { options: CreateAppOptions }) {
124
168
  * createApp boots a Gantry frontend: zoom guard, websocket, component
125
169
  * registry, TitleBar, the root layout (layout.tsx at the app root, if
126
170
  * present), and the page router (single-page mode when only pages/index
127
- * exists). The synthesized .gantry/main.tsx calls this; an app only
128
- * touches it to pass options.
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.
129
174
  */
130
- export function createApp(options: CreateAppOptions = {}): void {
175
+ export function createApp(app: GantryAppModule, options: CreateAppOptions = {}): void {
176
+ reg = app;
131
177
  // The optional root app.tsx wins over the synthesized defaults, so
132
178
  // apps customize everything without owning the entry file.
133
- if (appConfig?.default) {
134
- options = { ...options, ...appConfig.default };
179
+ if (reg.appConfig?.default) {
180
+ options = { ...options, ...reg.appConfig.default };
135
181
  }
136
182
  installZoomGuard();
137
183
  connect(options.socketURL);
138
184
 
139
185
  const registry: ComponentRegistry = {};
140
- for (const [key, mod] of Object.entries(pairedComponents)) {
186
+ for (const [key, mod] of Object.entries(reg.components)) {
141
187
  if (typeof mod.default === "function") {
142
188
  registry[key] = mod.default as FC<TeaComponentProps>;
143
189
  }
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export { DragStrip } from "./DragStrip";
8
8
  export type { DragStripProps } from "./DragStrip";
9
9
  export { ResizeFrame } from "./ResizeFrame";
10
10
  export { createApp } from "./app";
11
- export type { CreateAppOptions } from "./app";
11
+ export type { CreateAppOptions, GantryAppModule, GantryPage, GantryPageModule } from "./app";
12
12
  export { navigate, redirect, goBack, goForward, useRoute, isActive, Link, ExternalLink } from "./router";
13
13
  export type { LinkProps, ExternalLinkProps } from "./router";
14
14
  export { usePaired } from "./paired";
package/src/router.tsx CHANGED
@@ -9,10 +9,16 @@ import {
9
9
  } from "react";
10
10
  import { getShell } from "./bridge";
11
11
 
12
- const listeners = new Set<() => void>();
12
+ // Subscriptions go through a window-scoped event, NOT a module-local
13
+ // set: if bundling ever instantiates this module twice (it happened -
14
+ // Vite served excluded node_modules source under ?v=hash and bare URLs
15
+ // at once), a local set splits the subscribers and navigation silently
16
+ // stops re-rendering half the app. The window survives any number of
17
+ // module copies; the route itself already lives in location.pathname.
18
+ const NAV_EVENT = "gantry:navigate";
13
19
 
14
20
  function notify() {
15
- for (const fn of listeners) fn();
21
+ window.dispatchEvent(new Event(NAV_EVENT));
16
22
  }
17
23
 
18
24
  if (typeof window !== "undefined") {
@@ -47,8 +53,8 @@ export function goForward(): void {
47
53
  }
48
54
 
49
55
  function subscribe(fn: () => void): () => void {
50
- listeners.add(fn);
51
- return () => listeners.delete(fn);
56
+ window.addEventListener(NAV_EVENT, fn);
57
+ return () => window.removeEventListener(NAV_EVENT, fn);
52
58
  }
53
59
 
54
60
  /** useRoute returns the current pathname; re-renders on navigation. */
package/src/vite/index.js CHANGED
@@ -160,17 +160,20 @@ export function gantry(opts = {}) {
160
160
  const target = "http://127.0.0.1:" + goPort;
161
161
 
162
162
  return {
163
- // gantry-web ships TypeScript source through an npm file: link;
164
- // excluding it from prebundling keeps it in the normal
165
- // transform pipeline (plugin-react handles its tsx), and
166
- // deduping react avoids a second copy through the symlink.
167
- // Excluding it also hides its imports from the dep scanner, so
168
- // deps reached only through gantry-web must be force-included:
169
- // the CJS ones (react-dom/client) otherwise get served raw and
170
- // lose their named exports, and lucide-react would waterfall
171
- // one request per icon module.
163
+ // gantry-web MUST stay prebundlable (which is why it never
164
+ // imports virtual:gantry-app - esbuild cannot resolve virtual
165
+ // ids). A registry install is bundled into one optimized module;
166
+ // do NOT add it to optimizeDeps.exclude: served as raw
167
+ // node_modules source, Vite can reference the same file under
168
+ // two URLs (?v=hash vs bare) and instantiate the package twice,
169
+ // splitting the router's subscribers so navigation stops
170
+ // rendering. An npm file: link (framework development) resolves
171
+ // outside node_modules and is served as source automatically -
172
+ // there the force-included deps below matter: the CJS ones
173
+ // (react-dom/client) otherwise get served raw and lose their
174
+ // named exports, and lucide-react would waterfall one request
175
+ // per icon module.
172
176
  optimizeDeps: {
173
- exclude: ["gantry-web"],
174
177
  include: [
175
178
  "react",
176
179
  "react-dom",
package/types/index.d.ts CHANGED
@@ -1,32 +1,15 @@
1
1
  // Ambient types for the module the Gantry Vite plugin generates. This
2
2
  // file keeps editors happy without the synthesized .gantry/ folder
3
3
  // existing - include it via tsconfig "types": ["gantry-web/types"].
4
+ // The module's shape is GantryAppModule (exported by gantry-web); the
5
+ // synthesized .gantry/main.tsx passes the whole module to createApp.
4
6
 
5
7
  declare module "virtual:gantry-app" {
6
8
  import type { FC, ReactNode } from "react";
7
9
 
8
- export interface GantryPageModule {
9
- default: FC;
10
- /** export const chrome = false to hide the TitleBar on this page. */
11
- chrome?: boolean;
12
- /**
13
- * Which layouts/ wrap this page: a name ("compact"), several nested
14
- * outermost-first (["main", "compact"]), false for none, true to
15
- * force "main" even on a chromeless page. Default: "main" if it
16
- * exists (chromeless pages default to none).
17
- */
18
- layout?: boolean | string | string[];
19
- /** export const route = "/custom" to override the derived route. */
20
- route?: string;
21
- }
10
+ export type { GantryPage, GantryPageModule } from "gantry-web";
22
11
 
23
- export interface GantryPage {
24
- key: string;
25
- route: string;
26
- mod: GantryPageModule;
27
- }
28
-
29
- export const pages: GantryPage[];
12
+ export const pages: import("gantry-web").GantryPage[];
30
13
  export const components: Record<string, { default: FC }>;
31
14
  /** Layouts by short name: layouts/main/main.tsx -> "main". */
32
15
  export const layouts: Record<string, { default: FC<{ children?: ReactNode }> }>;