@uniflowed/vite 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -175,6 +175,51 @@ const CLIENT_COMMONJS_DEPENDENCIES = Object.freeze([
175
175
  */
176
176
  const STYLE_PREFIX = "uf-style:";
177
177
 
178
+ /**
179
+ * A reserved route file: `$page.js`, `$layout.jsx`, `$not-found.mdx`, …
180
+ *
181
+ * Built from `RESERVED` rather than written out. It used to be the literal
182
+ * `(page|layout|middleware|not-found)`, which is a fourth spelling of a grammar
183
+ * that already has three, and it was already missing `route` — so adding a
184
+ * route handler to a running dev server did not rebuild the table and the
185
+ * handler stayed invisible until a restart. A list that has to match another
186
+ * list has to be that list.
187
+ */
188
+ const RESERVED_FILE = new RegExp(
189
+ `/(${Object.values(RESERVED)
190
+ .map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
191
+ .join("|")})(\\.[a-z]+)?\\.(js|jsx|mdx)$`,
192
+ );
193
+
194
+ /**
195
+ * What the dev server sends the browser when the URL on screen has to be
196
+ * rendered again rather than reloaded: a server component changed, or the
197
+ * route table did. `virtual:uf/client` listens for it and refreshes the router
198
+ * on screen, which keeps every client component's state; see
199
+ * `refreshForHotUpdate` in `@uniflowed/router`. Any other module can listen
200
+ * too, through `import.meta.hot.on("uf:refresh", …)`.
201
+ */
202
+ const HOT_REFRESH = Object.freeze({ type: "custom", event: "uf:refresh", data: {} });
203
+
204
+ /** Whether two action tables name the same actions, module by module. */
205
+ function sameActions(previous, next) {
206
+ if (previous.size !== next.size) return false;
207
+ for (const [file, rows] of previous) {
208
+ const other = next.get(file);
209
+ if (other == null || other.length !== rows.length) return false;
210
+ for (let at = 0; at < rows.length; at += 1) {
211
+ if (rows[at].id !== other[at].id || rows[at].export !== other[at].export) return false;
212
+ }
213
+ }
214
+ return true;
215
+ }
216
+
217
+ /** Send `message` to the browser, through the client environment where there is one. */
218
+ function sendToClient(devServer, message) {
219
+ const hot = devServer.environments?.client?.hot ?? devServer.ws;
220
+ hot.send(message);
221
+ }
222
+
178
223
  /** The URL a NUL-prefixed module is served at in development. */
179
224
  export function devUrlFor(id) {
180
225
  return `/@id/__x00__${id}`;
@@ -390,6 +435,8 @@ function flowPlugin({
390
435
  {
391
436
  shipsPage: (route) => kept.has(route),
392
437
  relativeTo: root,
438
+ // Loaders a hot update can reach; see `routesModuleSource`.
439
+ hot: server != null && !isProduction,
393
440
  },
394
441
  );
395
442
  };
@@ -615,6 +662,7 @@ function flowPlugin({
615
662
  return flightClientSource(entryPath, {
616
663
  instrumentation: instrumentationFile(appRoot, true),
617
664
  strictMode: strictMode && !isProduction,
665
+ hot: server != null && !isProduction,
618
666
  navigation,
619
667
  staleTime,
620
668
  routing,
@@ -624,6 +672,7 @@ function flowPlugin({
624
672
  return clientModuleSource(entryPath, {
625
673
  instrumentation: instrumentationFile(appRoot, true),
626
674
  strictMode: strictMode && !isProduction,
675
+ hot: server != null && !isProduction,
627
676
  navigation,
628
677
  staleTime,
629
678
  mount,
@@ -789,7 +838,10 @@ function flowPlugin({
789
838
  const moduleSideEffects = styled ? true : undefined;
790
839
  if (!refresh) return { code: output, map, moduleSideEffects };
791
840
  const relative = path.relative(root, cleanId(id)).split(path.sep).join("/");
792
- return { ...addRefreshWrapper(output, map, relative), moduleSideEffects };
841
+ // A route module under the router root is refreshed as one: its loader
842
+ // and metadata go to the router rather than forcing a reload.
843
+ const route = cleanId(id).startsWith(appRoot) && RESERVED_FILE.test(cleanId(id));
844
+ return { ...addRefreshWrapper(output, map, relative, { route }), moduleSideEffects };
793
845
  },
794
846
 
795
847
  buildEnd() {
@@ -847,9 +899,13 @@ function flowPlugin({
847
899
  },
848
900
 
849
901
  // An edit to a server component changes what the rsc graph renders and no
850
- // module the browser holds, so Vite has nothing to tell the browser. It is
851
- // reloaded, which renders the edit; an edit to a client module — which the
852
- // rsc graph only holds references to — is left to Fast Refresh.
902
+ // module the browser holds, so Vite has nothing to tell the browser. It
903
+ // used to reload the document, which renders the edit and throws away every
904
+ // `useState` on the page. Now the browser is told `uf:refresh`, and the
905
+ // router on screen fetches the payload for its URL again — the same thing
906
+ // `router.refresh()` does — so the edit arrives as a React update and the
907
+ // client components under it keep their state. An edit to a client module,
908
+ // which the rsc graph only holds references to, is left to Fast Refresh.
853
909
  hotUpdate({ modules }) {
854
910
  if (flightState == null || server == null) return;
855
911
  if (this.environment?.name !== RSC_ENVIRONMENT) return;
@@ -857,7 +913,7 @@ function flowPlugin({
857
913
  const file = module.file ?? cleanId(module.id ?? "");
858
914
  return file !== "" && !flightState.clientModules.has(file);
859
915
  });
860
- if (serverSide) server.environments.client.hot.send({ type: "full-reload", path: "*" });
916
+ if (serverSide) server.environments.client.hot.send(HOT_REFRESH);
861
917
  },
862
918
 
863
919
  configureServer(devServer) {
@@ -920,18 +976,30 @@ function flowPlugin({
920
976
 
921
977
  // A reserved file appearing or disappearing changes the route table,
922
978
  // which lives in a virtual module the watcher knows nothing about.
923
- //
924
- // Built from `RESERVED` rather than written out. It used to be the
925
- // literal `(page|layout|middleware|not-found)`, which is a fourth
926
- // spelling of a grammar that already has three, and it was already
927
- // missing `route` — so adding a route handler to a running dev server
928
- // did not rebuild the table and the handler stayed invisible until a
929
- // restart. A list that has to match another list has to be that list.
930
- const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
931
- const stems = Object.values(RESERVED).map(escapeRegExp).join("|");
932
- const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
979
+ // The browser's copy of the route table, read before it is invalidated:
980
+ // an application rendered from its modules holds one, and
981
+ // `virtual:uf/client` accepts a new one in place. React Server
982
+ // Components keep theirs on the server.
983
+ const browserRouteTable = () =>
984
+ flightState == null ? devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes)) : null;
985
+ // Tell the browser its route table changed, without a reload either way.
986
+ // React Server Components render the table on the server, so the browser
987
+ // only has to ask for its URL again; an application rendered from its
988
+ // modules is sent the new table as a hot update. A browser that has not
989
+ // loaded the table yet has nothing to keep, and is reloaded.
990
+ const sendRouteTable = (clientTable) => {
991
+ if (flightState != null) {
992
+ sendToClient(devServer, HOT_REFRESH);
993
+ } else if (clientTable != null && typeof devServer.reloadModule === "function") {
994
+ devServer.reloadModule(clientTable).catch(() => {
995
+ sendToClient(devServer, { type: "full-reload", path: "*" });
996
+ });
997
+ } else {
998
+ sendToClient(devServer, { type: "full-reload", path: "*" });
999
+ }
1000
+ };
933
1001
  const onRouteFile = (file) => {
934
- if (!reserved.test(file) || !file.startsWith(appRoot)) return;
1002
+ if (!RESERVED_FILE.test(file) || !file.startsWith(appRoot)) return;
935
1003
  // The rsc graph's table too, where the pages, the route handlers and
936
1004
  // the middleware of an application React Server Components render
937
1005
  // live (ubugeeei-prod/uf#1487).
@@ -939,13 +1007,14 @@ function flowPlugin({
939
1007
  devServer.moduleGraph,
940
1008
  devServer.environments?.[RSC_ENVIRONMENT]?.moduleGraph,
941
1009
  ].filter((graph) => graph != null);
1010
+ const clientTable = browserRouteTable();
942
1011
  for (const graph of graphs) {
943
1012
  for (const id of [VIRTUAL.routes, VIRTUAL.server, VIRTUAL.client]) {
944
1013
  const module = graph.getModuleById(resolved(id));
945
1014
  if (module) graph.invalidateModule(module);
946
1015
  }
947
1016
  }
948
- devServer.ws.send({ type: "full-reload", path: "*" });
1017
+ sendRouteTable(clientTable);
949
1018
  };
950
1019
  devServer.watcher.on("add", onRouteFile);
951
1020
  devServer.watcher.on("unlink", onRouteFile);
@@ -976,6 +1045,7 @@ function flowPlugin({
976
1045
  devServer.moduleGraph,
977
1046
  devServer.environments?.[RSC_ENVIRONMENT]?.moduleGraph,
978
1047
  ].filter((graph) => graph != null);
1048
+ const clientTable = browserRouteTable();
979
1049
  for (const graph of graphs) {
980
1050
  invalidateActionModules(graph, previousActions);
981
1051
  invalidateActionModules(graph, nextActions);
@@ -984,7 +1054,18 @@ function flowPlugin({
984
1054
  const actions = graph.getModuleById(resolved(VIRTUAL.actions));
985
1055
  if (actions) graph.invalidateModule(actions);
986
1056
  }
987
- devServer.ws.send({ type: "full-reload", path: "*" });
1057
+ // A new or removed action changes the references the browser's
1058
+ // modules were compiled with, and those are not modules a render can
1059
+ // replace, so that is still a reload. Anything else the manifest says
1060
+ // — which routes ship a page, which modules are client components —
1061
+ // is the route table's business, and arrives the way a new route
1062
+ // file does. `uf dev` rewrites the manifest whenever a route file
1063
+ // comes or goes, so a reload here was a reload on every new page.
1064
+ if (sameActions(previousActions, nextActions)) {
1065
+ sendRouteTable(clientTable);
1066
+ } else {
1067
+ sendToClient(devServer, { type: "full-reload", path: "*" });
1068
+ }
988
1069
  };
989
1070
  devServer.watcher.on("add", onManifest);
990
1071
  devServer.watcher.on("change", onManifest);
@@ -718,10 +718,20 @@ export function flightClientSource(appEntry, options = {}) {
718
718
  // `app.rendering.staleTime`, in seconds, and nothing for the default `0`.
719
719
  const staleTime =
720
720
  options.staleTime > 0 ? `, staleTime: ${JSON.stringify(options.staleTime)}` : "";
721
- return `import { hydrateFlight } from "@uniflowed/router/rsc/client";
721
+ // In development, the dev server's `uf:refresh` — a server component or the
722
+ // route table changed — renders the URL on screen again instead of reloading
723
+ // it. See `hotUpdate` in `../index.js`.
724
+ const hot =
725
+ options.hot === true
726
+ ? `if (import.meta.hot) {
727
+ import.meta.hot.on("uf:refresh", () => refreshForHotUpdate());
728
+ }
729
+ `
730
+ : "";
731
+ return `import { hydrateFlight${options.hot === true ? ", refreshForHotUpdate" : ""} } from "@uniflowed/router/rsc/client";
722
732
  import App from ${JSON.stringify(appEntry)};
723
733
  ${clientInstrumentationSource(options.instrumentation)}hydrateFlight({ App${strictMode}${navigation}${staleTime}${routing} });
724
- `;
734
+ ${hot}`;
725
735
  }
726
736
 
727
737
  /**
@@ -599,7 +599,18 @@ const enqueueUpdate = debounce(async () => {
599
599
  performReactRefresh();
600
600
  }, 16);
601
601
 
602
- export function validateRefreshBoundaryAndEnqueueUpdate(id, prevExports, nextExports) {
602
+ // `dataExports` is uf's: a route module (`$page.js`, `$layout.js`, …) exports
603
+ // its `loader`, `metadata` and friends beside its component, and each of those
604
+ // is a new identity after every edit. Plain Fast Refresh calls that an
605
+ // incompatible export and reloads the page. The router re-reads them instead —
606
+ // the wrapper hands it the next exports — so here they only have to exist on
607
+ // both sides.
608
+ export function validateRefreshBoundaryAndEnqueueUpdate(
609
+ id,
610
+ prevExports,
611
+ nextExports,
612
+ dataExports = false,
613
+ ) {
603
614
  const ignoredExports = window.__getReactRefreshIgnoredExports?.({ id }) ?? [];
604
615
  if (predicateOnExport(ignoredExports, prevExports, (key) => key in nextExports) !== true) {
605
616
  return "Could not Fast Refresh (export removed)";
@@ -616,13 +627,14 @@ export function validateRefreshBoundaryAndEnqueueUpdate(id, prevExports, nextExp
616
627
  hasExports = true;
617
628
  if (isLikelyComponentType(value)) return true;
618
629
  if (isCompoundComponent(value)) return true;
630
+ if (dataExports) return true;
619
631
  return prevExports[key] === nextExports[key];
620
632
  },
621
633
  );
622
634
  if (hasExports && allExportsAreComponentsOrUnchanged === true) {
623
635
  enqueueUpdate();
624
636
  } else {
625
- return `Could not Fast Refresh ("${allExportsAreComponentsOrUnchanged}" export is incompatible). Learn more at __README_URL__#consistent-components-exports`;
637
+ return `Could not Fast Refresh ("${allExportsAreComponentsOrUnchanged}" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports`;
626
638
  }
627
639
  }
628
640
 
@@ -12,6 +12,20 @@
12
12
  // * every module that registered a component gets a header that points
13
13
  // `$RefreshReg$`/`$RefreshSig$` at this module and a footer that validates
14
14
  // the boundary and enqueues the refresh on `import.meta.hot.accept`.
15
+ //
16
+ // Two additions of uf's own. Every evaluation of a component module is
17
+ // published under its path in `window.__UF_LATEST_MODULES__`, so a Server
18
+ // Components payload that names the module resolves to the newest one — the
19
+ // one React has mounted — rather than to the first; see `latestModule` in
20
+ // `@uniflowed/router/internal/flight-browser.js`.
21
+ //
22
+ // And a route module (`$page.js`, `$layout.js`, …) is
23
+ // wrapped with `{ route: true }`. Its `loader`, `metadata` and the rest are not
24
+ // components, so plain Fast Refresh would give up on the first edit and reload
25
+ // the page, state and all. Here the component half is refreshed in place and
26
+ // the next exports go to the router (`window.__UF_HOT_ROUTE__`, installed by
27
+ // `@uniflowed/router` in development), which runs the route again with the new
28
+ // loader over the components React kept.
15
29
 
16
30
  import { readFileSync } from "node:fs";
17
31
  import { fileURLToPath } from "node:url";
@@ -19,8 +33,19 @@ import { fileURLToPath } from "node:url";
19
33
  /** Public URL the refresh runtime is served from. */
20
34
  export const RUNTIME_PUBLIC_PATH = "/@react-refresh";
21
35
 
22
- /** The resolved id Vite hands back for the runtime. */
23
- export const RUNTIME_RESOLVED_ID = "\0uf:react-refresh";
36
+ /**
37
+ * The resolved id Vite hands back for the runtime: the public path itself, as
38
+ * `@vitejs/plugin-react` does.
39
+ *
40
+ * It was `\0uf:react-refresh`, and that made Fast Refresh a no-op. The
41
+ * preamble in the document imports `/@react-refresh` by URL, while a component
42
+ * module's `import "/@react-refresh"` is rewritten by Vite to the URL of the id
43
+ * it resolved to — `/@id/__x00__uf:react-refresh`. Two URLs are two module
44
+ * instances to a browser, so the runtime the preamble hooked into React was
45
+ * not the runtime components registered with, and every edit was "hot
46
+ * updated" without React ever hearing of it. One id is one URL is one runtime.
47
+ */
48
+ export const RUNTIME_RESOLVED_ID = RUNTIME_PUBLIC_PATH;
24
49
 
25
50
  const RUNTIME_SOURCE_PATH = fileURLToPath(new URL("./refresh-runtime.js", import.meta.url));
26
51
 
@@ -53,7 +78,8 @@ const REACT_CLASS_COMPONENT = /extends\s+(?:React\.)?(?:Pure)?Component/;
53
78
  * The source map is shifted by the number of lines prepended, which is what
54
79
  * keeps a stack trace pointing at the author's line.
55
80
  */
56
- export function addRefreshWrapper(code, map, id) {
81
+ export function addRefreshWrapper(code, map, id, options = {}) {
82
+ const route = options.route === true;
57
83
  const hasRefresh = REFRESH_CONTENT.test(code);
58
84
  const onlyReactComponent = !hasRefresh && REACT_CLASS_COMPONENT.test(code);
59
85
  if (!hasRefresh && !onlyReactComponent) {
@@ -98,9 +124,23 @@ ${nextCode}
98
124
  if (import.meta.hot && !inWebWorker) {
99
125
  RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
100
126
  RefreshRuntime.registerExportsForReactRefresh(${JSON.stringify(id)}, currentExports);
127
+ (window.__UF_LATEST_MODULES__ ??= new Map()).set(new URL(import.meta.url).pathname, currentExports);
101
128
  import.meta.hot.accept((nextExports) => {
102
- if (!nextExports) return;
103
- const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(${JSON.stringify(id)}, currentExports, nextExports);
129
+ if (!nextExports) return;${
130
+ // First, and whether or not the boundary holds: the router answers
131
+ // this module's loader with these exports from now on, so a route
132
+ // table rebuilt after an invalidation still renders this instance and
133
+ // not a second copy of the module that React has never seen.
134
+ route
135
+ ? `
136
+ if (typeof window.__UF_HOT_ROUTE__ !== "function") {
137
+ import.meta.hot.invalidate("No router is mounted to run the route again");
138
+ return;
139
+ }
140
+ window.__UF_HOT_ROUTE__(${JSON.stringify(id)}, nextExports);`
141
+ : ""
142
+ }
143
+ const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(${JSON.stringify(id)}, currentExports, nextExports${route ? ", true" : ""});
104
144
  if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
105
145
  });
106
146
  });
@@ -1312,6 +1312,16 @@ export const VIRTUAL = Object.freeze({
1312
1312
  * relative to it. Diagnostics keep a path a person can act on — a shorter one
1313
1313
  * — and a deploy stops describing the machine it was built on.
1314
1314
  *
1315
+ * # `hot`, and which module a loader loads
1316
+ *
1317
+ * A dev server's browser table passes `hot: true`, and every loader then
1318
+ * carries `ufHotFile`: the module's path relative to the root, the same key the
1319
+ * Fast Refresh wrapper hands the router when that module is replaced. It is
1320
+ * how a hot update to `$page.js` reaches the route that loads it — the loader
1321
+ * itself is a closure over a specifier, and asking it again returns the module
1322
+ * the browser already evaluated. See "Hot updates" in
1323
+ * `@uniflowed/router/internal/runtime.js`.
1324
+ *
1315
1325
  * @param {{
1316
1326
  * routes: Route[],
1317
1327
  * handlers?: Handler[],
@@ -1322,11 +1332,13 @@ export const VIRTUAL = Object.freeze({
1322
1332
  * @param {{
1323
1333
  * shipsPage?: (route: Route) => boolean,
1324
1334
  * relativeTo?: string,
1335
+ * hot?: boolean,
1325
1336
  * }} [options]
1326
1337
  */
1327
1338
  export function routesModuleSource(table, options = {}) {
1328
1339
  const shipsPage = options.shipsPage ?? (() => true);
1329
1340
  const relativeTo = options.relativeTo ?? null;
1341
+ const hot = options.hot === true && relativeTo != null;
1330
1342
  /**
1331
1343
  * A `file` as this table should state it.
1332
1344
  *
@@ -1342,6 +1354,19 @@ export function routesModuleSource(table, options = {}) {
1342
1354
  }
1343
1355
  return path.relative(relativeTo, file).split(path.sep).join("/");
1344
1356
  };
1357
+ /** A lazy `import()` of `file`, tagged with its hot-update key under `hot`. */
1358
+ const lazy = (file) =>
1359
+ hot
1360
+ ? `hotLoader(${JSON.stringify(displayFile(file))}, () => import(${JSON.stringify(file)}))`
1361
+ : `() => import(${JSON.stringify(file)})`;
1362
+ // Declared once at the top of a development table; see `hot` above.
1363
+ // `hotFiles` is every key the table's loaders carry, so the router can tell
1364
+ // a module that is still routed from one whose file was deleted.
1365
+ const hotHeader = hot
1366
+ ? [
1367
+ "export const hotFiles = new Set();\nconst hotLoader = (ufHotFile, load) => {\n hotFiles.add(ufHotFile);\n load.ufHotFile = ufHotFile;\n return load;\n};",
1368
+ ]
1369
+ : [];
1345
1370
  const layoutIds = new Map();
1346
1371
  const layoutImports = [];
1347
1372
  const layoutId = (file) => {
@@ -1349,7 +1374,7 @@ export function routesModuleSource(table, options = {}) {
1349
1374
  if (id === undefined) {
1350
1375
  id = `layout${layoutIds.size}`;
1351
1376
  layoutIds.set(file, id);
1352
- layoutImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
1377
+ layoutImports.push(`const ${id} = ${lazy(file)};`);
1353
1378
  }
1354
1379
  return id;
1355
1380
  };
@@ -1372,7 +1397,7 @@ export function routesModuleSource(table, options = {}) {
1372
1397
  if (id === undefined) {
1373
1398
  id = `loading${loadingIds.size}`;
1374
1399
  loadingIds.set(file, id);
1375
- loadingImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
1400
+ loadingImports.push(`const ${id} = ${lazy(file)};`);
1376
1401
  }
1377
1402
  return id;
1378
1403
  };
@@ -1389,7 +1414,7 @@ export function routesModuleSource(table, options = {}) {
1389
1414
  if (id === undefined) {
1390
1415
  id = `template${templateIds.size}`;
1391
1416
  templateIds.set(file, id);
1392
- templateImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
1417
+ templateImports.push(`const ${id} = ${lazy(file)};`);
1393
1418
  }
1394
1419
  return id;
1395
1420
  };
@@ -1406,9 +1431,7 @@ export function routesModuleSource(table, options = {}) {
1406
1431
  const slotDefinitions = [];
1407
1432
  const slotFiles = new Set();
1408
1433
  const slotErrorBoundary = (boundary) =>
1409
- boundary == null
1410
- ? "null"
1411
- : `{ above: ${boundary.above}, module: () => import(${JSON.stringify(boundary.module)}) }`;
1434
+ boundary == null ? "null" : `{ above: ${boundary.above}, module: ${lazy(boundary.module)} }`;
1412
1435
  // One route a slot may render, as source. The same shape for a slot's own
1413
1436
  // routes and for its interceptions, because an intercepting page is composed
1414
1437
  // exactly the way every other page in the slot is.
@@ -1423,7 +1446,7 @@ export function routesModuleSource(table, options = {}) {
1423
1446
  params: ${JSON.stringify(route.params)},
1424
1447
  mdx: ${route.mdx},
1425
1448
  file: ${JSON.stringify(displayFile(route.page))},
1426
- page: () => import(${JSON.stringify(route.page)}),
1449
+ page: ${lazy(route.page)},
1427
1450
  layouts: [${route.layouts.map(layoutId).join(", ")}],
1428
1451
  loading: [${(route.loading ?? [])
1429
1452
  .map((boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`)
@@ -1454,7 +1477,7 @@ export function routesModuleSource(table, options = {}) {
1454
1477
  const fallback =
1455
1478
  slot.defaultPage == null
1456
1479
  ? " defaultPage: null,"
1457
- : ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
1480
+ : ` defaultPage: ${lazy(slot.defaultPage)},
1458
1481
  defaultFile: ${JSON.stringify(displayFile(slot.defaultPage))},`;
1459
1482
  // Only when there is one, so a slot that intercepts nothing is emitted byte
1460
1483
  // for byte the way it was before interception existed.
@@ -1504,7 +1527,7 @@ ${routes.join(",\n")}
1504
1527
  params: ${JSON.stringify(route.params)},
1505
1528
  mdx: ${route.mdx},
1506
1529
  file: ${JSON.stringify(displayFile(route.page))},
1507
- page: () => import(${JSON.stringify(route.page)}),
1530
+ page: ${lazy(route.page)},
1508
1531
  layouts: [${layouts.join(", ")}],
1509
1532
  loading: [${loading.join(", ")}],
1510
1533
  templates: [${templates.join(", ")}],
@@ -1517,8 +1540,7 @@ ${routes.join(",\n")}
1517
1540
  // emits a loader, and a name for `file` rather than a path nothing wrote.
1518
1541
  // See the note in `scanRoutes` and ubugeeei-prod/uf#351.
1519
1542
  const SYNTHESISED = JSON.stringify("@uniflowed/router");
1520
- const boundaryModule = (file) =>
1521
- file == null ? "null" : `() => import(${JSON.stringify(file)})`;
1543
+ const boundaryModule = (file) => (file == null ? "null" : `${lazy(file)}`);
1522
1544
  const boundaryFile = (file) => (file == null ? SYNTHESISED : JSON.stringify(displayFile(file)));
1523
1545
 
1524
1546
  // A list, because a not-found is a segment file: every directory may declare
@@ -1556,7 +1578,7 @@ ${routes.join(",\n")}
1556
1578
  path: ${JSON.stringify(handler.path)},
1557
1579
  params: ${JSON.stringify(handler.params)},
1558
1580
  file: ${JSON.stringify(displayFile(handler.module))},
1559
- load: () => import(${JSON.stringify(handler.module)}),
1581
+ load: ${lazy(handler.module)},
1560
1582
  }`,
1561
1583
  );
1562
1584
 
@@ -1569,7 +1591,7 @@ ${routes.join(",\n")}
1569
1591
  (entry) => ` {
1570
1592
  path: ${JSON.stringify(entry.path)},
1571
1593
  file: ${JSON.stringify(displayFile(entry.module))},
1572
- load: () => import(${JSON.stringify(entry.module)}),
1594
+ load: ${lazy(entry.module)},
1573
1595
  }`,
1574
1596
  );
1575
1597
 
@@ -1603,7 +1625,7 @@ ${routes.join(",\n")}
1603
1625
  }
1604
1626
  }
1605
1627
 
1606
- return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports, ...slotDefinitions].join("\n")}
1628
+ return `${[...hotHeader, ...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports, ...slotDefinitions].join("\n")}
1607
1629
  export const routes = [
1608
1630
  ${entries.join(",\n")}
1609
1631
  ];
@@ -1705,6 +1727,7 @@ function slotModuleFiles(slots) {
1705
1727
  * strictMode?: boolean,
1706
1728
  * navigation?: "client" | "document",
1707
1729
  * mount?: "hydrate" | "render",
1730
+ * hot?: boolean,
1708
1731
  * }} [options]
1709
1732
  */
1710
1733
  export function clientModuleSource(appEntry, options = {}) {
@@ -1715,11 +1738,34 @@ export function clientModuleSource(appEntry, options = {}) {
1715
1738
  // `app.rendering.staleTime`, in seconds, and nothing for the default `0`.
1716
1739
  const staleTime =
1717
1740
  options.staleTime > 0 ? `, staleTime: ${JSON.stringify(options.staleTime)}` : "";
1718
- return `import { ${mount} } from "@uniflowed/router/client";
1741
+ // In development this entry is the application's hot-update boundary for
1742
+ // the route table: a route file added or removed is a new
1743
+ // `virtual:uf/routes`, accepted here and installed in place, and the dev
1744
+ // server's `uf:refresh` renders the URL on screen again. Neither reloads the
1745
+ // document, so no component on it loses its state. A route module's own
1746
+ // edits reach the router through its Fast Refresh wrapper instead; see
1747
+ // `internal/refresh.js`.
1748
+ const hot =
1749
+ options.hot === true
1750
+ ? `if (import.meta.hot) {
1751
+ acceptHotRouteModules();
1752
+ import.meta.hot.on("uf:refresh", () => refreshForHotUpdate());
1753
+ import.meta.hot.accept(${JSON.stringify(VIRTUAL.routes)}, (next) => {
1754
+ if (next == null) import.meta.hot.invalidate("The route table could not be replaced");
1755
+ else replaceRoutesForHotUpdate(next);
1756
+ });
1757
+ }
1758
+ `
1759
+ : "";
1760
+ const imports =
1761
+ options.hot === true
1762
+ ? `${mount}, acceptHotRouteModules, refreshForHotUpdate, replaceRoutesForHotUpdate`
1763
+ : mount;
1764
+ return `import { ${imports} } from "@uniflowed/router/client";
1719
1765
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
1720
1766
  import App from ${JSON.stringify(appEntry)};
1721
1767
  ${clientInstrumentationSource(options.instrumentation)}${mount}({ App, routes, notFound, errors${strictMode}${navigation}${staleTime}${routing} });
1722
- `;
1768
+ ${hot}`;
1723
1769
  }
1724
1770
 
1725
1771
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,10 +36,10 @@
36
36
  "@mdx-js/rollup": "^3.1.1",
37
37
  "@ox-content/napi": "3.2.11",
38
38
  "@shikijs/rehype": "^3.23.0",
39
- "@uniflowed/host": "0.13.1",
40
- "@uniflowed/router": "0.13.1",
41
- "@uniflowed/server": "0.13.1",
42
- "@uniflowed/validator": "0.13.1",
39
+ "@uniflowed/host": "0.14.0",
40
+ "@uniflowed/router": "0.14.0",
41
+ "@uniflowed/server": "0.14.0",
42
+ "@uniflowed/validator": "0.14.0",
43
43
  "babel-plugin-relay": "^21.0.1",
44
44
  "estree-util-value-to-estree": "^3.5.0",
45
45
  "remark-gfm": "^4.0.1",