relay
relay copied to clipboard
Spreading fragment on a union type might cause runtime error because ref can be undefined
lets say i have this union type defined in the schema
union U = A | B
type Query {
unionType: U
}
and used by a query
query SomeQuery {
unionType {
... on A {
...someFragment
}
... on B {
id
}
}
}
The Relay compiler wont flag any issue of the above query but if the unionType resolves to B, we will get a runtime error:
Invariant Violation: Relay: Expected to receive an object where `...someFragment` was spread, but the fragment reference was not found`. This is most likely the result of: - Forgetting to spread `someFragment` in `useFragment()`'s parent's fragment. - Conditionally fetching `someFragment` but unconditionally passing a fragment reference prop to `useFragment()`. If the parent fragment only fetches the fragment conditionally - with e.g. `@include`, `@skip`, or inside a `... on SomeType { }` spread - then the fragment reference will not exist. In this case, pass `null` if the conditions for evaluating the fragment are not met (e.g. if the `@include(if)` value is false.).
There is no easy way to detect this issue until runtime so wondering if the relay compiler can give a warning message.
We have a proposed (started but not fully finished) feature called @alias which aims to close this gap. The idea is to give fragment spreads names in these ambiguous situations such that we can expose them as nullable properties. This would allow TypeScript/Flow to ensure that you handle the possibility that the fragment did not match before using it to render a component.
If anyone is interested in helping drive this forward let me know. Most of the work is done, but there's still a list of things that need to be done.
I haven't had time to document it in open source, but here's a copy paste from an internal doc:
@alias Directive on Fragment Spreads
The @alias directive allows you to expose a spread fragment — either a named fragment spread or an inline fragment — as a named field within your selection. This allows Relay to provide additional type safety in the case where your fragment’s type may not match the parent selection.
Let’s look at an example. Imagine you have a component that renders information about a Viewer:
function MyViewer({viewerKey}) {
const {name} = useFragment(graphql`
fragment MyViewer on Viewer {
name @required(action: THROW)
}`, viewerKey);
return `My name is ${name}. That's ${name.length} letters long!`;
}
To use that component in a component that has a fragment on Node (which Viewer implements), you could write something like this:
function MyNode({nodeKey}) {
const node = useFragment(graphql`
fragment MyFragment on Node {
...MyViewer
}`, nodeKey);
return <MyViewer viewerKey={node} />
}
Can you spot the problem? We don’t actually know that the node we are passing to <MyViewer /> is actually a Viewer <MyViewer />. If <MyNode /> tries to render a Comment — which also implements Node — we will get a runtime error in <MyViewer /> because the field name is not present on Comment.
TypeError: Cannot read properties of undefined (reading 'length')
Not only do we not get a type letting us know that about this potential issue, but even at runtime, there is no way way to check if node implements Viewer because Viewer is an abstract type!
Enter Aliased Fragments
Aliased fragments can solve this problem. Here’s what <MyNode /> would look like using them:
function MyNode({nodeKey}) {
const node = useFragment(graphql`
fragment MyFragment on Node {
...MyViewer @alias(as: "my_viewer")
}`, nodeKey);
// Relay returns the fragment key as its own nullable property
if(node.my_viewer == null) {
return null;
}
// Because `my_viewer` is typed as nullable, Flow/TypeScript will
// show an error if you try to use the `my_viewer` without first
// performing a null check.
// VVVVVVVVVVVVVV
return <MyViewer viewerKey={node.my_viewer} />
}
With this approach, you can see that Relay exposes the fragment key as its own nullable property, which allows us to check that node actually implements Viewer and even allows Flow to enforce that the component handles the possibility!
Inline Fragments
Inline fragments can suffer from a similar problem.
Under the Hood
For people familiar with Relay, or curious to learn, here is a brief description of how this feature is implemented:
Under the hood, @alias is implemented entirely within Relay (compiler and runtime). It does not require any server support. The Relay compiler interprets the @alias directive, and generates types indicating that the fragment key, or inline fragment data, will be attached to the new field, rather than directly on the parent object. In the Relay runtime artifact, it wraps the fragment node with a new node indicating the name of the alias and additional information about the type of the fragment.
The Relay compiler also inserts an additional field into the spread which allows it to determine if the fragment has matched:
fragment Foo on Node {
... on Viewer {
isViewer: __typename # <-- Relay inserts this
name
}
}
Relay can now check for the existence of the isViewer field in the response to know if the fragment matched.
When Relay reads the content of your fragment out of the store using its runtime artifact, it uses this information to attach the fragment key to this new field, rather than attaching it directly to the parent object.
@captbaritone this feature is great! Besides solving this problem it would also aid in proper memoization right? As now the objects passed down to components will only contain the fragment keys and not any other fields the parent component may ask. This way the child component, if using React.memo wouldn't re-render if the parent's fields that come alongside the fragment key change, because there would be none now with this feature.
In terms of contributing, curious if the list of things to do could be documented in an issue for visibility and for folks can chime in and help with each.
@captbaritone would you expect @alias'd fragments also solve the issue of losing union discrimination described in this thread?
it seems @alias directive is not working as Relay 16, with the message:
[ERROR] Error: ✖︎ Unexpected directive @alias. @alias is not currently enabled in this location.