facebook / facebook/relay

feat: split generated artifacts into separate runtime and type files

Open
#5,190 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
19k
Forks
1.9k
PR merge metrics
No merged PRs in 30d

Description

## Summary

In our monorepo at Microsoft, when using the `output` (artifact directory) config to avoid cross-package artifact conflicts in a monorepo, **type definitions are no longer co-located with their source fragments/queries**. This breaks the developer experience for shared component packages.

This proposal is adding an option to **split each generated artifact into two files**: a runtime artifact (placed either next to the source -same as current- or in the configured `output` directory) and a types-only artifact (always placed next to the source file in the default `__generated__` folder with a `types` in the name e.g. `__generated__/MyFragment.types.graphql.ts`).

## Problem

In a monorepo where multiple apps share component packages but have **different client resolvers**, the `output` config is necessary — without it, each app's compiler writes different runtime artifacts to the same `__generated__/` location in the shared package, and whichever runs last wins. However, using `output` redirects **all** artifacts — including type definitions — away from the source files. This means:

1. **Component packages lose TypeScript types** — no type information for fragments unless the compiler is manually run from within the component package.
2. **Import ergonomics degrade** — types require long relative paths to the centralized `output` directory (e.g., `'../../../../__generated__/MyFragment.graphql'`) instead of co-located `'./__generated__/MyFragment.graphql'`.
3. **Storybook breaks** — to get types back into component packages, one could run a second compiler instance (without `output`) that writes artifacts cross-package. But those artifacts contain resolver references, which breaks Storybook.

The root cause: **the compiler generates a single artifact file containing both runtime code and type definitions**, with no way to control where each part is written.

Image

Why is output needed in the first place?

By default, artifacts go in `__generated__/` next to the source file. In a monorepo this creates two problems:

**Artifact overwrite conflicts** — when `app-a` and `app-b` both compile against a shared component, they write to the same `__generated__/` file. If the apps have different client resolvers, the runtime artifacts differ — whichever compiler runs last wins. On CI where builds run in parallel, this **produces incorrect production builds**.

**Storybook resolver bleed** — when a host app's compiler writes artifacts into a component package, those artifacts reference client resolvers that the component package doesn't depend on. Storybook breaks trying to bundle resolver imports outside its dependency graph.

Setting `output` per app isolates the runtime artifacts, but at the cost of losing co-located types.

Current workarounds (all have drawbacks)

| Workaround | Drawback |
|---|---|
| Run relay compiler manually from each component package separately that dev is changing | Poor DX; not automatic during app dev |
| Maintain a separate `relay.config.dev.json` without `output` | Two compiler instances; Storybook still breaks from resolver-containing artifacts |

## Proposed Solution: Artifact Splitting

Add a config option that splits each artifact into two files:

- **Runtime artifact** → `output` directory (or default `__generated__/`). Contains the `node` object (`ConcreteRequest`, `ReaderFragment`, etc.) used at runtime.
- **Types artifact** → always co-located in `__generated__/` next to the source. Contains only TypeScript type exports (`$data`, `$key`, `$fragmentType`, etc.).

Image

Example: what the split files look like

**Runtime artifact** (`app-a/src/__generated__/MyFragment.graphql.ts`):
```typescript
import { ReaderFragment } from 'relay-runtime';
const node: ReaderFragment = { /* ... */ };
(node as any).hash = "abc123";
export default node;
```

**Types artifact** (`shared-component/src/components/__generated__/MyFragment.types.graphql.ts`):
```typescript
import type { FragmentType } from 'relay-runtime';
export type MyFragment$data = {
readonly name: string | null | undefined;
};
export type MyFragment$key = {
readonly " $data"?: MyFragment$data;
readonly " $fragmentSpreads": FragmentType;
};
```

### Why this works

- **No overwrite conflicts** — each app's runtime artifacts live in their own `output` directory; resolver references never bleed into component packages.
- **Types always co-located** — type artifacts are identical regardless of which compiler produces them (types depend on fragment shape, not resolver implementations), so concurrent writes are safe.
- **Storybook works** — component packages generate their own local runtime artifacts (no resolver imports); app compilers write to separate directories.
- **Single compiler during dev** — running from the app package updates both app runtime artifacts and component type artifacts in one pass.

We have implemented this in an internal fork of the Relay compiler and have been running it in our large production monorepo. Would be happy to contribute an upstream implementation.

---

Detailed example with artifact splitting

### Package structure

```
packages/
user-card/ # Shared component package
src/components/
UserCard.tsx
__generated__/
UserCard_dataFragment.types.graphql.ts ← TYPES (always here)
UserCard_dataFragment.graphql.ts ← RUNTIME (for Storybook)
.storybook/
relay.config.json # No `output` set

resolvers-backend-a/src/resolvers/ # @RelayResolver for avatarUrl (backend A)
resolvers-backend-b/src/resolvers/ # Different @RelayResolver (backend B)

app-a/ # Uses resolvers-backend-a
src/__generated__/
UserCard_dataFragment.graphql.ts ← RUNTIME (backend-a refs)
relay.config.json # output: "./src/__generated__"

app-b/ # Uses resolvers-backend-b
src/__generated__/
UserCard_dataFragment.graphql.ts ← RUNTIME (backend-b refs)
relay.config.json # output: "./src/__generated__"
```

### Compilation flow

| Compiler | Runtime artifact | Types artifact |
|---|---|---|
| `app-a` | `app-a/src/__generated__/...graphql.ts` (backend-a refs) | `user-card/src/components/__generated__/...types.graphql.ts` |
| `app-b` | `app-b/src/__generated__/...graphql.ts` (backend-b refs) | Same types file (identical content, safe to overwrite) |
| `user-card` Storybook | `user-card/src/components/__generated__/...graphql.ts` (local, no resolver refs) | Same types file |

No conflicts — Storybook never sees resolver imports from host apps.

### Fragment example

```tsx
// user-card/src/components/UserCard.tsx
import type { UserCard_dataFragment$key } from './__generated__/UserCard_dataFragment.types.graphql';

const UserCard = ({ dataRef }: { dataRef: UserCard_dataFragment$key }) => {
const data = useFragment(
graphql`
fragment UserCard_dataFragment on User {
name
avatar { url } # resolved differently per app
}
`,
dataRef
);
return

{data.name}
;
};
```

### Config examples

**App package** (`app-a/relay.config.json`):
```json
{
"sources": {
"app-a/src": "app-a",
"resolvers-backend-a/src": "app-a"
},
"projects": {
"app-a": {
"output": "app-a/src/__generated__",
"artifactGenerationMode": "splitRuntimeAndTypes"
}
}
}
```

**Component package** (`user-card/relay.config.json`) — no `output`, co-locates everything:
```json
{
"projects": {
"user-card": {
"artifactGenerationMode": "splitRuntimeAndTypes",
"schemaExtensions": ["schema-extensions/avatar"]
}
}
}
```

Compiler internals

### Architecture already supports splitting

Artifact generation in `content.rs` builds an ordered list of `ContentSection` objects:

1. Docblock + lint directives
2. **Type imports and definitions** (`$variables`, `$data`, `$key`, `$fragmentType`)
3. **Runtime node** (`const node: ConcreteRequest = { ... }`) + hash + export

Types (2) and runtime (3) are already constructed independently — splitting means emitting them to different files instead of concatenating.

### Existing `skip_types` mechanism

```rust
let skip_types =
if let Some(extra_artifacts_config) = &project_config.extra_artifacts_config {
(extra_artifacts_config.skip_types_for_artifact)(source_file)
} else {
false
};
```

The compiler can already produce runtime-only artifacts. The complementary types-only artifact is the other half of the split.

### Path resolution

`project_config.rs` already has both paths:
- `output` path → runtime artifacts
- Co-located `source_file.get_dir().join("__generated__")` → type artifacts

### Naming convention

- `MyFragment.graphql.ts` — runtime (backwards compatible)
- `MyFragment.types.graphql.ts` — types only

---

Contributor guide

Open the contributing guide

Research direction

Start by reading content.rs and project_config.rs, including the existing skip_types mechanism and the two artifact paths described in the issue. Trace how ContentSection objects are assembled and how project configuration is parsed. Done means the proposed splitRuntimeAndTypes option produces runtime artifacts in the configured output and co-located types artifacts using the stated naming convention.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, rust, typescript
Domain
build-system, developer-experience, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.