Component-owned fragments and Apollo data masking
createApolloClient sets dataMasking: true, and codegen emits masked types (inlineFragmentTypes: "mask"). Reading a field the fragment didn't select is therefore a type error, and the field is undefined at runtime. This page is the one home for our fragment rules; for how useFragment and masking work in general, see Apollo's fragments docs.
A screen is a tree of components, and every one of them needs data. There are two obvious ways to fetch it, and both go wrong as the tree grows:
ChatroomsView, ChatroomListItem, ChatroomHeader and ChatroomLabel are not components in this repo.
Masking is what keeps the coupling from coming back through the side door. Without it, a parent can read a field that only its child's fragment selected, and nothing says so. Apollo's data masking docs tell the story with a Posts list that filters on publishedAt, a field that only the PostDetails child's fragment selected. When PostDetails stops using publishedAt and drops it from its fragment, Posts still depends on it, finds it missing, and renders no posts. Apollo calls this "an implicit dependency between components". The same thing here: ChatroomsView filters chatrooms on resolved, which only ChatroomListItem_chatroom selects, and the day that fragment drops resolved the list silently empties. Nothing in ChatroomListItem looks wrong, so its own tests keep passing.
We mask in two places, and each catches something the other doesn't:
| Layer | Set by | What it catches |
|---|---|---|
| Types | Codegen: inlineFragmentTypes: "mask", with customDirectives: { apolloUnmask: true } so the plugin understands @unmask (packages/data-gql/src/codegen.ts) | At compile time, a component reading a field its own fragment didn't select. An operation's type holds only its own fields, and a spread fragment is a FragmentType reference. ChatroomsView reading chatroom.resolved is a type error, so the dependency is rejected in the editor and in pnpm typecheck. |
| Runtime | dataMasking: true in createApolloClient (packages/util-apollo/src/client.ts) | The data itself. Masked fields are absent from the objects a component receives, so a read that gets past the types (a cast, any, untyped code) through a request-based API (useQuery, useFragment, client.query, watchQuery, watchFragment) gets undefined in tests and in production, and fails now instead of working until the child changes. It also keeps the types honest: without it they would claim a field is absent while the object still held it. Neither layer catches a read through a cache API: client.readQuery, cache.readQuery, cache.readFragment and update callbacks are never masked and are typed as whole objects. |
@unmask, which is why @unmask needs a reason. To make the parent's dependency explicit and safe instead, add the field to the parent's own fragment or query.
Component_prop. ChatroomListItem_chatroom is the fragment of the ChatroomListItem component for its chatroom prop. The pattern is enforced by pnpm lint:graphql.ChatroomListItem renders ChatroomLabel, then ChatroomListItem_chatroom spreads ...ChatroomLabel_chatroom and the page's query does not.useFragment. It runs its own query only in the cases listed in When a component runs its own query.FragmentType, not data. A component takes the masked reference its parent passes and calls useFragment to open it.@unmask is an escape hatch and needs a reason. See @unmask.fragments.graphql next to it:
id itself, because Apollo needs it to find the entity again and to key the list:
#import line: codegen and lint read every document, so a spread of a fragment defined in another file just works.
pnpm codegen writes a …FragmentDoc (the document) and a …Fragment type (the type of the fragment's own fields) named after each fragment, next to the .graphql file. For ChatroomListItem_chatroom those are, e.g., ChatroomListItemChatroomFragmentDoc and ChatroomListItemChatroomFragment. A component takes a FragmentType for its prop and opens it with useFragment. Always pass fragmentName (the fragment's Component_prop name) to useFragment, readFragment, writeFragment and watchFragment. A fragment document that spreads a child's fragment holds more than one definition, and Apollo then requires fragmentName, so passing it from the start keeps the call working when someone later adds ...Child:
data holds this component's own fields plus a reference to ChatroomLabel_chatroom, which ChatroomLabel opens in the same way. Always pass fragmentName, as Apollo's own examples do, to useFragment, useSuspenseFragment, readFragment, writeFragment, and watchFragment, even when the fragment spreads nothing today; pnpm lint:graphql reports a call that leaves it out. The generated document of a fragment that spreads another holds the child's definition too, so it has more than one fragment in it, and Apollo throws fragmentName must be provided when there is not exactly 1 fragment when it can't tell which one to read. That means adding ...Child to a leaf's fragment later would break its parent's call at runtime, in a file the change never touched. Passing fragmentName works in every case. No lint rule checks it. The route is the only component that calls a query hook:
ChatroomLabel shows up in a list row and in a page header:
<ChatroomLabel chatroom={data} />. Neither reads ChatroomLabel's fields or imports its …FragmentDoc; only ChatroomLabel does, to call useFragment. A change to ChatroomLabel_chatroom reaches every screen that renders it through its own parents' spreads, with no edit to any of them. The Guild's fragments guide shows the same shape: Avatar_UserFragment is spread from both UserListItem_UserFragment and UserProfileHeader_QueryFragment.
What the rule forbids is a component importing another component's …FragmentDoc for any purpose, whether to read its fields, call writeFragment or read it by id, which skips the child and brings back the coupling masking removes. pnpm lint:graphql reports a .graphql import from outside the importing file's folder.
useFragment reads the fragment from the cache. It never makes a request, and it subscribes to that slice of the cache, so the component re-renders when its own fields change and not when something else in the cache does. It returns { complete, data, missing }:
complete is true when every field of the fragment is in the cache. After the check, data is typed as the whole fragment. Before it, data may be partial.complete: false that you don't expect usually means the parent didn't select the entity's id, or didn't spread the child's fragment, or the entity isn't in the cache. Don't paper over it with a cast. Why is useFragment incomplete? lists every cause.useSuspenseFragment is the drop-in alternative that suspends while the data is incomplete, so there is no complete to check. It pairs with @defer: don't defer the key fields, or the component can suspend indefinitely.useFragment the entity to read:
ChatroomListItemChatroomFragment is the type of the fragment's own fields, without the references to child fragments. Use it for a helper function or a custom hook that takes a component's data after useFragment has opened it. Don't use it as a prop type: a prop is the FragmentType reference the parent passes.
The default is a fragment. A component that renders data its parent's query already fetches, or could fetch at no extra cost (a list row, a card, a label, a section of a screen whose entity the screen already loads), declares a Component_prop fragment and reads it with useFragment. Apollo's bundled skill puts it the same way: a page should "generally" have one query, composed from the fragments of the components below it.
A component may call a query hook itself when at least one of these is true:
exposes/ file is only the entry, because a document cannot sit there (see GraphQL Colocation).cache-and-network with nextFetchPolicy: "cache-first"), a subscription-backed live panel inside a static screen, or polling. A fragment cannot refetch, so the part that needs fresher data has to own the query.useLazyQuery or useLoadableQuery on click, search or expand..map that renders a component calling useQuery can make one request and one cache watch per row, and the cost grows with the list. Read a fragment, or fetch the rows in the list's query.useBackgroundQuery or merge the two queries, unless the child needs the parent's result as a variable.@unmask makes a fragment's fields visible to the parent again, as if there were no masking:
# eslint-disable-next-line no-restricted-syntax -- <why>. Lint reports every @unmask without one; whether the reason is a good one is for the reviewer.@unmask(mode: "migrate") unmasks the fragment like plain @unmask, in the runtime data and in the generated types, and adds a development-only warning each time code reads a field that masking would hide. Apollo's directives docs describe it as a way to adopt data masking incrementally in existing applications. It is for moving existing code to fragments, not for new code.useFragment finds a component's object in the cache by its identity, which is id unless the cache is given keyFields for the type. A type with neither is stored inside its parent and has no entry of its own, so useFragment has nothing to look up: it returns complete: true with the fields missing, and Apollo logs "Could not identify object". A fragment on such a type is rejected by pnpm lint:graphql (axon/fragment-type-has-cache-identity), which reads the schema and packages/data-gql/src/cache-key-fields.json (the types that createPreparedInMemoryCache gives keyFields). The file is validated when it loads: each type maps to a non-empty array of field names that exist on the type, and a typo fails lint with a message that names it.
useFragment for it.cache-key-fields.json ("TextCall": ["callId"]), which gives the cache the same keyFields. Do not add a type only to silence the rule.@unmask does not clear the error, because the rule reports the fragment's definition and not its spreads.Query, Mutation, Subscription) are exempt.
Masking applies to every request-based API: useQuery, useMutation, useSubscription, client.query, and client.mutate. A mutation result that spreads a fragment is masked in the same way.
The cache APIs are never masked. cache.readFragment, cache.writeFragment, cache.updateQuery, and cache.modify see and write whole objects, so a subscription handler can update a fragment's fields directly (see Pub/Sub). GraphQL Cache covers when to update the cache and when to refetch.
A fragment on an interface or union selects the shared fields and then the fields of each member. The component that owns it handles each case:
useQuery for data the page already loads:
chatroom(id:) runs once per query.chatroom(id:) are different requests.ChatbotStatus_chatroom and LanguageBanner_chatroom fragments, each read with useFragment by its component, spread by the component that renders them, with the page's single query at the top. Breaking a fragment up this way doesn't shrink the payload by itself. It makes each field's owner obvious, so a field is removed when its component stops reading it.
Extracting a type from a query result with indexed access ties a component to the query's shape. Give the component a fragment instead:
no-result-type-indexing rejects the indexed form for generated *Query, *Mutation, and *Subscription types.
AudioCollectorDevice_device in a fragments.graphql next to the component.FragmentType<typeof …FragmentDoc> and open it with useFragment.pnpm codegen. TypeScript now shows every place that read a field the fragment doesn't select.id on every object whose fragment is read by a child. pnpm lint:graphql requires it where the type has one.complete before using the data from useFragment.repo/graphql-unused-fields review agent checks the ones a PR adds to a .graphql file, but not a component that stops reading a field, so check that yourself.id and no keyFields. Select its fields in the owning entity's fragment and pass them down as props.renderWithApollo builds its client with createApolloClient, so tests run with masking on, the same as the app. The factories and mock handlers return whole objects; the client masks them on the way in, so a component test sees exactly what the component would see in production. Render the component that owns the query and assert on the child's output, or give a child a reference from a query result.
To put fragment data in the cache directly, use Apollo's client.writeFragment({ fragment, fragmentName, data }) with from: { __typename, id }. A seedFragment helper (CORE-1305) is coming as a thin wrapper over it. The recipe for testing one fragment-owning component on its own is in Testing a Fragment-Owning Component on Its Own.
Import from the .graphql file. Vite resolves it to the generated .graphql.ts:
useFragment, readFragment, watchFragment, and writeFragment find the children. If you see the first error, check that packages/data-gql/src/codegen.ts sets no documentMode (graphQLTag writes only the fragment's own definition) and re-run pnpm codegen. The second means a useFragment, useSuspenseFragment, readFragment, writeFragment or watchFragment call is missing fragmentName: pass the fragment's own name.
The Component_prop pattern keeps names unique in practice, because component names are. Generic names like Data or ChatroomData will collide, and the pattern rejects them.
The FragmentType and MaybeMasked types only mask when @prepared911/data-gql is part of the TypeScript program. Every generated *.graphql.ts imports it, so a package that uses a generated document gets it automatically. If a package uses @apollo/client without any generated document and reads fragment data, import @prepared911/data-gql once so the types apply.
@unmask.