@uniflowed/router 0.0.0-alpha.12 → 0.0.0-alpha.13

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.
@@ -125,8 +125,13 @@ export type DocumentShell = {|
125
125
  * `</head>` React writes.
126
126
  */
127
127
  readonly head: string,
128
- /** Everything before the markup of an app that renders no document. */
128
+ /**
129
+ * For an app that renders no document: everything up to the point uf's own
130
+ * head can still take tags — so it ends *inside* an open `<head>`.
131
+ */
129
132
  readonly open: string,
133
+ /** The rest of that head, and everything up to the app's markup. */
134
+ readonly body: string,
130
135
  /** Everything after it. */
131
136
  readonly close: string,
132
137
  |};
@@ -293,6 +298,11 @@ function queueDestination(queue: ChunkQueue): NodeDestination {
293
298
  * React writes a document with no head at all — an app whose root layout is
294
299
  * `<html><body>` — the tags go in a head of uf's own, inserted after the
295
300
  * opening tag, which is what the browser would have synthesized anyway.
301
+ *
302
+ * The shell case waits for the end of the run of hoistable elements React
303
+ * opened with, because that is where *its* tags go — see [`hoisted`]. Both
304
+ * waits are bounded by the head, and both are the same idea: the head goes out
305
+ * once, and everything that belongs in it has to be in hand by then.
296
306
  */
297
307
  async function* assembled(
298
308
  chunks: AsyncGenerator<string, void, void>,
@@ -302,7 +312,7 @@ async function* assembled(
302
312
  let shape = "unknown";
303
313
 
304
314
  for await (const chunk of chunks) {
305
- if (shape === "document-open" || shape === "shell") {
315
+ if (shape === "document-open" || shape === "shell-open") {
306
316
  yield chunk;
307
317
  continue;
308
318
  }
@@ -312,11 +322,16 @@ async function* assembled(
312
322
  if (shape === "unknown") {
313
323
  continue;
314
324
  }
315
- if (shape === "shell") {
316
- yield shell.open + held;
317
- held = "";
325
+ }
326
+ if (shape === "shell") {
327
+ const split = hoisted(held);
328
+ if (!split.complete) {
318
329
  continue;
319
330
  }
331
+ shape = "shell-open";
332
+ yield shell.open + split.head + shell.body + split.rest;
333
+ held = "";
334
+ continue;
320
335
  }
321
336
  // A document, and the tags go where its head closes.
322
337
  const close = held.indexOf("</head>");
@@ -335,17 +350,21 @@ async function* assembled(
335
350
  }
336
351
  }
337
352
 
338
- // The render ended before the decision could be made, or before the head it
339
- // opened was closed: an empty document, or one with neither `</head>` nor
340
- // `<body>` in it. There is nothing left to wait for either way.
353
+ // The render ended before the decision could be made, or before what was
354
+ // being waited for arrived: an empty document, one with neither `</head>` nor
355
+ // `<body>` in it, or a shell that is hoistable elements all the way down.
356
+ // There is nothing left to wait for in any of them.
341
357
  if (shape === "document") {
342
358
  yield ufDoctype(held + shell.head);
343
359
  shape = "document-open";
344
- } else if (shape === "unknown") {
345
- shape = "shell";
346
- yield shell.open + held;
360
+ } else if (shape === "shell" || shape === "unknown") {
361
+ // `complete` is not consulted: nothing more is coming, so a run that was
362
+ // still open is over and whatever was left of it is markup like any other.
363
+ const split = hoisted(held);
364
+ shape = "shell-open";
365
+ yield shell.open + split.head + shell.body + split.rest;
347
366
  }
348
- if (shape === "shell") {
367
+ if (shape === "shell-open") {
349
368
  yield shell.close;
350
369
  } else {
351
370
  // The newline `assemble` ended a document with, kept: `uf build` writes
@@ -355,6 +374,97 @@ async function* assembled(
355
374
  }
356
375
  }
357
376
 
377
+ /**
378
+ * The head elements React opened the app's markup with, split from the rest.
379
+ *
380
+ * `complete` is false while the buffer might still be in the middle of one — a
381
+ * chunk that ends inside `<meta cont`, or after a `<link>` and before whatever
382
+ * follows it. Deciding on an incomplete buffer would be a classification that
383
+ * depends on where React split its output, which is the bug `documentShape`
384
+ * above is written the way it is to avoid. The split is filled in either way,
385
+ * because the caller that has run out of chunks has nothing left to wait for
386
+ * and wants it.
387
+ *
388
+ * # Why a leading run rather than the whole document
389
+ *
390
+ * React hoists a `<title>`, a `<meta>` and a `<link>` into the `<head>` of a
391
+ * document *it* rendered. uf's shell is not one — React is handed the app, not
392
+ * the document — so with the shell every one of those landed in the body, and
393
+ * `<link rel="canonical">` in a body is a canonical link Google does not read.
394
+ * The fix is for uf to do the hoisting into the head it wrote itself.
395
+ *
396
+ * A leading run is what can be hoisted without holding the document. `RouteView`
397
+ * renders the route's metadata first, before the layouts and the page, so the
398
+ * run is exactly that metadata and the wait ends at the first byte of the
399
+ * application's own markup. Scanning further would mean buffering an arbitrary
400
+ * amount of a document to find a `<meta>` that might be at the end of it, which
401
+ * is streaming in shape and buffering in fact — the same trade this module
402
+ * refuses in `ChunkQueue`. So a tag a component renders further in stays where
403
+ * it is, and on a client React will hoist it into `document.head` itself.
404
+ *
405
+ * # Reading React's markup with a regular expression
406
+ *
407
+ * Which is only safe because it is React's. React escapes `>` in an attribute
408
+ * value and `<` in text, so the first `>` after an opening tag ends it and the
409
+ * first `</title>` ends a title — neither can appear inside one. This function
410
+ * is not an HTML parser and must never be handed markup from anywhere else.
411
+ */
412
+ function hoisted(held: string): HoistedHead {
413
+ let index = 0;
414
+ while (index < held.length) {
415
+ const rest = held.slice(index);
416
+ const split = { head: held.slice(0, index), rest, complete: false };
417
+ const open = rest.match(/^<(title|meta|link)(?=[\s/>])/i);
418
+ if (open == null) {
419
+ // Not a hoistable element, or not yet enough bytes to say it is not one.
420
+ return { ...split, complete: !couldOpenHoistable(rest) };
421
+ }
422
+ const close = held.indexOf(">", index);
423
+ if (close === -1) {
424
+ return split;
425
+ }
426
+ if (open[1].toLowerCase() !== "title") {
427
+ index = close + 1;
428
+ continue;
429
+ }
430
+ const end = held.indexOf("</title>", close);
431
+ if (end === -1) {
432
+ return split;
433
+ }
434
+ index = end + "</title>".length;
435
+ }
436
+ // Every byte so far is a complete hoistable element, and the next one may
437
+ // still be on its way — the case a document that is metadata and nothing else
438
+ // ends in, and the reason the loop is bounded by the buffer rather than by
439
+ // `true`: a `while (true)` here is a function the checker reads as returning
440
+ // `void` on a path it cannot see is unreachable.
441
+ return { head: held, rest: "", complete: false };
442
+ }
443
+
444
+ /** What [`hoisted`] found, and whether more bytes could still change it. */
445
+ type HoistedHead = {|
446
+ /** The hoistable elements the markup opened with. */
447
+ readonly head: string,
448
+ /** Everything after them. */
449
+ readonly rest: string,
450
+ /** Whether the run is known to have ended. */
451
+ readonly complete: boolean,
452
+ |};
453
+
454
+ /**
455
+ * Whether `rest` could still turn into a hoistable element once more bytes
456
+ * arrive.
457
+ *
458
+ * True for `"<"` and for every proper prefix of `<title`, `<meta` and `<link` —
459
+ * the states a chunk boundary can leave the buffer in. False for `<main`, and
460
+ * false for `<titlebar>`, which is somebody's component and not a title however
461
+ * much of it has arrived.
462
+ */
463
+ function couldOpenHoistable(rest: string): boolean {
464
+ const text = rest.toLowerCase();
465
+ return ["<title", "<meta", "<link"].some((tag) => tag.startsWith(text));
466
+ }
467
+
358
468
  /**
359
469
  * uf's spelling of the doctype, in place of whichever one React wrote.
360
470
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.12",
3
+ "version": "0.0.0-alpha.13",
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",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "exports": {
14
14
  ".": "./index.js",
15
+ "./action": "./action.js",
15
16
  "./client": "./client.js",
16
17
  "./server": "./server.js",
17
18
  "./package.json": "./package.json",
@@ -19,6 +20,7 @@
19
20
  "./middleware": "./middleware.js"
20
21
  },
21
22
  "files": [
23
+ "action.js",
22
24
  "client.js",
23
25
  "handler.js",
24
26
  "index.js",
@@ -31,6 +33,6 @@
31
33
  "react-dom": ">=19"
32
34
  },
33
35
  "dependencies": {
34
- "@uniflowed/server": "0.0.0-alpha.12"
36
+ "@uniflowed/server": "0.0.0-alpha.13"
35
37
  }
36
38
  }
package/server.js CHANGED
@@ -23,7 +23,8 @@
23
23
  // `internal/stream.js` holds the mechanics and says which React renderer serves
24
24
  // which.
25
25
 
26
- import { DATA_ID, ROOT_ID } from "./internal/document.js";
26
+ import { noteRoute } from "@uniflowed/server/host";
27
+ import { ROOT_ID } from "./internal/document.js";
27
28
  import * as React from "react";
28
29
 
29
30
  import {
@@ -148,6 +149,23 @@ export type {
148
149
  } from "./middleware.js";
149
150
  export { createMiddlewareRunner } from "./middleware.js";
150
151
 
152
+ /**
153
+ * The endpoint a `"use server"` export is dialled at.
154
+ *
155
+ * Here rather than beside `@uniflowed/router/action`, which is the browser's
156
+ * half of the same feature and must stay reachable from a client component:
157
+ * this one refuses outside a request, so it imports `internal/request.js` and
158
+ * through it `node:async_hooks`. The two halves share `internal/action-wire.js`
159
+ * and nothing else, which is what keeps one grammar rather than two.
160
+ *
161
+ * `virtual:uf/server` calls it with the table `virtual:uf/actions` built from
162
+ * the RSC manifest, and every host runs it between the middleware and the
163
+ * route handlers. See `internal/action-endpoint.js` for what the endpoint
164
+ * refuses and why.
165
+ */
166
+ export type { ActionModule, ActionRecord } from "./internal/action-endpoint.js";
167
+ export { createActionDispatcher } from "./internal/action-endpoint.js";
168
+
151
169
  /**
152
170
  * What a URL turned out to be: a route to render, or a redirect to answer with.
153
171
  *
@@ -201,13 +219,31 @@ export function createRenderer(options: {|
201
219
  * The route to render, or the redirect to answer with instead.
202
220
  *
203
221
  * Shared by both entry points, because *what* a URL resolves to has nothing
204
- * to do with how the answer is delivered. Returning the redirect rather than
205
- * throwing it keeps the two callers from each having to remember that a
206
- * redirect is the one thing `resolveMatch` lets out.
222
+ * to do with how the answer is delivered — with one exception, which is
223
+ * `defer` and is the exception that proves it. Whether the router may hand
224
+ * the page a loader that has not answered yet *is* a question about delivery:
225
+ * only a renderer with a `<Suspense>` fallback to send first has anywhere to
226
+ * put the wait. `render` says yes and `prerender` says no; see
227
+ * `ResolveOptions.defer` and ubugeeei-prod/uf#373.
228
+ *
229
+ * Returning the redirect rather than throwing it keeps the two callers from
230
+ * each having to remember that a redirect is the one thing `resolveMatch`
231
+ * lets out.
232
+ *
233
+ * `onMatch` is what makes the request's log line say `/orders/:id` rather
234
+ * than `/orders/8813`. It is handed to `resolveMatch` rather than read off
235
+ * the route this returns, because a loader runs *inside* that call and a
236
+ * loader has things to log: recording the route afterwards would leave every
237
+ * line the loader wrote claiming to belong to no route at all. `noteRoute`
238
+ * does nothing outside a request, which is what lets `prerender` — a build,
239
+ * with no request anywhere — call the same function.
207
240
  */
208
- async function resolve(url: string): Promise<Resolution> {
241
+ async function resolve(url: string, defer: boolean): Promise<Resolution> {
209
242
  try {
210
- return { kind: "route", route: await resolveMatch(table, url) };
243
+ return {
244
+ kind: "route",
245
+ route: await resolveMatch(table, url, { defer, onMatch: noteRoute }),
246
+ };
211
247
  } catch (error) {
212
248
  if (error instanceof RedirectError) {
213
249
  return { kind: "redirect", error };
@@ -221,7 +257,7 @@ export function createRenderer(options: {|
221
257
  assets: RenderAssets,
222
258
  settings?: RenderOptions,
223
259
  ): Promise<RenderResult> {
224
- const resolution = await resolve(url);
260
+ const resolution = await resolve(url, true);
225
261
  if (resolution.kind === "redirect") {
226
262
  return redirectDocument(resolution.error);
227
263
  }
@@ -247,7 +283,7 @@ export function createRenderer(options: {|
247
283
  let body: DocumentBody;
248
284
  try {
249
285
  body = await renderDocument(<App url={url} initial={resolved} />, {
250
- shell: shellFor(resolved, assets),
286
+ shell: shellFor(assets),
251
287
  onError,
252
288
  });
253
289
  streaming = true;
@@ -277,7 +313,7 @@ export function createRenderer(options: {|
277
313
  // where somebody can fix it.
278
314
  streaming = true;
279
315
  body = await renderDocument(<App url={url} initial={resolved} />, {
280
- shell: shellFor(resolved, assets),
316
+ shell: shellFor(assets),
281
317
  onError,
282
318
  });
283
319
  }
@@ -296,7 +332,7 @@ export function createRenderer(options: {|
296
332
  assets: RenderAssets,
297
333
  settings?: RenderOptions,
298
334
  ): Promise<PrerenderResult> {
299
- const resolution = await resolve(url);
335
+ const resolution = await resolve(url, false);
300
336
  if (resolution.kind === "redirect") {
301
337
  return redirectResult(redirectDocument(resolution.error));
302
338
  }
@@ -306,7 +342,7 @@ export function createRenderer(options: {|
306
342
  let html: string;
307
343
  try {
308
344
  html = await prerenderDocument(<App url={url} initial={resolved} />, {
309
- shell: shellFor(resolved, assets),
345
+ shell: shellFor(assets),
310
346
  onError: report,
311
347
  });
312
348
  } catch (error) {
@@ -315,7 +351,7 @@ export function createRenderer(options: {|
315
351
  }
316
352
  resolved = await resolveFailure(table, url, error);
317
353
  html = await prerenderDocument(<App url={url} initial={resolved} />, {
318
- shell: shellFor(resolved, assets),
354
+ shell: shellFor(assets),
319
355
  onError: report,
320
356
  });
321
357
  }
@@ -369,14 +405,29 @@ function redirectDocument(error: RedirectError): RenderResult {
369
405
  * `internal/stream.js` picks between them on the opening bytes React writes;
370
406
  * everything either shape is made of is here, so what a uf document contains is
371
407
  * still readable in one place.
408
+ *
409
+ * # Why the shell is three strings and not one
410
+ *
411
+ * Because uf's own `<head>` has to still be open when React's head tags arrive.
412
+ * React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
413
+ * itself, and here it wrote none — so with one string this shell closed its
414
+ * head before the app had rendered a byte, and every `og:` tag and the
415
+ * `<link rel="canonical">` landed in the body, where a crawler ignores them.
416
+ * `open` is uf's head up to that point, `body` is the rest of it and the
417
+ * wrapper, and what goes between them is whatever `assembled` lifts out of the
418
+ * app's own markup. See ubugeeei-prod/uf#547.
419
+ *
420
+ * That is also why no `<title>` is written here any more. It was, from
421
+ * `resolved.metadata.title` — the same string `Head` renders — so a document
422
+ * carried two of them, one in each place, and only one was where a browser
423
+ * looks. Hoisting the rendered one leaves the metadata with a single source.
372
424
  */
373
- function shellFor(resolved: ResolvedRoute, assets: RenderAssets): DocumentShell {
374
- const head = headTags(assets) + dataScript(resolved.data);
375
- const title =
376
- resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
425
+ function shellFor(assets: RenderAssets): DocumentShell {
426
+ const head = headTags(assets);
377
427
  return {
378
428
  head,
379
- open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}</head><body><div id="${ROOT_ID}">`,
429
+ open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
430
+ body: `${head}</head><body><div id="${ROOT_ID}">`,
380
431
  close: `</div></body></html>\n`,
381
432
  };
382
433
  }
@@ -395,28 +446,6 @@ function headTags(assets: RenderAssets): string {
395
446
  return tags;
396
447
  }
397
448
 
398
- /**
399
- * The loader data, embedded for hydration.
400
- *
401
- * `<` is escaped inside the JSON so a string holding `</script>` cannot end
402
- * the element early, and the script's type keeps the browser from executing
403
- * it.
404
- */
405
- function dataScript(data: mixed): string {
406
- if (data === undefined) {
407
- return "";
408
- }
409
- const json = JSON.stringify(data)
410
- .replace(/</g, "\\u003c")
411
- .replace(/\u2028/g, "\\u2028")
412
- .replace(/\u2029/g, "\\u2029");
413
- return `<script id="${DATA_ID}" type="application/json">${json}</script>`;
414
- }
415
-
416
449
  function escapeAttribute(value: string): string {
417
450
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
418
451
  }
419
-
420
- function escapeText(value: string): string {
421
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
422
- }