Skip to content

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

@vanajmoorthy

Description

@vanajmoorthy

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:<connectionID>:edges:0, client:<connectionID>: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:

// 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

  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()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions