Embed component references in runtime response?
AlecAivazis opened this issue Β· comments
Ever since the isograph talk at GraphQL Conf, I've been trying to figure out how much of our fragment API we could trim down. I think I've found a pretty good balance between the totally streamlined and unified DX of isograph and staying compliant with the GraphQL spec. The general idea is that components register themselves as a field on a type. something like:
UserAvatar.tsx
type Props = {
user: GraphQL<`{
... on User @componentField(field: "Avatar") {
firstName
}
}`>
}
export default function UserInfo({ user }: Props) {
return (
<div>
{user.firstName}
</div>
)
}
+page.gql
query UserInfo {
viewer {
Avatar # <- does not actually exist in the schema but we would
# generate type extensions and have the field return a `Component` scalar
}
}
+page.tsx
export default ({ UserInfo }: PageProps) {
return (
<div>
<UserInfo.viewer.Avatar size={10} />
</div>
)
}
Behind the scenes, Houdini would import the appropriate file and embed a reference to the component in the query response. This leans even harder into Houdini tightly integrating into your bundler and that feels really good. Also every file has a distinct purpose without any kind of boilerplate or duplication to wire everything up and that feels even better.
This approach forces fragment name unique-ness into a shape that feels slightly easier to swallow but at the cost of potentially conflicting with the schema (although fields and components naturally have opposite casing conventions,but maybe we need another lint rule?).
An important thing to keep in mind is that this API is totally opt-in and would co-exist with the existing useFragment
api. I'm not sure this pattern makes sense for one off components like UserDetailsSummaryPane
. @componentField
pollutes global scope very visibly and probably only makes sense in reusable components like <User.Avatar />
. We could go as far as to create some linting rules that enforce that @componentField
doesn't show up in src/routes/
.
Why this is an improvement:
- no component import
- The type definition is the API. This means there's no need to import the type after defining the fragment
- never have to deal with your prop being different than what you get back from
useFragment
- it's driven by graphql and still spec compliant (we'd generate definitions that extend
User
to add theAvatar
field so IDEs could pick it up)
notes:
- the parent type "User" is inferred from the inline fragment
- the prop name is inferred by the analyzing the Props type but it can be explicitly set with the
prop
argument - named exports can be identified with the
export
argument - fragment names don't have to be unique but field names do
- Named exports can be supported with an argument to
@componentField
- We would extend @arguments to also fall on inline fragments
For completeness, here's what a named export in a vanilla javascript file would look like
graphql(`{
... on User @componentField(field: "Avatar", prop: "user", export: "UserInfo") {
firstName
}
})
export function UserInfo({ user }: Props) {
return (
<div>
{user.firstName}
</div>
)
}
I've start the work in #1176 and have a working demo for the simplest case π
This looks so good. π π π π I love it. Really clever to use ... on User
instead of a (needlessly named) fragment on User. I don't foresee any issues with the design at the moment.
Type inference
- I think that since you're explicitly defining props, you will correctly get TypeScript inference for e.g. the size prop. Correct?
- Does the Prop type that the user sees exclude the
user
prop?
Compilation Errors
- What if there are multiple
@componentField
annotations in the same props? Does Houdini fail gracefully in that case? - What if you have a legitimate need to pass a second fragment down (e.g. a fragment on viewer for the logged in user's name, and also a specific Foo). If only one of those is a
@componentField
, will this continue to work? - Are field-level merge conflicts reported in a sane way? (I think so...
__componentField__User_Avatar
is self-documenting)
Interactions with other directives
- How would
@arguments
be passed to Avatar? - How do you add
@defer
etc to Avatar's fragment definition? - Is there any directive for which it maybe makes sense to apply it to Avatar, but also the fragment? e.g.
@include
... is there the possibility that you want to include the Avatar but not fetch the fragment; and is that case fully covered by... on User @include {
in Avatar. (It probably is!) - Are there any directives that are on fragment definitions, but not on inline fragments, that you want to support, other than
@arguments
? At Meta, there are "@fb_owner" (or something) directives that fall on fragment definitions, for example.
Neighboring Avatars:
- I am not confident in Relay's behavior here, but it should refuse to compile something like
... on User {
foo # a string
}
... on NotUser {
foo # an int
}
If the foo
's types don't align, at least if the inline fragments are being combined into a single object (i.e. we're not generating a discriminated union.). Do we have enough info to ensure that
... on User {
Avatar
}
... on NotUser {
Avatar
}
Will generate a coherent type? (I don't know Houdini's behavior here.)
Misc
- Do component aliases work?
- What happens if the component alias conflicts with another component? With a field? With a field it selects?
- Does suspense work as expected, i.e. the component (and not the parent) suspends?
- Does @required work as expected? Do error boundaries?
- What about infinite recursion, both direct (Avatar -> Avatar) and indirect (Avatar -> Foo -> Avatar)?
- Will a VSCode extension suggest the auto-generated fragment? Is it accessible?
Wow thanks for taking the time to type all of that up!! It might be best to move this to discord where we can give each topic a thread but we'll see how far we can get here.
you will correctly get TypeScript inference for e.g. the size prop. Correct?
Does the Prop type that the user sees exclude the user prop?
The type generation still hasn't been added yet but since its totally "faked" (driven by a generated argument passed as a generic arg, not actually inferred from some value) i was planning on just tweaking the support for scalars to handle the Component
scalar specially and leaving behind a function that omits the prop name and returns the same value as the passed component.
What if there are multiple @componentField annotations in the same props? Does Houdini fail gracefully in that case?
Right now, it checks for overlaps between all componentFields and the schema to ensure every field only has one canonical definition. The tests for that live here.
What if you have a legitimate need to pass a second fragment down
I think this is a perfect use-case for the ol' useFragment
How would @arguments be passed to Avatar?
Current thinking is that I will translate them to arguments on the field. Ie,
... on User @componentField(field: "Avatar") @arguments(size: { type: "Int"}) {
}
becomes
type User {
Avatar(size: Int)
}
How do you add @defer etc to Avatar's fragment definition?
defer isn't supported at all in the framework so I think we punt on this for now. I imagine it would be able to be applied to the field and be treated as if it was applied to the fragment. They might not be semantically equivalent in all APIs but i'm not sure what else we could do π€
Equivalence between field and fragments re directives
So this was one of the reasons I wanted to keep the feature hidden behind a feature flag for a bit. I was just going to start by leaving directives in place at start and see if that works for people in the wild.
Neighboring Avatars
This is a good question. If we did want to prevent it, we could generate different scalars for every parent type but I'm not sure its a problem. From a type def generation point, it won't be an issue - every concrete member type of a union is generated independently so we can generate different field types even tho they're the same graphql type.
Do component aliases work?
Hadn't considered that! adding it to the task list now π I think it's definitely something that should be supported.
What happens if the component alias conflicts...
If i'm following the situation correctly, i think it wouldn't validate just like normal graphql fields but I could be mistaken.
Does suspense work as expected
Right now, suspense is mostly driven by the page/layout components (so that the loading states compose correctly). I'm still working on the fragment part of the suspense story but haven't gotten to it. I was working on it in the time up to graphqlconf and then got distracted by your talk in the best way possible π
Does @required work as expected
The @required
implementation predates our react support and svelte lacks an error boundary so there are no actions yet but its definitely something i want to add. I'm not sure it really make sense on a component field tho - just like it doesn't make sense for a fragment to have a single value (the reference would be null).
What about infinite recursion
Since the fields are sugar for fragments I dont think this would pass validation
Will a VSCode extension suggest the auto-generated fragment? Is it accessible?
It won't see the __componentField__
fragments but that's by design. It will see the Avatar
field since we already generate extensions that get picked up by the lsp.