-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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:
-
ISR build/revalidation (server): A page-level query runs server-side. The Relay environment processes the response, including
ConnectionHandler.update(), which callsbuildConnectionEdge()and creates client edge records (e.g.,client:<connectionID>:edges:0,client:<connectionID>:edges:1, etc.). The fullRecordSource— including these client-generated records — is serialized into the ISR HTML. -
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. -
Client refetch: A wrapper component (
StaticPageWrapper) usesuseLazyLoadQuerywithfetchPolicy: '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@connectionfield, causingConnectionHandler.update()to run again. -
Crash: Inside
ConnectionHandler.update(),buildConnectionEdge()callsstore.create(edgeID, edgeType)for edges whose client-generated IDs already exist in the store (published in step 2). TheRelayRecordSourceMutatorthrows because the record already exists.
Simplified page structure:
// pages/index.tsx: ISR page with @connection
const HomePage = ({ viewer, relay }) => (
<StaticPageWrapper
relay={relay}
viewer={viewer}
relayQuery={HomeQuery}
PageComponent={Home}
variables={{ count: 5 }}
/>
)
export const getStaticProps = async (context) => {
return getStaticPropsShared({ context, relayQuery: HomeQuery, options: { revalidate: 900 } })
}// 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',
})
// ...
}# 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:
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:
_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():
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, 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:
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
- 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. - Is the create-or-throw behavior in
buildConnectionEdgeintentional, or is this an oversight given thatConnectionHandler.update()can legitimately be called multiple times for the same connection? - Would you accept a PR to change
buildConnectionEdgeto use get-or-create?
Reproduction steps
- Create a Next.js app with ISR (
getStaticProps+revalidate) - Define a query with
@connectionon the ISR page - Serialize the Relay store from the server (including client-generated connection/edge records)
- On the client, publish the serialized records into the store via
store.publish() - Refetch the same query with
fetchPolicy: 'network-only' - Observe the invariant violation from
RelayRecordSourceMutator#create()