Serious issue with RepNotify and Struct UPROPERTY's

Have sought out Epic support on this issue, but am reposting here to make users aware of the bug and workaround for now.

I’ve found what I believe to be a severe bug with RepNotify functions when used with struct UPROPERTY’s, when specifying the optional ‘Previous’ value parameter. The parameter provided to “Previous Value” is always out-of-date. This is 100% reproducible and I’m surprised this hasn’t been picked up before.

Given the following USTRUCT():

USTRUCT()
struct FRepAbilityState
{
	GENERATED_BODY()
public:
	void MarkActive(const float ServerWorldTime)
	{
		UE_LOG(LogAbility, Log, TEXT("MARKED ACTIVE"));

		ActivateCounter++;
		LastActivationTime = ServerWorldTime;
	}

	void MarkInactive()
	{
		UE_LOG(LogAbility, Log, TEXT("MARKED INACTIVE"));

		DeactivateCounter++;
	}

	FRepAbilityState()
		: ActivateCounter(0)
		, DeactivateCounter(0)
		, LastActivationTime(0.f)
	{}

protected:
	UPROPERTY() uint8 ActivateCounter;
	UPROPERTY() uint8 DeactivateCounter;
	UPROPERTY() float LastActivationTime;
};

And a member variable of this struct in an Actor class, with the following RepNotify function:

UCLASS()
class ASomeActor : public AActor
{
	GENERATED_BODY()
protected:
	UPROPERTY(ReplicatedUsing = "OnRep_ActivationState")
	FRepAbilityState ActivationState;

	UFUNCTION()
	void OnRep_ActivationState(const FRepAbilityState& OldValue)
	{
		UE_LOG(LogAbility, Log, TEXT("Current - %s"), *ActivationState.ToString());
		UE_LOG(LogAbility, Log, TEXT("Previous - %s"), *OldValue.ToString());
	}
};

‘OldValue’ is always out of date when the Server calls ‘MarkActive()’, then ‘MarkInactive()’. This occurs whether they are called in the same frame, or after significant time between the calls. The output from visual studio yields the following results:

Server: MARKED ACTIVE
Client: Current - Activation: 1 --- Deactivation: 0 --- Time: 9.32
Client: Previous - Activation: 0 --- Deactivation: 0 --- Time: 0.00

Server: MARKED INACTIVE
Client: Current - Activation: 1 --- Deactivation: 1 --- Time: 9.32
Client: Previous - Activation: 0 --- Deactivation: 0 --- Time: 0.00

This is clearly incorrect. The second Client: Previous entry should have an activation value of 1, and a time of 9.32.

If however, I provide a manual implementation of NetSerialize() for the struct. which mimics what the serializer does anyway:

template<>
struct TStructOpsTypeTraits<FRepAbilityState> : public TStructOpsTypeTraitsBase2<FRepAbilityState>
{
	enum
	{
		WithNetSerializer = true,
	};
};

bool FRepAbilityState::NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
	Ar << LastActivationTime;
	Ar << ActivateCounter;
	Ar << DeactivateCounter;

	bOutSuccess = true;
	return true;
}

The RepNotify is called with the correct OldValue, as shown again by the log. Note that the only difference here is the manual inclusion of the NetSerialize() function - but the log now shows the correct (expected) output:

Server: MARKED ACTIVE
Client: Current - Activation: 1 --- Deactivation: 0 --- Time: 8.46
Client: Previous - Activation: 0 --- Deactivation: 0 --- Time: 0.00
Server: MARKED INACTIVE
Client: Current - Activation: 1 --- Deactivation: 1 --- Time: 8.46
Client: Previous - Activation: 1 --- Deactivation: 0 --- Time: 8.46

I’ve done some digging and found a (very old and unanswered) answerhub post which alludes to the same behaviour, indicating that the shadow data stored by the replicator is out-of-date when only single properties of the struct are modified. I’m surprised this hasn’t been noticed and fixed already as it is quite a severe issue - but perhaps it’s only noticeable when monitoring changes more closely.

Do you have a bug report number for this?

Apparently this has been fixed in 4.22. I haven’t yet repeated the test to see if this is the case.