re-rxjs / react-rxjs

React bindings for RxJS
https://react-rxjs.org
MIT License
549 stars 19 forks source link

v0.3.0 #104

Closed josepot closed 4 years ago

josepot commented 4 years ago

After putting a lot of thought into this... These are the changes/improvements that I would like to suggest for version 0.3.0:

codecov[bot] commented 4 years ago

Codecov Report

Merging #104 into main will not change coverage. The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##              main      #104   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           28        28           
  Lines          371       368    -3     
  Branches        41        38    -3     
=========================================
- Hits           371       368    -3     
Impacted Files Coverage Δ
packages/core/src/bind/index.ts 100.00% <ø> (ø)
packages/core/src/bind/connectFactoryObservable.ts 100.00% <100.00%> (ø)
packages/core/src/bind/connectObservable.ts 100.00% <100.00%> (ø)
packages/core/src/internal/react-enhancer.ts 100.00% <100.00%> (ø)
packages/core/src/internal/useObservable.ts 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more Δ = absolute <relative> (impact), ø = not affected, ? = missing data Powered by Codecov. Last update 74ee837...078d1bc. Read the comment docs.

voliva commented 4 years ago

It's very impressive, but I think this route has a dead end. I can't think of a way to make this test work:

it("dismisses the observable if it has completed without subscribers", async () => {
  let nInitCount = 0
  const observable$ = defer(() => {
    nInitCount += 1
    return from([1, 2, 3, 4, 5])
  })

  const complete$ = new Subject()
  const [useLatestNumber] = bind((id: number) =>
    concat(observable$, of(id), complete$).pipe(shareReplay(1)),
  )
  const { unmount } = renderHook(() => useLatestNumber(6))
  expect(nInitCount).toBe(1)

  unmount()
  await wait(270)

  complete$.complete()

  await wait(270)

  renderHook(() => useLatestNumber(6))
  expect(nInitCount).toBe(2) // Fails
})

Because in order to receive that complete, you need to be subscribed to it. But if you keep an active subscription, then we're breaking the majority of the streams that are used with bind (i.e. the ones that don't use shareReplay). It feels like the only way would be to see if that stream has unsubscribed from its sources, but we don't have control for that.

It's worth noting that from that point onwards it will work: When refcount hits 0, the stream will be cleaned of... (but maybe too late? - thinking out loud here)

If we decide to drop support for shareReplay in bind factory observables, we can always add a rule in eslint (and think of alternatives for a generic case).

josepot commented 4 years ago

Yep, you are right @voliva . I think that we should leave the factory bind as it is right now. The reason being that the practice that we should be promoting is that an observable returned by a factory bind should backed up by a "top level" observable. For instance:

const todos$ = todoActions$.pipe(
  split(
    event => event.payload.id,
    (event$, id) => event.pipe(
      takeWhile(event => event.type !== 'delete'),
      scan(
        (state, action) => {
          switch (action.type) {
            case "add":
            case "edit":
              return { ...state, text: action.payload.text }
            case "toggle":
              return { ...state, done: !state.done},
            default:
              return state
          }
        },
        { id, text: "", done: false }
      )
    )
  ),
  collect()
)

const [useTodosIds] = bind(todos$.pipe(map(todosMap => [...todosMap.keys()]))

const [useTodo] = bind((id: number) => todos$.pipe(
  take(1),
  switchMap(todosMap => todosMap.get(id))
))

Or, if todos$ was enhanced with collectValues, instead of collect, then useTodo would look like this:

const [useTodo] = bind((id: number) => todos$.pipe(
  map(todosMap => todosMap.get(id))
))

We should promote this practice even for things that come from React (rather than coming for an observable):

const itemIdsRequested$ = new Subject<number>()

const items$ = itemIdsRequested$.pipe(
  split(
    id => id,
    id$ => id$.pipe(switchMap(getItem))
  ),
  collectValues()
)

const [useItem] = bind((id: number) => merge(
  items$.pipe(
    filter(itemsMap => itemsMap.has(id)),
    map(itemsMap => itemsMap.get(id))
  ),
  defer(() => {
    itemIdsRequested$.next(id);
    return EMPTY;
  }),
))
const Item: React.FC<{id: number}> = ({ id }) => {
  const item = useItem(id);
  return <div>{item.name}</div>
}

const Items: React.FC: <{itemIds: number[]}> = ({ itemIds }) => {
  useSubscription(items$);
  return itemIds.map(id => (
    <Suspense key={id} fallback={<div>'Loading...'</div>}>
      <Item id={id} />
    </Suspense>
  )) 
}

Admittedly, it's not very ergonomic, but it gives the user full-control on how to cache the values/streams.