@uniflowed/router 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/client.js +8 -0
- package/internal/flight-browser.js +20 -1
- package/internal/resolve.js +46 -0
- package/internal/runtime.js +92 -0
- package/package.json +4 -4
- package/rsc-client.js +4 -0
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 ` +
|
package/internal/resolve.js
CHANGED
|
@@ -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();
|
package/internal/runtime.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.14.0",
|
|
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.
|
|
77
|
-
"@uniflowed/server": "0.
|
|
78
|
-
"@uniflowed/validator": "0.
|
|
76
|
+
"@uniflowed/hooks": "0.14.0",
|
|
77
|
+
"@uniflowed/server": "0.14.0",
|
|
78
|
+
"@uniflowed/validator": "0.14.0"
|
|
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
|
*
|