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. Opening a relation that itself has relations produces a document history stack: each opened relation pushes a DocumentMeta entry onto a shared stack, and navigating back pops it. All navigation state is owned by a single reducer, so nested modals reuse the same state rather than creating new instances.
Three recurring failure modes arise in nested contexts:
- Wrong model/ID resolution when a relation lives inside a repeatable component
- Inherited component context from the parent form bleeding into the modal
formatEditLayoutcrashes when deeply nested schemas aren't yet in the local cache
Architecture: Root vs. Nested Renderers#
RelationModalRenderer dispatches at render time based on whether a RelationModal context already exists :
RootRelationRenderer— no existing context: initializesuseReducerwith an emptydocumentHistory: [], buildsrootDocumentMetafrom the parent document, and mountsRelationModalProvider.NestedRelationRenderer— context already present: readsdispatchfrom the parent context and reuses 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 (owns useReducer)
└── RelationModalProvider (context: state + dispatch)
└── RelationModal (UI shell)
└── ... form for currentDocumentMeta ...
└── NestedRelationRenderer (reuses parent dispatch)
State Shape & Navigation Actions#
The State interface tracks:
| Field | Purpose |
|---|---|
documentHistory | Stack of DocumentMeta objects; top = active document |
isModalOpen | Modal visibility |
hasUnsavedChanges | Lifted from inner <Form> context |
confirmDialogIntent | null / 'close' / 'back' / 'navigate' / DocumentMeta — drives the discard-changes dialog |
fieldToConnect | Dot-path on parent for a newly created relation |
fieldToConnectUID | Component UID for the parent field |
Seven action types handle navigation: GO_TO_RELATION (push), GO_BACK (pop via slice(0, -1)), GO_FULL_PAGE, GO_TO_CREATED_RELATION, CANCEL_CONFIRM_DIALOG, CLOSE_MODAL, and SET_HAS_UNSAVED_CHANGES .
Unsaved-changes gate: If hasUnsavedChanges is true, any navigation action sets confirmDialogIntent rather than executing immediately, triggering a discard-changes dialog. hasUnsavedChanges is synced up from the inner <Form> via a useEffect in RelationModalBody .
Component Context Isolation#
The modal shell wraps all content in a reset ComponentProvider with id={undefined} level={-1} uid={undefined} . Without this reset, the modal would inherit componentUID, componentId, and nesting level from the parent form's useComponent context, causing relation search queries to resolve to 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 from useComponent were not used, so every search hit the root content type's endpoint instead of the component's, producing 400 errors or empty results.
Schema Resolution Guards in formatEditLayout#
formatEditLayout in useDocumentLayout.ts builds the edit layout for the document shown in the modal. It can receive an undefined schema when deeply nested content types haven't been loaded into useContentTypeSchema. Several guards protect this path:
1. Component-level guard : Before processing each entry in data.components, the function checks const componentSchema = components[uid]; if (!componentSchema) return acc;. This skips any component UID that appears in the persisted configuration but is absent from the /init response, preventing TypeError: Cannot read properties of undefined (reading 'attributes').
- PR #26167 introduced this guard pattern alongside an i18n locale preservation fix.
- PR #26203 reinforced it specifically for second-level nested relation modal scenarios.
2. Top-level schema guard — PR #26306 adds an early return at the top of formatEditLayout when schema is undefined, returning a safe empty EditLayout. This covers ≥2 levels of nesting through dynamic zones where the target content type schema isn't cached locally.
3. convertEditLayoutToFieldLayouts config guard — PR #26184 adds a check before accessing components.configurations[attribute.component]?.settings , preventing crashes when a component attribute's config entry is missing.
4. getMainField optional-chaining guard — PR #26295 guards getMainField with optional chaining for the relation branch, preventing undefined.attributes dereference when the component catalog entry is absent during client-side navigation between single types.
InputRenderer and Component Layout Lookup#
InputRenderer.tsx renders each field in the edit view. For component-type fields, it accesses the layout as components[props.attribute.component].layout . This lookup is only safe once formatEditLayout has successfully resolved the component layout — making the guards above a prerequisite for InputRenderer to function without crashing in nested modal contexts. The BaseInputRenderer also reads currentDocumentMeta.model via useDocumentContext to ensure it resolves layouts for the modal's active document, not the parent.
Key Files#
| File | Role |
|---|---|
RelationModal.tsx | All modal state, reducer, context, and navigation logic |
useDocumentLayout.ts | formatEditLayout — builds edit layout; guarded against missing schemas |
InputRenderer.tsx | Renders each edit view field; uses currentDocumentMeta.model to resolve component layouts |
Relations.tsx | Relation field; model/ID resolution using useComponent context |
ComponentContext.tsx | useComponent — provides id, uid, level, type to nested forms |
Related PRs#
| PR | Fix |
|---|---|
| #26023 | Relation search in nested repeatable components — use componentUID/componentId from context |
| #26167 | Guard component schema in formatEditLayout; preserve i18n locale on navigation |
| #26184 | Guard component config lookup in convertEditLayoutToFieldLayouts |
| #26203 | Null guard for missing component schema — second-level nested modal crash |
| #26295 | Optional-chaining guard for getMainField when component catalog entry is absent |
| #26306 | Early-return guard for undefined top-level schema in deeply nested modals |