Skip to content

[Sketch] Hoisted Query Selections#5295

Draft
captbaritone wants to merge 18 commits into
facebook:mainfrom
captbaritone:query-selection-hoist
Draft

[Sketch] Hoisted Query Selections#5295
captbaritone wants to merge 18 commits into
facebook:mainfrom
captbaritone:query-selection-hoist

Conversation

@captbaritone
Copy link
Copy Markdown
Contributor

@captbaritone captbaritone commented May 22, 2026

As discussed with folks (@mjmahone, @PascalSenn and @michaelstaib I think?) at GraphQL Conf 2026


This sketch explores a feature which allows access to the Query root from within any selection set.

fragment UserProfile on User {
  name
   # Relay can hoist this to the root of every query that includes this fragment
  __query {
    viewer {
      canDeleteUsers
    }
  }
}

Motivation

A common pain point in developing applications while using fragment co-location is that you need access to some global value in a deeply nested component. For example, you may want to check whether the current viewer has some specific permission or query for the set of options that are valid for a specific drop-down menu.

Today, this requires threading a separate fragment on Query all the way through to this potentially deeply nested component in parallel with the component's existing fragment, which is likely related to the data that component is actively rendering. Today, when this is impractical, we recommend updating the schema and re-exposing the root field(s) on whatever arbitrary type you already have a fragment on.

Hoisted Query Selections allows you to define the root data you need as a __query selection on any type and the Relay compiler/ can "hoist" the selections up to the root of all queries which include the fragment with the __query. Relay Runtime can then ensure the data is materialized out of the normalized store.

Example

Let's look at an example of how this works. Note that the real benefit here is not felt locally in the component that uses the data as much as its felt in the potentially long chain (potentially multiple long chains) of components between the query roots and the component that needs the data.

Before

 function UserProfile({ userRef, queryRef }) {
  const user = useFragment(
    graphql`
      fragment UserProfile_user on User {
        name
      }
    `,
    userRef,
  );
  const query = useFragment(
    graphql`
      fragment UserProfile_query on Query {
        viewer { canDeleteUsers }
      }
    `,
    queryRef,
  );

  return (
    <div>
      <h1>{user.name}</h1>
      {query.viewer.canDeleteUsers && <button>Delete</button>}
    </div>
  );
}

After

function UserProfile({ userRef }) {
  const data = useFragment(
    graphql`
      fragment UserProfile on User {
        name
        __query {
          viewer {
            canDeleteUsers
          }
        }
      }
    `,
    userRef,
  );

  return (
    <div>
      <h1>{data.name}</h1>
      {data.__query.viewer.canDeleteUsers && <button>Delete</button>}
    </div>
  );
}

Tradeoffs

Pros

  1. If there are any @skip/@include along the path from the query root to the fragment where __query is selected, Relay can include those conditions in the root selections.
  2. If multiple items fetch this data, Relay compiler will dedupe them. This is more efficient than re-exposing the root field on a named type, especially if the component is being rendered in a list.

Cons

  1. This is yet another special syntax for users to learn, and it's not part of the specification.
  2. It's unclear what should happen if the fragment is transitively referenced in a subscription or mutation. Should the __query be ignored? Should it error? Ignoring it could be confusing, and making it an error could significantly reduce the feature's utility since spreading fragments into mutations is a common pattern.

@meta-cla meta-cla Bot added the CLA Signed label May 22, 2026
Add support for `__query { ... }` in any selection set, which allows
selecting fields from the query root type. The compiler hoists these
selections to the query root at compile time.

- Register synthetic `__query` field in schema (InMemory + FlatBuffer)
- Intercept `__query` in build phase, validate against query root type
- Hoisting transform with O(N) performance and cycle-safe caching
- Integrated into both normalization and operation_text pipelines
- Condition preservation: @include/@Skip wrappers maintained
- Error on __query in mutation/subscription operations
Verifies the full compiler → server → runtime pipeline: the compiler
hoists __query selections to the query root, the server receives valid
GraphQL, and non-__query fields render correctly.
Add reader pipeline support so fragments can read __query data from
the root record in the store:

Compiler:
- Reader transform converts __query LinkedField → InlineFragment with
  @__queryRootSelection directive (runs after flatten)
- Codegen recognizes the directive and emits QueryRootSelection node

Runtime:
- New QueryRootSelection case in RelayReader reads selections from
  ROOT_ID instead of the current record
- Results merge into the current fragment's data object

E2E test verifies the full pipeline: a fragment with __query { greeting }
reads greeting from the query root while name comes from the User record.
- Remove __query interception from graphql-ir/build.rs (generic crate
  should not have Relay-specific features)
- Register __query in schema named_field() instead — the IR builder
  finds it via normal field lookup and builds selections against its
  return type (Query) automatically
- Gate all transforms behind enable_query_root_selection feature flag
  (disabled by default)
- Update tests and e2e fixture to enable the flag
- @Skip on __query directly
- Nested conditions: @include@Skip@include(on __query) — all
  three conditions accumulate on the hoisted selection
- Conditions inside __query (regular field-level, not hoisted)
- Cross-fragment: condition on parent field + condition on __query
  in the fragment (documents current behavior: fragment-level
  conditions propagate, but caller-level conditions only propagate
  in normalization pipeline where fragments are inlined)
Previously, the operation_text pipeline only propagated conditions
found within the same fragment as __query. Conditions from ancestor
fields/fragments in the caller were lost because fragments are
processed independently (not inlined).

Now each fragment spread carries the full condition stack from its
call site. When resolving transitive hoisted selections, the
conditions from each fragment boundary are accumulated, so deeply
nested @skip/@include across arbitrary fragment spreads all wrap
the final hoisted selections.
- Unify wrap_in_conditions/wrap_in_owned_conditions into one function
  using ConditionInfo type with a wrap() method
- Consolidate 4 fragment maps into 1 (FragmentExtraction struct)
- Use push/pop on mutable Vec for condition_stack instead of
  slice-and-collect (avoids O(depth) allocation per Condition node)
- Use .remove() instead of .get().cloned() in resolve_transitive_hoisted
- Remove empty-branch comment (inverted condition)
When a field (e.g. viewer) already exists at the query root and is also
selected via __query in a fragment, the flatten transform merges their
selections. Integration test confirms the final operation text has a
single viewer with combined selections (id + name).
When viewer exists at query root with @Skip and is also hoisted from
__query without conditions, flatten correctly keeps them separate
(different condition = different selection). Both appear in the
operation text — the server evaluates each independently.
QueryRootSelection reader now writes results into a __query sub-object
on the fragment data rather than merging flat. This avoids field name
collisions between __query selections and fields on the parent type.

Access pattern: data.__query.viewer.canDeleteUsers (not data.viewer)
When a user hovers over __query in their editor, the LSP shows:
"Relay extension: select fields from the query root within any
fragment. Selections inside __query { ... } are hoisted to the query
root at compile time, letting a fragment declare its dependency on
root-level data without threading it through every parent component.
Access the data via data.__query."
- Run rustfmt on all changed Rust files
- Add QueryRootSelection case to createUpdatableProxy.js exhaustive
  switch (Flow requires all ReaderSelection union members handled)
- Cast data.__query to SelectorData in RelayReader (Flow can't infer
  the type of dynamic property access on SelectorData)
- Regenerate compile_relay_artifacts_test.rs and
  hoist_query_selections_test.rs via fixture_tests_bin
  (CI checks that these match the auto-generated output)
- Fix Flow: use fresh SelectorData object for __query instead of
  casting from unknown (Flow disallows any casts and colon-cast syntax)
- Add query_selection_field() to InterfaceOnlySchema test impl
- Update compact serializer to strip 6 synthetic fields (was 5)
- Update schema test snapshots to include __query field
…t files

- Fix compact serializer to strip 6 synthetic fields (was 5)
- Add query_selection_field to InterfaceOnlySchema test impl
- Update relay-compiler-config-schema.json for new feature flag
- Regenerate all fixture test files via fixture_tests_bin
@captbaritone captbaritone force-pushed the query-selection-hoist branch from 3f3ccb2 to 18dbe3d Compare May 23, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant