@uniflowed/vite 0.0.0-alpha.6 → 0.0.0-alpha.7

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/index.js CHANGED
@@ -30,6 +30,7 @@ import path from "node:path";
30
30
  import mdx from "@mdx-js/rollup";
31
31
  import rehypeSlug from "rehype-slug";
32
32
 
33
+ import { reportRenderError } from "./internal/events.js";
33
34
  import { highlightPlugin } from "./internal/highlight.js";
34
35
  import remarkFrontmatter from "remark-frontmatter";
35
36
  import remarkGfm from "remark-gfm";
@@ -43,6 +44,7 @@ import {
43
44
  refreshRuntimeSource,
44
45
  } from "./internal/refresh.js";
45
46
  import {
47
+ RESERVED,
46
48
  VIRTUAL,
47
49
  clientModuleSource,
48
50
  routesModuleSource,
@@ -113,6 +115,23 @@ function flowPlugin({ routerRoot, appEntry, command }) {
113
115
  * hand, which is the part that goes wrong.
114
116
  */
115
117
  const styles = new Map();
118
+ /**
119
+ * The React Compiler findings already reported, so each is said once.
120
+ *
121
+ * `uf build` runs Vite twice — once for the browser bundle and once for the
122
+ * server one — over the same modules, so every finding was made twice and
123
+ * printed twice. An entry records which environment reported a module's
124
+ * findings first: a re-transform in *that* environment (a dev server, after
125
+ * an edit) clears it and reports again, and the other environment's pass over
126
+ * the same module stays quiet. Keying on the environment rather than on a
127
+ * flag is what keeps the second half true without making the first half
128
+ * false.
129
+ *
130
+ * @type {Map<string, { environment: string, signatures: Set<string> }>}
131
+ */
132
+ const reported = new Map();
133
+ /** Findings held back as a dependency's, waiting to be counted out loud. */
134
+ let suppressed = [];
116
135
 
117
136
  const ensureService = () => {
118
137
  service ??= new TransformService({ command, root });
@@ -194,9 +213,14 @@ function flowPlugin({ routerRoot, appEntry, command }) {
194
213
  sourceMap: true,
195
214
  });
196
215
  if (out == null) return null;
197
- for (const diagnostic of out.diagnostics) {
198
- this.warn?.(`${diagnostic.function ?? "a function"}: ${diagnostic.message}`);
199
- }
216
+ reportDiagnostics(this, {
217
+ id: cleanId(id),
218
+ root,
219
+ diagnostics: out.diagnostics,
220
+ environment: ssr ? "ssr" : "client",
221
+ reported,
222
+ suppressed,
223
+ });
200
224
  const map = out.map == null ? null : JSON.parse(out.map);
201
225
  // StyleX. `uf transform` compiled the module's `stylex.create` calls into
202
226
  // class names and handed back the rules they declared; the rules become a
@@ -207,18 +231,34 @@ function flowPlugin({ routerRoot, appEntry, command }) {
207
231
  // business: Vite already injects a stylesheet in dev, extracts it in a
208
232
  // build, code-splits it per chunk, and replaces it over HMR. A module
209
233
  // whose styles are gone stops importing it, and Vite notices.
234
+ const styled = out.css != null && out.css !== "";
210
235
  let output = out.code;
211
- if (out.css != null && out.css !== "") {
236
+ if (styled) {
212
237
  const styleId = `${STYLE_PREFIX}${cleanId(id)}.css`;
213
238
  styles.set(styleId, out.css);
214
239
  output = `import ${JSON.stringify(styleId)};\n${output}`;
215
240
  }
216
- if (!refresh) return { code: output, map };
241
+ // A module that compiled a stylesheet has a side effect, whatever its
242
+ // package says. `@uniflowed/stylex` declares `sideEffects: false` and is
243
+ // right about its source: `tokens.stylex.js` only exports a token set.
244
+ // What it exports after this transform is a token set *and* a `:root`
245
+ // block, and the page that imports `ufTokens` no longer names it at
246
+ // runtime — the compiler turned every read into the `var(--…)` it minted.
247
+ // So the import was unused, a side-effect-free module with no used
248
+ // exports was dropped, and the custom properties every one of those
249
+ // `var()`s resolves against went with it: rules that referred to nothing.
250
+ // Declaring the side effect here rather than editing the package is
251
+ // deliberate — the side effect is one this plugin added, so it is this
252
+ // plugin's to admit to. See ubugeeei-prod/uf#306.
253
+ const moduleSideEffects = styled ? true : undefined;
254
+ if (!refresh) return { code: output, map, moduleSideEffects };
217
255
  const relative = path.relative(root, cleanId(id)).split(path.sep).join("/");
218
- return addRefreshWrapper(output, map, relative);
256
+ return { ...addRefreshWrapper(output, map, relative), moduleSideEffects };
219
257
  },
220
258
 
221
259
  buildEnd() {
260
+ summariseSuppressed(this, suppressed);
261
+ suppressed = [];
222
262
  // A dev server keeps its service for the whole session; a build is
223
263
  // done with it here.
224
264
  if (server == null) {
@@ -246,9 +286,19 @@ function flowPlugin({ routerRoot, appEntry, command }) {
246
286
  service = null;
247
287
  });
248
288
 
249
- // A page or layout appearing or disappearing changes the route table,
289
+ // A reserved file appearing or disappearing changes the route table,
250
290
  // which lives in a virtual module the watcher knows nothing about.
251
- const reserved = /\/_uf\.(page|layout|middleware|not-found)(\.[a-z]+)?\.(js|jsx|mdx)$/;
291
+ //
292
+ // Built from `RESERVED` rather than written out. It used to be the
293
+ // literal `(page|layout|middleware|not-found)`, which is a fourth
294
+ // spelling of a grammar that already has three, and it was already
295
+ // missing `route` — so adding a route handler to a running dev server
296
+ // did not rebuild the table and the handler stayed invisible until a
297
+ // restart. A list that has to match another list has to be that list.
298
+ const stems = Object.values(RESERVED)
299
+ .map((stem) => stem.replaceAll(".", "\\."))
300
+ .join("|");
301
+ const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
252
302
  const onRouteFile = (file) => {
253
303
  if (!reserved.test(file) || !file.startsWith(appRoot)) return;
254
304
  const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
@@ -272,6 +322,7 @@ function flowPlugin({ routerRoot, appEntry, command }) {
272
322
  styles: [],
273
323
  preloads: [],
274
324
  });
325
+ if (result.error != null) reportRenderError(devServer, url, result.error);
275
326
  const html = await devServer.transformIndexHtml(url, result.html);
276
327
  response.statusCode = result.status;
277
328
  response.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -344,6 +395,124 @@ function wantsDocument(request) {
344
395
  return !/\.[a-z0-9]+$/i.test(pathname);
345
396
  }
346
397
 
398
+ /** The name of the environment variable that turns every finding back on. */
399
+ const ALL_DIAGNOSTICS = "UF_REACT_COMPILER_DIAGNOSTICS";
400
+
401
+ /**
402
+ * Report what the React Compiler said about one module.
403
+ *
404
+ * Every finding used to be printed as `a function: <message>` — no file, no
405
+ * line, no column, and the fallback string doing all the work because the
406
+ * compiler names an inner function about as often as not. The transform hook
407
+ * knows the module and the compiler gives a position for most findings, so
408
+ * both go into the message: Vite prints a plugin warning's `message` and
409
+ * nothing else, so a location that is not in the string is a location the
410
+ * reader never sees. `id` and `loc` go along for anything reading the log
411
+ * object rather than the line. See ubugeeei-prod/uf#307.
412
+ *
413
+ * A dependency's findings are held back. A React Compiler bailout inside
414
+ * `@uniflowed/form` is not something the person running the build can fix, and
415
+ * a channel carrying forty of them on every build is a channel people stop
416
+ * reading — which costs them the one finding that was theirs. They are counted
417
+ * and said once instead, and `UF_REACT_COMPILER_DIAGNOSTICS=all` prints every
418
+ * one for whoever is fixing the dependency.
419
+ */
420
+ function reportDiagnostics(context, { id, root, diagnostics, environment, reported, suppressed }) {
421
+ if (diagnostics.length === 0) return;
422
+ // A module transformed again by the environment that first reported it has
423
+ // been edited; anything else is the second bundle passing over the same file.
424
+ const previous = reported.get(id);
425
+ const ledger =
426
+ previous != null && previous.environment !== environment
427
+ ? previous
428
+ : { environment, signatures: new Set() };
429
+ reported.set(id, ledger);
430
+
431
+ const file = relativeId(root, id);
432
+ const mine = isProjectModule(root, id) || process.env[ALL_DIAGNOSTICS] === "all";
433
+ for (const diagnostic of diagnostics) {
434
+ // Everything a reader would be shown, so two findings that would print as
435
+ // the same line collapse into one. The compiler reports "Cannot access refs
436
+ // during render" once per pass that noticed it — three times for one `ref`
437
+ // — and three identical lines are not three things to fix.
438
+ const signature = `${diagnostic.kind}\0${diagnostic.line}\0${diagnostic.column}\0${diagnostic.message}`;
439
+ if (ledger.signatures.has(signature)) continue;
440
+ ledger.signatures.add(signature);
441
+ if (!mine) {
442
+ suppressed.push(file);
443
+ continue;
444
+ }
445
+ // Two conventions, both honoured. uf's own frames count columns from one
446
+ // (`uf_term::diagnostic`) and so does every editor a reader will paste
447
+ // `file:line:column` into; Rollup's `loc.column` counts from zero, which is
448
+ // what the compiler already gave us. The string gets the reader's number
449
+ // and the log object gets Rollup's.
450
+ const at = diagnostic.line == null ? "" : `:${diagnostic.line}:${(diagnostic.column ?? 0) + 1}`;
451
+ const who = diagnostic.function == null ? "" : ` (in ${diagnostic.function})`;
452
+ context.warn?.({
453
+ message: `${file}${at}: ${diagnostic.message}${who}`,
454
+ id,
455
+ loc:
456
+ diagnostic.line == null
457
+ ? undefined
458
+ : { file: id, line: diagnostic.line, column: diagnostic.column ?? 0 },
459
+ });
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Say how many findings were a dependency's, and whose.
465
+ *
466
+ * Held back is not the same as hidden: a build that quietly drops forty
467
+ * findings is a build that has decided for the reader that uf has no bugs. One
468
+ * line names the packages and how to see the rest.
469
+ */
470
+ function summariseSuppressed(context, suppressed) {
471
+ if (suppressed.length === 0) return;
472
+ const packages = [...new Set(suppressed.map(packageOf))].sort();
473
+ const count = suppressed.length;
474
+ context.warn?.(
475
+ `${count} React Compiler ${count === 1 ? "finding" : "findings"} in ` +
476
+ `${packages.join(", ")} — not this application's to fix; ` +
477
+ `set ${ALL_DIAGNOSTICS}=all to see them`,
478
+ );
479
+ }
480
+
481
+ /**
482
+ * Whether a finding about this module is the application author's to act on.
483
+ *
484
+ * Inside the project root *and* outside `node_modules`, rather than
485
+ * `node_modules` alone: uf's own packages reach an application through a
486
+ * workspace link in this repository and through `node_modules` everywhere
487
+ * else, and they are no more the reader's code in one case than the other.
488
+ */
489
+ function isProjectModule(root, id) {
490
+ const relative = path.relative(root, id);
491
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
492
+ return !relative.split(path.sep).includes("node_modules");
493
+ }
494
+
495
+ /** A module's path as a reader would write it: relative, with forward slashes. */
496
+ function relativeId(root, id) {
497
+ const relative = path.relative(root, id);
498
+ return relative === "" ? id : relative.split(path.sep).join("/");
499
+ }
500
+
501
+ /** The package a module belongs to, for the one line that names them. */
502
+ function packageOf(file) {
503
+ const parts = file.split("/");
504
+ const at = parts.lastIndexOf("node_modules");
505
+ if (at !== -1) {
506
+ const scoped = parts[at + 1]?.startsWith("@");
507
+ return parts.slice(at + 1, at + (scoped ? 3 : 2)).join("/");
508
+ }
509
+ // No `node_modules` in the path: a workspace link, resolved to a checkout.
510
+ // The directory the module hangs off is the closest thing to a package name
511
+ // that is true without reading its `package.json` from a warning path.
512
+ const up = parts.lastIndexOf("packages");
513
+ return up === -1 ? parts.slice(0, -1).join("/") || file : parts.slice(up, up + 2).join("/");
514
+ }
515
+
347
516
  function cleanId(id) {
348
517
  const at = id.indexOf("?");
349
518
  return at === -1 ? id : id.slice(0, at);
@@ -51,6 +51,26 @@ export function stripAnsi(text) {
51
51
  return text.replace(ANSI, "");
52
52
  }
53
53
 
54
+ /**
55
+ * Report a page that rendered its error boundary instead of itself.
56
+ *
57
+ * `uf dev` has two renderers — the plugin's middleware and the driver's — and
58
+ * this is the one place either of them says so, because a message written
59
+ * twice is a message that ends up saying two things. The document the browser
60
+ * gets is the application's error page, which is what a visitor would see;
61
+ * the exception belongs in the terminal, which is uf's.
62
+ *
63
+ * The stack is mapped back onto the Flow source first, so the frames name the
64
+ * file that was written rather than the one that was compiled.
65
+ */
66
+ export function reportRenderError(server, url, error) {
67
+ if (error instanceof Error) {
68
+ server.ssrFixStacktrace(error);
69
+ }
70
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
71
+ server.config.logger.error(`${url} rendered its error boundary\n${stripAnsi(detail)}`);
72
+ }
73
+
54
74
  /**
55
75
  * Describe an error for the channel: message, and a location when Babel or
56
76
  * Rolldown attached one.
@@ -23,6 +23,7 @@ export const RESERVED = Object.freeze({
23
23
  page: "_uf.page",
24
24
  middleware: "_uf.middleware",
25
25
  notFound: "_uf.not-found",
26
+ error: "_uf.error",
26
27
  route: "_uf.route",
27
28
  });
28
29
 
@@ -56,6 +57,38 @@ const MAX_DEPTH = 32;
56
57
  * @property {string} module absolute path of the handler module
57
58
  */
58
59
 
60
+ /**
61
+ * One not-found boundary — the page a path under `path` gets when nothing
62
+ * there matched.
63
+ *
64
+ * A `_uf.not-found.js` is a segment file like `_uf.layout.js`, so a directory
65
+ * declares the 404 for everything beneath it and the resolver takes the
66
+ * nearest one above the path. `layouts` are the layouts in scope *at that
67
+ * directory*, which is what wraps the boundary when it renders.
68
+ *
69
+ * @typedef {object} NotFoundBoundary
70
+ * @property {string} path route path of the directory that declares it
71
+ * @property {string} page absolute path of the page module
72
+ * @property {ReadonlyArray<string>} layouts absolute paths, root first
73
+ * @property {boolean} mdx whether the page is MDX content
74
+ */
75
+
76
+ /**
77
+ * One error boundary — what renders in place of the subtree under `path` when
78
+ * something in it throws.
79
+ *
80
+ * The same nearest-ancestor shape as a not-found boundary, and deliberately
81
+ * not the same extensions: an error module is handed an error and a `reset`,
82
+ * which is a component's contract. `.mdx` compiles to a component that takes
83
+ * no such thing, so a `_uf.error.mdx` would be a file the router loads and can
84
+ * never hand its arguments to.
85
+ *
86
+ * @typedef {object} ErrorBoundary
87
+ * @property {string} path route path of the directory that declares it
88
+ * @property {string} module absolute path of the error module
89
+ * @property {ReadonlyArray<string>} layouts absolute paths, root first
90
+ */
91
+
59
92
  /**
60
93
  * Scan `appRoot` for routes.
61
94
  *
@@ -64,13 +97,14 @@ const MAX_DEPTH = 32;
64
97
  * library project has no router root, and that is not a mistake.
65
98
  *
66
99
  * @param {string} appRoot absolute path of the router root (`app/`)
67
- * @returns {Route[]}
100
+ * @returns {{routes: Route[], handlers: Handler[], notFound: NotFoundBoundary[], errors: ErrorBoundary[]}}
68
101
  */
69
102
  export function scanRoutes(appRoot) {
70
103
  const routes = [];
71
104
  const handlers = [];
72
- let notFound = null;
73
- if (!isDirectory(appRoot)) return { routes, handlers, notFound };
105
+ const notFound = [];
106
+ const errors = [];
107
+ if (!isDirectory(appRoot)) return { routes, handlers, notFound, errors };
74
108
 
75
109
  const walk = (directory, segments, layouts, middleware, depth) => {
76
110
  if (depth > MAX_DEPTH) return;
@@ -105,9 +139,29 @@ export function scanRoutes(appRoot) {
105
139
  handlers.push({ path: routePath, pattern, params, module: handler });
106
140
  }
107
141
 
108
- if (depth === 0) {
109
- const own = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
110
- if (own) notFound = { page: own, layouts: nextLayouts, mdx: own.endsWith(".mdx") };
142
+ // At every depth, not only the root. This read `if (depth === 0)`, so
143
+ // `app/guide/_uf.not-found.js` was never looked for and a reader who
144
+ // followed a stale link into the manual was answered by the site's root
145
+ // 404, outside the manual's own layout. See ubugeeei-prod/uf#263.
146
+ const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
147
+ if (ownNotFound) {
148
+ notFound.push({
149
+ path: routeFromSegments(segments).path,
150
+ page: ownNotFound,
151
+ layouts: nextLayouts,
152
+ mdx: ownNotFound.endsWith(".mdx"),
153
+ });
154
+ }
155
+
156
+ // `errors` is the boundaries a project declares, not failures that
157
+ // happened: one entry per directory holding an `_uf.error.js`.
158
+ const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS);
159
+ if (ownError) {
160
+ errors.push({
161
+ path: routeFromSegments(segments).path,
162
+ module: ownError,
163
+ layouts: nextLayouts,
164
+ });
111
165
  }
112
166
 
113
167
  for (const entry of entries) {
@@ -129,7 +183,20 @@ export function scanRoutes(appRoot) {
129
183
  const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
130
184
  routes.sort(byPath);
131
185
  handlers.sort(byPath);
132
- return { routes, handlers, notFound };
186
+ // Sorted by path, not by which is nearest: the resolver picks the longest
187
+ // path that covers the URL, so it does not depend on this order, and sorting
188
+ // by nearness would hide that.
189
+ //
190
+ // Two boundaries can share a path, because a `(group)` directory is not a URL
191
+ // segment — `app/_uf.not-found.js` and `app/(marketing)/_uf.not-found.js` are
192
+ // both at `/`, and the URL cannot say which tree it is in. The sort is stable
193
+ // and `walk` records a directory's own boundary before descending, so the
194
+ // shallower file wins, which is the one that is the site's own 404 rather
195
+ // than one section's idea of it. Letting each group own a boundary needs the
196
+ // parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
197
+ notFound.sort(byPath);
198
+ errors.sort(byPath);
199
+ return { routes, handlers, notFound, errors };
133
200
  }
134
201
 
135
202
  function isDirectory(candidate) {
@@ -195,7 +262,7 @@ export const VIRTUAL = Object.freeze({
195
262
  * Layouts are deduplicated into one table so a layout shared by fifty routes
196
263
  * is one dynamic import, not fifty.
197
264
  *
198
- * @param {{routes: Route[], notFound: object | null}} table
265
+ * @param {{routes: Route[], handlers?: Handler[], notFound?: NotFoundBoundary[], errors?: ErrorBoundary[]}} table
199
266
  */
200
267
  export function routesModuleSource(table) {
201
268
  const layoutIds = new Map();
@@ -222,14 +289,32 @@ export function routesModuleSource(table) {
222
289
  }`;
223
290
  });
224
291
 
225
- const notFound = table.notFound
226
- ? `{
227
- mdx: ${table.notFound.mdx},
228
- file: ${JSON.stringify(table.notFound.page)},
229
- page: () => import(${JSON.stringify(table.notFound.page)}),
230
- layouts: [${table.notFound.layouts.map(layoutId).join(", ")}],
231
- }`
232
- : "null";
292
+ // A list, because a not-found is a segment file: every directory may declare
293
+ // one and the router takes the nearest above the path. `layoutId` is the
294
+ // same table the routes use, so a boundary that shares a layout with a page
295
+ // shares its dynamic import too.
296
+ const notFoundEntries = (table.notFound ?? []).map(
297
+ (boundary) => ` {
298
+ path: ${JSON.stringify(boundary.path)},
299
+ mdx: ${boundary.mdx},
300
+ file: ${JSON.stringify(boundary.page)},
301
+ page: () => import(${JSON.stringify(boundary.page)}),
302
+ layouts: [${boundary.layouts.map(layoutId).join(", ")}],
303
+ }`,
304
+ );
305
+
306
+ // An error boundary is loaded with the route it guards rather than when it
307
+ // is needed: React decides to render a boundary's fallback synchronously,
308
+ // during the render that threw, so a module that still has to be imported is
309
+ // a module that is not there when the only chance to use it arrives.
310
+ const errorEntries = (table.errors ?? []).map(
311
+ (boundary) => ` {
312
+ path: ${JSON.stringify(boundary.path)},
313
+ file: ${JSON.stringify(boundary.module)},
314
+ module: () => import(${JSON.stringify(boundary.module)}),
315
+ layouts: [${boundary.layouts.map(layoutId).join(", ")}],
316
+ }`,
317
+ );
233
318
 
234
319
  // Handlers are a separate table because nothing on the client wants them:
235
320
  // a route handler answers a request, so shipping its module to the browser
@@ -250,7 +335,12 @@ ${entries.join(",\n")}
250
335
  export const handlers = [
251
336
  ${handlerEntries.join(",\n")}
252
337
  ];
253
- export const notFound = ${notFound};
338
+ export const notFound = [
339
+ ${notFoundEntries.join(",\n")}
340
+ ];
341
+ export const errors = [
342
+ ${errorEntries.join(",\n")}
343
+ ];
254
344
  export default routes;
255
345
  `;
256
346
  }
@@ -264,9 +354,9 @@ export default routes;
264
354
  */
265
355
  export function clientModuleSource(appEntry) {
266
356
  return `import { hydrate } from "@uniflowed/router/client";
267
- import { routes, notFound } from ${JSON.stringify(VIRTUAL.routes)};
357
+ import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
268
358
  import App from ${JSON.stringify(appEntry)};
269
- hydrate({ App, routes, notFound });
359
+ hydrate({ App, routes, notFound, errors });
270
360
  `;
271
361
  }
272
362
 
@@ -275,10 +365,10 @@ hydrate({ App, routes, notFound });
275
365
  */
276
366
  export function serverModuleSource(appEntry) {
277
367
  return `import { createDispatcher, createRenderer } from "@uniflowed/router/server";
278
- import { routes, handlers, notFound } from ${JSON.stringify(VIRTUAL.routes)};
368
+ import { routes, handlers, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
279
369
  import App from ${JSON.stringify(appEntry)};
280
- export { routes, handlers, notFound };
281
- export const render = createRenderer({ App, routes, notFound });
370
+ export { routes, handlers, notFound, errors };
371
+ export const render = createRenderer({ App, routes, notFound, errors });
282
372
  export const dispatch = createDispatcher({ handlers });
283
373
  `;
284
374
  }