Skip to content

fix(generator): preserve legacy null-chain observation semantics - #63

Merged
glennawatson merged 3 commits into
reactiveui:mainfrom
danwalmsley:agent/preserve-whenanyvalue-null-chain-semantics
Aug 14, 2026
Merged

fix(generator): preserve legacy null-chain observation semantics#63
glennawatson merged 3 commits into
reactiveui:mainfrom
danwalmsley:agent/preserve-whenanyvalue-null-chain-semantics

Conversation

@danwalmsley

@danwalmsley danwalmsley commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@glennawatson I came across this behavioural difference when trying to upgrade some applications. Performance improvements are incredible :D but this is blocking the upgrade.

What kind of change does this PR introduce?

Bug fix - generated observation code no longer diverges from the runtime expression-chain semantics when an intermediate object in a property path is null.

What is the new behavior?

WhenAnyValue and the other generated observation APIs suppress emission while an intermediate parent is null, and reattach when it becomes non-null again:

  • EmptyObservable<T> is used while an intermediate parent is null.
  • The outer Switch subscription stays alive, so observation reattaches once the parent is set.
  • The behavior is applied across the generated observation mechanisms and the generic fallbacks.
  • ReturnObservable<T>(default(T)) is preserved for inline binding consumers, which need the default notification to clear targets and unregister handlers.
  • The distinction is made explicit through the new NullParentObservationBehavior enum.

A legitimate null or default leaf is unaffected: if Child exists and Child.Name is null, that null is still emitted.

What is the current behavior?

WhenAnyValue is documented as a drop-in ReactiveUI compatibility shim, but generated deep-property observations substitute default(T) whenever an intermediate object is null. Given:

source.WhenAnyValue(x => x.Child!.Name)

the generated implementation emits new ReturnObservable<T>(default(T)) while Child is null. An isolated comparison over an identical transition trace produced:

Implementation Emissions
ReactiveUI 22.3.1 Alice, Bob, Charlie
RuntimeObservationFallback Alice, Bob, Charlie
v4 generated implementation null, Alice, Bob, null, Charlie

Those synthetic values are observable behavior changes: reference-type consumers can receive an unexpected null and throw, and value-type consumers can receive a synthetic zero or false.

What might this PR break?

This is an intentional behavioural change for generated observation:

  • Consumers that relied on receiving default(T) while an intermediate was null will now receive nothing until the chain is repaired. This is the ReactiveUI-compatible behavior.
  • Inline binding consumers (Bind, BindOneWay, BindTwoWay, OneWayBind, BindCommand, BindInteraction) are deliberately unchanged and still receive the default, so targets are cleared and handlers unregistered.
  • Generated snapshots for the affected observation APIs change.

Checklist

  • I have read the Contribute guide
  • Tests have been added or updated (for bug fixes / features)
  • Docs have been added or updated (for bug fixes / features)
  • Changes target the main branch
  • PR title follows Conventional Commits

Additional information

Regression coverage added:

  • Compares generated WhenAnyValue directly with RuntimeObservationFallback.WhenAnyValue over an identical transition trace.
  • Protects genuine null leaf emissions.
  • Protects genuine default-valued value-type leaves.
  • Updates the existing WhenChanged null-intermediate expectation.
  • Retains the existing deep BindInteraction cleanup behavior.
  • Updates generated snapshots for the affected observation APIs.

Validation:

  • The test-only commit fails in the three expected null-intermediate cases, including the direct generated-versus-fallback comparison.
  • With the implementation applied locally, source-generator tests pass: 758/758.
  • With the implementation applied locally, generated-code tests pass: 179/179.
  • Source-generator Release build succeeds with zero warnings and errors.

@danwalmsley
danwalmsley marked this pull request as ready for review August 14, 2026 08:36
glennawatson
glennawatson previously approved these changes Aug 14, 2026
@glennawatson
glennawatson dismissed their stale review August 14, 2026 10:13

Superseded - regressions found in deeper property chains, see follow-up review.

@glennawatson glennawatson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for chasing this down Dan - the diagnosis is right, ReturnObservable(default(T)) on a null intermediate really does diverge from ReactiveUI's expression-chain semantics, and NullParentObservationBehavior is the right shape for the fix.

We hit a regression when testing it against deeper chains though, so a couple of things need fixing before this can land - details inline.

@glennawatson

Copy link
Copy Markdown
Contributor

@danwalmsley tagging for the notification. Just some regressions I found testing the branch. Thanks for the PR looks good other than that.

@glennawatson glennawatson changed the title Preserve legacy null-chain observation semantics fix(generator): preserve legacy null-chain observation semantics Aug 14, 2026
@danwalmsley

Copy link
Copy Markdown
Contributor Author

@glennawatson thank you for the speedy review, I have applied your suggested fixes and added tests.

let me know what you think :D

@glennawatson

Copy link
Copy Markdown
Contributor

Looks good @danwalmsley -- I'll do a release after the CI goes green (I'll merge in the other outstanding PRs as much as possible too first).

@glennawatson
glennawatson merged commit 92bda88 into reactiveui:main Aug 14, 2026
9 checks passed
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.

2 participants