@uniflowed/router 0.0.0-alpha.42 → 0.0.0-alpha.43

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.
Files changed (2) hide show
  1. package/internal/stream.js +121 -15
  2. package/package.json +3 -3
@@ -311,9 +311,16 @@ async function* assembled(
311
311
  chunks: AsyncGenerator<string, void, void>,
312
312
  shell: DocumentShell,
313
313
  transformHead?: (html: string) => Promise<string>,
314
+ layout?: Layout,
314
315
  ): AsyncGenerator<string, void, void> {
315
316
  let held = "";
316
317
  let shape = "unknown";
318
+ // A document uf wraps is the only one whose root is not `<body>` itself.
319
+ const wrapped = () => {
320
+ if (layout != null) {
321
+ layout.rootDepth = advanced(NOTHING_WRITTEN, shell.open + shell.body, 0).depth ?? 0;
322
+ }
323
+ };
317
324
  // The opening chunk is the only one the hook sees, and every path below
318
325
  // reaches exactly one of them. Awaiting here rather than at each `yield`
319
326
  // keeps them from drifting apart. The hook sees only the document opening:
@@ -343,6 +350,7 @@ async function* assembled(
343
350
  continue;
344
351
  }
345
352
  shape = "shell-open";
353
+ wrapped();
346
354
  yield await opening(shell.open + split.head + shell.body, split.rest);
347
355
  held = "";
348
356
  continue;
@@ -382,6 +390,7 @@ async function* assembled(
382
390
  // still open is over and whatever was left of it is markup like any other.
383
391
  const split = hoisted(held);
384
392
  shape = "shell-open";
393
+ wrapped();
385
394
  yield await opening(shell.open + split.head + shell.body, split.rest);
386
395
  }
387
396
  if (shape === "shell-open") {
@@ -721,7 +730,9 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
721
730
  bodyOf(
722
731
  outgoing(
723
732
  withPayload(
724
- assembled(queue.chunks(), options.shell, options.transformHead),
733
+ queue.chunks(),
734
+ options.shell,
735
+ options.transformHead,
725
736
  options.payload,
726
737
  options.nonce,
727
738
  ),
@@ -801,7 +812,9 @@ export function renderWithReadableStream(
801
812
  bodyOf(
802
813
  outgoing(
803
814
  withPayload(
804
- assembled(decoded(stream), options.shell, options.transformHead),
815
+ decoded(stream),
816
+ options.shell,
817
+ options.transformHead,
805
818
  options.payload,
806
819
  options.nonce,
807
820
  ),
@@ -861,26 +874,54 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
861
874
  : await ReactDOMStatic.prerender(node, settings);
862
875
  return bodyOf(
863
876
  withPayload(
864
- assembled(preludeChunks(result.prelude), options.shell, options.transformHead),
877
+ preludeChunks(result.prelude),
878
+ options.shell,
879
+ options.transformHead,
865
880
  options.payload,
866
881
  ),
867
882
  ).text();
868
883
  }
869
884
 
870
- /** `chunks` unchanged when there is no payload, and [`interleaved`] with one. */
885
+ /**
886
+ * What [`assembled`] learns about a document that [`interleaved`] needs.
887
+ *
888
+ * Which shape a document has is decided on its opening bytes, inside
889
+ * `assembled`, and `interleaved` reads that document's chunks — so by the time
890
+ * it has HTML to follow, the answer is here.
891
+ */
892
+ type Layout = {|
893
+ /**
894
+ * How many elements are open inside `<body>` where React's root begins: none
895
+ * for a document React wrote, and uf's own container for one uf wraps.
896
+ */
897
+ rootDepth: number,
898
+ |};
899
+
900
+ /** [`assembled`] alone when there is no payload, and [`interleaved`] with one. */
871
901
  function withPayload(
872
902
  chunks: AsyncGenerator<string, void, void>,
903
+ shell: DocumentShell,
904
+ transformHead: ?(html: string) => Promise<string>,
873
905
  payload: ?ReadableStream<Uint8Array>,
874
906
  nonce?: string | null,
875
907
  ): AsyncGenerator<string, void, void> {
876
- return payload == null ? chunks : interleaved(chunks, payload, nonce);
908
+ if (payload == null) {
909
+ return assembled(chunks, shell, transformHead ?? undefined);
910
+ }
911
+ const layout: Layout = { rootDepth: 0 };
912
+ return interleaved(
913
+ assembled(chunks, shell, transformHead ?? undefined, layout),
914
+ payload,
915
+ nonce,
916
+ layout,
917
+ );
877
918
  }
878
919
 
879
920
  /**
880
921
  * A document's chunks, with the Flight payload it was rendered from written
881
922
  * into it as it arrives.
882
923
  *
883
- * Four rules, and each is the answer to a way the obvious version is wrong.
924
+ * Six rules, and each is the answer to a way the obvious version is wrong.
884
925
  *
885
926
  * **Nothing before the head.** The first chunk this is handed is the whole
886
927
  * opening of the document — `assembled` does not let one go until the head is
@@ -906,6 +947,19 @@ function withPayload(
906
947
  * from chunk to chunk, and otherwise waits for the HTML that finishes what is
907
948
  * open.
908
949
  *
950
+ * **Only where React steps over it.** Between elements is where the *parser*
951
+ * keeps a payload element. React is stricter: hydrating, it steps over an
952
+ * element it did not render only among the children of its root and of
953
+ * `<html>`, `<head>` and `<body>`. Anywhere deeper the element is a child the
954
+ * tree has no fiber for, the hydration fails there, and React throws the
955
+ * server's markup away and renders the document again. A shell that is one
956
+ * large element — a frame around the page, all of it rendered on the server —
957
+ * is written in many pieces, several of which end between two of its children,
958
+ * and a payload that was already waiting used to go out at the first of them.
959
+ * So [`advanced`] also follows how many elements are open inside `<body>`, and
960
+ * a payload waits for the root's own level: between React's top-level
961
+ * elements, which is where every streamed segment is written anyway.
962
+ *
909
963
  * **Outside replaceable boundaries.** Even between tags, a payload inside a
910
964
  * Suspense fallback disappears when React reveals its content. A browser that
911
965
  * loads the Flight reader afterwards then sees an incomplete stream. React's
@@ -927,6 +981,7 @@ async function* interleaved(
927
981
  chunks: AsyncGenerator<string, void, void>,
928
982
  payload: ReadableStream<Uint8Array>,
929
983
  nonce?: string | null,
984
+ layout?: Layout,
930
985
  ): AsyncGenerator<string, void, void> {
931
986
  const encoder = createChunkEncoder(nonce);
932
987
  const reader = payload.getReader();
@@ -992,7 +1047,7 @@ async function* interleaved(
992
1047
  }
993
1048
  if (text !== "") {
994
1049
  yield text;
995
- boundary = advanced(boundary, text);
1050
+ boundary = advanced(boundary, text, layout?.rootDepth ?? 0);
996
1051
  }
997
1052
  next = chunks.next();
998
1053
  }
@@ -1029,9 +1084,35 @@ type Boundary = {|
1029
1084
  readonly replaceable: number,
1030
1085
  /** A tag, or a comment, the HTML has started and not yet finished. */
1031
1086
  readonly open: string,
1087
+ /** How many elements are open inside `<body>`; `null` until it has opened. */
1088
+ readonly depth: number | null,
1032
1089
  |};
1033
1090
 
1034
- const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, replaceable: 0, open: "" };
1091
+ const NOTHING_WRITTEN: Boundary = {
1092
+ safe: false,
1093
+ rawText: null,
1094
+ replaceable: 0,
1095
+ open: "",
1096
+ depth: null,
1097
+ };
1098
+
1099
+ /** Elements with no closing tag, for markup whose `/>` React did not write. */
1100
+ const VOID_ELEMENTS: $ReadOnlySet<string> = new Set([
1101
+ "area",
1102
+ "base",
1103
+ "br",
1104
+ "col",
1105
+ "embed",
1106
+ "hr",
1107
+ "img",
1108
+ "input",
1109
+ "link",
1110
+ "meta",
1111
+ "param",
1112
+ "source",
1113
+ "track",
1114
+ "wbr",
1115
+ ]);
1035
1116
 
1036
1117
  /**
1037
1118
  * `boundary`, once `html` has been written after it.
@@ -1044,12 +1125,21 @@ const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, replaceable: 0,
1044
1125
  * the content of an inline script or stylesheet, where a `>` is code, so an
1045
1126
  * opening `<script>` or `<style>` is followed to its closing tag. A tag split
1046
1127
  * across two chunks is carried in `open` and read whole with the next one.
1128
+ *
1129
+ * And it may follow only HTML that leaves `rootDepth` elements open inside
1130
+ * `<body>`, or none: the children of React's root and of `<body>` are the ones
1131
+ * hydration steps over. Every tag from `<body>` on is counted for that, React's
1132
+ * void elements by the `/>` it writes them with. Markup React did not write —
1133
+ * `dangerouslySetInnerHTML` — can leave a tag open that the parser would close,
1134
+ * and the count then stays too deep for the rest of the document. That errs the
1135
+ * safe way: the payload waits, and goes out before `</body>` as it always can.
1047
1136
  */
1048
- function advanced(boundary: Boundary, html: string): Boundary {
1137
+ function advanced(boundary: Boundary, html: string, rootDepth: number): Boundary {
1049
1138
  const text = boundary.open + html;
1050
1139
  let rawText = boundary.rawText;
1051
1140
  let replaceable = boundary.replaceable;
1052
- const tags = /<!--([\s\S]*?)-->|<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
1141
+ let depth = boundary.depth;
1142
+ const tags = /<!--([\s\S]*?)-->|<(\/?)([a-z][^\s/>]*)[^>]*?(\/?)>/gi;
1053
1143
  let tag = tags.exec(text);
1054
1144
  while (tag != null) {
1055
1145
  const comment = tag[1];
@@ -1060,20 +1150,36 @@ function advanced(boundary: Boundary, html: string): Boundary {
1060
1150
  }
1061
1151
  } else {
1062
1152
  const name = tag[3].toLowerCase();
1063
- if (tag[2] === "/") {
1064
- if (rawText === name) rawText = null;
1065
- } else if (rawText == null) {
1066
- rawText = name;
1153
+ const closes = tag[2] === "/";
1154
+ if (rawText != null) {
1155
+ // Inside a script or a stylesheet a `<` is code, up to its own end tag.
1156
+ if (closes && rawText === name) {
1157
+ rawText = null;
1158
+ if (depth != null) depth -= 1;
1159
+ }
1160
+ } else if (closes) {
1161
+ if (depth != null) depth -= 1;
1162
+ } else if (depth == null) {
1163
+ if (name === "body") depth = 0;
1164
+ else if (name === "script" || name === "style") rawText = name;
1165
+ } else if (tag[4] !== "/" && !VOID_ELEMENTS.has(name)) {
1166
+ depth += 1;
1167
+ if (name === "script" || name === "style") rawText = name;
1067
1168
  }
1068
1169
  }
1069
1170
  tag = tags.exec(text);
1070
1171
  }
1071
1172
  const start = text.lastIndexOf("<");
1072
1173
  return {
1073
- safe: rawText == null && replaceable === 0 && text.endsWith(">"),
1174
+ safe:
1175
+ rawText == null &&
1176
+ replaceable === 0 &&
1177
+ text.endsWith(">") &&
1178
+ (depth === 0 || (depth != null && depth === rootDepth)),
1074
1179
  rawText,
1075
1180
  replaceable,
1076
1181
  open: start > text.lastIndexOf(">") ? text.slice(start) : "",
1182
+ depth,
1077
1183
  };
1078
1184
  }
1079
1185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.42",
3
+ "version": "0.0.0-alpha.43",
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",
@@ -71,7 +71,7 @@
71
71
  }
72
72
  },
73
73
  "dependencies": {
74
- "@uniflowed/hooks": "0.0.0-alpha.42",
75
- "@uniflowed/server": "0.0.0-alpha.42"
74
+ "@uniflowed/hooks": "0.0.0-alpha.43",
75
+ "@uniflowed/server": "0.0.0-alpha.43"
76
76
  }
77
77
  }