Skip to content

Only export content/element items once per save-and-publish - #1018

Merged
KevinJump merged 1 commit into
v18/mainfrom
v18/fix-double-export-save-and-publish
Aug 5, 2026
Merged

Only export content/element items once per save-and-publish#1018
KevinJump merged 1 commit into
v18/mainfrom
v18/fix-double-export-save-and-publish

Conversation

@KevinJump

Copy link
Copy Markdown
Owner

What

uSync exported a content or Library (Element) item twice for a single save-and-publish, and three times for a save that un-published a culture. This fixes it so each item is exported once per Umbraco operation.

Why

Umbraco 18.1 raises ContentSavedNotification for a save-and-publish as well as ContentPublishedNotification (umbraco/Umbraco-CMS#23523, PR #23528).

Worth noting for review: the upstream PR was raised against release/17.6 and patches ContentService.CommitDocumentChangesInternal, so the diff makes it look Document-only. In v18 that method lives in PublishableContentServiceBase, shared by Documents and Elements — so on release/18.1 the extra notification fires for both, and ElementHandler had the same problem as ContentHandler.

ContentHandler and ElementHandler each listen for the saved and the published/unpublished notifications. The <remarks> on those handlers explained why — "some publication events do not fire the save notification" — and that premise is what broke.

How

Every notification raised by one Umbraco operation carries the same notification state instance — PublishableContentServiceBase raises them all with .WithState(notificationState), sourced from savingNotification.State. So ProcessItem now claims each item in that shared state (a HashSet<Guid> under a new uSync.EventExportedItemsKey) and skips it if an earlier notification in the same operation already exported it.

Three decisions worth a look:

  • First notification wins, i.e. Saved exports and Published skips. Safe because SaveContent(content) runs before the Saved notification is raised, and the publish values were applied before that — the item is already in its final published state, on the same instance both notifications carry.
  • Claim before export, not after. A failed export isn't retried by the next notification in the same operation; it would almost certainly fail again and add a second warning to notification.Messages.
  • Lock around the get-or-create. Dispatch within a scope is sequential so this is belt-and-braces, but the state dictionary is a plain Dictionary and SyncScopedNotificationPublisher can dispatch on a queued background thread when BackgroundNotifications is on.

SavedNotification handling for these two handlers is now routed through ProcessItem via an override, rather than the inline copy of the export loop in SyncHandlerRoot — that copy bypassed the de-duplication. It's untouched for every other handler.

MediaHandler derives from ContentHandlerBase, not PublishableContentHandlerBase, and has no publish notifications, so it's unaffected.

Behaviour

Action Before After
Save-and-publish 2 exports 1
Save-and-publish un-publishing a culture 3 exports 1
Publish / un-publish from tree 1 1 (unchanged)
Plain save 1 1 (unchanged)
Publish with descendants 1 per doc unchanged
uSync import 0 (events paused) unchanged

Testing

New uSync.Tests/SyncHandlers/ExportDeduplicationTests.cs covers the save-and-publish pair, the save+unpublish pair, publish-with-no-save, per-item claiming across a branch publish, re-claiming in a later operation, and that uSync's own notification state keys survive.

Full solution builds clean; all 190 tests pass (6 new).

Not included

Both came out of the same review of the Umbraco 18.1 changes, but are behaviour changes rather than bug fixes, so they're deliberately left out:

  • ContentSavedNotification.SavedCultures / ContentPublishedNotification.PublishedCultures (#23313) would let uSync export only the affected cultures, and skip no-op saves.
  • PublishableContentBaseSerializer still does save-then-publish as two operations rather than the new single-scope IPublishableContentService.SaveAndPublish (#23277).

🤖 Generated with Claude Code

Umbraco 18.1 raises the saved notification for a save-and-publish as well
as the published one (umbraco/Umbraco-CMS#23523). In v18 that change sits
in PublishableContentServiceBase, so it applies to Documents and Elements
alike.

ContentHandler and ElementHandler both listen for the saved *and* the
published/unpublished notifications, on the assumption that publishing
never raises a save. That assumption no longer holds: a single
save-and-publish serialized and wrote the item twice, and a save that
un-published a culture wrote it three times.

Every notification raised by one Umbraco operation carries the same
notification state instance (they are all raised WithState(notificationState)),
so ProcessItem now claims each item in that shared state and skips it if an
earlier notification in the same operation already exported it.

The first notification wins. Umbraco persists the item - including its
publish state - before raising any of them, so the export reflects the
finished operation whichever notification triggers it. The item is claimed
before the export is attempted, so a failed export is not retried, and
re-reported, by the next notification.

SavedNotification handling for these two handlers is now routed through
ProcessItem rather than the inline copy of the export loop in
SyncHandlerRoot, which bypassed the de-duplication. Publishing and
un-publishing from the tree still raise only the publish notification, and
are unaffected. MediaHandler does not derive from PublishableContentHandlerBase
and has no publish notifications, so it is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KevinJump
KevinJump merged commit bad27d9 into v18/main Aug 5, 2026
5 checks passed
@KevinJump
KevinJump deleted the v18/fix-double-export-save-and-publish branch August 5, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant