@uniflowed/router 0.13.1 → 0.14.1

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/client.js CHANGED
@@ -122,6 +122,14 @@ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/bas
122
122
  import { hydrationOptions } from "./internal/hydrate-options.js";
123
123
  import { readFormState } from "./internal/form-action.js";
124
124
 
125
+ // What `virtual:uf/client` calls in development to take a dev server's edits
126
+ // as renders rather than reloads; see "Hot updates" in `./internal/runtime.js`.
127
+ export {
128
+ acceptHotRouteModules,
129
+ refreshForHotUpdate,
130
+ replaceRoutesForHotUpdate,
131
+ } from "./internal/runtime.js";
132
+
125
133
  /**
126
134
  * The React root `hydrate` and `render` mounted, as far as a caller needs it:
127
135
  * a way to take the application down again. See `hydrate` for who needs that.
@@ -65,6 +65,25 @@ export function installServerCallback(): void {
65
65
  setServerCallback(callServerReference);
66
66
  }
67
67
 
68
+ /**
69
+ * The newest evaluation of the client module at `url`, in development.
70
+ *
71
+ * `@uniflowed/vite`'s Fast Refresh wrapper publishes every evaluation of a
72
+ * component module under its path, in `window.__UF_LATEST_MODULES__`. A
73
+ * client reference is a `React.lazy` that React matches against the component
74
+ * already mounted by identity, and after a hot update that component is the
75
+ * module's *newest* evaluation — so a payload that answered with the first one
76
+ * would be a different component, and a server edit would remount every client
77
+ * component it rendered, state and all. The registry never exists in a build.
78
+ */
79
+ function latestModule(url: string): ?ModuleNamespace {
80
+ const registry = window.__UF_LATEST_MODULES__;
81
+ if (registry == null) {
82
+ return null;
83
+ }
84
+ return registry.get(new URL(url, window.location.href).pathname);
85
+ }
86
+
68
87
  /**
69
88
  * Install the module hook React's Flight client resolves references through.
70
89
  *
@@ -80,7 +99,7 @@ export function installBrowserModules(): void {
80
99
  // chunk it lives in. Declared and then given its properties, so the hook is
81
100
  // built rather than merged into something that already existed.
82
101
  function parcelRequire(id: string): ModuleNamespace {
83
- const namespace = loaded.get(id);
102
+ const namespace = latestModule(id) ?? loaded.get(id);
84
103
  if (namespace == null) {
85
104
  throw new Error(
86
105
  `@uniflowed/router: the client module ${id} was required before it loaded. React's ` +
@@ -649,7 +649,53 @@ type InterceptedUrl = {|
649
649
 
650
650
  const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
651
651
 
652
+ /**
653
+ * Route modules a dev server replaced, by the key their loader carries.
654
+ *
655
+ * A loader is `() => import("/app/$page.js")`, and asking it again after a hot
656
+ * update returns the module the browser evaluated first — the browser keys
657
+ * modules by URL, and the update arrived under another one. So the Fast Refresh
658
+ * wrapper hands the router the next exports, and a loader the development
659
+ * route table tagged with `ufHotFile` (see `routesModuleSource` in
660
+ * `@uniflowed/vite`) answers with those. Empty outside development: nothing
661
+ * else writes to it.
662
+ */
663
+ const hotModules: Map<string, mixed> = new Map();
664
+
665
+ /** Answer `file`'s loader with `exports` from now on. Development only. */
666
+ export function replaceHotModule(file: string, exports: mixed): void {
667
+ hotModules.set(file, exports);
668
+ }
669
+
670
+ /**
671
+ * Keep the replaced modules a new route table still loads, and forget the
672
+ * rest. `files` is the table's `hotFiles`; `null`, from a table that carries
673
+ * none, forgets everything.
674
+ *
675
+ * Kept rather than forgotten, because the new table's `import()` names the
676
+ * module under a URL of its own, and a second evaluation of `$page.js` is a
677
+ * second `Page` React has never seen: it would remount the page and throw away
678
+ * the state the update was meant to keep. The replaced exports *are* the
679
+ * module's newest version — every edit to a route module goes through
680
+ * `replaceHotModule` — so answering with them is answering with the file.
681
+ */
682
+ export function retainHotModules(files: ?$ReadOnlySet<string>): void {
683
+ for (const file of [...hotModules.keys()]) {
684
+ if (files == null || !files.has(file)) {
685
+ hotModules.delete(file);
686
+ }
687
+ }
688
+ }
689
+
652
690
  export function loadOnce<T>(load: () => Promise<T>): Promise<T> {
691
+ if (hotModules.size > 0) {
692
+ // $FlowFixMe[prop-missing] a development table's loaders carry their module's key.
693
+ const file: mixed = load.ufHotFile;
694
+ if (typeof file === "string" && hotModules.has(file)) {
695
+ // $FlowFixMe[incompatible-type] the module this loader imports, replaced.
696
+ return Promise.resolve(hotModules.get(file));
697
+ }
698
+ }
653
699
  let pending = moduleCache.get(load);
654
700
  if (pending == null) {
655
701
  pending = load();
@@ -114,7 +114,9 @@ import {
114
114
  interceptingRoutes,
115
115
  loadOnce,
116
116
  pageSearchParams,
117
+ replaceHotModule,
117
118
  resolveFailure,
119
+ retainHotModules,
118
120
  resolveInterception,
119
121
  resolveMatch,
120
122
  } from "./resolve.js";
@@ -412,6 +414,96 @@ hook useMountedRouter(router: Router, showNotFound: ShowNotFound): void {
412
414
  });
413
415
  }
414
416
 
417
+ // ---------------------------------------------------------------------------
418
+ // Hot updates, in development
419
+ // ---------------------------------------------------------------------------
420
+ //
421
+ // A dev server edit used to reach this router one way: a reload, which renders
422
+ // the edit and discards every `useState`, scroll position and open dialog on
423
+ // the page. Three kinds of edit now arrive as a render instead, and all three
424
+ // end in `refreshForHotUpdate` — the mounted router's own `refresh()`, which
425
+ // resolves the URL on screen again under the components React already has, so
426
+ // state survives wherever the tree still has the same shape:
427
+ //
428
+ // * a server component, under React Server Components: `@uniflowed/vite`
429
+ // sends `uf:refresh`, and the payload is fetched again;
430
+ // * a route file added or removed: a new `virtual:uf/routes`, installed by
431
+ // `replaceRoutesForHotUpdate`, or `uf:refresh` for the server's table;
432
+ // * a route module's loader, metadata or other data export: its Fast Refresh
433
+ // wrapper calls the hook `acceptHotRouteModules` installs, the next exports
434
+ // answer that module's loader from then on, and the loader runs again.
435
+ //
436
+ // `@uniflowed/vite` generates the calls into `virtual:uf/client` for a dev
437
+ // server and never for a build, so none of this is reached in production.
438
+
439
+ /**
440
+ * Long enough to land after Fast Refresh's own 16 ms batch, so React has the
441
+ * new component families before the router renders with them, and to collapse
442
+ * one save that touched several modules into one refresh.
443
+ */
444
+ const HOT_REFRESH_DELAY = 32;
445
+
446
+ let hotRefreshTimer: TimeoutID | null = null;
447
+
448
+ /**
449
+ * Render the URL on screen again because the dev server replaced something
450
+ * under it. Reloads the document when no router is mounted — a page that ships
451
+ * no client page, say — or when the refresh itself fails, because a reload is
452
+ * always right and the render only usually is.
453
+ */
454
+ export function refreshForHotUpdate(): void {
455
+ if (!isBrowser()) {
456
+ return;
457
+ }
458
+ if (hotRefreshTimer != null) {
459
+ clearTimeout(hotRefreshTimer);
460
+ }
461
+ hotRefreshTimer = setTimeout(() => {
462
+ hotRefreshTimer = null;
463
+ const router = mountedRouter;
464
+ if (router == null) {
465
+ window.location.reload();
466
+ return;
467
+ }
468
+ router.refresh().catch((error) => {
469
+ // Said before the document goes, so the console that is about to be
470
+ // cleared says why a hot update became a reload.
471
+ console.warn("[uf] could not render the hot update in place, reloading:", error);
472
+ window.location.reload();
473
+ });
474
+ }, HOT_REFRESH_DELAY);
475
+ }
476
+
477
+ /**
478
+ * Install a route table the dev server rebuilt, and render the URL on screen
479
+ * against it. `table` is the new `virtual:uf/routes` itself; the modules
480
+ * replaced under the old table stay replaced unless its `hotFiles` no longer
481
+ * names them. See `retainHotModules` for why.
482
+ */
483
+ export function replaceRoutesForHotUpdate(
484
+ table: $ReadOnly<{ ...RouteTable, hotFiles?: $ReadOnlySet<string>, ... }>,
485
+ ): void {
486
+ installRoutes({ routes: table.routes, notFound: table.notFound, errors: table.errors });
487
+ retainHotModules(table.hotFiles);
488
+ refreshForHotUpdate();
489
+ }
490
+
491
+ /**
492
+ * Let a route module's Fast Refresh wrapper hand this router its next exports.
493
+ * See `addRefreshWrapper` in `@uniflowed/vite`, which calls it as
494
+ * `window.__UF_HOT_ROUTE__(file, exports)` with the module's path from the
495
+ * project root.
496
+ */
497
+ export function acceptHotRouteModules(): void {
498
+ if (!isBrowser()) {
499
+ return;
500
+ }
501
+ window.__UF_HOT_ROUTE__ = (file: string, exports: mixed) => {
502
+ replaceHotModule(file, exports);
503
+ refreshForHotUpdate();
504
+ };
505
+ }
506
+
415
507
  /**
416
508
  * Show the page a loader's `notFound()` would have shown for the URL on screen,
417
509
  * because something below the route's error boundary threw `NotFoundError`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,8 +73,8 @@
73
73
  }
74
74
  },
75
75
  "dependencies": {
76
- "@uniflowed/hooks": "0.13.1",
77
- "@uniflowed/server": "0.13.1",
78
- "@uniflowed/validator": "0.13.1"
76
+ "@uniflowed/hooks": "0.14.1",
77
+ "@uniflowed/server": "0.14.1",
78
+ "@uniflowed/validator": "0.14.1"
79
79
  }
80
80
  }
package/rsc-client.js CHANGED
@@ -45,6 +45,10 @@ import {
45
45
  import { hydrationOptions } from "./internal/hydrate-options.js";
46
46
  import { readFormState } from "./internal/form-action.js";
47
47
 
48
+ // What `virtual:uf/client` calls in development when a server component
49
+ // changed; see "Hot updates" in `./internal/runtime.js`.
50
+ export { refreshForHotUpdate } from "./internal/runtime.js";
51
+
48
52
  /**
49
53
  * Hydrate a document React Server Components rendered.
50
54
  *