Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype of the RxJS interop layer for signals #49154

Closed
wants to merge 4 commits into from

Conversation

alxhub
Copy link
Member

@alxhub alxhub commented Feb 22, 2023

For context, see this Github Discussion on our experiments with fine-grained reactivity in Angular

(the first commit here is #49150 which serves as a base for this implementation)

This is the prototype implementation of the RxJS interop layer, sketched out as a separate package (@angular/rxjs-interop).

@angular-robot angular-robot bot added detected: feature PR contains a feature commit area: build & ci Related the build and CI infrastructure of the project labels Feb 22, 2023
@ngbot ngbot bot added this to the Backlog milestone Feb 22, 2023
Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

LGTM barring open comments

packages/rxjs-interop/src/from_observable.ts Outdated Show resolved Hide resolved
packages/rxjs-interop/src/from_signal.ts Outdated Show resolved Hide resolved
packages/rxjs-interop/src/from_signal.ts Outdated Show resolved Hide resolved

it('should produce an observable that tracks a signal', async () => {
const counter = signal(0);
const counter$ = fromSignal(counter).pipe(take(3), toArray()).toPromise();
Copy link
Contributor

Choose a reason for hiding this comment

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

A bit unusual to use the $ suffix on a promise. Besides, toPromise() is deprecated in RxJS7.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, good catch - fixed.

Angular's on RxJS 6 though, so no fancy latestValueFrom for us.

Copy link
Contributor

Choose a reason for hiding this comment

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

What stoping you from moving to a higher version of Rxjs?

Copy link
Member Author

Choose a reason for hiding this comment

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

Google uses RxJS 6 internally, so Angular needs to build with it.

We could (and probably will) upgrade to using 7, but we'd still need to be compatible with 6, so it's not been a priority.

@alxhub alxhub force-pushed the fw/reactivity/rxjs-interop/first branch 3 times, most recently from 4730e8e to d94877e Compare February 23, 2023 00:05
@alxhub alxhub marked this pull request as ready for review February 23, 2023 22:58
@yangfan3211
Copy link

can I remove zone when I use Signal?

Copy link
Member

@josephperrott josephperrott left a comment

Choose a reason for hiding this comment

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

LGTM for dev-infra

Note: this will require us to create this package on npm prior to being able to perform a release after this pull request lands.

@alxhub I also wanted to ask, why is this not in core as well? It seems to me that it makes more sense to have this colocated with signals rather than ths which feels a little like an indirection.

@menosprezzi
Copy link

menosprezzi commented Mar 6, 2023

Hey @alxhub! Nice work! But why Signal type dont just implement the InteropObservable type to build the bridge between rxjs and signals? Is there a reason?
If we did so, translating between both will ease!

Think how the usage sounds better:

combineLatest([myPrimitiveSignal, myObservable$]) // ...and so on!

@@ -0,0 +1 @@
This package includes utilities related to using the RxJS library in conjunction with Angular's signal-based reactivity system.

Choose a reason for hiding this comment

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

Do we want to mention signals explicitly here? I guess long-term we want to put other non-signal, RxJS related stuff here.

@pullapprove pullapprove bot requested a review from atscott March 7, 2023 20:06
Angular RxJS Interop
=========

This package includes utilities related to using the RxJS library in conjunction with Angular's signal-based reactivity system.

Choose a reason for hiding this comment

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

Do we want to mention signals / reactivity explicitly here? I guess long-term we want to put other non-signal, RxJS related stuff here.

"peerDependencies": {
"@angular/core": "0.0.0-PLACEHOLDER",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.11.4 || ~0.12.0"

Choose a reason for hiding this comment

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

Do we need explicit peer dep on zone.js?

Choose a reason for hiding this comment

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

@alxhub Can you explain please this part please?
I mean it was a rxjs-interop package, probably the plan was @angular/rxjs-interop.
Now as I see, rxjs-interop will be the part of core, so zone.js is still will be a dependency there. As I heard, signals are advertised as zoneless solutions, which is currently the only (main) reason for me why we should use them.
I would like to understand that this pull request is just a step forward or there are other reason why we still need zone.js.

I think having another npm package as @angular/rxjs-interop or something is better until zone.js cannot be eliminated from core. In long term I agree, it should be in core library.

*
* @developerPreview
*/
export function fromObservable<T, U>(source: Observable<T>, initialValue: U): Signal<T|U>;

Choose a reason for hiding this comment

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

I find it surprising that the type of the signal and initial state are different. What is the use-case that we are targeting here?

Copy link
Contributor

Choose a reason for hiding this comment

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

My guess is that it's mainly useful when you have an Observable<Something> and you want null or undefined as default value.

Copy link

Choose a reason for hiding this comment

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

implementation of rxjs firstValueFrom and lastValueFrom also have different type for defaultValue.
But maybe instead of initialValue rxjs defaultValue is more fitting as it is not only initial - so in a state of suspense but also if observable completed without any value too. Also keeping rxjs config approach can be useful in future if we would like to extend this config,

export interface FirstValueFromConfig<T> {
  defaultValue: T;
}

export function firstValueFrom<T, D>(source: Observable<T>, config: FirstValueFromConfig<D>): Promise<T | D>;
export function firstValueFrom<T>(source: Observable<T>): Promise<T>;

Then again If the source Observable does not complete or emit, you will end up with a Promise that is hung up, and potentially all of the state of an async function hanging out in memory,
So they differentiate those two states and do not throw nor return a default value if there is no emission before read.

Initial value could also be achieved by using startWith operator but that would not work if the subsequent emission would be empty.

Choose a reason for hiding this comment

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

it's mainly useful when you have an Observable and you want null or undefined as default value.

But this is strange since having null as a default value means that anyone can read this value so it should be legitimate type of a signal. In this case it would be T | null

Copy link

Choose a reason for hiding this comment

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

Defaulting to null | undefined instead of initialValue: U would be | async pipe | null problem again

Copy link

Choose a reason for hiding this comment

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

Just throwing in my 2 cents here...

RxJS doesn't distinguish between observables that emit immediately vs asynchronously. So I don't think consumers of observables should rely on knowledge about when they're going to emit. This could cause unexpected errors. Much better would be to type it as T | null always, but optimally let devs pass in a type argument (or infer from initial state passed in) if they happen to know 100% that the observable emits immediately. But in my opinion even this explicit type parameter is dangerous because you could modify the observable upstream and TypeScript could not prevent a runtime error. And I don't see a significant difference between null and undefined here, but I haven't thought that through carefully.

The only way safe way out of this TypeScript awkwardness is if the observables themselves contained knowledge about whether they are going to emit immediately or not.

Copy link

Choose a reason for hiding this comment

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

I was thinking about this before, not sure if its possible for the given use case, but you could introduce a 'loading' object which is static and use it as a type. That way you could have a difference between loading, null, undefined and T.

What you would have to do if handle then that type in every pipe in angular etc. Maybe a overkill but just wanted to point the idea out.


// TODO(alxhub): subscription cleanup logic

return computed(() => {

Choose a reason for hiding this comment

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

Why is it computed? I guess there are 2 things we need to do:

  • make sure that it is not mutable;
  • it is branded (?)

Normally a simple function would be enough, I guess this is about branding?

Copy link

Choose a reason for hiding this comment

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

What dose it mean 'branded'?

Copy link

Choose a reason for hiding this comment

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

@alxhub alxhub force-pushed the fw/reactivity/rxjs-interop/first branch 2 times, most recently from 7ae3be6 to 377013b Compare March 29, 2023 02:17
@alxhub alxhub force-pushed the fw/reactivity/rxjs-interop/first branch 2 times, most recently from baf1ef3 to b833473 Compare March 29, 2023 19:59
@alxhub alxhub added action: merge The PR is ready for merge by the caretaker and removed action: review The PR is still awaiting reviews from at least one requested reviewer labels Mar 29, 2023
@alxhub alxhub force-pushed the fw/reactivity/rxjs-interop/first branch from b833473 to 78fa475 Compare March 29, 2023 23:23
We've been experimenting with the DeepReadonly type that would make
signal values deeply read-only and prevent accidental changes without
going to the owner of data. What we've found out during the experiments
is that additional safety net has more drawbacks than benefits: it just
introduces too much friction to be practical for daily usage.
case StateKind.Error:
throw current.error;
case StateKind.NoValue:
throw new Error(`fromObservable() signal read before the Observable emitted`);

Choose a reason for hiding this comment

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

nit: should it be RuntimeError?

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: TODO is left in. This needs a runtime error code.

effect(() => {
seenValue = counter();
});
await Promise.resolve();

Choose a reason for hiding this comment

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

thinking out loud: those tests will fail when we change the timing of effects, right? Which kind of rises the question of testing patterns for effects. A separate discussion that we should have.

Choose a reason for hiding this comment

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

As an alternative we could have written those tests with just a signal and / or computed and just assert that new value is available. We might leave a test or two with effect but IMO ones based on just signals would be more readable.

Copy link
Member

@pkozlowski-opensource pkozlowski-opensource left a comment

Choose a reason for hiding this comment

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

LGTM

Left a set of bunch of nit / "thinking out loud" comments but nothing that would block this PR. We will need to coordinate this PR landing with other in-flight ones as we will have conflicts around DeepReadonly and timing of effects.

Reviewed-for: public-api
Reviewed-for: fw-core

This commit adds the infrastructure for `@angular/core/rxjs-interop`, a new
core entrypoint which provides bidirectional interoperability between
Angular's built-in reactivity system of synchronous signals, and the RxJS
`Observable` abstraction.

The new entrypoint is set up as an empty shell in this commit, with its
implementation to follow in a followup.
This commit adds the basic sketch for the implementation of `fromObservable`
and `fromSignal`, the two basic primitives which form the RxJS interop layer
with signals.
This commit implements an RxJS operator `takeUntilDestroyed` which
terminates an Observable when the current context (component, directive,
etc) is destroyed. `takeUntilDestroyed` will inject the current `DestroyRef`
if none is provided, or use one provided as an argument.
Copy link
Contributor

@jessicajaniuk jessicajaniuk left a comment

Choose a reason for hiding this comment

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

LGTM other than a non-blocking nit

reviewed-for: fw-core, public-api

case StateKind.Error:
throw current.error;
case StateKind.NoValue:
throw new Error(`fromObservable() signal read before the Observable emitted`);
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: TODO is left in. This needs a runtime error code.

Copy link
Contributor

@jessicajaniuk jessicajaniuk left a comment

Choose a reason for hiding this comment

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

LGTM other than a non-blocking nit

reviewed-for: fw-core, public-api

@atscott
Copy link
Contributor

atscott commented Mar 30, 2023

This PR was merged into the repository by commit e883198.

@atscott atscott closed this in 267c5e8 Mar 30, 2023
atscott pushed a commit that referenced this pull request Mar 30, 2023
This commit adds the infrastructure for `@angular/core/rxjs-interop`, a new
core entrypoint which provides bidirectional interoperability between
Angular's built-in reactivity system of synchronous signals, and the RxJS
`Observable` abstraction.

The new entrypoint is set up as an empty shell in this commit, with its
implementation to follow in a followup.

PR Close #49154
atscott pushed a commit that referenced this pull request Mar 30, 2023
…49154)

This commit adds the basic sketch for the implementation of `fromObservable`
and `fromSignal`, the two basic primitives which form the RxJS interop layer
with signals.

PR Close #49154
atscott pushed a commit that referenced this pull request Mar 30, 2023
This commit implements an RxJS operator `takeUntilDestroyed` which
terminates an Observable when the current context (component, directive,
etc) is destroyed. `takeUntilDestroyed` will inject the current `DestroyRef`
if none is provided, or use one provided as an argument.

PR Close #49154
@e-oz
Copy link

e-oz commented Apr 7, 2023

I'm assuming that we've firmly decided on the following:

  • we throw when the initial value is not provided (as opposed to defaulting to null / undefined.

If you already decided on it, why are you discussing it in RFC?

@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators May 8, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
action: merge The PR is ready for merge by the caretaker area: build & ci Related the build and CI infrastructure of the project area: core Issues related to the framework runtime detected: feature PR contains a feature commit target: major This PR is targeted for the next major release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet