@uniflowed/router 0.0.0-alpha.14 → 0.0.0-alpha.15

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
@@ -18,6 +18,37 @@
18
18
  // Development only, and dynamically imported so a production bundle has no path
19
19
  // to it. See ubugeeei-prod/uf#508.
20
20
  //
21
+ // # And whether React DevTools can see the page at all
22
+ //
23
+ // The other question only this module is in a position to ask.
24
+ // `@uniflowed/vite` installs the hook DevTools attaches through, above every
25
+ // module in the document; whether that worked *on this page* is a fact about a
26
+ // running browser, and the line after hydration is where it can be read.
27
+ // `internal/devtools.js` has the two findings and sends them to the same
28
+ // terminal the hydration report goes to. See ubugeeei-prod/uf#503.
29
+ //
30
+ // # Strict Mode, in development, by default
31
+ //
32
+ // `uf dev` generates `strictMode: true` into `virtual:uf/client` and `uf build`
33
+ // does not, so a development render is doubled and a visitor's is not. That is
34
+ // React's own check for the thing it cannot check any other way: a component
35
+ // whose render is not pure, and an effect whose cleanup does not undo its
36
+ // setup, both behave correctly until the one production render that interleaves
37
+ // with something — and Strict Mode makes them behave incorrectly at once, on
38
+ // the machine of the person writing them.
39
+ //
40
+ // The wrapper is the argument to `hydrateRoot` rather than something inside
41
+ // `<App>`, and that is load-bearing rather than tidy. React decides whether to
42
+ // double-invoke a mount's effects at the *topmost fiber it is placing*: if that
43
+ // fiber is not itself in Strict Mode, React stops there and never looks inside
44
+ // it. A `<StrictMode>` further down still doubles the renders under it — that
45
+ // comes from the fiber's own mode — and doubles no effect at all, so it would
46
+ // have bought the half of the check that is easy to notice and silently lost
47
+ // the half that finds the bug. It renders no element, so the hydrated tree is
48
+ // unchanged and the markup comparison above is unaffected.
49
+ // `app.react.strictMode: false` in `uf.config.js` turns it off. See
50
+ // ubugeeei-prod/uf#516.
51
+ //
21
52
  // # A route can decline to be hydrated
22
53
  //
23
54
  // uf's server-component analysis decides which routes have a `"use client"`
@@ -29,7 +60,7 @@
29
60
  // See ubugeeei-prod/uf#350.
30
61
 
31
62
  import * as React from "react";
32
- import { startTransition } from "react";
63
+ import { StrictMode, startTransition } from "react";
33
64
  import { hydrateRoot } from "react-dom/client";
34
65
 
35
66
  import {
@@ -54,6 +85,7 @@ export async function hydrate(options: {|
54
85
  readonly routes: RouteTable["routes"],
55
86
  readonly notFound: RouteTable["notFound"],
56
87
  readonly errors: RouteTable["errors"],
88
+ readonly strictMode?: boolean,
57
89
  |}): Promise<void> {
58
90
  const table: RouteTable = {
59
91
  routes: options.routes,
@@ -95,11 +127,28 @@ export async function hydrate(options: {|
95
127
  recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
96
128
  }
97
129
 
130
+ // `<StrictMode>` renders no element of its own, so the tree React hydrates
131
+ // against the server's markup is the same tree either way and the flag can
132
+ // be a development-only difference without being a hydration difference.
133
+ const tree = <App url={url} initial={resolved} />;
134
+
98
135
  startTransition(() => {
99
136
  hydrateRoot(
100
137
  container,
101
- <App url={url} initial={resolved} />,
138
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
102
139
  recovery == null ? undefined : { onRecoverableError: recovery },
103
140
  );
104
141
  });
142
+
143
+ // And, in development only, whether the panel a developer is about to open
144
+ // can see any of that. `react-dom` announced itself while it was being
145
+ // imported — long before this line — so the answer is already settled and
146
+ // this only reads it. Behind the same `import.meta.hot` gate as the
147
+ // hydration reporter, dynamically imported for the same reason: a production
148
+ // bundle has no path to the module rather than merely no reason to run it.
149
+ // See `./internal/devtools.js` and ubugeeei-prod/uf#503.
150
+ if (import.meta.hot != null) {
151
+ const { reportDevtools } = await import("./internal/devtools.js");
152
+ reportDevtools(window);
153
+ }
105
154
  }
@@ -0,0 +1,131 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: whether React DevTools can actually attach
4
+ // to the page this browser just hydrated.
5
+ //
6
+ // `@uniflowed/vite` installs the hook DevTools attaches through, as a classic
7
+ // script above every module — see `packages/vite/internal/devtools.js`, which
8
+ // has the argument. That is uf saying what it intends. This is the half that
9
+ // checks it happened, and the two are not the same claim: the preamble is
10
+ // injected by a `transformIndexHtml` hook, into a document a project's own Vite
11
+ // plugins also write to, and the thing that has to be true is an *ordering* —
12
+ // the hook exists before `react-dom` is evaluated — which no amount of reading
13
+ // the injector can establish about a particular page.
14
+ //
15
+ // It runs once, after hydration, in development only, and says nothing at all
16
+ // when there is nothing wrong. See ubugeeei-prod/uf#503.
17
+ //
18
+ // # Why it reports rather than throws
19
+ //
20
+ // Nothing here is a reason to stop a page. DevTools not attaching costs a
21
+ // developer a panel, and a framework that refused to render over it would have
22
+ // turned a missing convenience into an outage. So the two findings go to the
23
+ // terminal on the same channel as every other browser-side diagnostic — see
24
+ // `./diagnostics.js` — and the page carries on.
25
+ //
26
+ // # The two findings, and why they are the two
27
+ //
28
+ // A third condition — React's *development* build, which is what gives the
29
+ // panel props, hooks and source positions — is not checked here because a
30
+ // browser cannot tell the difference from the outside, and because uf owns it
31
+ // end to end: `mode` is `development` and `uf transform` is called with
32
+ // `development: true`, both asserted in `tests/library/devtools.test.js`
33
+ // against the plugin rather than against a page. What is left is what only a
34
+ // running page knows.
35
+ //
36
+ // 1. **There is no hook.** Something ran before `react-dom` and there was
37
+ // nothing for it to register with, or the preamble did not reach this
38
+ // document. Either way no renderer was announced and the panel will say
39
+ // the page is not using React.
40
+ // 2. **There is more than one renderer.** Two copies of `react-dom` each
41
+ // injected, and DevTools shows the tree of whichever it heard from — which
42
+ // is the shape of the "multiple renderers concurrently rendering the same
43
+ // context provider" report, and a component tree that is missing half the
44
+ // page for a reason nothing on screen explains.
45
+
46
+ import { reportDiagnostic } from "./diagnostics.js";
47
+
48
+ /**
49
+ * The global React registers itself with.
50
+ *
51
+ * The same string as `DEVTOOLS_HOOK` in `@uniflowed/vite`'s
52
+ * `internal/devtools.js`, written out again rather than imported for the reason
53
+ * that file's neighbour `internal/diagnostics.js` gives about the endpoint
54
+ * paths: `@uniflowed/vite` is loaded by Vite before any Flow transform exists
55
+ * and this module is Flow, so the import cannot go either way.
56
+ * `tests/library/devtools.test.js` asserts the two spellings agree, which is
57
+ * what makes a duplicated constant honest.
58
+ */
59
+ export const DEVTOOLS_HOOK: string = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
60
+
61
+ /** The part of a page this module reads. */
62
+ type HookWindow = {
63
+ [key: string]: mixed,
64
+ ...
65
+ };
66
+
67
+ /**
68
+ * What is wrong with this page's DevTools hook, as a diagnostic, or `null`.
69
+ *
70
+ * Separated from the reporting so that a test can ask the question without a
71
+ * channel to answer on, and because the wording is the part worth pinning: a
72
+ * reader who sees this in a terminal has to be able to act on it without
73
+ * reading this file.
74
+ */
75
+ export function devtoolsProblem(win: HookWindow): {|
76
+ readonly message: string,
77
+ readonly detail: $ReadOnlyArray<string>,
78
+ |} | null {
79
+ const hook = win[DEVTOOLS_HOOK];
80
+ if (hook == null || typeof hook !== "object") {
81
+ return {
82
+ message: `React DevTools cannot attach: nothing installed \`${DEVTOOLS_HOOK}\` before react-dom ran`,
83
+ detail: [
84
+ "React registers itself with that global while `react-dom` is evaluated, once and never again.",
85
+ "`uf dev` injects the hook as a classic script at the top of the head; a plugin that replaces",
86
+ "`transformIndexHtml`'s output, or a document that does not go through it, takes it away.",
87
+ ],
88
+ };
89
+ }
90
+
91
+ // `renderers` is a Map React puts its renderer in, keyed by the id `inject`
92
+ // handed back. Anything else there is a hook uf did not install and DevTools
93
+ // did not either, and guessing at its shape would report a problem that is
94
+ // really this module not recognising one.
95
+ const renderers = (hook: $FlowFixMe).renderers;
96
+ const count = renderers instanceof Map ? renderers.size : null;
97
+ if (count != null && count > 1) {
98
+ return {
99
+ message: `React DevTools has ${count} renderers on this page and will show one of them`,
100
+ detail: [
101
+ "Two copies of `react-dom` are loaded, so half the component tree is in a tree the panel cannot see.",
102
+ '`resolve.dedupe: ["react", "react-dom"]` is what usually prevents it; a linked package with its',
103
+ "own `react-dom` in `node_modules` is what usually causes it.",
104
+ ],
105
+ };
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Report the problem, if there is one, to the terminal running `uf dev`.
112
+ *
113
+ * Throws nothing and returns nothing, and the guard is around the whole body
114
+ * rather than around the reading: this is called from the line after a
115
+ * successful hydration, so every failure available to it — a hook object whose
116
+ * property getter throws, a host with no `fetch` to report through — is a
117
+ * development convenience failing, and a development convenience that can take
118
+ * a working page down is worse than no convenience at all.
119
+ */
120
+ export function reportDevtools(win: HookWindow): void {
121
+ try {
122
+ const problem = devtoolsProblem(win);
123
+ if (problem == null) {
124
+ return;
125
+ }
126
+ reportDiagnostic({ severity: "warn", message: problem.message, detail: problem.detail });
127
+ } catch {
128
+ // Deliberately silent. There is no second channel to complain on, and the
129
+ // thing being reported was never worth interrupting anybody for.
130
+ }
131
+ }
@@ -39,9 +39,9 @@
39
39
  // `uf dev` serves this path and nothing else does: a built application has no
40
40
  // `/__uf/` anything, so a call in production posts to a path that answers 404
41
41
  // and the rejected promise is swallowed here. That is a fallback rather than a
42
- // design — the only caller is behind `import.meta.hot` in `../client.js`, so a
43
- // production bundle has no path to this module rather than merely no answer
44
- // from it.
42
+ // design — both callers are behind `import.meta.hot` in `../client.js`, the
43
+ // hydration report and the DevTools check, so a production bundle has no path
44
+ // to this module rather than merely no answer from it.
45
45
  //
46
46
  // Nothing here opens a connection until it is called, importing it does nothing
47
47
  // at all, and what it sends goes to the page's own origin as a path rather than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.14",
3
+ "version": "0.0.0-alpha.15",
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",
@@ -33,7 +33,7 @@
33
33
  "react-dom": ">=19"
34
34
  },
35
35
  "dependencies": {
36
- "@uniflowed/hooks": "0.0.0-alpha.14",
37
- "@uniflowed/server": "0.0.0-alpha.14"
36
+ "@uniflowed/hooks": "0.0.0-alpha.15",
37
+ "@uniflowed/server": "0.0.0-alpha.15"
38
38
  }
39
39
  }