Nested Relation Modal Navigation#
The Content Manager's relation modal lets editors open, edit, and create related documents inline without leaving the parent edit view. Navigating into a relation that itself has relations produces a document history stack: each opened relation pushes a DocumentMeta entry onto the stack, and going back pops it. A single shared reducer owns all navigation state; nested modals consume it rather than creating their own.
Three recurring problems arise in nested contexts: (1) incorrect model/ID resolution when a relation lives inside a component, (2) component context inherited from the parent form bleeding into the modal, and (3) formatEditLayout crashing when deeply nested schemas aren't yet cached. The architecture and the fixes described below address each layer.
Architecture: Root vs. Nested Renderers#
RelationModalRenderer checks at render time whether a RelationModal context already exists:
- No existing context →
RootRelationRenderer: initializes theuseReducerwith an emptydocumentHistory: [], buildsrootDocumentMetafrom the parent document, readsrelationOpenModefrom layout settings, and mounts theRelationModalProvidercontext. - Context already present →
NestedRelationRenderer: readsdispatchfrom the parent context and re-uses it — no new reducer is created.
currentDocumentMeta is always state.documentHistory.at(-1) ?? rootDocumentMeta , so the top of the stack is the "active" document in the modal at any moment.
RootRelationRenderer
└── RelationModalProvider (owns reducer state)
└── RelationModal (UI shell)
└── ... form for currentDocumentMeta ...
└── NestedRelationRenderer (reuses parent dispatch)
└── RelationModalTrigger
State Shape & Reducer#
The State interface:
| Field | Type | Purpose |
|---|---|---|
documentHistory | DocumentMeta[] | Navigation stack; top = active document |
isModalOpen | boolean | Modal visibility |
hasUnsavedChanges | boolean | Lifted from inner <Form> context |
confirmDialogIntent | null | 'close' | 'back' | 'navigate' | DocumentMeta | Drives the discard-changes dialog |
fieldToConnect | string? | Dot-path on parent for newly created relation |
fieldToConnectUID | string? | Component UID for the parent field |
Six action types: GO_TO_RELATION (push to stack), GO_BACK (pop via slice(0, -1)), GO_FULL_PAGE, GO_TO_CREATED_RELATION, CLOSE_MODAL, SET_HAS_UNSAVED_CHANGES, CANCEL_CONFIRM_DIALOG.
Unsaved-changes gate: If hasUnsavedChanges is true and shouldBypassConfirmation is false, any navigation action sets confirmDialogIntent instead of executing immediately . hasUnsavedChanges is synced up from the inner <Form> via a useEffect in RelationModalBody .
Component Context Isolation#
The RelationModal UI shell wraps all modal content in a reset ComponentProvider:
<ComponentProvider id={undefined} level={-1} uid={undefined} type={undefined}>
Without this reset, the modal would inherit the parent form's componentUID, componentId, and nesting level from the useComponent context , causing relation search queries to use the wrong model and ID.
The Relations field resolves the search target as :
const model = componentUID || currentDocumentMeta.model;
const id = componentUID ? componentId?.toString() : documentId;
PR #26023 fixed the case where a relation is nested inside a repeatable component — before the fix, componentUID/componentId were always ignored, causing every search to hit the root content type's endpoint instead of the component's.
Schema Resolution Guards in formatEditLayout#
formatEditLayout in useDocumentLayout.ts builds the edit layout for the document currently shown in the modal. It can receive an undefined schema when deeply nested content types haven't been loaded into useContentTypeSchema.
Two guards protect this path:
-
Component-level guard : Before processing each entry in
data.components, checksconst componentSchema = components[uid]; if (!componentSchema) return acc;. This skips any component UID that appears in persisted configuration but is absent from the/initresponse — preventingTypeError: Cannot read properties of undefined (reading 'attributes'). -
Top-level schema guard — PR #26306 : Adds an early return at the top of
formatEditLayoutwhenschemaisundefined, returning a safe emptyEditLayout(layout: [], components: {}, metadatas: {}, options: {}). This covers the case of ≥2 levels of nesting through dynamic zones where the target content type schema isn't cached locally.
PR #26203 is a companion fix that adds the component dictionary guard more explicitly — specifically addressing the second-level nested modal scenario where the config references an unknown component UID.
Key Files#
| File | Role |
|---|---|
RelationModal.tsx | All modal state, reducer, context, and navigation logic |
useDocumentLayout.ts | formatEditLayout — builds edit layout; guarded against missing schemas |
Relations.tsx | Relation field; model/ID resolution using useComponent context |
ComponentContext.tsx | useComponent — provides id, uid, level, type to nested forms |
Related PRs: