Repository navigation
Content Types: Refresh composing types when a property is added to a composition (closes #24117) - #24126
Conversation
Adding a property to a content type used as a composition left every type composed of (or inheriting from) it serving a published content type built before the property existed, so the value read as null on the front end and in Preview until the application restarted. ComposeContentTypeChanges only propagated to deriving types for changes in hasPropertyMainImpact, which never included a property addition. The deriving types therefore got no change entry at all, and neither the published content type cache nor the converted content cache was ever cleared for them. Propagation now also runs for a property addition, emitting RefreshOther for the deriving types - enough to clear both caches, without the cmsContentNu rebuild a pure addition does not need. The traversal is also made transitive (GetComposedOfTransitive), so a change reaches types further down an inheritance or composition chain rather than only the direct consumers. Reloading the published caches now clears IPublishedContentTypeCache as well, so "Reload Memory Cache" can recover a site left in this state instead of the rebuilt content being projected through the same stale definitions.
|
Claude finished @AndyButland's task in 5m 20s —— View job PR ReviewTarget: Fixes a silent stale-cache bug where adding a property to a content type used as a composition left all derived types serving a published content type built before the property existed, with no recovery short of an app restart. Three related gaps are addressed: propagating property additions to derived types, making that propagation transitive, and clearing published content type cache on a memory reload.
CriticalNone ImportantNone Suggestions
Approved with Suggestions for improvementGood to go. The three root causes are correctly diagnosed and patched, the obsolete-constructor pattern is applied cleanly, |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Alias changes still leave descendant published types stale, and the new traversal scales quadratically.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Fixes stale published content types when compositions gain properties, including transitive descendants and manual cache reload recovery.
Changes:
- Propagates property-set changes transitively without unnecessary database-cache rebuilds.
- Clears published content-type definitions during full published-cache reloads.
- Adds integration coverage for composition, inheritance, and reload scenarios.
| File | Description |
|---|---|
ContentCacheRefresherIdKeyMapTests.cs |
Updates refresher construction. |
DocumentHybridCacheDocumentTypeTests.cs |
Tests composed property additions without blob rebuilds. |
ContentTypeEditingServiceTests.Update.cs |
Verifies refresh payload propagation. |
ReloadPublishedCacheTests.cs |
Tests full-reload content-type invalidation. |
PublishedContentTypeCacheTests.cs |
Tests direct and transitive invalidation. |
ContentTypeServiceBase{TRepository,TItem}.cs |
Adds transitive change propagation. |
ContentCacheRefresher.cs |
Clears published content types on full reload. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…eview A content type's published content type resolves its composition aliases recursively through its compositions, so renaming a content type left every type deriving from it reporting the old alias - breaking IsComposedOf on the front end and CompositionSchemaIds in the Delivery API - until another refresh or a restart. hasAliasChanged now propagates too; deriving types take RefreshOther, since the stored blob is keyed by property alias rather than content type alias and so needs no rebuild. GetComposedOfTransitive built its reverse edges by rescanning every content type for each type it found, making a base-type save O(types x descendants). It now builds the reverse lookup once, as GetPropertyAliasesReservedByDescendants does, so the traversal is O(types + composition edges). Also: name the target in both ContentCacheRefresher obsolete messages so the migration path is unambiguous when two of them are present, and assert in ReloadPublishedCacheTests that a cache hit re-serves the same instance, so the reference comparison after the reload cannot pass vacuously. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t multiple saves (in)directly affecting the same content type only yield a single change
|
@AndyButland this looks good. I have pushed a few updates:
The optimization in a nutshell:
Neither of these changes are decisively critical, particularly not for "normal operations", but please do have a look and see if you agree. You're more than welcome to roll them back if you disagree. |
|
Thanks @kjac, the updates look good to me. |
|
|
Good to go then 👍 |




Description
Adding a property to a content type that is used as a composition left every document type composed of (or inheriting from) it serving a published content type built before the property existed.
content.Value("newAlias")returnednullon the front end and in Preview even after the value was filled in and published. Reload Memory Cache and Rebuild Database Cache didn't help — only an application restart did.Fixes #24117.
The issue report diagnoses this accurately; this PR implements the suggested fix plus three related gaps found while verifying it.
Root cause
ContentTypeServiceBase.ComposeContentTypeChangesonly propagated a change to the types that derive from the edited one when the change was inhasPropertyMainImpact:A property addition is not in that list, so it fell into the
elsebranch —RefreshOtherfor the edited type alone — and theGetComposedOfpropagation loop was skipped entirely. The composing types got no change entry at all, so neitherContentTypeCacheRefresher.RefreshnorCacheRefreshingNotificationHandlerever cleared their published content type or their converted content cache.Adding a property directly to a document type works, because that type's own id is in the payload.
It can be masked because
ContentTypeCacheRefresher.Refreshclears the whole converted cache when the model factory is anIAutoPublishedModelFactory. With a live ModelsBuilder that blanket clear hides the bug, so it passes then but not withModelsMode = Nothing.Three further gaps found while verifying
The propagation was never transitive.
GetComposedOf(id)returns direct consumers only (x.ContentTypeComposition.Any(y => y.Id == id)), butCompositionPropertyTypesrecurses. Inheritance is stored as composition, so forBase ← Middle ← Leafa change onBasereachedMiddleand neverLeaf. This affected the existingRefreshMainpropagation too, not just the new path.There was no recovery short of a restart.
IPublishedContentTypeCache.ClearAll()is declared and implemented but not called by Reload Memory Cache.A content type alias change didn't propagate either (raised in review).
hasAliasChangedputs the edited type intoRefreshMainbut was never part of the propagation condition.PublishedContentTypesnapshotscontentType.CompositionAliases()at construction, and that recurses throughContentTypeComposition— so after renaming a composition, every deriving type kept reporting the old alias. Observable through the publicIPublishedContent.IsComposedOftemplate API (also behindDescendantsOrSelfOfType) and throughContentTypeSchemaService.CompositionSchemaIdsfor the Delivery API.Changes
Propagate a property addition to the deriving types.
rawDataAffectedis hoisted out of the main-impactifand the propagation loop lifted alongside it. The loop now runs forhasPropertyMainImpact || hasAnyPropertyBeenAdded || hasAliasChanged. A pure addition emitsRefreshOtherfor the deriving types — which satisfiesRequiresConvertedCacheClearOnly()and so clears both the published content type cache and the converted content cache, without acmsContentNurebuild. The added alias has no stored value to re-key, so no rebuild is warranted.Propagate a content type alias change too, on the same
RefreshOtherpath. The stored blob is keyed by property alias, not content type alias, so nothing re-keys there either — the deriving types just need their published content type rebuilt so their composition aliases are resolved afresh.Make the propagation transitive. A private
GetComposedOfTransitivewalks the composition graph and is used for both theRefreshMainand theRefreshOtherpropagation.Clear the published content type cache on a reload.
ContentCacheRefresher.Refreshcalls_publishedContentTypeCache.ClearAll()when aTreeChangeTypes.RefreshAllpayload arrives, so Reload Memory Cache can recover a site left in this state.Testing
Five new tests, each verified to fail before the corresponding production change and pass after.
Manual verification
Run every scenario on this branch, then repeat on
v17/devto confirm the old behaviour.Prerequisites
ModelsModetoNothinginsrc/Umbraco.Web.UI/appsettings.Development.json, and confirm it on Settings → Models Builder. Under the defaultInMemoryAutothe whole converted cache is cleared on any content type change, which hides the bug.{ "Umbraco": { "CMS": { "ModelsBuilder": { "ModelsMode": "Nothing" } } } }Setup
heading. Save.heading.Verification: a property added to a composition is visible immediately
videoId HasProperty=False.videoIdto Hero Tab and save. Do not restart.v17/dev:videoId HasProperty=False.videoId HasProperty=True.videoIdon the Home node, Save and publish, then view the page and Preview.v17/dev:value=is empty, although the value is stored and displays correctly in the back office, which readsIContentType. It appears on the front end only after a restart.