facebook / facebook/relay

ConnectionHandler.buildConnectionEdge() crashes with store.create() when edge records already exist in the store (ISR/SSG hydration scenario)

Open
#5,195 0 comments 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

`buildConnectionEdge()` in `ConnectionHandler` unconditionally calls `store.create(edgeID, typeName)` for each edge it processes. This throws an invariant (`RelayRecordSourceMutator#create(): Cannot create a record with id \`%s\`, this record already exists.`) when the store already contains a record with that client generated edge ID.

This occurs in our Next.js ISR (Incremental Static Regeneration) setup where connection records — including the client-generated edge records created by the connection handler on the server — are pre-populated in the client store via `store.publish()` during hydration. When a subsequent network fetch causes `ConnectionHandler.update()` to process the same connection, `buildConnectionEdge()` generates edge IDs that collide with records already in the store.

## Relay version

`relay-runtime@20.1.1`

## Our setup

We use Next.js with ISR (`getStaticProps` + `revalidate`) for pages that include `@connection` fields. Our data-fetching pattern works as follows:

1. **ISR build/revalidation (server)**: A page-level query runs server-side. The Relay environment processes the response, including `ConnectionHandler.update()`, which calls `buildConnectionEdge()` and creates client edge records (e.g., `client::edges:0`, `client::edges:1`, etc.). The full `RecordSource` — including these client-generated records — is serialized into the ISR HTML.

2. **Client hydration**: On page load, we publish the serialized ISR records into the client Relay store using `environment.getStore().publish(new RecordSource(initialRecords))`. This bypasses the connection handler — records are published directly, not processed through handlers.

3. **Client refetch**: A wrapper component (`StaticPageWrapper`) uses `useLazyLoadQuery` with `fetchPolicy: 'network-only'` on the client to refetch fresh data (which may include user-specific data not available at ISR time). This refetch returns the same `@connection` field, causing `ConnectionHandler.update()` to run again.

4. **Crash**: Inside `ConnectionHandler.update()`, `buildConnectionEdge()` calls `store.create(edgeID, edgeType)` for edges whose client-generated IDs already exist in the store (published in step 2). The `RelayRecordSourceMutator` throws because the record already exists.

Simplified page structure:

```tsx
// pages/index.tsx: ISR page with @connection
const HomePage = ({ viewer, relay }) => (

)

export const getStaticProps = async (context) => {
return getStaticPropsShared({ context, relayQuery: HomeQuery, options: { revalidate: 900 } })
}
```

```tsx
// StaticPageWrapper — refetches with network-only on client
function StaticPageWrapper({ relay, viewer, relayQuery, variables, PageComponent }) {
const isClient = useHasLoadedOnClient()

// store-only during SSR/hydration, network-only once on the client
const clientData = useLazyLoadQuery(relayQuery, variables, {
fetchPolicy: isClient ? 'network-only' : 'store-only',
})
// ...
}
```

```graphql
# The fragment used on the ISR home page
fragment home_viewer on Viewer
@refetchable(queryName: "HomePaginationQuery")
@argumentDefinitions(count: { type: "Int!" }, afterCursor: { type: "String" }) {
webLayouts {
homePage {
mainLayoutBlocks(first: $count, after: $afterCursor)
@connection(key: "home_mainLayoutBlocks") {
edges {
node { ...ContentPieceSection_contentPieceSelection }
}
}
}
}
}
```

## Root cause in ConnectionHandler

The crash is in [`buildConnectionEdge`](https://github.com/facebook/relay/blob/main/packages/relay-runtime/handlers/connection/ConnectionHandler.js):

```js
function buildConnectionEdge(store, connection, edge) {
if (edge == null) return edge;
var edgeIndex = connection.getValue(NEXT_EDGE_INDEX);
var edgeID = generateClientID(connection.getDataID(), EDGES, edgeIndex);
var connectionEdge = store.create(edgeID, edge.getType()); // 💥 throws if edgeID exists
connectionEdge.copyFieldsFrom(edge);
// ...
return connectionEdge;
}
```

`store.create()` delegates to `RelayRecordSourceMutator#create()` which has an invariant check:

```js
_proto.create = function create(dataID, typeName) {
!(this._base.getStatus(dataID) !== EXISTENT && this._sink.getStatus(dataID) !== EXISTENT)
? invariant(false, 'Cannot create a record with id `%s`, this record already exists.', dataID)
: void 0;
// ...
};
```

Since our ISR hydration publishes the client edge records directly into the store's base source, the mutator's `_base` contains these records and the invariant fails.

We believe the collision is specifically caused by `NEXT_EDGE_INDEX` being reset when ISR records are re-published on client-side navigations (the ISR snapshot always has `NEXT_EDGE_INDEX` set to the initial page size), while previously created edge records from earlier visits/refetches remain in the store. But it may also happen on first visit if the ISR-published edge records collide with what the handler generates.

## Current workaround

We use a custom `handlerProvider` that wraps `store.create` with get-or-create semantics during `ConnectionHandler.update()`:

```typescript
export function handlerProvider(handle: string) {
if (handle === 'connection') {
return {
update(store: RecordSourceProxy, payload: HandleFieldPayload) {
const originalCreate = store.create
store.create = (dataID: string, typeName: string) => {
const existing = store.get(dataID)
if (existing) return existing
return originalCreate.call(store, dataID, typeName)
}
try {
ConnectionHandler.update(store, payload)
} finally {
store.create = originalCreate
}
},
}
}
return DefaultHandlerProvider(handle)
}
```

This is based on the same pattern used to address [#1668](https://github.com/facebook/relay/issues/1668), which was the same underlying issue (`store.create` throwing for duplicate IDs) but for the `viewer` handler rather than the `connection` handler.

## Suggested fix

Change `buildConnectionEdge` to use get-or-create semantics instead of create-or-throw:

```js
function buildConnectionEdge(store, connection, edge) {
if (edge == null) return edge;
var edgeIndex = connection.getValue(NEXT_EDGE_INDEX);
var edgeID = generateClientID(connection.getDataID(), EDGES, edgeIndex);
- var connectionEdge = store.create(edgeID, edge.getType());
+ var connectionEdge = store.get(edgeID) || store.create(edgeID, edge.getType());
connectionEdge.copyFieldsFrom(edge);
if (connectionEdge.getValue('cursor') == null) {
connectionEdge.setValue(null, 'cursor');
}
connection.setValue(edgeIndex + 1, NEXT_EDGE_INDEX);
return connectionEdge;
}
```

This would be consistent with the expectation that `buildConnectionEdge` should be idempotent — re-processing the same connection data should update existing edge records rather than crash.

## Questions we have

1. Is there a better approach for hydrating a Relay store with pre-built connection data (including client edge records) from ISR/SSG? We currently use `store.publish()` directly, which bypasses handlers.
2. Is the create-or-throw behavior in `buildConnectionEdge` intentional, or is this an oversight given that `ConnectionHandler.update()` can legitimately be called multiple times for the same connection?
3. Would you accept a PR to change `buildConnectionEdge` to use get-or-create?

## Reproduction steps

1. Create a Next.js app with ISR (`getStaticProps` + `revalidate`)
2. Define a query with `@connection` on the ISR page
3. Serialize the Relay store from the server (including client-generated connection/edge records)
4. On the client, publish the serialized records into the store via `store.publish()`
5. Refetch the same query with `fetchPolicy: 'network-only'`
6. Observe the invariant violation from `RelayRecordSourceMutator#create()`

Contributor guide

Open the contributing guide

Research direction

Read packages/relay-runtime/handlers/connection/ConnectionHandler.js, especially buildConnectionEdge, then run the documented ISR/SSG hydration reproduction. Done means repeated processing of an existing client-generated edge no longer raises the record-creation invariant and the connection data remains correct.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, next.js, react
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.