From 09015d52ae0564b8f23d118634c38b0d86bd66f5 Mon Sep 17 00:00:00 2001 From: kjac Date: Mon, 11 May 2026 15:25:47 +0200 Subject: [PATCH 1/3] Ensure correct change tracking when unpublishing --- .../Models/ContentRepositoryExtensions.cs | 17 ++++- src/Umbraco.Core/Services/ContentService.cs | 22 ++++-- .../PublishStatus/PublishStatusService.cs | 19 ++++- .../PublishStatusServiceTests.Query.cs | 74 +++++++++++++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index f9917db4bee6..fe0d8a1dbdfe 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -454,7 +454,22 @@ public static bool UnpublishCulture(this IContent content, string? culture = "*" /// Clears all publish culture information from the content item. /// /// The content item to clear publish information from. - public static void ClearPublishInfos(this IContent content) => content.PublishCultureInfos = null; + public static void ClearPublishInfos(this IContent content) + { + if (content.PublishCultureInfos is null) + { + return; + } + + // Pass each published culture through ClearPublishInfo([culture]) to ensure correct change tracking. + var cultures = content.PublishCultureInfos.Values.Select(c => c.Culture).ToArray(); + foreach (var culture in cultures) + { + content.ClearPublishInfo(culture); + } + + content.PublishCultureInfos = null; + } /// /// Returns false if the culture is already unpublished diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 2ed186f68c1e..0bdf80702818 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1670,9 +1670,7 @@ void SaveDocument(IContent c) { // Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo culturesUnpublishing = content.GetCulturesUnpublishing(); - culturesPublishing = variesByCulture - ? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; + culturesPublishing = GetCulturesPublishing(content); // ensure that the document can be published, and publish handling events, business rules, etc publishResult = StrategyCanPublish( @@ -1743,6 +1741,12 @@ void SaveDocument(IContent c) // won't happen in a branch if (unpublishing) { + if (culturesUnpublishing is null) + { + culturesUnpublishing = content.GetCulturesUnpublishing(); + culturesPublishing = GetCulturesPublishing(content); + } + IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope if (content.VersionId != newest?.VersionId) { @@ -1805,12 +1809,13 @@ void SaveDocument(IContent c) var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - if (publishResult == null) + PublishResultType? publishResultType = publishResult?.Result ?? unpublishResult?.Result; + if (publishResultType == null) { - throw new PanicException("publishResult == null - should not happen"); + throw new PanicException("publishResultType == null - should not happen"); } - switch (publishResult.Result) + switch (publishResultType) { case PublishResultType.FailedPublishMandatoryCultureMissing: // Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) @@ -2428,6 +2433,11 @@ internal IEnumerable PublishBranch( return result; } + private IReadOnlyList? GetCulturesPublishing(IContent content) + => content.ContentType.VariesByCulture() + ? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() + : null; + #endregion #region Delete diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs index 6f19c9c8310c..ae6b35edb719 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs @@ -144,14 +144,14 @@ public async Task AddOrUpdateStatusAsync(Guid documentKey, CancellationToken can { using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); ISet publishedCultures = await _publishStatusRepository.GetPublishStatusAsync(documentKey, cancellationToken); - _publishedCultures[documentKey] = publishedCultures; + UpdatePublishedCultures(documentKey, publishedCultures); scope.Complete(); } /// public Task RemoveAsync(Guid documentKey, CancellationToken cancellationToken) { - _publishedCultures.TryRemove(documentKey, out _); + RemovePublishedCultures(documentKey); return Task.CompletedTask; } @@ -166,8 +166,23 @@ public async Task AddOrUpdateStatusWithDescendantsAsync(Guid rootDocumentKey, Ca } foreach ((Guid documentKey, ISet publishedCultures) in publishStatus) + { + UpdatePublishedCultures(documentKey, publishedCultures); + } + } + + private void UpdatePublishedCultures(Guid documentKey, ISet publishedCultures) + { + if (publishedCultures.Any()) { _publishedCultures[documentKey] = publishedCultures; } + else + { + RemovePublishedCultures(documentKey); + } } + + private void RemovePublishedCultures(Guid documentKey) + => _publishedCultures.TryRemove(documentKey, out _); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs index e4e40f468d52..24d01a76c7fb 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTests.Query.cs @@ -181,4 +181,78 @@ await GetRequiredService() Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, cultureToUnpublish)); Assert.IsTrue(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, Constants.System.InvariantCulture)); } + + [TestCase(true)] + [TestCase(false)] + public async Task Fully_Unpublished_Culture_Variant_Document_Tracks_Unpublished_State_For_All_Cultures(bool unpublishAllCulturesAtOnce) + { + await GetRequiredService() + .CreateAsync(new Language("da-DK", "Danish"), Constants.Security.SuperUserKey); + + var contentTypeKey = Guid.NewGuid(); + var contentType = new ContentTypeBuilder() + .WithKey(contentTypeKey) + .WithAlias("variant") + .WithContentVariation(ContentVariation.Culture) + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new ContentTypeSort(contentTypeKey, 1, "variant")]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + + IContent root = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Root EN") + .WithCultureName("da-DK", "Root DA") + .Build(); + ContentService.Save(root); + + IContent child = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Child EN") + .WithCultureName("da-DK", "Child DA") + .WithParent(root) + .Build(); + ContentService.Save(child); + + IContent grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Grandchild EN") + .WithCultureName("da-DK", "Grandchild DA") + .WithParent(child) + .Build(); + ContentService.Save(grandchild); + + ContentService.PublishBranch(root, PublishBranchFilter.IncludeUnpublished, ["en-US", "da-DK"]); + + // must refresh the child instance before unpublishing it, to reflect the state changes from the branch publish above + child = ContentService.GetById(child.Key)!; + + if (unpublishAllCulturesAtOnce) + { + ContentService.Unpublish(child); + } + else + { + ContentService.Unpublish(child, "en-US"); + + // refresh again before unpublishing the last culture + child = ContentService.GetById(child.Key)!; + ContentService.Unpublish(child, "da-DK"); + } + + // refresh to get the latest state + child = ContentService.GetById(child.Key)!; + Assert.IsFalse(child.Published); + Assert.IsEmpty(child.PublishedCultures); + Assert.IsEmpty(child.PublishCultureInfos!); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(child.Key, "en-US")); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(child.Key, "da-DK")); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(child.Key, Constants.System.InvariantCulture)); + + Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, "da-DK")); + Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, "en-US")); + Assert.IsFalse(PublishStatusQueryService.HasPublishedAncestorPath(grandchild.Key, Constants.System.InvariantCulture)); + } } From 6f869073ec469fb09db6617c0406f9566d3cda8b Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 12 May 2026 08:02:33 +0200 Subject: [PATCH 2/3] Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs Co-authored-by: Andy Butland --- src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs index ae6b35edb719..66287f94a5e5 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs @@ -173,7 +173,7 @@ public async Task AddOrUpdateStatusWithDescendantsAsync(Guid rootDocumentKey, Ca private void UpdatePublishedCultures(Guid documentKey, ISet publishedCultures) { - if (publishedCultures.Any()) + if (publishedCultures.Count > 0) { _publishedCultures[documentKey] = publishedCultures; } From 9cbd9f593640b841e86bde4c31640201988bceaf Mon Sep 17 00:00:00 2001 From: kjac Date: Tue, 12 May 2026 08:28:47 +0200 Subject: [PATCH 3/3] Add comment --- src/Umbraco.Core/Models/ContentRepositoryExtensions.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index fe0d8a1dbdfe..9fe458c29190 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -468,6 +468,8 @@ public static void ClearPublishInfos(this IContent content) content.ClearPublishInfo(culture); } + // Following #22799 the explicit calls to `ClearPublishInfo` for each culture cause the unpublish in all cultures. + // `PublishCultureInfos` is set to null purely to retain previous behaviour at a property level. content.PublishCultureInfos = null; }