@uniflowed/router 0.0.0-alpha.6 → 0.0.0-alpha.8

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
@@ -6,25 +6,58 @@
6
6
  // The current route's chunks are loaded and its embedded loader data read
7
7
  // *before* `hydrateRoot`, so the first client render is synchronous and
8
8
  // matches the server's markup exactly.
9
+ //
10
+ // # A route can decline to be hydrated
11
+ //
12
+ // uf's server-component analysis decides which routes have a `"use client"`
13
+ // boundary anywhere in them, and `@uniflowed/vite` leaves the page out of the
14
+ // client route table for the ones that have none. Such a route has nothing in
15
+ // the browser to attach: the document the server wrote is the whole of it. So
16
+ // this returns without calling `hydrateRoot`, and the `<a>` elements a `Link`
17
+ // rendered stay what the server made them — real links the browser follows.
18
+ // See ubugeeei-prod/uf#350.
9
19
 
10
20
  import * as React from "react";
11
21
  import { startTransition } from "react";
12
22
  import { hydrateRoot } from "react-dom/client";
13
23
 
14
- import { type AppProps, type RouteTable, installRoutes, resolveMatch } from "./internal/runtime.js";
24
+ import {
25
+ type AppProps,
26
+ type RouteTable,
27
+ hasClientPage,
28
+ installRoutes,
29
+ matchRoute,
30
+ resolveMatch,
31
+ } from "./internal/runtime.js";
15
32
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
16
33
 
17
34
  /**
18
35
  * Hydrate the current document.
36
+ *
37
+ * Resolves without mounting anything when the current route ships no client
38
+ * page — see the header. The promise settling is not a claim that React is on
39
+ * the document.
19
40
  */
20
41
  export async function hydrate(options: {|
21
42
  readonly App: React.ComponentType<AppProps>,
22
43
  readonly routes: RouteTable["routes"],
23
44
  readonly notFound: RouteTable["notFound"],
45
+ readonly errors: RouteTable["errors"],
24
46
  |}): Promise<void> {
25
- const table: RouteTable = { routes: options.routes, notFound: options.notFound };
47
+ const table: RouteTable = {
48
+ routes: options.routes,
49
+ notFound: options.notFound,
50
+ errors: options.errors,
51
+ };
26
52
  installRoutes(table);
27
53
 
54
+ // Before the loader data is read and before `resolveMatch` is called: both
55
+ // would go looking for a page module that is not in this bundle.
56
+ const matched = matchRoute(table.routes, window.location.pathname);
57
+ if (matched != null && !hasClientPage(matched.route)) {
58
+ return;
59
+ }
60
+
28
61
  const url = window.location.pathname + window.location.search;
29
62
  const embedded = document.getElementById(DATA_ID);
30
63
  const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
package/handler.js CHANGED
@@ -25,8 +25,16 @@
25
25
  // It does answer `405` itself when the path matches and the method does not,
26
26
  // with the `Allow` header the specification requires — that is not the
27
27
  // handler's business, and every handler would otherwise write it.
28
-
29
- import { contextFor, drainDeferred, runWithContext } from "@uniflowed/server/host";
28
+ //
29
+ // It also does not establish the request a handler is inside. The host does,
30
+ // around the whole of it, so a handler and the guard above it share one
31
+ // context; see the same section in `./middleware.js`. This module used to
32
+ // build its own and drain it the moment the handler returned, which the
33
+ // comment there called "the response is in hand" — true, and not what
34
+ // `after()` promises. A handler that streams its body has not sent a byte at
35
+ // that point. See ubugeeei-prod/uf#389.
36
+
37
+ import { requireRequest } from "./internal/request.js";
30
38
  import type { RouteParams } from "./internal/runtime.js";
31
39
 
32
40
  /** What a handler is given besides the request. */
@@ -76,6 +84,9 @@ export function createDispatcher(options: {|
76
84
  const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
77
85
 
78
86
  return async function dispatch(request: Request): Promise<Response | null> {
87
+ // The host's half of the contract, checked rather than assumed; see
88
+ // `./internal/request.js`.
89
+ requireRequest("dispatch");
79
90
  const url = new URL(request.url);
80
91
  for (const record of table) {
81
92
  const params = matchPath(record.path, url.pathname);
@@ -90,17 +101,13 @@ export function createDispatcher(options: {|
90
101
  return methodNotAllowed(module);
91
102
  }
92
103
 
93
- // Inside the request, so a handler that calls `headers()`, `cookies()`
94
- // or `after()` has something to answer about. `drainDeferred` runs after
95
- // the response is in hand, which is what `after()` means.
96
- const context = contextFor(request);
97
- const response = await runWithContext(context, () =>
98
- handler(request, {
99
- params,
100
- searchParams: url.searchParams,
101
- }),
102
- );
103
- await drainDeferred(context);
104
+ // In the host's request, so a handler that calls `headers()`,
105
+ // `cookies()` or `after()` answers about the same one its guard did, and
106
+ // what it defers is drained once, by the host, after the bytes are out.
107
+ const response = await handler(request, {
108
+ params,
109
+ searchParams: url.searchParams,
110
+ });
104
111
 
105
112
  // A `HEAD` answered by `GET` must not carry the body. The test is
106
113
  // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
package/index.js CHANGED
@@ -6,17 +6,29 @@
6
6
  // `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
7
7
  // is generated from the directory at build time; this module is the runtime
8
8
  // that matches, loads, navigates and renders it.
9
+ //
10
+ // `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
11
+ // path that matched nothing, and what renders in place of a subtree that threw.
12
+ // Both are segment files, resolved by the nearest one above the path.
13
+
14
+ import * as React from "react";
15
+
16
+ import type { RouteError } from "./internal/runtime.js";
9
17
 
10
18
  export type {
11
19
  AppProps,
20
+ ErrorBoundary,
21
+ ErrorModule,
12
22
  LayoutModule,
13
23
  LinkPrefetch,
14
24
  LoaderArgs,
15
25
  Metadata,
16
26
  MetadataArgs,
17
27
  NavigateOptions,
28
+ NotFoundBoundary,
18
29
  PageModule,
19
30
  ResolvedRoute,
31
+ RouteError,
20
32
  RouteInfo,
21
33
  RouteMatch,
22
34
  RouteParamSpec,
@@ -28,19 +40,26 @@ export type {
28
40
  } from "./internal/runtime.js";
29
41
 
30
42
  export {
43
+ ForbiddenError,
31
44
  Link,
32
45
  NotFoundError,
33
46
  RedirectError,
34
47
  RouteView,
35
48
  RouterProvider,
49
+ UnauthorizedError,
50
+ forbidden,
51
+ hasClientPage,
36
52
  matchRoute,
37
53
  notFound,
38
54
  parseSearch,
39
55
  permanentRedirect,
40
56
  redirect,
57
+ resolveFailure,
41
58
  resolveMatch,
59
+ routeErrorStatus,
42
60
  routerView,
43
61
  splitUrl,
62
+ unauthorized,
44
63
  useIsServer,
45
64
  useLoaderData,
46
65
  useRoute,
@@ -57,10 +76,16 @@ export type PageProps<
57
76
  readonly data: TData,
58
77
  |};
59
78
 
79
+ /** Props an `_uf.error.js` component receives. */
80
+ export type ErrorProps = {|
81
+ readonly error: RouteError,
82
+ readonly reset: () => void,
83
+ |};
84
+
60
85
  /** Props a layout receives. */
61
86
  export type LayoutProps<
62
87
  TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
63
88
  > = {|
64
89
  readonly params: TParams,
65
- readonly children: React$Node,
90
+ readonly children: React.Node,
66
91
  |};
@@ -0,0 +1,43 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the request the server half runs inside.
4
+ //
5
+ // One function, and it exists because two modules need the same refusal.
6
+ // `createMiddlewareRunner` and `createDispatcher` both used to build a request
7
+ // context of their own — `contextFor`, then `runWithContext`, then
8
+ // `drainDeferred` — which gave one request up to two contexts and ran what
9
+ // `after()` deferred before the response was written, and in the middleware's
10
+ // case before there was a response at all. See ubugeeei-prod/uf#389.
11
+ //
12
+ // Both now run inside whatever request the host established, which means both
13
+ // depend on the host having established one. That dependency is checked rather
14
+ // than assumed, for the reason `createApplicationHandler` gives about
15
+ // `entry.runMiddleware` itself: a missing half of the contract should be a
16
+ // failure on the first request, not an application that answers strangely.
17
+ //
18
+ // Server-only, like the two modules that import it. Nothing the browser loads
19
+ // reaches this.
20
+
21
+ import { insideRequest } from "@uniflowed/server/host";
22
+
23
+ /**
24
+ * Refuse to run outside a request, naming what has to establish one.
25
+ *
26
+ * The message is addressed to whoever wired the host, because that is who can
27
+ * fix it. Left to fail on its own, a mis-wired host would surface as the first
28
+ * `cookies()` in somebody's guard throwing "called outside a request … a
29
+ * static prerender, a module's top level, or a client component" — three
30
+ * places to look, none of them this one — and an application that calls no
31
+ * server function at all would sail past that and lose every `after()` instead.
32
+ */
33
+ export function requireRequest(entry: string): void {
34
+ if (insideRequest()) {
35
+ return;
36
+ }
37
+ throw new Error(
38
+ `@uniflowed/router: ${entry}() was called outside a request. A host owns the request: ` +
39
+ "begin it with `beginRequest` from `@uniflowed/server/host` (a bundled application " +
40
+ "re-exports it from `virtual:uf/server`), run the whole request inside `run`, and call " +
41
+ "`settle` once the response has been sent. See ubugeeei-prod/uf#389.",
42
+ );
43
+ }