Skip to content

Subscription leakage with circular dependencies (using defer) #7147

Description

@jeffijoe

Describe the bug

This came up as I was building a search component. Basically, what is happening is that subscriptions are not being unsubscribed from when using a combination of defer, delay and share.

So far my testing leads me to believe it has to do with using defer to create a circular dependency. The use case is that you want a search pipeline that needs to refer to the previous search result to get the next page token/cursor, and the cursor observable itself needs to refer to the search pipeline.

Expected behavior

The subscription should be cleaned up accordingly.

Reproduction code

import { Observable, Subject, defer, BehaviorSubject } from "rxjs";
import {
  tap,
  share,
  delay,
  mapTo,
  withLatestFrom,
  startWith
} from "rxjs/operators";

console.clear();

// Imagine an observable that emits search results based on input.
function searchObservable(input: Observable<string>) {
  const searchText$ = input.pipe(
    // Watch the console; this proves that the subscription is never disposed.
    tap((i) => console.log("got search text", i))
  );

  // This references the `searchResults$` observable below via `defer`.
  // The use case would be to grab the next page token/cursor from the
  // response.
  /* eslint-disable @typescript-eslint/no-use-before-define */
  const cursor$: Observable<string | null> = defer(() => searchResults$).pipe(
    startWith(null),
    share()
  );

  // This references the `cursor$` observable above, creating the circular dependency.
  const searchResults$ = searchText$.pipe(
    withLatestFrom(cursor$),
    delay(0),
    mapTo("boo"),
    // Share the search results subscription so we don't create multiple
    // searchers. This is because the results are returned as well as used
    // for the cursor above.
    share()
  );

  return searchResults$;
}

// This works.
function searchObservableWithWorkaround(input: Observable<string>) {
  return new Observable<any>((observer) => {
    const cursor = new BehaviorSubject<string | null>(null);
    const searchText$ = input.pipe(
      tap((i) => console.log("got search text", i))
    );

    const searchResults$ = searchText$.pipe(
      withLatestFrom(cursor),
      delay(0),
      mapTo("wee"),
      share()
    );

    const sub = searchResults$.subscribe(observer);

    return () => {
      sub.unsubscribe();
    };
  });
}

async function run() {
  // Search input
  let inputSource = new Subject<string>();

  // First time.
  let sub = searchObservable(inputSource).subscribe(console.log);
  // < 'got search text once'
  inputSource.next("once");
  await waitABit();
  sub.unsubscribe();
  await waitABit();

  // Second time
  sub = searchObservable(inputSource).subscribe(console.log);
  // < 'got search text twice'
  // < 'got search text twice'
  inputSource.next("twice");
  await waitABit();
  sub.unsubscribe();
  await waitABit();

  // Third time
  sub = searchObservable(inputSource).subscribe(console.log);
  // < 'got search text thrice'
  // < 'got search text thrice'
  // < 'got search text thrice'
  inputSource.next("thrice");
  await waitABit();
  sub.unsubscribe();
  await waitABit();

  // -----

  console.log("--- Using workaround ---");

  inputSource.complete(); // cleanup from previous
  inputSource = new Subject<string>();

  // First time.
  sub = searchObservableWithWorkaround(inputSource).subscribe(console.log);
  // < 'got search text once'
  inputSource.next("once");
  await waitABit();
  sub.unsubscribe();
  await waitABit();

  sub = searchObservableWithWorkaround(inputSource).subscribe(console.log);
  // < 'got search text still once'
  inputSource.next("still once");
  await waitABit();
  sub.unsubscribe();
  await waitABit();
}

run();

function waitABit() {
  return new Promise((resolve) => setTimeout(resolve, 50));
}

Reproduction URL

https://codesandbox.io/s/jolly-allen-py7boq?file=/src/index.ts

Version

6.x, 7.x, 8.x

Environment

No response

Additional context

I have a workaround for my use case, which involves creating a subject as a proxy to create the circular reference, then complete the subject when the returned observable is unsubscribed from.

Metadata

Metadata

Assignees

Labels

AGENDA ITEMFlagged for discussion at core team meetings

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions