Skip to content

SSR serializes <Errored> errors verbatim: production page load ships what the server-function wire sanitizes #3468

Description

@ryansolid

Summary

A plain error caught by <Errored> during SSR is serialized to the client verbatim (message, cause, own enumerable props; only .stack is stripped). The same error thrown by a server function over HTTP is sanitized to "Internal Server Error" outside the dev build (#3116). A "use server" function called in-process during SSR never touches dispatch, so the production page load leaks what the RPC wire withholds. Reported with a wire-level reproduction in #3415.

Where

packages/solid/src/server/signals.ts, createErrorBoundary:

const serializeError = (err: any) => {
  if (ctx && boundaryId && !runWithOwner(owner, () => getContext(NoHydrateContext))) {
    ctx.serialize(boundaryId, err);
  }
};

hydratedCreateErrorBoundary (packages/solid/src/client/hydration.ts) loads the record and rethrows it so the fallback hydrates against the same err(). The SSR codec (packages/web/serialization/src/serializer.ts) strips only Feature.ErrorPrototypeStack, keyed on NODE_ENV.

Other routes with the same leak: a createAsync rejection serialized into the stream (ctx.serialize(id, deferred.promise) rejects with the raw reason), and Errors nested in serialized data.

Constraint

The fallback is rendered on the server with the error. Whatever the client receives must be what the server rendered with, or the fallback mismatches on hydration. So the boundary must sanitize before renderFallback, not just before serialize.

Proposed fix

  1. Boundary: outside the dev build, replace an unbranded error with a generic Error before rendering the fallback and serializing. markSafeError (Symbol.for("solid.SafeError"), a registered symbol — no @solidjs/web dependency needed) passes through. Dev/prod line is the build variant (server.dev.js behind development), matching sanitizeServerError. The original stays server-side: SSR_RENDER_ERROR_CONTAINED already carries it; add an SSR_ERROR_SANITIZED finding mirroring SERVER_FN_ERROR_SANITIZED.
  2. Serializer backstop: apply the same policy to every Error the SSR serializer encodes (rejected promises, nested values), so the boundary is not the only guard — the shape of the result-graph walk in fix(web): sanitize failures that escape through the result graph #3113.
  3. Policy hook (follow-up, separate issue if preferred): a single server-side error hook, configured once, that every runtime-handled error passes through — contained SSR render error, rejected async source, server-function throw (direct or HTTP), uncontained render error — receiving { error, kind, boundary | functionId, event } and returning the wire value (undefined → default sanitization). This is the reporting seam as well as the mapping seam: today "report" exists only on the observe tier's diagnostics channel and "map" only for server functions via wrapInvocation. Needs once-per-error semantics (a direct server-function call that fails during SSR is both a server-function error and a contained render error; a <Loading> re-pull recurs the same throw). Two tiers, matching wrapInvocation: an ambient once-per-process registration (configure* in @solidjs/web — the only tier that sees direct in-process calls during SSR, per the existing entry-only caveat on the per-handler wrapInvocation), overridable per request. For vite-plugin-solid that means the ambient call goes in the serverFunctions.configure module and the per-request form rides the generated handler's existing handleRequest(request, { nonce, serverFunctions, ... }) options bag, threaded into the generated render(request, context) via context like clientEntry is today — no new plugin option needed. Note the plugin's generated DefaultErrorBoundary does console.error(props.error()) in the fallback, which today runs in the browser during hydration with the raw serialized error.

Tests

  • SSR document for an <Errored> catching new Error("secret") in the prod build carries no "secret"; fallback markup and serialized record agree; hydration is clean.
  • markSafeError passes through on the SSR path with own-properties.
  • Rejected createAsync serialized into the stream carries the sanitized reason.
  • Dev build keeps full fidelity.

Refs: #3415, #3116, #3113, #3152, #3414.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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