Conversation
Relay's own thrown-error text for every field-level failure kind except client-side Relay Resolvers is a hardcoded, generic string — even relay_field_payload.error, fired for an actual server-reported GraphQL error on a queried field, throws "Unexpected response payload - check server logs for details." and never includes the real message (relay-runtime's handlePotentialSnapshotErrors.js). Confirmed against a real case: a period referencing a deleted category makes CategoryPeriodSummary.category (non-null) fail, collapsing the whole ActivityTotalsDisplayQuery response — but the query selects periodSummaryByMember before periodSummaryByCategory, so Relay's read-time "missing expected data" check reports the earlier, unrelated field first, and that event carries no message field at all. The user sees "Missing expected data at path 'location.periodSummaryByMember'" with no way to tell it was actually a bad category. relayFieldLogger already receives the real message on relay_field_ payload.error/relay_resolver.error events, synchronously, moments before the generic throw. It now buffers those (capped, cleared on read) and exposes takeRecentFieldErrorMessages(). describeCaughtError uses that to substitute the real message whenever the caught error's own text is one of Relay's generic "Relay: ..." strings, leaving every other error (including the "No data returned..." whole-query failure, which already embeds the real text itself) untouched. That buffer-read is a destructive side effect — it drains on read, so it's safe to call exactly once per real catch, but not from render-phase code: this app has React's StrictMode enabled (main.tsx), which deliberately double-invokes things like a useState lazy initializer specifically to catch impurity like this. A first (thrown-away) invocation would drain the real message and a second, committed one would find the buffer already empty. componentDidCatch — react-error-boundary's onError — is a real lifecycle method instead, and React guarantees it runs exactly once per catch even under StrictMode. A new useCaughtErrorMessage hook resolves the message there and stores it in state; PageErrorFallback takes the resolved message as a prop rather than computing it itself, falling back to the raw error's own message for the brief window before onError has run (in practice invisible, since the text sits behind a collapsed "Show details" until then). RelayErrorBoundary adopts the hook directly. The three plain ErrorBoundary + PageErrorFallback pairings that had no room to plumb a resolved message through FallbackComponent (Router.tsx's root, home/ Layout.tsx, admin/Layout.tsx's pre-environment shell) move to a new PageErrorBoundary, which pairs the same hook with a plain resetErrorBoundary (no Relay store/retry machinery needed for what it wraps). RelayErrorBoundary.test.tsx and the new PageErrorBoundary.test.tsx each add a test that renders under <StrictMode> and asserts the real buffered message appears — verified against a naive render-phase read that it actually fails there, not just that it passes with the onError-based approach.
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Relay's own thrown-error text for every field-level failure kind
except client-side Relay Resolvers is a hardcoded, generic string —
even relay_field_payload.error, fired for an actual server-reported
GraphQL error on a queried field, throws "Unexpected response payload
message (relay-runtime's handlePotentialSnapshotErrors.js). Confirmed
against a real case: a period referencing a deleted category makes
CategoryPeriodSummary.category (non-null) fail, collapsing the whole
ActivityTotalsDisplayQuery response — but the query selects
periodSummaryByMember before periodSummaryByCategory, so Relay's
read-time "missing expected data" check reports the earlier,
unrelated field first, and that event carries no message field at
all. The user sees "Missing expected data at path
'location.periodSummaryByMember'" with no way to tell it was actually
a bad category.
relayFieldLogger already receives the real message on relay_field_
payload.error/relay_resolver.error events, synchronously, moments
before the generic throw. It now buffers those (capped, cleared on
read) and exposes takeRecentFieldErrorMessages(). describeCaughtError
uses that to substitute the real message whenever the caught error's
own text is one of Relay's generic "Relay: ..." strings, leaving every
other error (including the "No data returned..." whole-query failure,
which already embeds the real text itself) untouched.
That buffer-read is a destructive side effect — it drains on read, so
it's safe to call exactly once per real catch, but not from
render-phase code: this app has React's StrictMode enabled
(main.tsx), which deliberately double-invokes things like a useState
lazy initializer specifically to catch impurity like this. A first
(thrown-away) invocation would drain the real message and a second,
committed one would find the buffer already empty. componentDidCatch
— react-error-boundary's onError — is a real lifecycle method
instead, and React guarantees it runs exactly once per catch even
under StrictMode. A new useCaughtErrorMessage hook resolves the
message there and stores it in state; PageErrorFallback takes the
resolved message as a prop rather than computing it itself, falling
back to the raw error's own message for the brief window before
onError has run (in practice invisible, since the text sits behind a
collapsed "Show details" until then).
RelayErrorBoundary adopts the hook directly. The three plain
ErrorBoundary + PageErrorFallback pairings that had no room to plumb a
resolved message through FallbackComponent (Router.tsx's root, home/
Layout.tsx, admin/Layout.tsx's pre-environment shell) move to a new
PageErrorBoundary, which pairs the same hook with a plain
resetErrorBoundary (no Relay store/retry machinery needed for what it
wraps).
RelayErrorBoundary.test.tsx and the new PageErrorBoundary.test.tsx each
add a test that renders under and asserts the real
buffered message appears — verified against a naive render-phase read
that it actually fails there, not just that it passes with the
onError-based approach.
Stack created with Sapling. Best reviewed with ReviewStack.