Repository: angular/angular
File: packages/core/rxjs-interop/src/to_signal.ts (TypeScript)

Code before:

  if (ngDevMode && requiresCleanup && !options?.injector) {
    assertInInjectionContext(toSignal);
  }

  const cleanupRef = requiresCleanup
    ? (options?.injector?.get(DestroyRef) ?? inject(DestroyRef))
    : null;

  const equal = makeToSignalEqual(options?.equal);

  // 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<State<T | U>>;
  if (options?.requireSync) {
    // Initially the signal is in a `NoValue` state.
    state = signal({kind: StateKind.NoValue}, {equal});
  } else {
    // If an initial value was passed, use it. Otherwise, use `undefined` as the initial value.
    state = signal<State<T | U>>(
      {kind: StateKind.Value, value: options?.initialValue as U},
      {equal, debugName: options?.debugName},
    );
  }

  let destroyUnregisterFn: (() => void) | undefined;

  // Note: This code cannot run inside a reactive context (see assertion above). If we'd support
  // this, we would subscribe to the observable outside of the current reactive context, avoiding
  // that side-effect signal reads/writes are attribute to the current consumer. The current
  // consumer only needs to be notified when the `state` signal changes through the observable
  // subscription. Additional context (related to async pipe):
  // https://github.com/angular/angular/pull/50522.
  const sub = source.subscribe({
    next: (value) => state.set({kind: StateKind.Value, value}),
    error: (error) => {
      state.set({kind: StateKind.Error, error});
      destroyUnregisterFn?.();
    },
    complete: () => {
      destroyUnregisterFn?.();
    },
    // Completion of the Observable is meaningless to the signal. Signals don't have a concept of
    // "complete".
  });

  if (options?.requireSync && state().kind === StateKind.NoValue) {
    throw new ɵRuntimeError(
      ɵRuntimeErrorCode.REQUIRE_SYNC_WITHOUT_SYNC_EMIT,
      (typeof ngDevMode === 'undefined' || ngDevMode) &&
        '`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.',

Review comment (bug):
I think that we need to pass the `debugName` to the if-case `state` definition as well. The returned `computed` needs it, too.

It seems that `toSignal` can also be considered part of [compound signals](https://github.com/angular/angular/issues/63227). Therefore, we can use a similar naming strategy to the resource debug name (i.e. `toSignal#${debugName}.${internal_signal_name}`; later we can create a transformer and a DevTools graph cluster after the respective PRs get merged).

@JeanMeche, thoughts?

Code after:

  if (ngDevMode && requiresCleanup && !options?.injector) {
    assertInInjectionContext(toSignal);
  }

  const cleanupRef = requiresCleanup
    ? (options?.injector?.get(DestroyRef) ?? inject(DestroyRef))
    : null;

  const equal = makeToSignalEqual(options?.equal);

  // 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<State<T | U>>;
  if (options?.requireSync) {
    // Initially the signal is in a `NoValue` state.
    state = signal(
      {kind: StateKind.NoValue},
      {equal, ...(ngDevMode ? createDebugNameObject(options?.debugName, 'state') : undefined)},
    );
  } else {
    // If an initial value was passed, use it. Otherwise, use `undefined` as the initial value.
    state = signal<State<T | U>>(
      {kind: StateKind.Value, value: options?.initialValue as U},
      {equal, ...(ngDevMode ? createDebugNameObject(options?.debugName, 'state') : undefined)},
    );
  }

  let destroyUnregisterFn: (() => void) | undefined;

  // Note: This code cannot run inside a reactive context (see assertion above). If we'd support
  // this, we would subscribe to the observable outside of the current reactive context, avoiding
  // that side-effect signal reads/writes are attribute to the current consumer. The current
  // consumer only needs to be notified when the `state` signal changes through the observable
  // subscription. Additional context (related to async pipe):
  // https://github.com/angular/angular/pull/50522.
  const sub = source.subscribe({
    next: (value) => state.set({kind: StateKind.Value, value}),
    error: (error) => {
      state.set({kind: StateKind.Error, error});
      destroyUnregisterFn?.();
    },
    complete: () => {
      destroyUnregisterFn?.();
    },
    // Completion of the Observable is meaningless to the signal. Signals don't have a concept of
    // "complete".
  });

  if (options?.requireSync && state().kind === StateKind.NoValue) {
    throw new ɵRuntimeError(