UVE pushes REST-sourced pageAsset to headless clients — #36410 gate checks requestMetadata existence, not asset provenance
@gortiz-dotcms is already working on this.
Since Sep 7, 2026.
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
Problem Statement
UVE pushes its own REST-fetched copy of the page into a headless client's iframe, overwriting the client's correct GraphQL-sourced render with a structurally different payload. Relationship fields arrive flattened to bare identifier strings, and Site-or-Folder / Category fields are missing entirely or carry different data. Components that read a field on a related item then throw, and the page dies in EDIT_MODE on open with no editor interaction.
This is a follow-up to #36410, which shipped in 26.07.17-01 and passed QA against its own acceptance criteria. The customer is still broken in Production on 26.07.21-01, and the reason is that #36410's headless gate checks the wrong condition.
Mechanism
edit-ema-editor.component.ts:452 skips the iframe push when requestMetadata is empty:
if (pageType === PageType.HEADLESS && !hasClientQuery) {
return;
}
this.reloadIframeContent();
That asks whether the client's GraphQL query has been registered — not whether the payload about to be sent actually came from it. The two are not equivalent:
CLIENT_READYarrives →setCustomClient()setsrequestMetadata,pageReload()fires an async GraphQL fetch,setIsClientReady(true)(dot-uve-actions-handler.service.ts:293-328, one synchronous block).pageAsset()(withPage.ts:232) depends onrequestMetadata()and returns a new object literal per recomputation.$reloadEditorContent's customequalcomparespageAssetRefby reference (withEditor.ts:266), so step 1 alone re-emits it.- The effect re-runs:
isClientReadyistrue,hasClientQueryistrue— both gates pass. ButpageAssetResponseis still the REST asset, becausemarkPageLoading()deliberately preserves it and the GraphQL fetch has not resolved. reloadIframeContent()pushesclientResponse, i.e. the REST asset merely tagged withrequestMetadata.
Once requestMetadata is set it is never re-checked for provenance, so the gate stays open and any later re-emit pushes whatever is in the store. The PR's "self-healing: once CLIENT_READY arrives, the same effect re-fires and pushes correct GraphQL data" is the defect — the re-fire happens before the correct data exists.
Why depth is not the fix. depth=0 returns identifiers only — ContentHelper.addRelatedContentToJsonArray case 0 → relatedContent.getIdentifier(); only case 1 calls contentletToJSON. And addRelationshipsToJSON filters to field instanceof RelationshipField (ContentHelper.java:566), so HostFolderField and Category fields are never expanded at any depth. GraphQL resolves those through dedicated fetchers (SiteOrFolderFieldDataFetcher → DotSiteOrFolder, which declares folderPath). No value of depth makes a REST payload match a GraphQL query — which is why the fix has to be "don't send REST data to headless clients", not "send better REST data".
Note: #36410's own description and PR body both characterise depth=0 as returning one level of related objects. It returns identifier strings. That framing is why QA passed — the fixture only called .length / .map(), which succeeds on an array of identifiers.
Impact. Headless sites on Evergreen. The reporting customer has 26 of 30 GraphQL fragments requesting nested relationship objects; every contentlet on the affected pages is impacted. Roughly 20 further components degrade silently (Encountered two children with the same key, undefined) rather than crashing, so crash count understates the reach.
Steps to Reproduce
- Configure a site with a UVE app config pointing at a headless front end (any truthy
clientHost→pageType = HEADLESS). - Build a page whose components read nested fields on related content — e.g.
image.inode, or a Site-or-Folder field'sfolderPath— with no optional chaining. - Have the front end fetch the page through the SDK's GraphQL client (
client.page.get()), which is the default from@dotcms/clientv1 onward. - Open the page in UVE
EDIT_MODE. - Observe: the page renders correctly from the app's own GraphQL fetch, then crashes with no editor interaction.
- Log every
uve-set-page-datamessage the app receives during that one load.
Expected: every payload the client receives carries relationship fields as resolved objects, matching the client's registered GraphQL query.
Actual: REST-shaped payloads are interleaved with the client's own renders. Customer capture from one EDIT_MODE load of /news-and-insights:
#1 ours (GraphQL)
#2 ours (GraphQL)
#3 UVE REST push <-- unresolved relationships
#4 UVE REST push <-- unresolved relationships
#5 ours (GraphQL)
#6 ours (GraphQL)
Same fields, same load, client payload vs. pushed payload:
| Content type | Field | Client (GraphQL) | Pushed (REST) |
|---|---|---|---|
| FeaturedLinks | featuredLinksLink1 |
resolved object | bare identifier |
| FeaturedLinks | featuredLinksLink2 |
resolved object | bare identifier |
| FeaturedLinks | featuredLinksLink3 |
resolved object | bare identifier |
| FeaturedLinks | featuredLinksSpotlightLink |
resolved object | bare identifier |
| EmailCapture | emailCaptureTeaserCta |
resolved object | bare identifier |
| NewsCollection | newsCollectionTopicLinks |
resolved objects (3) | identifiers (3) |
| NewsCollection | newsCollectionRootPath |
resolved object | absent (folder as ID instead) |
| NewsCollection | newsCollectionInitialData |
resolved object | absent |
| Campaign Hero | singleStylingBackgroundImage |
resolved object | unresolved |
Resulting failures:
TypeError: Cannot read properties of undefined (reading 'startsWith')
at buildPath <- receives image.inode
at buildImagePath
at NewsHighlight
TypeError: Cannot read properties of undefined (reading 'folderPath')
at buildQuery
at NewsCollection
Pushes #3 and #4 arrive after the app has already rendered twice, so the registration the gate checks for had already happened and the pushes went through regardless. This rules out the "first load of the session" explanation given in #36410.
Nothing on the client side converts a resolved object into an identifier string, so the two payloads cannot share a source.
Reporter's caveat on method: the capture is app-level (
useEditableDotCMSPagesubscribing toCONTENT_CHANGES), so React batching means two messages in one tick collapse into one entry and the numbering is approximate. The content comparison is unaffected. A rawwindowmessage listener capture is available on request if message-level fidelity is needed.
Acceptance Criteria
-
UVE never sends
UVE_SET_PAGE_DATAto aHEADLESSclient carrying apageAssetfetched from/api/v1/page/json. Only assets sourced from the client's registered GraphQL request are pushed. -
Provenance is tracked on the stored asset itself (e.g.
pageAssetResponserecords its source) rather than inferred from whetherrequestMetadatais set. -
The reported repro no longer reproduces: opening a headless page in
EDIT_MODEproduces zero REST-shaped pushes. Verification must cover both observed failure patterns, not one:- a relationship field flattened from resolved objects to bare identifier strings (e.g.
newsCollectionTopicLinks) - a field present in the client's GraphQL payload and absent entirely from the REST shape (e.g.
newsCollectionRootPath, a Site-or-Folder field that resolves to an object withfolderPath)
#36410's QA exercised only the first pattern, which is why it passed while the reporter remained broken.
- a relationship field flattened from resolved objects to bare identifier strings (e.g.
-
Edge case — the reload fallback is keyed on the absence of a GraphQL-sourced asset for the current page, not on whether
requestMetadatais set. When no such asset is available, UVE sendsUVE_RELOAD_PAGEinstead of page data so the client re-renders from its own source, and no REST-shaped page data is sent. This must hold in every state in the matrix below. -
After an author edit (drag/drop, add, remove, save) on a headless page with a registered query, the preview reflects the change using GraphQL-sourced data.
-
Regression — traditional pages are unchanged: they continue to receive server-rendered REST content and exit the reload effect before the headless branch, exactly as today.
-
Test — a spec asserts no
UVE_SET_PAGE_DATAcarrying a REST-sourced asset is emitted for aHEADLESSpage, including the case whererequestMetadatais set but the stored asset came from REST. This closes the gap #36410's QA notes recorded as code-reviewed only. -
Test — a spec asserts
UVE_RELOAD_PAGEis emitted for aHEADLESSpage with no registered GraphQL request. -
The four other
UVE_SET_PAGE_DATAsenders are audited and confirmed unable to deliver a REST-sourced asset to a headless client:uve-optimistic-save.service.ts:54,withPageApi.ts:566,withPageApi.ts:599,withSave.ts:130.
State matrix for the headless push
| State | requestMetadata |
Stored asset | Expected behaviour |
|---|---|---|---|
Never registered — CLIENT_READY never fired |
null |
REST | No page-data push; send UVE_RELOAD_PAGE |
| Registered, GraphQL fetch not yet resolved | set | REST | No page-data push; send UVE_RELOAD_PAGE |
| Registered, then the client's GraphQL request aborted or failed | set | REST (stale) | No page-data push; send UVE_RELOAD_PAGE |
| Registered, GraphQL fetch resolved | set | GraphQL | Push the GraphQL-sourced asset |
Rows 2 and 3 are the states the shipped gate cannot distinguish — requestMetadata is non-null in both, so the push proceeds. Row 3 is the state captured by the reporter (net::ERR_ABORTED on their GraphQL POST during a mode switch). Note that withPageApi's catchError patches uveStatus to ERROR and returns EMPTY without calling setPageAsset, so a failed fetch leaves the stale REST asset in the store rather than clearing it.
Related findings — NOT in scope for this issue
Recorded here so they are not lost; each needs its own issue once root-caused.
- Relationship fields resolving to zero related items.
heroImage,heroVideo,heroImageMobileRenditionandfeaturedLinksViewAllcome back as[].getJSONArrayValue(ContentHelper.java:713) returnsjsonArray.get(0)only whenallowOnlyOne && !isEmpty(), so[]on a single-cardinality field means zero relations resolved — raisingdepthwould not populate these. Suspect the child-side field case, or the language/live filtering incontentlet.getRelated(...); notelanguageFallbackis hardcodedfalseon the page-render path per #35862. - REST/GraphQL shape parity for
HostFolderFieldand Category fields. REST page JSON cannot express these at any depth (see Mechanism above). Either add REST parity or document the divergence. Scope note: this issue resolves the reporter'sfolderPathfailure by ensuring no REST-shaped payload reaches a headless client at all — it does not add the field to the REST shape. That parity gap remains out of scope here. - A Category field returning different data, not unresolved data.
newsCollectionCategoryFiltercarries one resolved object in the client payload and four in the pushed one. This is not a resolved-vs-unresolved difference and does not fit the pattern above.
dotCMS Version
Evergreen 26.07.21-01 (Production and UAT, dotCMS Cloud, standard track). Regression confirmed present in 26.07.17-01 onward — i.e. in the release that shipped #36410. Reproduced on Production.
Severity
High - Major functionality broken
Links
- Freshdesk ticket 38109: https://dotcms.freshdesk.com/a/tickets/38109
- Follow-up to #36410 (closed, QA Passed, shipped in
26.07.17-01) and PR #36412 - Slack: #team-maintenance thread · #t3-support thread
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.