@mantajs/dashboard 0.1.16 → 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 = [];
@@ -101,38 +94,33 @@ function findDashboardSrc() {
101
94
  function customDashboardPlugin() {
102
95
  const componentsDir = import_path.default.resolve(process.cwd(), "src/admin/components");
103
96
  const overridesByName = /* @__PURE__ */ new Map();
104
- const appliedOverrides = /* @__PURE__ */ new Set();
105
- const knownDashboardComponents = /* @__PURE__ */ new Set();
97
+ const dashboardComponents = /* @__PURE__ */ new Set();
106
98
  const dashboardSrc = findDashboardSrc();
107
99
  if (dashboardSrc) {
108
100
  for (const f of collectComponentFiles(dashboardSrc)) {
109
101
  const cName = getComponentName(f);
110
- if (cName) knownDashboardComponents.add(cName);
102
+ if (cName) dashboardComponents.add(cName);
111
103
  }
112
104
  }
113
105
  if (import_fs.default.existsSync(componentsDir)) {
114
- const collectedFiles = collectComponentFiles(componentsDir).sort();
115
- for (const fullPath of collectedFiles) {
116
- const fileName = import_path.default.basename(fullPath);
117
- 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)$/, "");
118
108
  if (name && name !== "index") {
119
- if (overridesByName.has(name) && process.env.NODE_ENV === "development") {
120
- console.warn(
121
- `[custom-dashboard] Duplicate override "${name}": ${overridesByName.get(name)} will be replaced by ${fullPath}`
122
- );
123
- }
124
109
  overridesByName.set(name, fullPath);
125
110
  }
126
111
  }
127
112
  }
128
113
  const hasOverrides = overridesByName.size > 0;
114
+ let currentServer = null;
115
+ let watcherCreated = false;
116
+ const knownOverrideFiles = new Set(overridesByName.values());
129
117
  if (process.env.NODE_ENV === "development") {
130
118
  if (hasOverrides) {
131
119
  console.log("[custom-dashboard] overrides:", [...overridesByName.keys()]);
132
120
  }
133
- if (knownDashboardComponents.size > 0) {
121
+ if (dashboardComponents.size > 0) {
134
122
  console.log(
135
- `[custom-dashboard] Scanned ${knownDashboardComponents.size} dashboard components for override matching`
123
+ `[custom-dashboard] Scanned ${dashboardComponents.size} dashboard components`
136
124
  );
137
125
  }
138
126
  }
@@ -149,6 +137,10 @@ function customDashboardPlugin() {
149
137
  config.optimizeDeps.esbuildOptions.plugins.push({
150
138
  name: "dashboard-component-overrides",
151
139
  setup(build) {
140
+ build.onResolve({ filter: /^__mantajs_override__:/ }, (args) => ({
141
+ path: args.path,
142
+ external: true
143
+ }));
152
144
  build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
153
145
  if (overrides.size === 0) return void 0;
154
146
  const normalized = args.path.replace(/\\/g, "/");
@@ -161,15 +153,9 @@ function customDashboardPlugin() {
161
153
  return void 0;
162
154
  }
163
155
  if (process.env.NODE_ENV === "development") {
164
- console.log(
165
- `[custom-dashboard] Redirecting entry: ${args.path} \u2192 ${srcEntry}`
166
- );
156
+ console.log(`[custom-dashboard] Redirecting entry \u2192 ${srcEntry}`);
167
157
  }
168
- return {
169
- contents,
170
- loader: "tsx",
171
- resolveDir: import_path.default.dirname(srcEntry)
172
- };
158
+ return { contents, loader: "tsx", resolveDir: import_path.default.dirname(srcEntry) };
173
159
  });
174
160
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
175
161
  if (overrides.size === 0) return void 0;
@@ -180,92 +166,124 @@ function customDashboardPlugin() {
180
166
  const componentName = getComponentName(args.path);
181
167
  if (componentName && overrides.has(componentName)) {
182
168
  const overridePath = overrides.get(componentName);
183
- const ext = import_path.default.extname(overridePath).slice(1);
184
- const loader = VALID_LOADERS[ext] || "tsx";
185
- let contents;
186
- try {
187
- contents = import_fs.default.readFileSync(overridePath, "utf-8");
188
- } catch {
189
- return void 0;
190
- }
191
- appliedOverrides.add(componentName);
169
+ const normalizedPath = overridePath.replace(/\\/g, "/");
192
170
  if (process.env.NODE_ENV === "development") {
193
- console.log(
194
- `[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`
195
- );
171
+ console.log(`[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`);
196
172
  }
197
173
  return {
198
- contents,
199
- loader,
200
- resolveDir: import_path.default.dirname(overridePath)
174
+ contents: `export * from "${OVERRIDE_PREFIX}${normalizedPath}"`,
175
+ loader: "tsx",
176
+ resolveDir: import_path.default.dirname(args.path)
201
177
  };
202
178
  }
203
179
  return void 0;
204
180
  });
205
181
  }
206
182
  });
183
+ config.optimizeDeps.esbuildOptions.define = {
184
+ ...config.optimizeDeps.esbuildOptions.define,
185
+ "__MANTAJS_OVERRIDES__": JSON.stringify(
186
+ [...overrides.keys()].sort().join(",")
187
+ )
188
+ };
207
189
  config.optimizeDeps.force = true;
208
190
  },
209
191
  configureServer(server) {
192
+ currentServer = server;
210
193
  if (!import_fs.default.existsSync(componentsDir)) return;
211
- let debounceTimer = null;
212
- const extractName = (file) => {
213
- const normalized = file.replace(/\\/g, "/");
214
- if (!normalized.startsWith(componentsDir.replace(/\\/g, "/"))) return null;
215
- const ext = import_path.default.extname(file);
216
- if (!COMPONENT_EXT_SET.has(ext)) return null;
217
- const fileName = import_path.default.basename(file);
218
- const name = fileName.replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
219
- if (!name || name === "index") return null;
220
- return name;
221
- };
222
- const triggerRestart = (name, reason) => {
223
- if (debounceTimer) clearTimeout(debounceTimer);
224
- debounceTimer = setTimeout(() => {
225
- const newOverrides = /* @__PURE__ */ new Map();
226
- const collectedFiles = collectComponentFiles(componentsDir).sort();
227
- for (const fullPath of collectedFiles) {
228
- const fn = import_path.default.basename(fullPath);
229
- const n = fn.replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
230
- if (n && n !== "index") {
231
- newOverrides.set(n, fullPath);
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);
212
+ }
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" });
232
227
  }
228
+ return;
233
229
  }
234
- overridesByName.clear();
235
- for (const [k, v] of newOverrides) overridesByName.set(k, v);
236
- console.log(
237
- `[custom-dashboard] Override "${name}" ${reason} \u2192 restarting Vite...`
238
- );
239
- console.log(
240
- `[custom-dashboard] overrides:`,
241
- [...overridesByName.keys()]
242
- );
243
- server.restart();
244
- }, 300);
245
- };
246
- server.watcher.add(componentsDir);
247
- server.watcher.on("add", (file) => {
248
- const name = extractName(file);
249
- if (name && knownDashboardComponents.has(name)) {
250
- triggerRestart(name, "created");
251
- }
252
- });
253
- server.watcher.on("change", (file) => {
254
- const name = extractName(file);
255
- if (name && appliedOverrides.has(name)) {
256
- triggerRestart(name, "modified");
257
- }
258
- });
259
- server.watcher.on("unlink", (file) => {
260
- const name = extractName(file);
261
- if (name && appliedOverrides.has(name)) {
262
- appliedOverrides.delete(name);
263
- triggerRestart(name, "deleted");
264
- }
265
- });
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);
259
+ });
260
+ }
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
+ );
266
281
  },
267
282
  resolveId(source) {
268
283
  if (source === MENU_VIRTUAL_ID) return MENU_RESOLVED_ID;
284
+ if (source.startsWith(OVERRIDE_PREFIX)) {
285
+ return source.slice(OVERRIDE_PREFIX.length);
286
+ }
269
287
  return null;
270
288
  },
271
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 = [];
@@ -66,38 +59,33 @@ function findDashboardSrc() {
66
59
  function customDashboardPlugin() {
67
60
  const componentsDir = path.resolve(process.cwd(), "src/admin/components");
68
61
  const overridesByName = /* @__PURE__ */ new Map();
69
- const appliedOverrides = /* @__PURE__ */ new Set();
70
- const knownDashboardComponents = /* @__PURE__ */ new Set();
62
+ const dashboardComponents = /* @__PURE__ */ new Set();
71
63
  const dashboardSrc = findDashboardSrc();
72
64
  if (dashboardSrc) {
73
65
  for (const f of collectComponentFiles(dashboardSrc)) {
74
66
  const cName = getComponentName(f);
75
- if (cName) knownDashboardComponents.add(cName);
67
+ if (cName) dashboardComponents.add(cName);
76
68
  }
77
69
  }
78
70
  if (fs.existsSync(componentsDir)) {
79
- const collectedFiles = collectComponentFiles(componentsDir).sort();
80
- for (const fullPath of collectedFiles) {
81
- const fileName = path.basename(fullPath);
82
- 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)$/, "");
83
73
  if (name && name !== "index") {
84
- if (overridesByName.has(name) && process.env.NODE_ENV === "development") {
85
- console.warn(
86
- `[custom-dashboard] Duplicate override "${name}": ${overridesByName.get(name)} will be replaced by ${fullPath}`
87
- );
88
- }
89
74
  overridesByName.set(name, fullPath);
90
75
  }
91
76
  }
92
77
  }
93
78
  const hasOverrides = overridesByName.size > 0;
79
+ let currentServer = null;
80
+ let watcherCreated = false;
81
+ const knownOverrideFiles = new Set(overridesByName.values());
94
82
  if (process.env.NODE_ENV === "development") {
95
83
  if (hasOverrides) {
96
84
  console.log("[custom-dashboard] overrides:", [...overridesByName.keys()]);
97
85
  }
98
- if (knownDashboardComponents.size > 0) {
86
+ if (dashboardComponents.size > 0) {
99
87
  console.log(
100
- `[custom-dashboard] Scanned ${knownDashboardComponents.size} dashboard components for override matching`
88
+ `[custom-dashboard] Scanned ${dashboardComponents.size} dashboard components`
101
89
  );
102
90
  }
103
91
  }
@@ -114,6 +102,10 @@ function customDashboardPlugin() {
114
102
  config.optimizeDeps.esbuildOptions.plugins.push({
115
103
  name: "dashboard-component-overrides",
116
104
  setup(build) {
105
+ build.onResolve({ filter: /^__mantajs_override__:/ }, (args) => ({
106
+ path: args.path,
107
+ external: true
108
+ }));
117
109
  build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
118
110
  if (overrides.size === 0) return void 0;
119
111
  const normalized = args.path.replace(/\\/g, "/");
@@ -126,15 +118,9 @@ function customDashboardPlugin() {
126
118
  return void 0;
127
119
  }
128
120
  if (process.env.NODE_ENV === "development") {
129
- console.log(
130
- `[custom-dashboard] Redirecting entry: ${args.path} \u2192 ${srcEntry}`
131
- );
121
+ console.log(`[custom-dashboard] Redirecting entry \u2192 ${srcEntry}`);
132
122
  }
133
- return {
134
- contents,
135
- loader: "tsx",
136
- resolveDir: path.dirname(srcEntry)
137
- };
123
+ return { contents, loader: "tsx", resolveDir: path.dirname(srcEntry) };
138
124
  });
139
125
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
140
126
  if (overrides.size === 0) return void 0;
@@ -145,92 +131,124 @@ function customDashboardPlugin() {
145
131
  const componentName = getComponentName(args.path);
146
132
  if (componentName && overrides.has(componentName)) {
147
133
  const overridePath = overrides.get(componentName);
148
- const ext = path.extname(overridePath).slice(1);
149
- const loader = VALID_LOADERS[ext] || "tsx";
150
- let contents;
151
- try {
152
- contents = fs.readFileSync(overridePath, "utf-8");
153
- } catch {
154
- return void 0;
155
- }
156
- appliedOverrides.add(componentName);
134
+ const normalizedPath = overridePath.replace(/\\/g, "/");
157
135
  if (process.env.NODE_ENV === "development") {
158
- console.log(
159
- `[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`
160
- );
136
+ console.log(`[custom-dashboard] Override: ${componentName} \u2192 ${overridePath}`);
161
137
  }
162
138
  return {
163
- contents,
164
- loader,
165
- resolveDir: path.dirname(overridePath)
139
+ contents: `export * from "${OVERRIDE_PREFIX}${normalizedPath}"`,
140
+ loader: "tsx",
141
+ resolveDir: path.dirname(args.path)
166
142
  };
167
143
  }
168
144
  return void 0;
169
145
  });
170
146
  }
171
147
  });
148
+ config.optimizeDeps.esbuildOptions.define = {
149
+ ...config.optimizeDeps.esbuildOptions.define,
150
+ "__MANTAJS_OVERRIDES__": JSON.stringify(
151
+ [...overrides.keys()].sort().join(",")
152
+ )
153
+ };
172
154
  config.optimizeDeps.force = true;
173
155
  },
174
156
  configureServer(server) {
157
+ currentServer = server;
175
158
  if (!fs.existsSync(componentsDir)) return;
176
- let debounceTimer = null;
177
- const extractName = (file) => {
178
- const normalized = file.replace(/\\/g, "/");
179
- if (!normalized.startsWith(componentsDir.replace(/\\/g, "/"))) return null;
180
- const ext = path.extname(file);
181
- if (!COMPONENT_EXT_SET.has(ext)) return null;
182
- const fileName = path.basename(file);
183
- const name = fileName.replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
184
- if (!name || name === "index") return null;
185
- return name;
186
- };
187
- const triggerRestart = (name, reason) => {
188
- if (debounceTimer) clearTimeout(debounceTimer);
189
- debounceTimer = setTimeout(() => {
190
- const newOverrides = /* @__PURE__ */ new Map();
191
- const collectedFiles = collectComponentFiles(componentsDir).sort();
192
- for (const fullPath of collectedFiles) {
193
- const fn = path.basename(fullPath);
194
- const n = fn.replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
195
- if (n && n !== "index") {
196
- newOverrides.set(n, fullPath);
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);
177
+ }
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" });
197
192
  }
193
+ return;
198
194
  }
199
- overridesByName.clear();
200
- for (const [k, v] of newOverrides) overridesByName.set(k, v);
201
- console.log(
202
- `[custom-dashboard] Override "${name}" ${reason} \u2192 restarting Vite...`
203
- );
204
- console.log(
205
- `[custom-dashboard] overrides:`,
206
- [...overridesByName.keys()]
207
- );
208
- server.restart();
209
- }, 300);
210
- };
211
- server.watcher.add(componentsDir);
212
- server.watcher.on("add", (file) => {
213
- const name = extractName(file);
214
- if (name && knownDashboardComponents.has(name)) {
215
- triggerRestart(name, "created");
216
- }
217
- });
218
- server.watcher.on("change", (file) => {
219
- const name = extractName(file);
220
- if (name && appliedOverrides.has(name)) {
221
- triggerRestart(name, "modified");
222
- }
223
- });
224
- server.watcher.on("unlink", (file) => {
225
- const name = extractName(file);
226
- if (name && appliedOverrides.has(name)) {
227
- appliedOverrides.delete(name);
228
- triggerRestart(name, "deleted");
229
- }
230
- });
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);
224
+ });
225
+ }
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
+ );
231
246
  },
232
247
  resolveId(source) {
233
248
  if (source === MENU_VIRTUAL_ID) return MENU_RESOLVED_ID;
249
+ if (source.startsWith(OVERRIDE_PREFIX)) {
250
+ return source.slice(OVERRIDE_PREFIX.length);
251
+ }
234
252
  return null;
235
253
  },
236
254
  load(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantajs/dashboard",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "B2B Admin Dashboard for Medusa - Fork of @medusajs/dashboard",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -5,18 +5,13 @@ import fs from "fs"
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
@@ -90,41 +85,44 @@ function findDashboardSrc(): string | null {
90
85
  *
91
86
  * Handles:
92
87
  * 1. Menu config virtual module (virtual:dashboard/menu-config)
93
- * 2. Component overrides — any file in src/admin/components/ overrides the
94
- * 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.
95
105
  */
96
106
  export function customDashboardPlugin(): Plugin {
97
107
  const componentsDir = path.resolve(process.cwd(), "src/admin/components")
98
108
  const overridesByName = new Map<string, string>()
99
109
 
100
- // Track which overrides were actually matched during esbuild pre-bundling.
101
- // Only these are real overrides the rest are regular project components
102
- // that happen to live in the same directory.
103
- const appliedOverrides = new Set<string>()
104
-
105
- // Scan dashboard source at startup to know all possible override targets.
106
- // This lets the file watcher decide if a newly created file could be an
107
- // override, without maintaining any hardcoded list.
108
- const knownDashboardComponents = new Set<string>()
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>()
109
113
  const dashboardSrc = findDashboardSrc()
110
114
  if (dashboardSrc) {
111
115
  for (const f of collectComponentFiles(dashboardSrc)) {
112
116
  const cName = getComponentName(f)
113
- if (cName) knownDashboardComponents.add(cName)
117
+ if (cName) dashboardComponents.add(cName)
114
118
  }
115
119
  }
116
120
 
121
+ // Collect initial overrides from disk
117
122
  if (fs.existsSync(componentsDir)) {
118
- const collectedFiles = collectComponentFiles(componentsDir).sort()
119
- for (const fullPath of collectedFiles) {
120
- const fileName = path.basename(fullPath)
121
- 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)$/, "")
122
125
  if (name && name !== "index") {
123
- if (overridesByName.has(name) && process.env.NODE_ENV === "development") {
124
- console.warn(
125
- `[custom-dashboard] Duplicate override "${name}": ${overridesByName.get(name)} will be replaced by ${fullPath}`
126
- )
127
- }
128
126
  overridesByName.set(name, fullPath)
129
127
  }
130
128
  }
@@ -132,13 +130,20 @@ export function customDashboardPlugin(): Plugin {
132
130
 
133
131
  const hasOverrides = overridesByName.size > 0
134
132
 
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
+
135
140
  if (process.env.NODE_ENV === "development") {
136
141
  if (hasOverrides) {
137
142
  console.log("[custom-dashboard] overrides:", [...overridesByName.keys()])
138
143
  }
139
- if (knownDashboardComponents.size > 0) {
144
+ if (dashboardComponents.size > 0) {
140
145
  console.log(
141
- `[custom-dashboard] Scanned ${knownDashboardComponents.size} dashboard components for override matching`
146
+ `[custom-dashboard] Scanned ${dashboardComponents.size} dashboard components`
142
147
  )
143
148
  }
144
149
  }
@@ -148,14 +153,10 @@ export function customDashboardPlugin(): Plugin {
148
153
  enforce: "pre",
149
154
 
150
155
  config(config) {
151
- // Always exclude the menu virtual module
152
156
  config.optimizeDeps = config.optimizeDeps || {}
153
157
  config.optimizeDeps.exclude = config.optimizeDeps.exclude || []
154
158
  config.optimizeDeps.exclude.push(MENU_VIRTUAL_ID)
155
159
 
156
- // Always set up the esbuild override plugin — even if there are no
157
- // overrides yet, the configureServer watcher may add some at runtime
158
- // and trigger a restart.
159
160
  config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {}
160
161
  config.optimizeDeps.esbuildOptions.plugins =
161
162
  config.optimizeDeps.esbuildOptions.plugins || []
@@ -164,18 +165,21 @@ export function customDashboardPlugin(): Plugin {
164
165
  config.optimizeDeps.esbuildOptions.plugins.push({
165
166
  name: "dashboard-component-overrides",
166
167
  setup(build) {
167
- // 1. Redirect the dist entry to source so esbuild processes
168
- // individual TSX files instead of one big pre-built bundle.
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
169
177
  build.onLoad({ filter: /app\.(mjs|js)$/ }, (args) => {
170
- // Only activate when there are overrides
171
178
  if (overrides.size === 0) return undefined
172
-
173
179
  const normalized = args.path.replace(/\\/g, "/")
174
180
  if (!normalized.includes("/dashboard/dist/")) return undefined
175
181
 
176
- const srcEntry = normalized
177
- .replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx")
178
-
182
+ const srcEntry = normalized.replace(/\/dist\/app\.(mjs|js)$/, "/src/app.tsx")
179
183
  let contents: string
180
184
  try {
181
185
  contents = fs.readFileSync(srcEntry, "utf-8")
@@ -184,53 +188,34 @@ export function customDashboardPlugin(): Plugin {
184
188
  }
185
189
 
186
190
  if (process.env.NODE_ENV === "development") {
187
- console.log(
188
- `[custom-dashboard] Redirecting entry: ${args.path} → ${srcEntry}`
189
- )
190
- }
191
- return {
192
- contents,
193
- loader: "tsx",
194
- resolveDir: path.dirname(srcEntry),
191
+ console.log(`[custom-dashboard] Redirecting entry → ${srcEntry}`)
195
192
  }
193
+ return { contents, loader: "tsx", resolveDir: path.dirname(srcEntry) }
196
194
  })
197
195
 
198
- // 2. Intercept individual source files to swap with overrides.
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
199
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, (args) => {
200
200
  if (overrides.size === 0) return undefined
201
-
202
201
  const normalized = args.path.replace(/\\/g, "/")
203
202
  if (!normalized.includes("/dashboard/src/")) return undefined
204
203
 
205
- // Skip index/barrel files to preserve re-exports
206
204
  const fileName = path.basename(args.path)
207
205
  if (fileName.startsWith("index.")) return undefined
208
206
 
209
207
  const componentName = getComponentName(args.path)
210
208
  if (componentName && overrides.has(componentName)) {
211
209
  const overridePath = overrides.get(componentName)!
212
- const ext = path.extname(overridePath).slice(1)
213
- const loader = VALID_LOADERS[ext] || "tsx"
214
-
215
- let contents: string
216
- try {
217
- contents = fs.readFileSync(overridePath, "utf-8")
218
- } catch {
219
- return undefined
220
- }
221
-
222
- // Track this as a real applied override
223
- appliedOverrides.add(componentName)
210
+ const normalizedPath = overridePath.replace(/\\/g, "/")
224
211
 
225
212
  if (process.env.NODE_ENV === "development") {
226
- console.log(
227
- `[custom-dashboard] Override: ${componentName} → ${overridePath}`
228
- )
213
+ console.log(`[custom-dashboard] Override: ${componentName} → ${overridePath}`)
229
214
  }
230
215
  return {
231
- contents,
232
- loader: loader as any,
233
- resolveDir: path.dirname(overridePath),
216
+ contents: `export * from "${OVERRIDE_PREFIX}${normalizedPath}"`,
217
+ loader: "tsx",
218
+ resolveDir: path.dirname(args.path),
234
219
  }
235
220
  }
236
221
  return undefined
@@ -238,88 +223,148 @@ export function customDashboardPlugin(): Plugin {
238
223
  },
239
224
  })
240
225
 
241
- // Force re-optimisation so overrides are always applied
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
+ }
242
235
  config.optimizeDeps.force = true
243
236
  },
244
237
 
245
238
  configureServer(server: ViteDevServer) {
246
- if (!fs.existsSync(componentsDir)) return
239
+ // Always update server ref (called again after each server.restart())
240
+ currentServer = server
247
241
 
248
- let debounceTimer: ReturnType<typeof setTimeout> | null = null
249
-
250
- /** Extract override-candidate name from a watched file, or null */
251
- const extractName = (file: string): string | null => {
252
- const normalized = file.replace(/\\/g, "/")
253
- if (!normalized.startsWith(componentsDir.replace(/\\/g, "/"))) return null
254
- const ext = path.extname(file)
255
- if (!COMPONENT_EXT_SET.has(ext)) return null
256
- const fileName = path.basename(file)
257
- const name = fileName.replace(/\.(tsx?|jsx?|mts|mjs)$/, "")
258
- if (!name || name === "index") return null
259
- return name
260
- }
242
+ if (!fs.existsSync(componentsDir)) return
261
243
 
262
- /** Re-collect overrides from disk and restart the dev server */
263
- const triggerRestart = (name: string, reason: string) => {
264
- if (debounceTimer) clearTimeout(debounceTimer)
265
- debounceTimer = setTimeout(() => {
266
- const newOverrides = new Map<string, string>()
267
- const collectedFiles = collectComponentFiles(componentsDir).sort()
268
- for (const fullPath of collectedFiles) {
269
- const fn = path.basename(fullPath)
270
- const n = fn.replace(/\.(tsx?|jsx?|mts|mjs)$/, "")
271
- if (n && n !== "index") {
272
- newOverrides.set(n, fullPath)
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)
273
+ }
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" })
273
288
  }
289
+ return
274
290
  }
275
291
 
276
- overridesByName.clear()
277
- for (const [k, v] of newOverrides) overridesByName.set(k, v)
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)
304
+ }
305
+ }
278
306
 
279
- console.log(
280
- `[custom-dashboard] Override "${name}" ${reason} → restarting Vite...`
281
- )
282
- console.log(
283
- `[custom-dashboard] overrides:`,
284
- [...overridesByName.keys()]
285
- )
307
+ const action = fileExists ? "created" : "deleted"
308
+ console.log(`[custom-dashboard] Override "${name}" ${action} → restarting...`)
309
+ console.log(`[custom-dashboard] overrides:`, [...overridesByName.keys()])
286
310
 
287
- server.restart()
288
- }, 300)
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)
330
+ })
289
331
  }
332
+ },
290
333
 
291
- server.watcher.add(componentsDir)
292
-
293
- // ADD: new file restart only if its name matches a known dashboard
294
- // component (i.e. it could be a new override)
295
- server.watcher.on("add", (file: string) => {
296
- const name = extractName(file)
297
- if (name && knownDashboardComponents.has(name)) {
298
- triggerRestart(name, "created")
299
- }
300
- })
301
-
302
- // CHANGE: file modified — restart only if it's an active override
303
- // (was matched by esbuild during pre-bundling)
304
- server.watcher.on("change", (file: string) => {
305
- const name = extractName(file)
306
- if (name && appliedOverrides.has(name)) {
307
- triggerRestart(name, "modified")
308
- }
309
- })
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 []
339
+ }
340
+ },
310
341
 
311
- // UNLINK: file deleted — restart only if it was an active override
312
- server.watcher.on("unlink", (file: string) => {
313
- const name = extractName(file)
314
- if (name && appliedOverrides.has(name)) {
315
- appliedOverrides.delete(name)
316
- triggerRestart(name, "deleted")
317
- }
318
- })
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
+ )
319
359
  },
320
360
 
321
361
  resolveId(source) {
322
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
+ }
323
368
  return null
324
369
  },
325
370