@pylonsync/functions 0.22.19 → 0.22.21
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/package.json
CHANGED
|
@@ -687,6 +687,28 @@ describe("loading.tsx is wired into the client build", () => {
|
|
|
687
687
|
expect(sharedChunks).toContain("app/dashboard/loading");
|
|
688
688
|
});
|
|
689
689
|
|
|
690
|
+
test("first hydration wraps the page in the same Suspense boundary the server renders", async () => {
|
|
691
|
+
tempDir = makeFixture(
|
|
692
|
+
{
|
|
693
|
+
"dashboard/page.tsx": PAGE_BODY("Dash"),
|
|
694
|
+
"dashboard/loading.tsx": LOADING_BODY,
|
|
695
|
+
},
|
|
696
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
697
|
+
);
|
|
698
|
+
originalCwd = process.cwd();
|
|
699
|
+
process.chdir(tempDir);
|
|
700
|
+
await buildClientBundle();
|
|
701
|
+
const runtime = fs.readFileSync(
|
|
702
|
+
path.join(tempDir, ".pylon", "client-runtime.ts"),
|
|
703
|
+
"utf8",
|
|
704
|
+
);
|
|
705
|
+
// The hydrating tree is built with the page's component path, which
|
|
706
|
+
// resolves its loading module and wraps the page in <Suspense>
|
|
707
|
+
// (see ssr-hydration.test.ts for why a missing boundary breaks hydration).
|
|
708
|
+
expect(runtime).toContain("buildTree(Page, Layouts, currentPageProps, data.component)");
|
|
709
|
+
expect(runtime).toContain("createElement(Suspense, { fallback: createElement(Loading, props) }, tree)");
|
|
710
|
+
});
|
|
711
|
+
|
|
690
712
|
test("an app with no loading.tsx still builds and navigates", async () => {
|
|
691
713
|
// The runtime's import of ./loading-registry is unconditional, so the
|
|
692
714
|
// staged module must exist even with nothing to put in it.
|
|
@@ -348,7 +348,7 @@ ${entries}
|
|
|
348
348
|
const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
|
|
349
349
|
// DO NOT EDIT — overwritten on every pylon dev / build.
|
|
350
350
|
|
|
351
|
-
import { createElement } from "react";
|
|
351
|
+
import { createElement, Suspense } from "react";
|
|
352
352
|
import { hydrateRoot } from "react-dom/client";
|
|
353
353
|
import { configureClient } from "@pylonsync/react";
|
|
354
354
|
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
@@ -368,8 +368,18 @@ let pendingNav = null;
|
|
|
368
368
|
let manifestPromise = null;
|
|
369
369
|
const prefetchedChunks = new Set();
|
|
370
370
|
|
|
371
|
-
function buildTree(Page, Layouts, props) {
|
|
371
|
+
function buildTree(Page, Layouts, props, loadingFor) {
|
|
372
372
|
let tree = createElement(Page, props);
|
|
373
|
+
// The server wraps the page of a route with a loading.tsx in ONE Suspense
|
|
374
|
+
// boundary, fallback = that loading module (ssr-runtime.ts, #278). The
|
|
375
|
+
// tree that hydrates the server HTML must have the same boundary, or React
|
|
376
|
+
// finds <Suspense> where it expects the page's first element, throws a
|
|
377
|
+
// hydration mismatch (#418) and re-renders the whole document; clicks in
|
|
378
|
+
// that window are lost. \`loadingFor\` is the page's component path.
|
|
379
|
+
const Loading = loadingFor ? nearestLoading(loadingFor) : null;
|
|
380
|
+
if (Loading) {
|
|
381
|
+
tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);
|
|
382
|
+
}
|
|
373
383
|
for (let i = Layouts.length - 1; i >= 0; i--) {
|
|
374
384
|
const Layout = Layouts[i];
|
|
375
385
|
if (!Layout) continue;
|
|
@@ -378,6 +388,13 @@ function buildTree(Page, Layouts, props) {
|
|
|
378
388
|
return tree;
|
|
379
389
|
}
|
|
380
390
|
|
|
391
|
+
// The loading module the server wraps \`component\` in: the nearest
|
|
392
|
+
// loading.tsx walking up from the page's directory.
|
|
393
|
+
function nearestLoading(component) {
|
|
394
|
+
const key = nearestBoundaryComponent(component, "loading", Object.keys(LOADING_MODULES));
|
|
395
|
+
return key ? LOADING_MODULES[key] : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
381
398
|
// ---------------------------------------------------------------------------
|
|
382
399
|
// Client error / not-found boundary. notFound() (from @pylonsync/react) and
|
|
383
400
|
// any other error thrown DURING a client render — the normal case when an
|
|
@@ -714,7 +731,7 @@ export function hydrate(component, Page, Layouts) {
|
|
|
714
731
|
currentPageProps = withClientProps(data);
|
|
715
732
|
currentComponent = data.component;
|
|
716
733
|
const tree = withBoundary(
|
|
717
|
-
buildTree(Page, Layouts, currentPageProps),
|
|
734
|
+
buildTree(Page, Layouts, currentPageProps, data.component),
|
|
718
735
|
data.component,
|
|
719
736
|
navEpoch,
|
|
720
737
|
);
|
|
@@ -164,3 +164,77 @@ describe("streaming hydration (#278)", () => {
|
|
|
164
164
|
}
|
|
165
165
|
});
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
// A route with a loading.tsx: ssr-runtime.ts wraps the page in ONE Suspense
|
|
169
|
+
// boundary whose fallback is the loading module, inside the layouts. The
|
|
170
|
+
// client's first hydration must build the same tree (buildTree's
|
|
171
|
+
// `loadingFor` in ssr-client-bundler.ts). Without the boundary, React finds
|
|
172
|
+
// the server's Suspense marker where it expects the page's first element and
|
|
173
|
+
// throws a hydration mismatch (#418): Stack0 Cloud's dashboard did this on
|
|
174
|
+
// every load, since app/dashboard/loading.tsx exists.
|
|
175
|
+
describe("route-level loading.tsx hydration", () => {
|
|
176
|
+
function Layout({ children }: { children?: any }) {
|
|
177
|
+
return React.createElement("main", { id: "layout" }, children);
|
|
178
|
+
}
|
|
179
|
+
function Loading() {
|
|
180
|
+
return React.createElement("p", { id: "pending" }, "Loading…");
|
|
181
|
+
}
|
|
182
|
+
function DashPage() {
|
|
183
|
+
return React.createElement("div", { id: "page" }, "Dashboard");
|
|
184
|
+
}
|
|
185
|
+
const withBoundary = (page: any) =>
|
|
186
|
+
React.createElement(Suspense, { fallback: React.createElement(Loading) }, page);
|
|
187
|
+
|
|
188
|
+
async function serverHtml(): Promise<string> {
|
|
189
|
+
const stream = await renderToReadableStream(
|
|
190
|
+
React.createElement(Layout, null, withBoundary(React.createElement(DashPage))),
|
|
191
|
+
);
|
|
192
|
+
await (stream as any).allReady;
|
|
193
|
+
const reader = stream.getReader();
|
|
194
|
+
let html = "";
|
|
195
|
+
for (;;) {
|
|
196
|
+
const { value, done } = await reader.read();
|
|
197
|
+
if (done) break;
|
|
198
|
+
html += dec.decode(value);
|
|
199
|
+
}
|
|
200
|
+
return html;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function hydrateErrors(clientTree: any): Promise<string[]> {
|
|
204
|
+
const html = await serverHtml();
|
|
205
|
+
const win = new Window({ url: "http://localhost/" });
|
|
206
|
+
const root = win.document.createElement("div");
|
|
207
|
+
root.insertAdjacentHTML("afterbegin", html);
|
|
208
|
+
win.document.body.appendChild(root);
|
|
209
|
+
registerDom(win);
|
|
210
|
+
const errors: string[] = [];
|
|
211
|
+
const origErr = console.error;
|
|
212
|
+
console.error = (...a: any[]) => {
|
|
213
|
+
errors.push(a.map(String).join(" "));
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
const { hydrateRoot } = await import("react-dom/client");
|
|
217
|
+
hydrateRoot(root as any, clientTree, {
|
|
218
|
+
onRecoverableError: (e: any) => errors.push(String(e?.message ?? e)),
|
|
219
|
+
});
|
|
220
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
221
|
+
} finally {
|
|
222
|
+
console.error = origErr;
|
|
223
|
+
}
|
|
224
|
+
return errors.filter((e) => /hydrat|did not match|mismatch/i.test(e));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test("the client tree with the same boundary hydrates without a mismatch", async () => {
|
|
228
|
+
const errors = await hydrateErrors(
|
|
229
|
+
React.createElement(Layout, null, withBoundary(React.createElement(DashPage))),
|
|
230
|
+
);
|
|
231
|
+
expect(errors).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("the client tree without the boundary mismatches (the bug)", async () => {
|
|
235
|
+
const errors = await hydrateErrors(
|
|
236
|
+
React.createElement(Layout, null, React.createElement(DashPage)),
|
|
237
|
+
);
|
|
238
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
239
|
+
});
|
|
240
|
+
});
|