diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index 64f449888089..d14e42140504 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -418,11 +418,6 @@ export class DebugNode { }; } -// @public -export type DeepReadonly = T extends (infer R)[] ? ReadonlyArray> : (T extends Function ? T : (T extends object ? { - readonly [P in keyof T]: DeepReadonly; -} : T)); - // @public export const DEFAULT_CURRENCY_CODE: InjectionToken; @@ -1366,7 +1361,7 @@ export interface SelfDecorator { export function setTestabilityGetter(getter: GetTestability): void; // @public -export type Signal = (() => DeepReadonly) & { +export type Signal = (() => T) & { [SIGNAL]: unknown; }; diff --git a/goldens/public-api/core/rxjs-interop/index.md b/goldens/public-api/core/rxjs-interop/index.md new file mode 100644 index 000000000000..049217e4f883 --- /dev/null +++ b/goldens/public-api/core/rxjs-interop/index.md @@ -0,0 +1,32 @@ +## API Report File for "@angular/core_rxjs-interop" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { DestroyRef } from '@angular/core'; +import { Injector } from '@angular/core'; +import { MonoTypeOperatorFunction } from 'rxjs'; +import { Observable } from 'rxjs'; +import { Signal } from '@angular/core'; + +// @public +export function fromObservable(source: Observable): Signal; + +// @public +export function fromObservable(source: Observable, initialValue: U): Signal; + +// @public +export function fromSignal(source: Signal, options?: FromSignalOptions): Observable; + +// @public +export interface FromSignalOptions { + injector?: Injector; +} + +// @public +export function takeUntilDestroyed(destroyRef?: DestroyRef): MonoTypeOperatorFunction; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/bazel/test/ng_package/core_package.spec.ts b/packages/bazel/test/ng_package/core_package.spec.ts index 7ed7e3d5bc6c..d76a309396a5 100644 --- a/packages/bazel/test/ng_package/core_package.spec.ts +++ b/packages/bazel/test/ng_package/core_package.spec.ts @@ -65,6 +65,12 @@ describe('@angular/core ng_package', () => { esm: './esm2022/core.mjs', default: './fesm2022/core.mjs' }, + './rxjs-interop': { + types: './rxjs-interop/index.d.ts', + esm2022: './esm2022/rxjs-interop/rxjs-interop.mjs', + esm: './esm2022/rxjs-interop/rxjs-interop.mjs', + default: './fesm2022/rxjs-interop.mjs' + }, './testing': { types: './testing/index.d.ts', esm2022: './esm2022/testing/testing.mjs', diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 171a1f0b115f..6c68002254d2 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -75,6 +75,7 @@ ng_package( ], deps = [ ":core", + "//packages/core/rxjs-interop", "//packages/core/testing", ], ) diff --git a/packages/core/rxjs-interop/BUILD.bazel b/packages/core/rxjs-interop/BUILD.bazel new file mode 100644 index 000000000000..a2bf1e4314ae --- /dev/null +++ b/packages/core/rxjs-interop/BUILD.bazel @@ -0,0 +1,28 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +exports_files(["package.json"]) + +ng_module( + name = "rxjs-interop", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), + deps = [ + "//packages:types", + "//packages/core", + "@npm//rxjs", + ], +) + +filegroup( + name = "files_for_docgen", + srcs = glob([ + "*.ts", + "src/**/*.ts", + ]) + ["PACKAGE.md"], +) diff --git a/packages/core/rxjs-interop/PACKAGE.md b/packages/core/rxjs-interop/PACKAGE.md new file mode 100644 index 000000000000..49a3b7db83fb --- /dev/null +++ b/packages/core/rxjs-interop/PACKAGE.md @@ -0,0 +1 @@ +Includes utilities related to using the RxJS library in conjunction with Angular's signal-based reactivity system. \ No newline at end of file diff --git a/packages/core/rxjs-interop/index.ts b/packages/core/rxjs-interop/index.ts new file mode 100644 index 000000000000..4bb6ad763157 --- /dev/null +++ b/packages/core/rxjs-interop/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './src/index'; diff --git a/packages/core/rxjs-interop/public_api.ts b/packages/core/rxjs-interop/public_api.ts new file mode 100644 index 000000000000..ba852d8175d2 --- /dev/null +++ b/packages/core/rxjs-interop/public_api.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @module + * @description + * Entry point for all public APIs of this package. + */ +export * from './src/index'; + +// This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/core/rxjs-interop/src/from_observable.ts b/packages/core/rxjs-interop/src/from_observable.ts new file mode 100644 index 000000000000..5bcefa6f7bb6 --- /dev/null +++ b/packages/core/rxjs-interop/src/from_observable.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {assertInInjectionContext, computed, DestroyRef, inject, signal, Signal, WritableSignal} from '@angular/core'; +import {Observable} from 'rxjs'; + +/** + * Get the current value of an `Observable` as a reactive `Signal`. + * + * `fromObservable` returns a `Signal` which provides synchronous reactive access to values produced + * by the given `Observable`, by subscribing to that `Observable`. The returned `Signal` will always + * have the most recent value emitted by the subscription, and will throw an error if the + * `Observable` errors. + * + * The subscription will last for the lifetime of the current injection context. That is, if + * `fromObservable` is called from a component context, the subscription will be cleaned up when the + * component is destroyed. When called outside of a component, the current `EnvironmentInjector`'s + * lifetime will be used (which is typically the lifetime of the application itself). + * + * If the `Observable` does not produce a value before the `Signal` is read, the `Signal` will throw + * an error. To avoid this, use a synchronous `Observable` (potentially created with the `startWith` + * operator) or pass an initial value to `fromObservable` as the second argument. + * + * `fromObservable` must be called in an injection context. + */ +export function fromObservable(source: Observable): Signal; + +/** + * Get the current value of an `Observable` as a reactive `Signal`. + * + * `fromObservable` returns a `Signal` which provides synchronous reactive access to values produced + * by the given `Observable`, by subscribing to that `Observable`. The returned `Signal` will always + * have the most recent value emitted by the subscription, and will throw an error if the + * `Observable` errors. + * + * The subscription will last for the lifetime of the current injection context. That is, if + * `fromObservable` is called from a component context, the subscription will be cleaned up when the + * component is destroyed. When called outside of a component, the current `EnvironmentInjector`'s + * lifetime will be used (which is typically the lifetime of the application itself). + * + * Before the `Observable` emits its first value, the `Signal` will return the configured + * `initialValue`. If the `Observable` is known to produce a value before the `Signal` will be read, + * `initialValue` does not need to be passed. + * + * `fromObservable` must be called in an injection context. + * + * @developerPreview + */ +export function fromObservable( + // fromObservable(Observable) -> Signal + source: Observable, initialValue: U): Signal; +export function fromObservable(source: Observable, initialValue?: U): Signal { + assertInInjectionContext(fromObservable); + + // Note: T is the Observable value type, and U is the initial value type. They don't have to be + // the same - the returned signal gives values of type `T`. + let state: WritableSignal>; + if (initialValue === undefined && arguments.length !== 2) { + // No initial value was passed, so initially the signal is in a `NoValue` state and will throw + // if accessed. + state = signal({kind: StateKind.NoValue}); + } else { + // An initial value was passed, so use it. + state = signal>({kind: StateKind.Value, value: initialValue!}); + } + + const sub = source.subscribe({ + next: value => state.set({kind: StateKind.Value, value}), + error: error => state.set({kind: StateKind.Error, error}), + // Completion of the Observable is meaningless to the signal. Signals don't have a concept of + // "complete". + }); + + // Unsubscribe when the current context is destroyed. + inject(DestroyRef).onDestroy(sub.unsubscribe.bind(sub)); + + // The actual returned signal is a `computed` of the `State` signal, which maps the various states + // to either values or errors. + return computed(() => { + const current = state(); + switch (current.kind) { + case StateKind.Value: + return current.value; + case StateKind.Error: + throw current.error; + case StateKind.NoValue: + // TODO(alxhub): use a RuntimeError when we finalize the error semantics + throw new Error(`fromObservable() signal read before the Observable emitted`); + } + }); +} + +const enum StateKind { + NoValue, + Value, + Error, +} + +interface NoValueState { + kind: StateKind.NoValue; +} + +interface ValueState { + kind: StateKind.Value; + value: T; +} + +interface ErrorState { + kind: StateKind.Error; + error: unknown; +} + +type State = NoValueState|ValueState|ErrorState; diff --git a/packages/core/rxjs-interop/src/from_signal.ts b/packages/core/rxjs-interop/src/from_signal.ts new file mode 100644 index 000000000000..46d69994e087 --- /dev/null +++ b/packages/core/rxjs-interop/src/from_signal.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {assertInInjectionContext, effect, inject, Injector, Signal} from '@angular/core'; +import {Observable} from 'rxjs'; + +/** + * Options for `fromSignal`. + * + * @developerPreview + */ +export interface FromSignalOptions { + /** + * The `Injector` to use when creating the effect. + * + * If this isn't specified, the current injection context will be used. + */ + injector?: Injector; +} + +/** + * Exposes the value of an Angular `Signal` as an RxJS `Observable`. + * + * The signal's value will be propagated into the `Observable`'s subscribers using an `effect`. + * + * `fromSignal` must be called in an injection context. + * + * @developerPreview + */ +export function fromSignal( + source: Signal, + options?: FromSignalOptions, + ): Observable { + !options?.injector && assertInInjectionContext(fromSignal); + const injector = options?.injector ?? inject(Injector); + + // Creating a new `Observable` allows the creation of the effect to be lazy. This allows for all + // references to `source` to be dropped if the `Observable` is fully unsubscribed and thrown away. + return new Observable(observer => { + const watcher = effect(() => { + try { + observer.next(source()); + } catch (err) { + observer.error(err); + } + }, {injector, manualCleanup: true}); + return () => watcher.destroy(); + }); +} diff --git a/packages/core/rxjs-interop/src/index.ts b/packages/core/rxjs-interop/src/index.ts new file mode 100644 index 000000000000..c3b525a1b8e9 --- /dev/null +++ b/packages/core/rxjs-interop/src/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export {fromObservable} from './from_observable'; +export {fromSignal, FromSignalOptions} from './from_signal'; +export {takeUntilDestroyed} from './take_until_destroyed'; diff --git a/packages/core/rxjs-interop/src/take_until_destroyed.ts b/packages/core/rxjs-interop/src/take_until_destroyed.ts new file mode 100644 index 000000000000..d026d960f253 --- /dev/null +++ b/packages/core/rxjs-interop/src/take_until_destroyed.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {assertInInjectionContext, DestroyRef, inject} from '@angular/core'; +import {MonoTypeOperatorFunction, Observable} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; + +/** + * Operator which completes the Observable when the calling context (component, directive, service, + * etc) is destroyed. + * + * @param destroyRef optionally, the `DestroyRef` representing the current context. This can be + * passed explicitly to use `takeUntilDestroyed` outside of an injection context. Otherwise, the + * current `DestroyRef` is injected. + * + * @developerPreview + */ +export function takeUntilDestroyed(destroyRef?: DestroyRef): MonoTypeOperatorFunction { + if (!destroyRef) { + assertInInjectionContext(takeUntilDestroyed); + destroyRef = inject(DestroyRef); + } + + const destroyed$ = new Observable(observer => { + destroyRef!.onDestroy(observer.next.bind(observer)); + }); + + return (source: Observable) => { + return source.pipe(takeUntil(destroyed$)); + }; +} diff --git a/packages/core/rxjs-interop/test/BUILD.bazel b/packages/core/rxjs-interop/test/BUILD.bazel new file mode 100644 index 000000000000..cd6fa0a56b08 --- /dev/null +++ b/packages/core/rxjs-interop/test/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "karma_web_test_suite", "ts_library") +load("//tools/circular_dependency_test:index.bzl", "circular_dependency_test") + +circular_dependency_test( + name = "circular_deps_test", + entry_point = "angular/packages/core/rxjs-interop/index.mjs", + deps = ["//packages/core/rxjs-interop"], +) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + deps = [ + "//packages:types", + "//packages/core", + "//packages/core/rxjs-interop", + "//packages/core/src/signals", + "//packages/core/testing", + "//packages/private/testing", + "@npm//rxjs", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node"], + deps = [ + ":test_lib", + ], +) + +karma_web_test_suite( + name = "test_web", + deps = [ + ":test_lib", + ], +) diff --git a/packages/core/rxjs-interop/test/from_observable_spec.ts b/packages/core/rxjs-interop/test/from_observable_spec.ts new file mode 100644 index 000000000000..eb535d7d6359 --- /dev/null +++ b/packages/core/rxjs-interop/test/from_observable_spec.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core'; +import {fromObservable} from '@angular/core/rxjs-interop'; +import {BehaviorSubject, Subject} from 'rxjs'; + +import {test} from './util'; + +describe('fromObservable()', () => { + it('should reflect the last emitted value of an Observable', test(() => { + const counter$ = new BehaviorSubject(0); + const counter = fromObservable(counter$); + + expect(counter()).toBe(0); + counter$.next(1); + expect(counter()).toBe(1); + counter$.next(3); + expect(counter()).toBe(3); + })); + + it('should notify when the last emitted value of an Observable changes', test(() => { + let seenValue: number = 0; + const counter$ = new BehaviorSubject(1); + const counter = fromObservable(counter$); + + expect(counter()).toBe(1); + + counter$.next(2); + expect(counter()).toBe(2); + })); + + it('should propagate an error returned by the Observable', test(() => { + const counter$ = new BehaviorSubject(1); + const counter = fromObservable(counter$); + + expect(counter()).toBe(1); + + counter$.error('fail'); + expect(counter).toThrow('fail'); + })); + + it('should unsubscribe when the current context is destroyed', test(() => { + const counter$ = new BehaviorSubject(0); + const injector = Injector.create({providers: []}) as EnvironmentInjector; + const counter = runInInjectionContext(injector, () => fromObservable(counter$)); + + expect(counter()).toBe(0); + counter$.next(1); + expect(counter()).toBe(1); + + // Destroying the injector should unsubscribe the Observable. + injector.destroy(); + + // The signal should have the last value observed. + expect(counter()).toBe(1); + + // And this value should no longer be updating (unsubscribed). + counter$.next(2); + expect(counter()).toBe(1); + })); + + describe('with no initial value', () => { + it('should throw if called before a value is emitted', test(() => { + const counter$ = new Subject(); + const counter = fromObservable(counter$); + + expect(() => counter()).toThrow(); + counter$.next(1); + expect(counter()).toBe(1); + })); + + it('should not throw if a value is emitted before called', test(() => { + const counter$ = new Subject(); + const counter = fromObservable(counter$); + + counter$.next(1); + expect(() => counter()).not.toThrow(); + })); + }); + + describe('with an initial value', () => { + it('should return the initial value if called before a value is emitted', test(() => { + const counter$ = new Subject(); + const counter = fromObservable(counter$, null); + + expect(counter()).toBeNull(); + counter$.next(1); + expect(counter()).toBe(1); + })); + + it('should not return the initial value if called after a value is emitted', test(() => { + const counter$ = new Subject(); + const counter = fromObservable(counter$, null); + + counter$.next(1); + expect(counter()).not.toBeNull(); + })); + }); +}); diff --git a/packages/core/rxjs-interop/test/from_signal_spec.ts b/packages/core/rxjs-interop/test/from_signal_spec.ts new file mode 100644 index 000000000000..ac6fb6860d2b --- /dev/null +++ b/packages/core/rxjs-interop/test/from_signal_spec.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {computed, signal} from '@angular/core'; +import {fromSignal} from '@angular/core/rxjs-interop'; +import {take, toArray} from 'rxjs/operators'; + +import {test} from './util'; + +describe('fromSignal()', () => { + it('should produce an observable that tracks a signal', test(async () => { + const counter = signal(0); + const counterValues = fromSignal(counter).pipe(take(3), toArray()).toPromise(); + + // Initial effect execution, emits 0. + await Promise.resolve(); + + counter.set(1); + // Emits 1. + await Promise.resolve(); + + counter.set(2); + counter.set(3); + // Emits 3 (ignores 2 as it was batched by the effect). + await Promise.resolve(); + + expect(await counterValues).toEqual([0, 1, 3]); + })); + + it('should propagate errors from the signal', test(async () => { + const source = signal(1); + const counter = computed(() => { + const value = source(); + if (value === 2) { + throw 'fail'; + } else { + return value; + } + }); + + const counter$ = fromSignal(counter); + + let currentValue: number = 0; + let currentError: any = null; + + const sub = counter$.subscribe({ + next: value => currentValue = value, + error: err => currentError = err, + }); + + await Promise.resolve(); + expect(currentValue).toBe(1); + + source.set(2); + await Promise.resolve(); + expect(currentError).toBe('fail'); + + sub.unsubscribe(); + })); + + it('should not monitor the signal if the Observable is never subscribed', test(async () => { + let counterRead = false; + const counter = computed(() => { + counterRead = true; + return 0; + }); + + fromSignal(counter); + + // Simply creating the Observable shouldn't trigger a signal read. + expect(counterRead).toBeFalse(); + + // Nor should the signal be read after effects have run. + await Promise.resolve(); + expect(counterRead).toBeFalse(); + })); + + it('should not monitor the signal if the Observable has no active subscribers', test(async () => { + const counter = signal(0); + + // Tracks how many reads of `counter()` there have been. + let readCount = 0; + const trackedCounter = computed(() => { + readCount++; + return counter(); + }); + + const counter$ = fromSignal(trackedCounter); + + const sub = counter$.subscribe(); + expect(readCount).toBe(0); + + await Promise.resolve(); + expect(readCount).toBe(1); + + // Sanity check of the read tracker - updating the counter should cause it to be read again + // by the active effect. + counter.set(1); + await Promise.resolve(); + expect(readCount).toBe(2); + + // Tear down the only subscription and hence the effect that's monitoring the signal. + sub.unsubscribe(); + + // Now, setting the signal shouldn't trigger any additional reads, as the Observable is no + // longer interested in its value. + + counter.set(2); + await Promise.resolve(); + + expect(readCount).toBe(2); + })); +}); diff --git a/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts b/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts new file mode 100644 index 000000000000..6f933ea806e6 --- /dev/null +++ b/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DestroyRef, EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; + +import {takeUntilDestroyed} from '../src/take_until_destroyed'; + +describe('takeUntilDestroyed', () => { + it('should complete an observable when the current context is destroyed', () => { + const injector = Injector.create({providers: []}) as EnvironmentInjector; + const source$ = new BehaviorSubject(0); + const tied$ = runInInjectionContext(injector, () => source$.pipe(takeUntilDestroyed())); + + let completed = false; + let last = 0; + + tied$.subscribe({ + next(value) { + last = value; + }, + complete() { + completed = true; + } + }); + + source$.next(1); + expect(last).toBe(1); + + injector.destroy(); + expect(completed).toBeTrue(); + source$.next(2); + expect(last).toBe(1); + }); + + it('should allow a manual DestroyRef to be passed', () => { + const injector = Injector.create({providers: []}) as EnvironmentInjector; + const source$ = new BehaviorSubject(0); + const tied$ = source$.pipe(takeUntilDestroyed(injector.get(DestroyRef))); + + let completed = false; + let last = 0; + + tied$.subscribe({ + next(value) { + last = value; + }, + complete() { + completed = true; + } + }); + + source$.next(1); + expect(last).toBe(1); + + injector.destroy(); + expect(completed).toBeTrue(); + source$.next(2); + expect(last).toBe(1); + }); +}); diff --git a/packages/core/rxjs-interop/test/util.ts b/packages/core/rxjs-interop/test/util.ts new file mode 100644 index 000000000000..f9a3011ba3a8 --- /dev/null +++ b/packages/core/rxjs-interop/test/util.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ApplicationRef, EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core'; + +export function test(fn: () => void|Promise): () => Promise { + return async () => { + const injector = Injector.create({ + providers: [ + {provide: EnvironmentInjector, useFactory: () => injector}, + {provide: ApplicationRef, useFactory: () => ({injector})}, + ] + }) as EnvironmentInjector; + try { + return await runInInjectionContext(injector, fn); + } finally { + injector.destroy(); + } + }; +} diff --git a/packages/core/src/core_reactivity_export_internal.ts b/packages/core/src/core_reactivity_export_internal.ts index 4c2bcb14f72b..8fb3d4276635 100644 --- a/packages/core/src/core_reactivity_export_internal.ts +++ b/packages/core/src/core_reactivity_export_internal.ts @@ -11,7 +11,6 @@ export { computed, CreateComputedOptions, CreateSignalOptions, - DeepReadonly, isSignal, Signal, signal, diff --git a/packages/core/src/signals/BUILD.bazel b/packages/core/src/signals/BUILD.bazel index 0e10db6afff2..559f2b1ceb30 100644 --- a/packages/core/src/signals/BUILD.bazel +++ b/packages/core/src/signals/BUILD.bazel @@ -3,6 +3,7 @@ load("//tools:defaults.bzl", "ts_library", "tsec_test") package(default_visibility = [ "//packages:__pkg__", "//packages/core:__subpackages__", + "//packages/rxjs-interop/test:__subpackages__", "//tools/public_api_guard:__pkg__", ]) diff --git a/packages/core/src/signals/README.md b/packages/core/src/signals/README.md index d07e6ecd0890..3dc69f730e75 100644 --- a/packages/core/src/signals/README.md +++ b/packages/core/src/signals/README.md @@ -4,7 +4,7 @@ This directory contains the code for Angular's reactive primitive, an implementa ## Conceptual surface -Angular Signals are zero-argument functions (`() => DeepReadonly`). When executed, they return the current value of the signal. Executing signals does not trigger side effects, though it may lazily recompute intermediate values (lazy memoization). +Angular Signals are zero-argument functions (`() => T`). When executed, they return the current value of the signal. Executing signals does not trigger side effects, though it may lazily recompute intermediate values (lazy memoization). Particular contexts (such as template expressions) can be _reactive_. In such contexts, executing a signal will return the value, but also register the signal as a dependency of the context in question. The context's owner will then be notified if any of its signal dependencies produces a new value (usually, this results in the re-execution of those expressions to consume the new values). @@ -39,10 +39,6 @@ If the equality function determines that 2 values are equal it will: * block update of signal’s value; * skip change propagation. -#### `DeepReadonly` - -Values read from signals are wrapped in a TypeScript type `DeepReadonly`, which recursively tags all properties and arrays in the inner type as `readonly`, preventing simple mutations. This acts as a safeguard against accidental mutation of signal values outside of the mutation API for `WritableSignal`s. - ### Declarative derived values: `computed()` `computed()` creates a memoizing signal, which calculates its value from the values of some number of input signals. diff --git a/packages/core/src/signals/index.ts b/packages/core/src/signals/index.ts index 9c4eedc955c7..11c9c2283518 100644 --- a/packages/core/src/signals/index.ts +++ b/packages/core/src/signals/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -export {DeepReadonly, isSignal, Signal, ValueEqualityFn} from './src/api'; +export {isSignal, Signal, ValueEqualityFn} from './src/api'; export {computed, CreateComputedOptions} from './src/computed'; export {setActiveConsumer} from './src/graph'; export {CreateSignalOptions, signal, WritableSignal} from './src/signal'; diff --git a/packages/core/src/signals/src/api.ts b/packages/core/src/signals/src/api.ts index d2cb3563db82..84a8f3826890 100644 --- a/packages/core/src/signals/src/api.ts +++ b/packages/core/src/signals/src/api.ts @@ -25,7 +25,7 @@ const SIGNAL = Symbol('SIGNAL'); * * @developerPreview */ -export type Signal = (() => DeepReadonly)&{ +export type Signal = (() => T)&{ [SIGNAL]: unknown; }; @@ -89,18 +89,3 @@ export function defaultEquals(a: T, b: T) { // as objects (`typeof null === 'object'`). return (a === null || typeof a !== 'object') && Object.is(a, b); } - -// clang-format off -/** - * Makes `T` read-only at the property level. - * - * Objects have their properties mapped to `DeepReadonly` types and arrays are converted to - * `ReadonlyArray`s of `DeepReadonly` values. - * - * @developerPreview - */ -export type DeepReadonly = T extends(infer R)[] ? ReadonlyArray> : - (T extends Function ? T : - (T extends object ? {readonly[P in keyof T]: DeepReadonly} : - T)); -// clang-format on diff --git a/packages/core/src/signals/src/signal.ts b/packages/core/src/signals/src/signal.ts index ba221ea9018e..0f578111a451 100644 --- a/packages/core/src/signals/src/signal.ts +++ b/packages/core/src/signals/src/signal.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {createSignalFromFunction, DeepReadonly, defaultEquals, Signal, ValueEqualityFn} from './api'; +import {createSignalFromFunction, defaultEquals, Signal, ValueEqualityFn} from './api'; import {ReactiveNode} from './graph'; /** @@ -81,9 +81,9 @@ class WritableSignalImpl extends ReactiveNode { this.producerMayHaveChanged(); } - signal(): DeepReadonly { + signal(): T { this.producerAccessed(); - return this.value as unknown as DeepReadonly; + return this.value; } } diff --git a/packages/core/test/signals/signal_spec.ts b/packages/core/test/signals/signal_spec.ts index a8e1a5daa56e..63d7b2f9913b 100644 --- a/packages/core/test/signals/signal_spec.ts +++ b/packages/core/test/signals/signal_spec.ts @@ -97,18 +97,4 @@ describe('signals', () => { state.set(stateValue); expect(derived()).toEqual('object:5'); }); - - it('should prohibit mutable access to properties at a type level', () => { - const state = signal({name: 'John'}); - - // @ts-expect-error - state().name = 'Jacob'; - }); - - it('should prohibit mutable access to arrays at a type level', () => { - const state = signal(['John']); - - // @ts-expect-error - state().push('Jacob'); - }); });