dotCMS / dotCMS/core

UVE pushes REST-sourced pageAsset to headless clients — #36410 gate checks requestMetadata existence, not asset provenance

Open
#37,097 2 comments 0 reactions 1 assignee View on GitHub

@gortiz-dotcms is already working on this.

Since Sep 7, 2026.

OKR : Customer Support Team : Maintenance Type : Defect
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:

  1. CLIENT_READY arrives → setCustomClient() sets requestMetadata, pageReload() fires an async GraphQL fetch, setIsClientReady(true) (dot-uve-actions-handler.service.ts:293-328, one synchronous block).
  2. pageAsset() (withPage.ts:232) depends on requestMetadata() and returns a new object literal per recomputation. $reloadEditorContent's custom equal compares pageAssetRef by reference (withEditor.ts:266), so step 1 alone re-emits it.
  3. The effect re-runs: isClientReady is true, hasClientQuery is true — both gates pass. But pageAssetResponse is still the REST asset, because markPageLoading() deliberately preserves it and the GraphQL fetch has not resolved.
  4. reloadIframeContent() pushes clientResponse, i.e. the REST asset merely tagged with requestMetadata.

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 0relatedContent.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 (SiteOrFolderFieldDataFetcherDotSiteOrFolder, 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
  1. Configure a site with a UVE app config pointing at a headless front end (any truthy clientHostpageType = HEADLESS).
  2. Build a page whose components read nested fields on related content — e.g. image.inode, or a Site-or-Folder field's folderPath — with no optional chaining.
  3. Have the front end fetch the page through the SDK's GraphQL client (client.page.get()), which is the default from @dotcms/client v1 onward.
  4. Open the page in UVE EDIT_MODE.
  5. Observe: the page renders correctly from the app's own GraphQL fetch, then crashes with no editor interaction.
  6. Log every uve-set-page-data message 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 (useEditableDotCMSPage subscribing to CONTENT_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 raw window message listener capture is available on request if message-level fidelity is needed.

Acceptance Criteria
  • UVE never sends UVE_SET_PAGE_DATA to a HEADLESS client carrying a pageAsset fetched 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. pageAssetResponse records its source) rather than inferred from whether requestMetadata is set.

  • The reported repro no longer reproduces: opening a headless page in EDIT_MODE produces 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 with folderPath)

    #36410's QA exercised only the first pattern, which is why it passed while the reporter remained broken.

  • Edge case — the reload fallback is keyed on the absence of a GraphQL-sourced asset for the current page, not on whether requestMetadata is set. When no such asset is available, UVE sends UVE_RELOAD_PAGE instead 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_DATA carrying a REST-sourced asset is emitted for a HEADLESS page, including the case where requestMetadata is 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_PAGE is emitted for a HEADLESS page with no registered GraphQL request.

  • The four other UVE_SET_PAGE_DATA senders 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.

  1. Relationship fields resolving to zero related items. heroImage, heroVideo, heroImageMobileRendition and featuredLinksViewAll come back as []. getJSONArrayValue (ContentHelper.java:713) returns jsonArray.get(0) only when allowOnlyOne && !isEmpty(), so [] on a single-cardinality field means zero relations resolved — raising depth would not populate these. Suspect the child-side field case, or the language/live filtering in contentlet.getRelated(...); note languageFallback is hardcoded false on the page-render path per #35862.
  2. REST/GraphQL shape parity for HostFolderField and 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's folderPath failure 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.
  3. A Category field returning different data, not unresolved data. newsCollectionCategoryFilter carries 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.