@mantajs/dashboard 0.1.15 → 0.1.17

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/README.md CHANGED
@@ -112,6 +112,16 @@ const OrderActivitySection = () => {
112
112
  export default OrderActivitySection
113
113
  ```
114
114
 
115
+ **Developer experience (HMR):**
116
+
117
+ | Action | Behavior | Details |
118
+ |--------|----------|---------|
119
+ | **Modify** an override | **HMR** (Hot Module Replacement) | The component is swapped in-place — no page reload, no React state loss. Instant feedback. |
120
+ | **Create** a new override | **Automatic full reload** (~2-3s) | The Vite server restarts to rebuild the pre-bundle, then the browser reloads automatically. No manual refresh needed. |
121
+ | **Delete** an override | **Automatic full reload** (~2-3s) | Same as creation — the original dashboard component is restored automatically. |
122
+
123
+ Under the hood, override files are kept as **separate Vite modules** (not inlined into the pre-bundled chunk). This allows React Fast Refresh to handle modifications via standard HMR. Creation and deletion require a server restart because the esbuild chunk structure changes.
124
+
115
125
  **Important notes:**
116
126
 
117
127
  - Override files are discovered **recursively** in `src/admin/components/` and all its subdirectories.
@@ -143,6 +153,44 @@ your-project/
143
153
 
144
154
  This uses Medusa's `@medusajs/admin-vite-plugin` to discover routes in `src/admin/routes/`, combined with a custom merge function that ensures backward compatibility.
145
155
 
156
+ **Route page example:**
157
+
158
+ ```tsx
159
+ // src/admin/routes/orders/page.tsx
160
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
161
+ import { ShoppingCart } from "@medusajs/icons"
162
+ import { Container, Heading } from "@medusajs/ui"
163
+
164
+ const OrdersPage = () => {
165
+ return (
166
+ <Container>
167
+ <Heading level="h1">My Custom Orders Page</Heading>
168
+ {/* Your custom orders list */}
169
+ </Container>
170
+ )
171
+ }
172
+
173
+ export const config = defineRouteConfig({
174
+ label: "Orders",
175
+ icon: ShoppingCart,
176
+ })
177
+
178
+ export default OrdersPage
179
+ ```
180
+
181
+ **Developer experience:**
182
+
183
+ Route overrides use Medusa's standard admin extension system (`@medusajs/admin-vite-plugin`), which provides its own HMR. Modifying a route page triggers a standard Vite HMR update — the page refreshes instantly without a full reload.
184
+
185
+ **Component overrides vs Route overrides — when to use which:**
186
+
187
+ | Use case | Approach |
188
+ |----------|----------|
189
+ | Replace a **full page** (e.g., the orders list) | Route override (`src/admin/routes/orders/page.tsx`) |
190
+ | Replace a **section** inside a page (e.g., the customer info block in order detail) | Component override (`src/admin/components/order-customer-section.tsx`) |
191
+ | Add a **new page** that doesn't exist in the dashboard | Route override (`src/admin/routes/my-page/page.tsx`) |
192
+ | Tweak a **reusable UI element** used across multiple pages | Component override |
193
+
146
194
  ### 3. Custom Menu Configuration
147
195
 
148
196
  Define your own sidebar menu by creating a `src/admin/menu.config.tsx` file:
@@ -22,8 +22,23 @@ type MenuConfig = {
22
22
  *
23
23
  * Handles:
24
24
  * 1. Menu config virtual module (virtual:dashboard/menu-config)
25
- * 2. Component overrides — any file in src/admin/components/ overrides the
26
- * dashboard component with the same name.
25
+ * 2. Component overrides — any file in src/admin/components/ whose name
26
+ * matches a dashboard component replaces it at build time.
27
+ *
28
+ * HMR Architecture:
29
+ * - Override files are NOT inlined into the esbuild pre-bundled chunk.
30
+ * Instead, the chunk contains `export * from "/@fs/path/to/override.tsx"`,
31
+ * keeping the override as a **separate Vite module**.
32
+ * - Because the override is a separate module exporting React components,
33
+ * @vitejs/plugin-react adds React Fast Refresh boundaries
34
+ * (import.meta.hot.accept). This makes the module self-accepting for HMR.
35
+ * - On MODIFICATION: Vite detects the file change, transforms it, and sends
36
+ * an HMR update. React Fast Refresh swaps the component — no page reload.
37
+ * - On CREATION or DELETION: The esbuild pre-bundle must be rebuilt (the
38
+ * chunk structure changes). We restart Vite + send a full-reload to the
39
+ * browser so it picks up the new chunks automatically.
40
+ * - The fs.watch is independent from Vite's internal watcher, so it
41
+ * survives server.restart() calls without losing events.
27
42
  */
28
43
  declare function customDashboardPlugin(): Plugin;
29
44
  declare const menuConfigPlugin: typeof customDashboardPlugin;
@@ -22,8 +22,23 @@ type MenuConfig = {
22
22
  *
23
23
  * Handles:
24
24
  * 1. Menu config virtual module (virtual:dashboard/menu-config)
25
- * 2. Component overrides — any file in src/admin/components/ overrides the
26
- * dashboard component with the same name.
25
+ * 2. Component overrides — any file in src/admin/components/ whose name
26
+ * matches a dashboard component replaces it at build time.
27
+ *
28
+ * HMR Architecture:
29
+ * - Override files are NOT inlined into the esbuild pre-bundled chunk.
30
+ * Instead, the chunk contains `export * from "/@fs/path/to/override.tsx"`,
31
+ * keeping the override as a **separate Vite module**.
32
+ * - Because the override is a separate module exporting React components,
33
+ * @vitejs/plugin-react adds React Fast Refresh boundaries
34
+ * (import.meta.hot.accept). This makes the module self-accepting for HMR.
35
+ * - On MODIFICATION: Vite detects the file change, transforms it, and sends
36
+ * an HMR update. React Fast Refresh swaps the component — no page reload.
37
+ * - On CREATION or DELETION: The esbuild pre-bundle must be rebuilt (the
38
+ * chunk structure changes). We restart Vite + send a full-reload to the
39
+ * browser so it picks up the new chunks automatically.
40
+ * - The fs.watch is independent from Vite's internal watcher, so it
41
+ * survives server.restart() calls without losing events.
27
42
  */
28
43
  declare function customDashboardPlugin(): Plugin;
29
44
  declare const menuConfigPlugin: typeof customDashboardPlugin;
@@ -38,16 +38,9 @@ var import_path = __toESM(require("path"));
38
38
  var import_fs = __toESM(require("fs"));
39
39
  var MENU_VIRTUAL_ID = "virtual:dashboard/menu-config";
40
40
  var MENU_RESOLVED_ID = "\0" + MENU_VIRTUAL_ID;
41
+ var OVERRIDE_PREFIX = "__mantajs_override__:";
41
42
  var COMPONENT_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mts", ".mjs"];
42
43
  var COMPONENT_EXT_SET = new Set(COMPONENT_EXTENSIONS);
43
- var VALID_LOADERS = {
44
- tsx: "tsx",
45
- ts: "ts",
46
- jsx: "jsx",
47
- js: "js",
48
- mts: "ts",
49
- mjs: "js"
50
- };
51
44
  function collectComponentFiles(dir, depth = 0) {
52
45
  if (depth > 20) return [];
53
46
  const results = [];
@@ -86,27 +79,50 @@ function getComponentName(filePath) {
86
79
  }
87
80
  return baseName;
88
81
  }
82
+ function findDashboardSrc() {
83
+ const cwd = process.cwd();
84
+ const candidates = [
85
+ import_path.default.join(cwd, "node_modules", "@medusajs", "dashboard", "src"),
86
+ import_path.default.join(cwd, "node_modules", "@mantajs", "dashboard", "src"),
87
+ import_path.default.join(cwd, ".yalc", "@mantajs", "dashboard", "src")
88
+ ];
89
+ for (const dir of candidates) {
90
+ if (import_fs.default.existsSync(dir)) return dir;
91
+ }
92
+ return null;
93
+ }
89
94
  function customDashboardPlugin() {
90
95
  const componentsDir = import_path.default.resolve(process.cwd(), "src/admin/components");
91
96
  const overridesByName = /* @__PURE__ */ new Map();
97
+ const dashboardComponents = /* @__PURE__ */ new Set();
98
+ const dashboardSrc = findDashboardSrc();
99
+ if (dashboardSrc) {
100
+ for (const f of collectComponentFiles(dashboardSrc)) {
101
+ const cName = getComponentName(f);
102
+ if (cName) dashboardComponents.add(cName);
103
+ }
104
+ }
92
105
  if (import_fs.default.existsSync(componentsDir)) {
93
- const collectedFiles = collectComponentFiles(componentsDir).sort();
94
- for (const fullPath of collectedFiles) {
95
- const fileName = import_path.default.basename(fullPath);
96
- const name = fileName.replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
106
+ for (const fullPath of collectComponentFiles(componentsDir).sort()) {
107
+ const name = import_path.default.basename(fullPath).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
97
108
  if (name && name !== "index") {
98
- if (overridesByName.has(name) && process.env.NODE_ENV === "development") {
99
- console.warn(
100
- `[custom-dashboard] Duplicate override "${name}": ${overridesByName.get(name)} will be replaced by ${fullPath}`
101
- );
102
- }
103
109
  overridesByName.set(name, fullPath);
104
110
  }
105
111
  }
106
112
  }
107
113
  const hasOverrides = overridesByName.size > 0;
108
- if (hasOverrides && process.env.NODE_ENV === "development") {
109
- console.log("[custom-dashboard] overrides:", [...overridesByName.keys()]);
114
+ let currentServer = null;
115
+ let watcherCreated = false;
116
+ const knownOverrideFiles = new Set(overridesByName.values());
117
+ if (process.env.NODE_ENV === "development") {
118
+ if (hasOverrides) {
119
+ console.log("[custom-dashboard] overrides:", [...overridesByName.keys()]);
120
+ }
121
+ if (dashboardComponents.size > 0) {
122
+ console.log(
123
+ `[custom-dashboard] Scanned ${dashboardComponents.size} dashboard components`
124
+ );
125
+ }
110
126
  }
111
127
  return {
112
128
  name: "custom-dashboard",
@@ -115,70 +131,159 @@ function customDashboardPlugin() {
115
131
  config.optimizeDeps = config.optimizeDeps || {};
116
132
  config.optimizeDeps.exclude = config.optimizeDeps.exclude || [];
117
133
  config.optimizeDeps.exclude.push(MENU_VIRTUAL_ID);
118
- if (hasOverrides) {
119
- config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {};
120
- config.optimizeDeps.esbuildOptions.plugins = config.optimizeDeps.esbuildOptions.plugins || [];
121
- const overrides = overridesByName;
122
- config.optimizeDeps.esbuildOptions.plugins.push({
123
- name: "dashboard-component-overrides",
124
- setup(build) {
125
- build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
126
- const normalized = args.path.replace(/\\/g, "/");
127
- if (!normalized.includes("/dashboard/dist/")) return void 0;
128
- const srcEntry = normalized.replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx");
129
- let contents;
130
- try {
131
- contents = import_fs.default.readFileSync(srcEntry, "utf-8");
132
- } catch {
133
- return void 0;
134
- }
134
+ config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {};
135
+ config.optimizeDeps.esbuildOptions.plugins = config.optimizeDeps.esbuildOptions.plugins || [];
136
+ const overrides = overridesByName;
137
+ config.optimizeDeps.esbuildOptions.plugins.push({
138
+ name: "dashboard-component-overrides",
139
+ setup(build) {
140
+ build.onResolve({ filter: /^__mantajs_override__:/ }, (args) => ({
141
+ path: args.path,
142
+ external: true
143
+ }));
144
+ build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
145
+ if (overrides.size === 0) return void 0;
146
+ const normalized = args.path.replace(/\\/g, "/");
147
+ if (!normalized.includes("/dashboard/dist/")) return void 0;
148
+ const srcEntry = normalized.replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx");
149
+ let contents;
150
+ try {
151
+ contents = import_fs.default.readFileSync(srcEntry, "utf-8");
152
+ } catch {
153
+ return void 0;
154
+ }
155
+ if (process.env.NODE_ENV === "development") {
156
+ console.log(`[custom-dashboard] Redirecting entry \u2192 ${srcEntry}`);
157
+ }
158
+ return { contents, loader: "tsx", resolveDir: import_path.default.dirname(srcEntry) };
159
+ });
160
+ build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
161
+ if (overrides.size === 0) return void 0;
162
+ const normalized = args.path.replace(/\\/g, "/");
163
+ if (!normalized.includes("/dashboard/src/")) return void 0;
164
+ const fileName = import_path.default.basename(args.path);
165
+ if (fileName.startsWith("index.")) return void 0;
166
+ const componentName = getComponentName(args.path);
167
+ if (componentName && overrides.has(componentName)) {
168
+ const overridePath = overrides.get(componentName);
169
+ const normalizedPath = overridePath.replace(/\\/g, "/");
135
170
  if (process.env.NODE_ENV === "development") {
136
- console.log(
137
- `[custom-dashboard] Redirecting entry: ${args.path} \u2192 ${srcEntry}`
138
- );
171
+ console.log(`[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`);
139
172
  }
140
173
  return {
141
- contents,
174
+ contents: `export * from "${OVERRIDE_PREFIX}${normalizedPath}"`,
142
175
  loader: "tsx",
143
- resolveDir: import_path.default.dirname(srcEntry)
176
+ resolveDir: import_path.default.dirname(args.path)
144
177
  };
145
- });
146
- build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
147
- const normalized = args.path.replace(/\\/g, "/");
148
- if (!normalized.includes("/dashboard/src/")) return void 0;
149
- const fileName = import_path.default.basename(args.path);
150
- if (fileName.startsWith("index.")) return void 0;
151
- const componentName = getComponentName(args.path);
152
- if (componentName && overrides.has(componentName)) {
153
- const overridePath = overrides.get(componentName);
154
- const ext = import_path.default.extname(overridePath).slice(1);
155
- const loader = VALID_LOADERS[ext] || "tsx";
156
- let contents;
157
- try {
158
- contents = import_fs.default.readFileSync(overridePath, "utf-8");
159
- } catch {
160
- return void 0;
161
- }
162
- if (process.env.NODE_ENV === "development") {
163
- console.log(
164
- `[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`
165
- );
166
- }
167
- return {
168
- contents,
169
- loader,
170
- resolveDir: import_path.default.dirname(overridePath)
171
- };
178
+ }
179
+ return void 0;
180
+ });
181
+ }
182
+ });
183
+ config.optimizeDeps.esbuildOptions.define = {
184
+ ...config.optimizeDeps.esbuildOptions.define,
185
+ "__MANTAJS_OVERRIDES__": JSON.stringify(
186
+ [...overrides.keys()].sort().join(",")
187
+ )
188
+ };
189
+ config.optimizeDeps.force = true;
190
+ },
191
+ configureServer(server) {
192
+ currentServer = server;
193
+ if (!import_fs.default.existsSync(componentsDir)) return;
194
+ if (!watcherCreated) {
195
+ watcherCreated = true;
196
+ let debounceTimer = null;
197
+ import_fs.default.watch(componentsDir, { recursive: true }, (_event, filename) => {
198
+ if (!filename) return;
199
+ const ext = import_path.default.extname(filename);
200
+ if (!COMPONENT_EXT_SET.has(ext)) return;
201
+ const name = import_path.default.basename(filename).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
202
+ if (!name || name === "index") return;
203
+ if (!dashboardComponents.has(name)) return;
204
+ const fullPath = import_path.default.resolve(componentsDir, filename);
205
+ const fileExists = import_fs.default.existsSync(fullPath);
206
+ const wasKnown = knownOverrideFiles.has(fullPath);
207
+ if (fileExists && wasKnown) {
208
+ const mods = currentServer?.moduleGraph.getModulesByFile(fullPath);
209
+ if (mods && mods.size > 0) {
210
+ for (const mod of mods) {
211
+ currentServer.moduleGraph.invalidateModule(mod);
172
212
  }
173
- return void 0;
174
- });
213
+ currentServer.ws.send({
214
+ type: "update",
215
+ updates: [...mods].map((mod) => ({
216
+ type: "js-update",
217
+ path: mod.url,
218
+ acceptedPath: mod.url,
219
+ timestamp: Date.now(),
220
+ explicitImportRequired: false
221
+ }))
222
+ });
223
+ console.log(`[custom-dashboard] Override "${name}" modified \u2192 HMR`);
224
+ } else {
225
+ console.log(`[custom-dashboard] Override "${name}" not in graph \u2192 force-reload`);
226
+ currentServer?.ws.send({ type: "custom", event: "mantajs:force-reload" });
227
+ }
228
+ return;
175
229
  }
230
+ if (debounceTimer) clearTimeout(debounceTimer);
231
+ debounceTimer = setTimeout(async () => {
232
+ overridesByName.clear();
233
+ knownOverrideFiles.clear();
234
+ for (const fp of collectComponentFiles(componentsDir).sort()) {
235
+ const n = import_path.default.basename(fp).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
236
+ if (n && n !== "index") {
237
+ overridesByName.set(n, fp);
238
+ knownOverrideFiles.add(fp);
239
+ }
240
+ }
241
+ const action = fileExists ? "created" : "deleted";
242
+ console.log(`[custom-dashboard] Override "${name}" ${action} \u2192 restarting...`);
243
+ console.log(`[custom-dashboard] overrides:`, [...overridesByName.keys()]);
244
+ try {
245
+ if (!currentServer) {
246
+ console.warn(`[custom-dashboard] No server available for restart`);
247
+ return;
248
+ }
249
+ await currentServer.restart();
250
+ currentServer.ws.send({
251
+ type: "custom",
252
+ event: "mantajs:force-reload"
253
+ });
254
+ console.log(`[custom-dashboard] Force-reload sent to browser`);
255
+ } catch (e) {
256
+ console.error(`[custom-dashboard] Restart failed:`, e);
257
+ }
258
+ }, 300);
176
259
  });
177
- config.optimizeDeps.force = true;
178
260
  }
179
261
  },
262
+ handleHotUpdate({ file }) {
263
+ if (knownOverrideFiles.has(file)) {
264
+ return [];
265
+ }
266
+ },
267
+ transformIndexHtml(html) {
268
+ return html.replace(
269
+ "</head>",
270
+ `<script type="module">
271
+ if (import.meta.hot) {
272
+ import.meta.hot.on("mantajs:force-reload", () => {
273
+ const url = new URL(location.href);
274
+ url.searchParams.set("_r", Date.now().toString());
275
+ location.replace(url.href);
276
+ });
277
+ }
278
+ </script>
279
+ </head>`
280
+ );
281
+ },
180
282
  resolveId(source) {
181
283
  if (source === MENU_VIRTUAL_ID) return MENU_RESOLVED_ID;
284
+ if (source.startsWith(OVERRIDE_PREFIX)) {
285
+ return source.slice(OVERRIDE_PREFIX.length);
286
+ }
182
287
  return null;
183
288
  },
184
289
  load(id) {
@@ -3,16 +3,9 @@ import path from "path";
3
3
  import fs from "fs";
4
4
  var MENU_VIRTUAL_ID = "virtual:dashboard/menu-config";
5
5
  var MENU_RESOLVED_ID = "\0" + MENU_VIRTUAL_ID;
6
+ var OVERRIDE_PREFIX = "__mantajs_override__:";
6
7
  var COMPONENT_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mts", ".mjs"];
7
8
  var COMPONENT_EXT_SET = new Set(COMPONENT_EXTENSIONS);
8
- var VALID_LOADERS = {
9
- tsx: "tsx",
10
- ts: "ts",
11
- jsx: "jsx",
12
- js: "js",
13
- mts: "ts",
14
- mjs: "js"
15
- };
16
9
  function collectComponentFiles(dir, depth = 0) {
17
10
  if (depth > 20) return [];
18
11
  const results = [];
@@ -51,27 +44,50 @@ function getComponentName(filePath) {
51
44
  }
52
45
  return baseName;
53
46
  }
47
+ function findDashboardSrc() {
48
+ const cwd = process.cwd();
49
+ const candidates = [
50
+ path.join(cwd, "node_modules", "@medusajs", "dashboard", "src"),
51
+ path.join(cwd, "node_modules", "@mantajs", "dashboard", "src"),
52
+ path.join(cwd, ".yalc", "@mantajs", "dashboard", "src")
53
+ ];
54
+ for (const dir of candidates) {
55
+ if (fs.existsSync(dir)) return dir;
56
+ }
57
+ return null;
58
+ }
54
59
  function customDashboardPlugin() {
55
60
  const componentsDir = path.resolve(process.cwd(), "src/admin/components");
56
61
  const overridesByName = /* @__PURE__ */ new Map();
62
+ const dashboardComponents = /* @__PURE__ */ new Set();
63
+ const dashboardSrc = findDashboardSrc();
64
+ if (dashboardSrc) {
65
+ for (const f of collectComponentFiles(dashboardSrc)) {
66
+ const cName = getComponentName(f);
67
+ if (cName) dashboardComponents.add(cName);
68
+ }
69
+ }
57
70
  if (fs.existsSync(componentsDir)) {
58
- const collectedFiles = collectComponentFiles(componentsDir).sort();
59
- for (const fullPath of collectedFiles) {
60
- const fileName = path.basename(fullPath);
61
- const name = fileName.replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
71
+ for (const fullPath of collectComponentFiles(componentsDir).sort()) {
72
+ const name = path.basename(fullPath).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
62
73
  if (name && name !== "index") {
63
- if (overridesByName.has(name) && process.env.NODE_ENV === "development") {
64
- console.warn(
65
- `[custom-dashboard] Duplicate override "${name}": ${overridesByName.get(name)} will be replaced by ${fullPath}`
66
- );
67
- }
68
74
  overridesByName.set(name, fullPath);
69
75
  }
70
76
  }
71
77
  }
72
78
  const hasOverrides = overridesByName.size > 0;
73
- if (hasOverrides && process.env.NODE_ENV === "development") {
74
- console.log("[custom-dashboard] overrides:", [...overridesByName.keys()]);
79
+ let currentServer = null;
80
+ let watcherCreated = false;
81
+ const knownOverrideFiles = new Set(overridesByName.values());
82
+ if (process.env.NODE_ENV === "development") {
83
+ if (hasOverrides) {
84
+ console.log("[custom-dashboard] overrides:", [...overridesByName.keys()]);
85
+ }
86
+ if (dashboardComponents.size > 0) {
87
+ console.log(
88
+ `[custom-dashboard] Scanned ${dashboardComponents.size} dashboard components`
89
+ );
90
+ }
75
91
  }
76
92
  return {
77
93
  name: "custom-dashboard",
@@ -80,70 +96,159 @@ function customDashboardPlugin() {
80
96
  config.optimizeDeps = config.optimizeDeps || {};
81
97
  config.optimizeDeps.exclude = config.optimizeDeps.exclude || [];
82
98
  config.optimizeDeps.exclude.push(MENU_VIRTUAL_ID);
83
- if (hasOverrides) {
84
- config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {};
85
- config.optimizeDeps.esbuildOptions.plugins = config.optimizeDeps.esbuildOptions.plugins || [];
86
- const overrides = overridesByName;
87
- config.optimizeDeps.esbuildOptions.plugins.push({
88
- name: "dashboard-component-overrides",
89
- setup(build) {
90
- build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
91
- const normalized = args.path.replace(/\\/g, "/");
92
- if (!normalized.includes("/dashboard/dist/")) return void 0;
93
- const srcEntry = normalized.replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx");
94
- let contents;
95
- try {
96
- contents = fs.readFileSync(srcEntry, "utf-8");
97
- } catch {
98
- return void 0;
99
- }
99
+ config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {};
100
+ config.optimizeDeps.esbuildOptions.plugins = config.optimizeDeps.esbuildOptions.plugins || [];
101
+ const overrides = overridesByName;
102
+ config.optimizeDeps.esbuildOptions.plugins.push({
103
+ name: "dashboard-component-overrides",
104
+ setup(build) {
105
+ build.onResolve({ filter: /^__mantajs_override__:/ }, (args) => ({
106
+ path: args.path,
107
+ external: true
108
+ }));
109
+ build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
110
+ if (overrides.size === 0) return void 0;
111
+ const normalized = args.path.replace(/\\/g, "/");
112
+ if (!normalized.includes("/dashboard/dist/")) return void 0;
113
+ const srcEntry = normalized.replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx");
114
+ let contents;
115
+ try {
116
+ contents = fs.readFileSync(srcEntry, "utf-8");
117
+ } catch {
118
+ return void 0;
119
+ }
120
+ if (process.env.NODE_ENV === "development") {
121
+ console.log(`[custom-dashboard] Redirecting entry \u2192 ${srcEntry}`);
122
+ }
123
+ return { contents, loader: "tsx", resolveDir: path.dirname(srcEntry) };
124
+ });
125
+ build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
126
+ if (overrides.size === 0) return void 0;
127
+ const normalized = args.path.replace(/\\/g, "/");
128
+ if (!normalized.includes("/dashboard/src/")) return void 0;
129
+ const fileName = path.basename(args.path);
130
+ if (fileName.startsWith("index.")) return void 0;
131
+ const componentName = getComponentName(args.path);
132
+ if (componentName && overrides.has(componentName)) {
133
+ const overridePath = overrides.get(componentName);
134
+ const normalizedPath = overridePath.replace(/\\/g, "/");
100
135
  if (process.env.NODE_ENV === "development") {
101
- console.log(
102
- `[custom-dashboard] Redirecting entry: ${args.path} \u2192 ${srcEntry}`
103
- );
136
+ console.log(`[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`);
104
137
  }
105
138
  return {
106
- contents,
139
+ contents: `export * from "${OVERRIDE_PREFIX}${normalizedPath}"`,
107
140
  loader: "tsx",
108
- resolveDir: path.dirname(srcEntry)
141
+ resolveDir: path.dirname(args.path)
109
142
  };
110
- });
111
- build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
112
- const normalized = args.path.replace(/\\/g, "/");
113
- if (!normalized.includes("/dashboard/src/")) return void 0;
114
- const fileName = path.basename(args.path);
115
- if (fileName.startsWith("index.")) return void 0;
116
- const componentName = getComponentName(args.path);
117
- if (componentName && overrides.has(componentName)) {
118
- const overridePath = overrides.get(componentName);
119
- const ext = path.extname(overridePath).slice(1);
120
- const loader = VALID_LOADERS[ext] || "tsx";
121
- let contents;
122
- try {
123
- contents = fs.readFileSync(overridePath, "utf-8");
124
- } catch {
125
- return void 0;
126
- }
127
- if (process.env.NODE_ENV === "development") {
128
- console.log(
129
- `[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`
130
- );
131
- }
132
- return {
133
- contents,
134
- loader,
135
- resolveDir: path.dirname(overridePath)
136
- };
143
+ }
144
+ return void 0;
145
+ });
146
+ }
147
+ });
148
+ config.optimizeDeps.esbuildOptions.define = {
149
+ ...config.optimizeDeps.esbuildOptions.define,
150
+ "__MANTAJS_OVERRIDES__": JSON.stringify(
151
+ [...overrides.keys()].sort().join(",")
152
+ )
153
+ };
154
+ config.optimizeDeps.force = true;
155
+ },
156
+ configureServer(server) {
157
+ currentServer = server;
158
+ if (!fs.existsSync(componentsDir)) return;
159
+ if (!watcherCreated) {
160
+ watcherCreated = true;
161
+ let debounceTimer = null;
162
+ fs.watch(componentsDir, { recursive: true }, (_event, filename) => {
163
+ if (!filename) return;
164
+ const ext = path.extname(filename);
165
+ if (!COMPONENT_EXT_SET.has(ext)) return;
166
+ const name = path.basename(filename).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
167
+ if (!name || name === "index") return;
168
+ if (!dashboardComponents.has(name)) return;
169
+ const fullPath = path.resolve(componentsDir, filename);
170
+ const fileExists = fs.existsSync(fullPath);
171
+ const wasKnown = knownOverrideFiles.has(fullPath);
172
+ if (fileExists && wasKnown) {
173
+ const mods = currentServer?.moduleGraph.getModulesByFile(fullPath);
174
+ if (mods && mods.size > 0) {
175
+ for (const mod of mods) {
176
+ currentServer.moduleGraph.invalidateModule(mod);
137
177
  }
138
- return void 0;
139
- });
178
+ currentServer.ws.send({
179
+ type: "update",
180
+ updates: [...mods].map((mod) => ({
181
+ type: "js-update",
182
+ path: mod.url,
183
+ acceptedPath: mod.url,
184
+ timestamp: Date.now(),
185
+ explicitImportRequired: false
186
+ }))
187
+ });
188
+ console.log(`[custom-dashboard] Override "${name}" modified \u2192 HMR`);
189
+ } else {
190
+ console.log(`[custom-dashboard] Override "${name}" not in graph \u2192 force-reload`);
191
+ currentServer?.ws.send({ type: "custom", event: "mantajs:force-reload" });
192
+ }
193
+ return;
140
194
  }
195
+ if (debounceTimer) clearTimeout(debounceTimer);
196
+ debounceTimer = setTimeout(async () => {
197
+ overridesByName.clear();
198
+ knownOverrideFiles.clear();
199
+ for (const fp of collectComponentFiles(componentsDir).sort()) {
200
+ const n = path.basename(fp).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
201
+ if (n && n !== "index") {
202
+ overridesByName.set(n, fp);
203
+ knownOverrideFiles.add(fp);
204
+ }
205
+ }
206
+ const action = fileExists ? "created" : "deleted";
207
+ console.log(`[custom-dashboard] Override "${name}" ${action} \u2192 restarting...`);
208
+ console.log(`[custom-dashboard] overrides:`, [...overridesByName.keys()]);
209
+ try {
210
+ if (!currentServer) {
211
+ console.warn(`[custom-dashboard] No server available for restart`);
212
+ return;
213
+ }
214
+ await currentServer.restart();
215
+ currentServer.ws.send({
216
+ type: "custom",
217
+ event: "mantajs:force-reload"
218
+ });
219
+ console.log(`[custom-dashboard] Force-reload sent to browser`);
220
+ } catch (e) {
221
+ console.error(`[custom-dashboard] Restart failed:`, e);
222
+ }
223
+ }, 300);
141
224
  });
142
- config.optimizeDeps.force = true;
143
225
  }
144
226
  },
227
+ handleHotUpdate({ file }) {
228
+ if (knownOverrideFiles.has(file)) {
229
+ return [];
230
+ }
231
+ },
232
+ transformIndexHtml(html) {
233
+ return html.replace(
234
+ "</head>",
235
+ `<script type="module">
236
+ if (import.meta.hot) {
237
+ import.meta.hot.on("mantajs:force-reload", () => {
238
+ const url = new URL(location.href);
239
+ url.searchParams.set("_r", Date.now().toString());
240
+ location.replace(url.href);
241
+ });
242
+ }
243
+ </script>
244
+ </head>`
245
+ );
246
+ },
145
247
  resolveId(source) {
146
248
  if (source === MENU_VIRTUAL_ID) return MENU_RESOLVED_ID;
249
+ if (source.startsWith(OVERRIDE_PREFIX)) {
250
+ return source.slice(OVERRIDE_PREFIX.length);
251
+ }
147
252
  return null;
148
253
  },
149
254
  load(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantajs/dashboard",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "B2B Admin Dashboard for Medusa - Fork of @medusajs/dashboard",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -1,22 +1,17 @@
1
- import { Plugin } from "vite"
1
+ import { Plugin, ViteDevServer } from "vite"
2
2
  import path from "path"
3
3
  import fs from "fs"
4
4
 
5
5
  const MENU_VIRTUAL_ID = "virtual:dashboard/menu-config"
6
6
  const MENU_RESOLVED_ID = "\0" + MENU_VIRTUAL_ID
7
7
 
8
+ // Unique prefix for override imports — esbuild marks these as external,
9
+ // then Vite's resolveId resolves them to the actual file paths.
10
+ const OVERRIDE_PREFIX = "__mantajs_override__:"
11
+
8
12
  const COMPONENT_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mts", ".mjs"]
9
13
  const COMPONENT_EXT_SET = new Set(COMPONENT_EXTENSIONS)
10
14
 
11
- const VALID_LOADERS: Record<string, string> = {
12
- tsx: "tsx",
13
- ts: "ts",
14
- jsx: "jsx",
15
- js: "js",
16
- mts: "ts",
17
- mjs: "js",
18
- }
19
-
20
15
  /**
21
16
  * Recursively collect all component files from a directory tree.
22
17
  * Includes a depth guard to prevent symlink loops and skips hidden
@@ -68,29 +63,66 @@ function getComponentName(filePath: string): string | null {
68
63
  return baseName
69
64
  }
70
65
 
66
+ /**
67
+ * Find the dashboard source directory by checking known install paths.
68
+ * Works with yarn resolutions, direct installs, and yalc links.
69
+ */
70
+ function findDashboardSrc(): string | null {
71
+ const cwd = process.cwd()
72
+ const candidates = [
73
+ path.join(cwd, "node_modules", "@medusajs", "dashboard", "src"),
74
+ path.join(cwd, "node_modules", "@mantajs", "dashboard", "src"),
75
+ path.join(cwd, ".yalc", "@mantajs", "dashboard", "src"),
76
+ ]
77
+ for (const dir of candidates) {
78
+ if (fs.existsSync(dir)) return dir
79
+ }
80
+ return null
81
+ }
82
+
71
83
  /**
72
84
  * Unified Vite plugin for @mantajs/dashboard.
73
85
  *
74
86
  * Handles:
75
87
  * 1. Menu config virtual module (virtual:dashboard/menu-config)
76
- * 2. Component overrides — any file in src/admin/components/ overrides the
77
- * dashboard component with the same name.
88
+ * 2. Component overrides — any file in src/admin/components/ whose name
89
+ * matches a dashboard component replaces it at build time.
90
+ *
91
+ * HMR Architecture:
92
+ * - Override files are NOT inlined into the esbuild pre-bundled chunk.
93
+ * Instead, the chunk contains `export * from "/@fs/path/to/override.tsx"`,
94
+ * keeping the override as a **separate Vite module**.
95
+ * - Because the override is a separate module exporting React components,
96
+ * @vitejs/plugin-react adds React Fast Refresh boundaries
97
+ * (import.meta.hot.accept). This makes the module self-accepting for HMR.
98
+ * - On MODIFICATION: Vite detects the file change, transforms it, and sends
99
+ * an HMR update. React Fast Refresh swaps the component — no page reload.
100
+ * - On CREATION or DELETION: The esbuild pre-bundle must be rebuilt (the
101
+ * chunk structure changes). We restart Vite + send a full-reload to the
102
+ * browser so it picks up the new chunks automatically.
103
+ * - The fs.watch is independent from Vite's internal watcher, so it
104
+ * survives server.restart() calls without losing events.
78
105
  */
79
106
  export function customDashboardPlugin(): Plugin {
80
107
  const componentsDir = path.resolve(process.cwd(), "src/admin/components")
81
108
  const overridesByName = new Map<string, string>()
82
109
 
110
+ // Scan dashboard source once at startup — used to decide if a changed
111
+ // file is a potential override (~966 names, ~30 KB).
112
+ const dashboardComponents = new Set<string>()
113
+ const dashboardSrc = findDashboardSrc()
114
+ if (dashboardSrc) {
115
+ for (const f of collectComponentFiles(dashboardSrc)) {
116
+ const cName = getComponentName(f)
117
+ if (cName) dashboardComponents.add(cName)
118
+ }
119
+ }
120
+
121
+ // Collect initial overrides from disk
83
122
  if (fs.existsSync(componentsDir)) {
84
- const collectedFiles = collectComponentFiles(componentsDir).sort()
85
- for (const fullPath of collectedFiles) {
86
- const fileName = path.basename(fullPath)
87
- const name = fileName.replace(/\.(tsx?|jsx?|mts|mjs)$/, "")
123
+ for (const fullPath of collectComponentFiles(componentsDir).sort()) {
124
+ const name = path.basename(fullPath).replace(/\.(tsx?|jsx?|mts|mjs)$/, "")
88
125
  if (name && name !== "index") {
89
- if (overridesByName.has(name) && process.env.NODE_ENV === "development") {
90
- console.warn(
91
- `[custom-dashboard] Duplicate override "${name}": ${overridesByName.get(name)} will be replaced by ${fullPath}`
92
- )
93
- }
94
126
  overridesByName.set(name, fullPath)
95
127
  }
96
128
  }
@@ -98,8 +130,22 @@ export function customDashboardPlugin(): Plugin {
98
130
 
99
131
  const hasOverrides = overridesByName.size > 0
100
132
 
101
- if (hasOverrides && process.env.NODE_ENV === "development") {
102
- console.log("[custom-dashboard] overrides:", [...overridesByName.keys()])
133
+ // Mutable ref to the latest Vite server — updated on each configureServer
134
+ let currentServer: ViteDevServer | null = null
135
+ let watcherCreated = false
136
+
137
+ // Track known override file paths to distinguish modify vs create/delete
138
+ const knownOverrideFiles = new Set<string>(overridesByName.values())
139
+
140
+ if (process.env.NODE_ENV === "development") {
141
+ if (hasOverrides) {
142
+ console.log("[custom-dashboard] overrides:", [...overridesByName.keys()])
143
+ }
144
+ if (dashboardComponents.size > 0) {
145
+ console.log(
146
+ `[custom-dashboard] Scanned ${dashboardComponents.size} dashboard components`
147
+ )
148
+ }
103
149
  }
104
150
 
105
151
  return {
@@ -107,98 +153,218 @@ export function customDashboardPlugin(): Plugin {
107
153
  enforce: "pre",
108
154
 
109
155
  config(config) {
110
- // Always exclude the menu virtual module
111
156
  config.optimizeDeps = config.optimizeDeps || {}
112
157
  config.optimizeDeps.exclude = config.optimizeDeps.exclude || []
113
158
  config.optimizeDeps.exclude.push(MENU_VIRTUAL_ID)
114
159
 
115
- if (hasOverrides) {
116
- // Strategy: the package.json points to dist/app.mjs (so the browser
117
- // gets a working pre-bundled chunk — no blank page). But during
118
- // esbuild pre-bundling we redirect the dist entry to the source TSX
119
- // via onLoad, so esbuild follows individual imports and we can swap
120
- // component files with the user's overrides.
121
- config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {}
122
- config.optimizeDeps.esbuildOptions.plugins =
123
- config.optimizeDeps.esbuildOptions.plugins || []
124
-
125
- const overrides = overridesByName
126
- config.optimizeDeps.esbuildOptions.plugins.push({
127
- name: "dashboard-component-overrides",
128
- setup(build) {
129
- // 1. Redirect the dist entry to source so esbuild processes
130
- // individual TSX files instead of one big pre-built bundle.
131
- build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
132
- const normalized = args.path.replace(/\\/g, "/")
133
- if (!normalized.includes("/dashboard/dist/")) return undefined
134
-
135
- const srcEntry = normalized
136
- .replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx")
137
-
138
- let contents: string
139
- try {
140
- contents = fs.readFileSync(srcEntry, "utf-8")
141
- } catch {
142
- return undefined
143
- }
160
+ config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {}
161
+ config.optimizeDeps.esbuildOptions.plugins =
162
+ config.optimizeDeps.esbuildOptions.plugins || []
163
+
164
+ const overrides = overridesByName
165
+ config.optimizeDeps.esbuildOptions.plugins.push({
166
+ name: "dashboard-component-overrides",
167
+ setup(build) {
168
+ // Mark override imports as external — this keeps override files as
169
+ // separate ES modules that Vite processes individually, enabling
170
+ // React Fast Refresh HMR instead of requiring a full page reload.
171
+ build.onResolve({ filter: /^__mantajs_override__:/ }, (args) => ({
172
+ path: args.path,
173
+ external: true,
174
+ }))
175
+
176
+ // Redirect dist entry source so esbuild processes individual files
177
+ build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
178
+ if (overrides.size === 0) return undefined
179
+ const normalized = args.path.replace(/\\/g, "/")
180
+ if (!normalized.includes("/dashboard/dist/")) return undefined
181
+
182
+ const srcEntry = normalized.replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx")
183
+ let contents: string
184
+ try {
185
+ contents = fs.readFileSync(srcEntry, "utf-8")
186
+ } catch {
187
+ return undefined
188
+ }
189
+
190
+ if (process.env.NODE_ENV === "development") {
191
+ console.log(`[custom-dashboard] Redirecting entry → ${srcEntry}`)
192
+ }
193
+ return { contents, loader: "tsx", resolveDir: path.dirname(srcEntry) }
194
+ })
195
+
196
+ // For overridden components, emit a re-export from /@fs/ instead of
197
+ // inlining the file contents. The override becomes a separate Vite
198
+ // module with full HMR support via React Fast Refresh.
199
+ build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
200
+ if (overrides.size === 0) return undefined
201
+ const normalized = args.path.replace(/\\/g, "/")
202
+ if (!normalized.includes("/dashboard/src/")) return undefined
203
+
204
+ const fileName = path.basename(args.path)
205
+ if (fileName.startsWith("index.")) return undefined
206
+
207
+ const componentName = getComponentName(args.path)
208
+ if (componentName && overrides.has(componentName)) {
209
+ const overridePath = overrides.get(componentName)!
210
+ const normalizedPath = overridePath.replace(/\\/g, "/")
144
211
 
145
212
  if (process.env.NODE_ENV === "development") {
146
- console.log(
147
- `[custom-dashboard] Redirecting entry: ${args.path} → ${srcEntry}`
148
- )
213
+ console.log(`[custom-dashboard] Override: ${componentName} → ${overridePath}`)
149
214
  }
150
215
  return {
151
- contents,
216
+ contents: `export * from "${OVERRIDE_PREFIX}${normalizedPath}"`,
152
217
  loader: "tsx",
153
- resolveDir: path.dirname(srcEntry),
218
+ resolveDir: path.dirname(args.path),
219
+ }
220
+ }
221
+ return undefined
222
+ })
223
+ },
224
+ })
225
+
226
+ // Include override state in esbuild define — this changes Vite's dep
227
+ // optimization hash (?v=xxx), forcing the browser to fetch fresh chunks
228
+ // whenever overrides are added or removed (prevents stale cache 404s).
229
+ config.optimizeDeps.esbuildOptions.define = {
230
+ ...config.optimizeDeps.esbuildOptions.define,
231
+ '__MANTAJS_OVERRIDES__': JSON.stringify(
232
+ [...overrides.keys()].sort().join(',')
233
+ ),
234
+ }
235
+ config.optimizeDeps.force = true
236
+ },
237
+
238
+ configureServer(server: ViteDevServer) {
239
+ // Always update server ref (called again after each server.restart())
240
+ currentServer = server
241
+
242
+ if (!fs.existsSync(componentsDir)) return
243
+
244
+ // Create ONE independent watcher that survives server.restart().
245
+ // Uses Node's fs.watch (FSEvents on macOS) — lightweight, no polling.
246
+ if (!watcherCreated) {
247
+ watcherCreated = true
248
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
249
+
250
+ fs.watch(componentsDir, { recursive: true }, (_event, filename) => {
251
+ if (!filename) return
252
+ const ext = path.extname(filename)
253
+ if (!COMPONENT_EXT_SET.has(ext)) return
254
+
255
+ const name = path.basename(filename).replace(/\.(tsx?|jsx?|mts|mjs)$/, "")
256
+ if (!name || name === "index") return
257
+
258
+ // Only act if this file name matches a dashboard component
259
+ if (!dashboardComponents.has(name)) return
260
+
261
+ const fullPath = path.resolve(componentsDir, filename)
262
+ const fileExists = fs.existsSync(fullPath)
263
+ const wasKnown = knownOverrideFiles.has(fullPath)
264
+
265
+ if (fileExists && wasKnown) {
266
+ // MODIFICATION — send HMR update ourselves. After server.restart(),
267
+ // Vite's internal chokidar may not fire for override files, so we
268
+ // handle it entirely from our independent fs.watch.
269
+ const mods = currentServer?.moduleGraph.getModulesByFile(fullPath)
270
+ if (mods && mods.size > 0) {
271
+ for (const mod of mods) {
272
+ currentServer!.moduleGraph.invalidateModule(mod)
154
273
  }
155
- })
156
-
157
- // 2. Intercept individual source files to swap with overrides.
158
- build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
159
- const normalized = args.path.replace(/\\/g, "/")
160
- if (!normalized.includes("/dashboard/src/")) return undefined
161
-
162
- // Skip index/barrel files to preserve re-exports
163
- const fileName = path.basename(args.path)
164
- if (fileName.startsWith("index.")) return undefined
165
-
166
- const componentName = getComponentName(args.path)
167
- if (componentName && overrides.has(componentName)) {
168
- const overridePath = overrides.get(componentName)!
169
- const ext = path.extname(overridePath).slice(1)
170
- const loader = VALID_LOADERS[ext] || "tsx"
171
-
172
- let contents: string
173
- try {
174
- contents = fs.readFileSync(overridePath, "utf-8")
175
- } catch {
176
- return undefined
177
- }
178
-
179
- if (process.env.NODE_ENV === "development") {
180
- console.log(
181
- `[custom-dashboard] Override: ${componentName} ${overridePath}`
182
- )
183
- }
184
- return {
185
- contents,
186
- loader: loader as any,
187
- resolveDir: path.dirname(overridePath),
188
- }
274
+ currentServer!.ws.send({
275
+ type: "update",
276
+ updates: [...mods].map((mod) => ({
277
+ type: "js-update" as const,
278
+ path: mod.url,
279
+ acceptedPath: mod.url,
280
+ timestamp: Date.now(),
281
+ explicitImportRequired: false,
282
+ })),
283
+ })
284
+ console.log(`[custom-dashboard] Override "${name}" modified → HMR`)
285
+ } else {
286
+ console.log(`[custom-dashboard] Override "${name}" not in graph → force-reload`)
287
+ currentServer?.ws.send({ type: "custom", event: "mantajs:force-reload" })
288
+ }
289
+ return
290
+ }
291
+
292
+ // CREATION or DELETION — the esbuild pre-bundle must be rebuilt
293
+ // because the chunk structure changes (new external ref or removed).
294
+ if (debounceTimer) clearTimeout(debounceTimer)
295
+ debounceTimer = setTimeout(async () => {
296
+ // Re-scan overrides from disk
297
+ overridesByName.clear()
298
+ knownOverrideFiles.clear()
299
+ for (const fp of collectComponentFiles(componentsDir).sort()) {
300
+ const n = path.basename(fp).replace(/\.(tsx?|jsx?|mts|mjs)$/, "")
301
+ if (n && n !== "index") {
302
+ overridesByName.set(n, fp)
303
+ knownOverrideFiles.add(fp)
189
304
  }
190
- return undefined
191
- })
192
- },
305
+ }
306
+
307
+ const action = fileExists ? "created" : "deleted"
308
+ console.log(`[custom-dashboard] Override "${name}" ${action} → restarting...`)
309
+ console.log(`[custom-dashboard] overrides:`, [...overridesByName.keys()])
310
+
311
+ // Vite preserves the WebSocket connection across restart() — the
312
+ // browser never disconnects. Await restart, then tell the client
313
+ // to do a cache-busting reload (location.reload() reuses cached
314
+ // modules; our custom event navigates to a timestamped URL instead).
315
+ try {
316
+ if (!currentServer) {
317
+ console.warn(`[custom-dashboard] No server available for restart`)
318
+ return
319
+ }
320
+ await currentServer.restart()
321
+ currentServer.ws.send({
322
+ type: "custom",
323
+ event: "mantajs:force-reload",
324
+ })
325
+ console.log(`[custom-dashboard] Force-reload sent to browser`)
326
+ } catch (e) {
327
+ console.error(`[custom-dashboard] Restart failed:`, e)
328
+ }
329
+ }, 300)
193
330
  })
331
+ }
332
+ },
194
333
 
195
- // Force re-optimisation so overrides are always applied
196
- config.optimizeDeps.force = true
334
+ handleHotUpdate({ file }) {
335
+ // Suppress Vite's default HMR for override files — our fs.watch
336
+ // handles modifications (HMR) and deletions (restart) instead.
337
+ if (knownOverrideFiles.has(file)) {
338
+ return []
197
339
  }
198
340
  },
199
341
 
342
+ transformIndexHtml(html) {
343
+ // Inject a client-side script that listens for our force-reload event.
344
+ // Unlike location.reload(), this navigates to a cache-busting URL so
345
+ // the browser re-fetches all modules (including pre-bundled chunks).
346
+ return html.replace(
347
+ "</head>",
348
+ `<script type="module">
349
+ if (import.meta.hot) {
350
+ import.meta.hot.on("mantajs:force-reload", () => {
351
+ const url = new URL(location.href);
352
+ url.searchParams.set("_r", Date.now().toString());
353
+ location.replace(url.href);
354
+ });
355
+ }
356
+ </script>
357
+ </head>`
358
+ )
359
+ },
360
+
200
361
  resolveId(source) {
201
362
  if (source === MENU_VIRTUAL_ID) return MENU_RESOLVED_ID
363
+ // Resolve override imports to the actual file path — Vite then serves
364
+ // the file through its transform pipeline (including React Fast Refresh).
365
+ if (source.startsWith(OVERRIDE_PREFIX)) {
366
+ return source.slice(OVERRIDE_PREFIX.length)
367
+ }
202
368
  return null
203
369
  },
204
370