Hello everyone,
I’m developing a plugin for Unreal Engine 5 and I’m running into major challenges with the undo/transaction system. I’ve spent a long time troubleshooting how to integrate my code with this system, but I still can’t figure out a reliable approach. I’m hoping others who’ve faced similar issues can shed some light on this.
Background
My plugin performs several undoable operations:
- Modifying custom UObjects: For example, I have a class
UCoinPileSeedEditor
that manages certain properties. I mark it as transactional in its constructor:
UCoinPileSeedEditor::UCoinPileSeedEditor()
{
SetFlags(RF_Transient | RF_Transactional);
}
- Spawning preview actors: I spawn actors (e.g.,
ACoinPileSeedLevelPreview
) to visualize changes. Their constructor also sets:
ACoinPileSeedLevelPreview::ACoinPileSeedLevelPreview()
{
PrimaryActorTick.bCanEverTick = false;
mStaticMeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMeshComponent"));
RootComponent = mStaticMeshComponent;
SetFlags(RF_Transient | RF_Transactional);
bIsEditorOnlyActor = true;
}
- I use
FScopedTransaction
to bracket modifications and expect the changes (including actor creation) to be undoable.
The Core Problem
Unreal’s transaction system has several limitations that make things difficult:
- No Nested Transactions:
UE5 does not support nested transactions. For instance, inside the constructor ofFScopedTransaction
you have:
void FScopedTransaction::Construct(const TCHAR* TransactionContext, const FText& SessionName, UObject* PrimaryObject, const bool bShouldActuallyTransact)
{
if(bShouldActuallyTransact && GEditor && GEditor->CanTransact() && ensure(!GIsTransacting))
{
Index = GEditor->BeginTransaction(TransactionContext, SessionName, PrimaryObject);
check(IsOutstanding());
}
else
{
Index = -1;
}
}
The ensure(!GIsTransacting)
call forces a crash if a transaction is already active. But—and this is my main issue—I often cannot know in advance whether my function is being called inside an existing transaction or not.
- Conflicting Transactions with GEditor->AddActor:
When spawning an actor withGEditor->AddActor
, the code starts its own transaction:
FScopedTransaction Transaction(NSLOCTEXT("UnrealEd", "AddActor", "Add Actor"));
if (!(InObjectFlags & RF_Transactional))
{
// Don't attempt a transaction if the actor we are spawning isn't transactional
Transaction.Cancel();
}
If I’ve already started a transaction in my higher-level code, this internal transaction in AddActor
gets canceled or conflicts, making it unpredictable whether my actor creation is properly recorded for undo.
-
Lack of Visibility:
There is no reliable API to detect if a transaction is already in progress (beyond checking the globalGIsTransacting
flag), nor any way to know the call hierarchy that might be wrapping my function. This makes it nearly impossible to decide whether to create a new transaction or let an existing one handle the changes. -
Actor Spawning Not Integrated with Undo:
I tried two approaches for spawning preview actors:
- Using the basic SpawnActor method:
UWorld* World = EditedCoinPile->GetWorld();
if (World)
{
FTransform SpawnTransform(FRotator::ZeroRotator, NewSeedLocation);
ACoinPileSeedLevelPreview* PreviewActor = World->SpawnActor<ACoinPileSeedLevelPreview>(ACoinPileSeedLevelPreview::StaticClass(), SpawnTransform);
// ... attach, scale, and initialize actor ...
}
- Using GEditor->AddActor:
ULevel* Level = EditedCoinPile->GetLevel();
if (Level)
{
Level->Modify();
FTransform SpawnTransform(FRotator::ZeroRotator, NewSeedLocation);
AActor* NewActor = GEditor->AddActor(Level, ACoinPileSeedLevelPreview::StaticClass(), SpawnTransform, false, true);
ACoinPileSeedLevelPreview* PreviewActor = Cast<ACoinPileSeedLevelPreview>(NewActor);
// ... attach, scale, and initialize actor ...
}
Questions for the Community
-
Handling Active Transactions:
How do you detect or manage situations where your functions might be called inside an existing transaction? Do you use a wrapper or some custom logic to adjust behavior based on transaction state? -
Best Practices for Editor Operations:
For complex editor tools, is it better to rely on a single high-level transaction to encapsulate all operations, or should individual actions always attempt to create their own transactions? How do you avoid conflicts when internal functions (like those in GEditor) also try to create transactions? -
Making Actor Spawning Undoable:
Has anyone successfully ensured that actor spawns (especially for transient preview actors) are fully integrated into the undo system? What additional steps are required so that an undo not only reverts property changes but also removes the spawned actors? -
Further References and Workarounds:
Are there any official guidelines, detailed examples, or community patterns that explain how to design undoable editor operations—particularly regarding the transaction system—in complex plugins?
Thank you for your time and any help you can offer!
Koromire