@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39
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/action.js +6 -0
- package/client.js +29 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/compose.js +12 -0
- package/internal/error-view.js +4 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-chunks.js +32 -8
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +208 -26
- package/internal/server-route.js +6 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +111 -21
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +11 -0
- package/server-components.js +4 -0
- package/server.js +20 -484
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the routes a navigation already has.
|
|
4
|
+
//
|
|
5
|
+
// A navigation used to ask the server every time. A `Link` prefetched a route's
|
|
6
|
+
// payload on hover and handed it to exactly one click, and every other way back
|
|
7
|
+
// to a page the reader had just seen, a second visit or the back button, waited
|
|
8
|
+
// on the network again (ubugeeei-prod/uf#960). This module is what a navigation
|
|
9
|
+
// reads first instead: the route it fetched or prefetched, kept for
|
|
10
|
+
// `app.rendering.staleTime` seconds and asked for again after that.
|
|
11
|
+
//
|
|
12
|
+
// # Off until a project says a number
|
|
13
|
+
//
|
|
14
|
+
// uf's caches are opt-in, and this one is too. `staleTime` is `0` until a
|
|
15
|
+
// project writes one, and at `0` nothing is kept, so a navigation shows what the
|
|
16
|
+
// server answered for it just now, which is the guarantee every project had
|
|
17
|
+
// before the setting existed.
|
|
18
|
+
//
|
|
19
|
+
// # What is kept
|
|
20
|
+
//
|
|
21
|
+
// One promise per route, never a copy of what it resolved to:
|
|
22
|
+
//
|
|
23
|
+
// - an application React Server Components render keeps the payload fetch: the
|
|
24
|
+
// route's state and its tree, with the `$loading.js` fallbacks the tree
|
|
25
|
+
// carries, so going back to a page that was still streaming shows the loading
|
|
26
|
+
// shell the first visit did;
|
|
27
|
+
// - an application rendered from its modules keeps the resolved route: the
|
|
28
|
+
// loader's data, and the page, layouts and loading boundaries it loaded.
|
|
29
|
+
//
|
|
30
|
+
// A promise rather than its value, so a click on a link whose prefetch is still
|
|
31
|
+
// in flight waits on that request instead of making a second one. The router
|
|
32
|
+
// forgets an entry whose fetch failed or turned out not to be a route.
|
|
33
|
+
//
|
|
34
|
+
// # Keyed by the application path
|
|
35
|
+
//
|
|
36
|
+
// The path and query the route table is asked about, without
|
|
37
|
+
// `app.router.basePath`: `/docs/guide?tab=api` under `/docs` is kept as
|
|
38
|
+
// `/guide?tab=api`. A fragment is never part of a key, because a server never
|
|
39
|
+
// sees one.
|
|
40
|
+
//
|
|
41
|
+
// # Cleared, not revalidated in place
|
|
42
|
+
//
|
|
43
|
+
// `router.refresh()` and every server action clear the whole cache. An action
|
|
44
|
+
// is a write, and which pages it changed is the server's to know, so the next
|
|
45
|
+
// navigation to any of them asks again rather than showing what the write made
|
|
46
|
+
// untrue.
|
|
47
|
+
|
|
48
|
+
import { applicationPathOf } from "./base-path.js";
|
|
49
|
+
import type { FetchedFlight } from "./flight.js";
|
|
50
|
+
import type { ResolvedRoute } from "./resolve.js";
|
|
51
|
+
|
|
52
|
+
/** How many routes each cache keeps at once. Past it, the oldest is dropped. */
|
|
53
|
+
export const NAVIGATION_CACHE_LIMIT = 32;
|
|
54
|
+
|
|
55
|
+
let staleTimeMs: number = 0;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Say how long, in seconds, a route a navigation fetched is shown again without
|
|
59
|
+
* asking. `0`, the default, keeps nothing. Called once, by the entry that
|
|
60
|
+
* started the application.
|
|
61
|
+
*/
|
|
62
|
+
export function installStaleTime(seconds: number): void {
|
|
63
|
+
staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
|
|
64
|
+
if (staleTimeMs === 0) {
|
|
65
|
+
clearNavigationCache();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether navigations keep what they fetch. */
|
|
70
|
+
export function keepsNavigations(): boolean {
|
|
71
|
+
return staleTimeMs > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The key a URL's route is kept under: its application path, then its query. */
|
|
75
|
+
export function navigationKey(pathname: string, search: string): string {
|
|
76
|
+
return `${applicationPathOf(pathname) ?? pathname}${search}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One kind of kept route. */
|
|
80
|
+
export type NavigationCache<T> = {|
|
|
81
|
+
/** The route kept under `key`, while it is fresh. A stale one is dropped. */
|
|
82
|
+
readonly read: (key: string) => T | null,
|
|
83
|
+
/** Keep `value` under `key` from now. Nothing is kept while the stale time is `0`. */
|
|
84
|
+
readonly store: (key: string, value: T) => void,
|
|
85
|
+
/** Drop what `key` keeps, if it is still `value`. */
|
|
86
|
+
readonly forget: (key: string, value: T) => void,
|
|
87
|
+
readonly clear: () => void,
|
|
88
|
+
/** How many routes are kept, fresh or not. */
|
|
89
|
+
readonly size: () => number,
|
|
90
|
+
|};
|
|
91
|
+
|
|
92
|
+
function createNavigationCache<T>(): NavigationCache<T> {
|
|
93
|
+
const entries: Map<string, {| readonly value: T, readonly at: number |}> = new Map();
|
|
94
|
+
return {
|
|
95
|
+
read(key) {
|
|
96
|
+
const entry = entries.get(key);
|
|
97
|
+
if (entry == null) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (Date.now() - entry.at >= staleTimeMs) {
|
|
101
|
+
entries.delete(key);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return entry.value;
|
|
105
|
+
},
|
|
106
|
+
store(key, value) {
|
|
107
|
+
if (staleTimeMs === 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Deleted first, so a route kept again moves to the young end.
|
|
111
|
+
entries.delete(key);
|
|
112
|
+
if (entries.size >= NAVIGATION_CACHE_LIMIT) {
|
|
113
|
+
const oldest = entries.keys().next();
|
|
114
|
+
if (oldest.done !== true) {
|
|
115
|
+
entries.delete(oldest.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
entries.set(key, { value, at: Date.now() });
|
|
119
|
+
},
|
|
120
|
+
forget(key, value) {
|
|
121
|
+
if (entries.get(key)?.value === value) {
|
|
122
|
+
entries.delete(key);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
clear() {
|
|
126
|
+
entries.clear();
|
|
127
|
+
},
|
|
128
|
+
size() {
|
|
129
|
+
return entries.size;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Payload fetches, for an application React Server Components render. */
|
|
135
|
+
export const flightNavigations: NavigationCache<Promise<FetchedFlight>> = createNavigationCache();
|
|
136
|
+
|
|
137
|
+
/** Resolved routes, for an application rendered from its modules. */
|
|
138
|
+
export const routeNavigations: NavigationCache<Promise<ResolvedRoute>> = createNavigationCache();
|
|
139
|
+
|
|
140
|
+
/** Forget every kept route. `router.refresh()` and each server action call this. */
|
|
141
|
+
export function clearNavigationCache(): void {
|
|
142
|
+
flightNavigations.clear();
|
|
143
|
+
routeNavigations.clear();
|
|
144
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a server's document, made ready for React to
|
|
4
|
+
// hydrate.
|
|
5
|
+
//
|
|
6
|
+
// Where a server wrote the head's metadata, an element React leaves behind, and
|
|
7
|
+
// the placeholder action React writes into a form whose action is a function
|
|
8
|
+
// (its server and its client spell that placeholder differently): none of these
|
|
9
|
+
// comes from a route, and each would be reported as a mismatch. So each is put
|
|
10
|
+
// right before `hydrateRoot` compares the markup with the tree. Both ways of
|
|
11
|
+
// hydrating do it — `hydrate` in `../client.js`, for a route rendered from its
|
|
12
|
+
// modules, and `hydrateFlight` in `../rsc-client.js`, for one React Server
|
|
13
|
+
// Components rendered — so the code lives here rather than in either entry.
|
|
14
|
+
|
|
15
|
+
export function prepareDocumentForHydration(document: Document): void {
|
|
16
|
+
const head = document.head;
|
|
17
|
+
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
18
|
+
if (envelope != null && head.firstChild !== envelope) {
|
|
19
|
+
head.insertBefore(envelope, head.firstChild);
|
|
20
|
+
}
|
|
21
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
|
|
22
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
|
|
23
|
+
document.getElementById("_R_")?.remove();
|
|
24
|
+
normalizeReactFormActions(document);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
|
|
28
|
+
if (meta == null) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const colorScheme = head.querySelector('meta[name="color-scheme"]');
|
|
32
|
+
if (colorScheme != null && colorScheme !== meta) {
|
|
33
|
+
head.insertBefore(meta, colorScheme);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
head.appendChild(meta);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
|
|
40
|
+
const CLIENT_FORM_PLACEHOLDER =
|
|
41
|
+
"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')";
|
|
42
|
+
|
|
43
|
+
function normalizeReactFormActions(document: Document): void {
|
|
44
|
+
for (const form of document.querySelectorAll("form")) {
|
|
45
|
+
if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
|
|
46
|
+
form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the React that React Server Components need.
|
|
4
|
+
//
|
|
5
|
+
// The router installs beside React 19.2.3, the React that Expo SDK 57 and React
|
|
6
|
+
// Native 0.87 ship. Matching a URL, the native route table and a route rendered
|
|
7
|
+
// from its modules need nothing newer (ubugeeei-prod/uf#992). React Server
|
|
8
|
+
// Components do. They render through `react-server-dom-parcel`, React's own
|
|
9
|
+
// Flight renderer and client, which is released with React and requires the
|
|
10
|
+
// React it was released with: 19.3. So that package is an optional peer, and
|
|
11
|
+
// every module that loads it asks here first.
|
|
12
|
+
//
|
|
13
|
+
// # Why a check at run time
|
|
14
|
+
//
|
|
15
|
+
// A peer range cannot say it. The router has one range for `react`, and that
|
|
16
|
+
// range has to admit 19.2.3 for a native app, while an optional peer that is
|
|
17
|
+
// absent is never compared with anything. The failure is also not where the
|
|
18
|
+
// mistake is: React's Flight client imports against React 19.2 and fails later,
|
|
19
|
+
// inside a render, in terms of React's internals. So each entry that loads
|
|
20
|
+
// Flight refuses before it does anything, and names the React it found and the
|
|
21
|
+
// one it needs.
|
|
22
|
+
//
|
|
23
|
+
// It is a call rather than a statement at module scope, because no shipped
|
|
24
|
+
// module runs anything when it is imported
|
|
25
|
+
// (`crates/uf_lib/tests/package_surface.rs`).
|
|
26
|
+
|
|
27
|
+
import * as React from "react";
|
|
28
|
+
|
|
29
|
+
/** The oldest React that React Server Components render on. */
|
|
30
|
+
export const SERVER_COMPONENTS_REACT: string = "19.3.0";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why `entry` cannot run on React `installed`, or `null` when it can.
|
|
34
|
+
*
|
|
35
|
+
* The release numbers are compared and a prerelease tag is ignored, so a 19.3
|
|
36
|
+
* canary counts as 19.3: React names a canary after the release it leads to.
|
|
37
|
+
*/
|
|
38
|
+
export function serverComponentsRefusal(entry: string, installed: string): string | null {
|
|
39
|
+
const found = releaseOf(installed);
|
|
40
|
+
const needed = releaseOf(SERVER_COMPONENTS_REACT);
|
|
41
|
+
if (found != null && needed != null && !isBefore(found, needed)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
`@uniflowed/router: ${entry} needs React ${SERVER_COMPONENTS_REACT} or newer for React ` +
|
|
46
|
+
`Server Components, and the React it loaded is ${installed}. Install react, react-dom and ` +
|
|
47
|
+
"react-server-dom-parcel at ^19.3.0, or set `app.rsc: false` in uf.config.js to render " +
|
|
48
|
+
"routes from their modules, which the router supports from React 19.2.3."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refuse, with [`serverComponentsRefusal`]'s sentence, unless the React this
|
|
54
|
+
* module loaded can render React Server Components.
|
|
55
|
+
*/
|
|
56
|
+
export function requireServerComponentsReact(entry: string): void {
|
|
57
|
+
const refusal = serverComponentsRefusal(entry, React.version);
|
|
58
|
+
if (refusal != null) {
|
|
59
|
+
throw new Error(refusal);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `[major, minor, patch]` of a version, or `null` when it does not start with one. */
|
|
64
|
+
function releaseOf(version: string): [number, number, number] | null {
|
|
65
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
66
|
+
return match == null ? null : [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether release `a` comes before release `b`. */
|
|
70
|
+
function isBefore(a: [number, number, number], b: [number, number, number]): boolean {
|
|
71
|
+
for (let index = 0; index < 3; index += 1) {
|
|
72
|
+
if (a[index] !== b[index]) {
|
|
73
|
+
return a[index] < b[index];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|