Skip to content

[2.0 / next] SSR: a boundary that fails through an async read renders no error fallback outside server-component scope, and hydration asks again #4287

Closed

Description

@ryansolid

Rule. A boundary that takes a failure during the server render is streamed with its fallback drawn, so the browser shows the client's frame, and the client adopts it rather than asking again (ReactiveScript 10 SV-6, SV-3b).

Shape. <Errored fallback={e => <p>failed: {e().message}</p>}> around a
<Loading> whose content reads an async memo; the memo rejects with an
error marked safe (markSafeError), either before the shell flushes
(ms = 1) or after (ms = 200, the default).

Observed.

                     client component                     runInServerComponentScope
fails after shell    <template id="100"> </template>      <template id="100"><p>failed: no user 7</p></template>
fails before shell   <main></main>                        <main><p>failed: no user 7</p></main>

Outside server-component scope the boundary's HTML is empty and the error
is serialized for the client; until hydration the browser shows neither the loading fallback nor the error
fallback, a frame no client tick shows. errorOutcome in ssrLoadingBoundary and renderOutcome in the
server createErrorBoundary both return early unless
inServerComponentScope.

After hydration (dv9-hydrate/). The client renders the failed
boundary from scratch: it asks the question again and shows the loading
fallback, never the failure the server sent.

shell                      "loading"
the read fails (streamed)  ""
hydrated                   "loading"

Expected: the error fallback is drawn on the server in both scopes, and the client adopts it. (A failure thrown synchronously in content the server renders is drawn.)

Reproduced on next @ 2a0da2f. Run from a checkout with solid-js, @solidjs/web and @solidjs/compiler built from next: node dv9-ssr-after-shell.mjs [client|server-component] [ms] (server build); node dv9-hydrate/run.mjs (needs playwright + esbuild).

Repro

dv9-ssr-after-shell.mjs

// ms: when the read rejects — 200 (default) is after the shell has flushed, 1 is before.
import { createMemo, Loading, Errored, createComponent, runInServerComponentScope } from "solid-js";
import { renderToStream, ssr, escape, markSafeError } from "@solidjs/web";
const mode = process.argv[2] ?? "client"; const ms = Number(process.argv[3] ?? 200);
let reject; const p = new Promise((_, j) => (reject = j)); p.catch(() => {});
function App() {
  const user = createMemo(() => p);
  return ssr(["<main>", "</main>"], createComponent(Errored, {
    fallback: e => ssr(["<p>failed: ", "</p>"], escape(e().message)),
    get children() { return createComponent(Loading, { fallback: ssr(["<p>loading</p>"]), get children() { return ssr(["<p>", "</p>"], () => escape(user().name)); } }); },
  }));
}
const view = () => createComponent(App, {});
renderToStream(mode === "server-component" ? () => runInServerComponentScope(view) : view)
  .pipe({ write: c => console.log(c.replace(/<script>[\s\S]*?<\/script>/g, "")), end() {} });
setTimeout(() => reject(markSafeError(new Error("no user 7"))), ms);   // marked safe: a failure the client may see

dv9-hydrate/app.jsx

import { createMemo } from "solid-js";
export function App(props) {
  const user = createMemo(() => props.ask("u"));
  return <Errored fallback={e => <p>failed: {e().message}</p>}>
    <Loading fallback={<p>loading</p>}><p>{user()}</p></Loading>
  </Errored>;
}

dv9-hydrate/run.mjs

// The server's read fails after the shell (a safe error, so the client may see it); the stream sends an empty fragment
// and the error. Then the client hydrates. Expected: "failed: no user", the server's answer used, not asked again.
import { markSafeError } from "@solidjs/web";
import { harness, wait } from "../_hydrate.mjs";
let reject;
const h = await harness(new URL(".", import.meta.url).pathname, () => new Promise((_, j) => { reject = j; }));
const page = await h.open("hydrate");
const show = async label => console.log(label.padEnd(26), JSON.stringify(await page.evaluate(() => window.repro.text())));
await show("shell");
reject(markSafeError(new Error("no user"))); await wait(100);
await show("the read fails (streamed)");
await page.evaluate(() => window.repro.start("hydrate")); await wait(100);
await show("hydrated");
console.log("asked on the client:".padEnd(26), JSON.stringify(await page.evaluate(() => window.repro.asked())), "(one is the trace Solid runs under a mocked fetch; expected: the server's error, no new question)");
await h.close();

_hydrate.mjs is the hydration harness posted in full in #4272.

Found by ReactiveScript's conformance suite, which diffs its spec against Solid's SCENARIOS.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions