Comparing version 3.1.3 to 4.0.0-canary.0
@@ -18,3 +18,3 @@ import {Observable} from 'rxjs' | ||
observable: ObservableType, | ||
initialValue: InitialValue, | ||
initialValue: InitialValue | (() => InitialValue), | ||
): InitialValue | ObservedValueOf<ObservableType> | ||
@@ -21,0 +21,0 @@ |
@@ -1,3 +0,3 @@ | ||
import { useRef, useEffect, useMemo, useSyncExternalStore, useState } from "react"; | ||
import { catchError, of, finalize, share } from "rxjs"; | ||
import { useMemo, useSyncExternalStore, useState, useEffect } from "react"; | ||
import { catchError, of, finalize, share, timer, asapScheduler } from "rxjs"; | ||
import { map, tap } from "rxjs/operators"; | ||
@@ -11,31 +11,27 @@ import { observableCallback } from "observable-callback"; | ||
function useObservable(observable, initialValue) { | ||
const initialValueRef = useRef(getValue(initialValue)); | ||
useEffect(() => { | ||
initialValueRef.current = getValue(initialValue); | ||
}, [initialValue]); | ||
if (!cache.has(observable)) { | ||
const entry = { | ||
snapshot: getValue(initialValue) | ||
}; | ||
entry.observable = observable.pipe( | ||
map((value) => ({ snapshot: value, error: void 0 })), | ||
catchError((error) => of({ snapshot: void 0, error })), | ||
tap(({ snapshot, error }) => { | ||
entry.snapshot = snapshot, entry.error = error; | ||
}), | ||
// Note: any value or error emitted by the provided observable will be mapped to the cache entry's mutable state | ||
// and the observable is thereafter only used as a notifier to call `onStoreChange`, hence the `void` return type. | ||
map((value) => { | ||
}), | ||
// Ensure that the cache entry is deleted when the observable completes or errors. | ||
finalize(() => cache.delete(observable)), | ||
share({ resetOnRefCountZero: () => timer(0, asapScheduler) }) | ||
), entry.observable.subscribe().unsubscribe(), cache.set(observable, entry); | ||
} | ||
const store = useMemo(() => { | ||
if (!cache.has(observable)) { | ||
const entry = { | ||
snapshot: initialValueRef.current | ||
}; | ||
entry.observable = observable.pipe( | ||
map((value) => ({ snapshot: value, error: void 0 })), | ||
catchError((error) => of({ snapshot: void 0, error })), | ||
tap(({ snapshot, error }) => { | ||
entry.snapshot = snapshot, entry.error = error; | ||
}), | ||
// Note: any value or error emitted by the provided observable will be mapped to the cache entry's mutable state | ||
// and the observable is thereafter only used as a notifier to call `onStoreChange`, hence the `void` return type. | ||
map((value) => { | ||
}), | ||
// Ensure that the cache entry is deleted when the observable completes or errors. | ||
finalize(() => cache.delete(observable)), | ||
share() | ||
), entry.subscription = entry.observable.subscribe(), cache.set(observable, entry); | ||
} | ||
const instance = cache.get(observable); | ||
return instance.subscription.closed && (instance.subscription = instance.observable.subscribe()), { | ||
return { | ||
subscribe: (onStoreChange) => { | ||
const subscription = instance.observable.subscribe(onStoreChange); | ||
return instance.subscription.unsubscribe(), () => { | ||
return () => { | ||
subscription.unsubscribe(); | ||
@@ -54,3 +50,3 @@ }; | ||
store.getSnapshot, | ||
typeof initialValueRef.current > "u" ? void 0 : () => initialValueRef.current | ||
typeof initialValue > "u" ? void 0 : () => getValue(initialValue) | ||
); | ||
@@ -57,0 +53,0 @@ } |
{ | ||
"name": "react-rx", | ||
"version": "3.1.3", | ||
"version": "4.0.0-canary.0", | ||
"description": "React + RxJS = <3", | ||
@@ -65,11 +65,2 @@ "keywords": [ | ||
], | ||
"scripts": { | ||
"build": "pkg build --strict --clean --check", | ||
"dev": "pnpm --filter 'react-rx-website' dev", | ||
"format": "prettier --cache --write .", | ||
"lint": "eslint --cache .", | ||
"prepublishOnly": "pnpm build", | ||
"test": "vitest run --typecheck", | ||
"watch": "pnpm build -- --watch" | ||
}, | ||
"browserslist": "extends @sanity/browserslist-config", | ||
@@ -82,21 +73,21 @@ "prettier": "@sanity/prettier-config", | ||
"devDependencies": { | ||
"@sanity/pkg-utils": "^6.10.3", | ||
"@sanity/prettier-config": "^1.0.2", | ||
"@sanity/pkg-utils": "^6.11.7", | ||
"@sanity/prettier-config": "^1.0.3", | ||
"@sanity/semantic-release-preset": "^5.0.0", | ||
"@testing-library/dom": "^10.3.1", | ||
"@testing-library/react": "^16.0.0", | ||
"@testing-library/dom": "^10.4.0", | ||
"@testing-library/react": "^16.0.1", | ||
"@types/node": "^18.17.5", | ||
"@types/react": "^18.3.3", | ||
"@types/react-dom": "^18.3.0", | ||
"@typescript-eslint/eslint-plugin": "^7.16.0", | ||
"@typescript-eslint/parser": "^7.16.0", | ||
"eslint": "^8.57.0", | ||
"@types/react": "^18.3.12", | ||
"@types/react-dom": "^18.3.1", | ||
"@typescript-eslint/eslint-plugin": "^8.12.2", | ||
"@typescript-eslint/parser": "^8.12.2", | ||
"eslint": "^8.57.1", | ||
"eslint-config-prettier": "^9.1.0", | ||
"eslint-plugin-prettier": "^5.1.3", | ||
"eslint-plugin-react": "^7.34.3", | ||
"eslint-plugin-react-compiler": "0.0.0-experimental-51a85ea-20240601", | ||
"eslint-plugin-react-hooks": "^4.6.2", | ||
"eslint-plugin-prettier": "^5.2.1", | ||
"eslint-plugin-react": "^7.37.2", | ||
"eslint-plugin-react-compiler": "beta", | ||
"eslint-plugin-react-hooks": "^5.0.0", | ||
"eslint-plugin-simple-import-sort": "^12.1.1", | ||
"jsdom": "^24.1.0", | ||
"prettier": "^3.3.2", | ||
"prettier": "^3.3.3", | ||
"react": "^18.3.1", | ||
@@ -106,5 +97,5 @@ "react-dom": "^18.3.1", | ||
"rxjs": "^7.8.1", | ||
"semantic-release": "^24.0.0", | ||
"typescript": "5.5.3", | ||
"vitest": "^2.0.2" | ||
"semantic-release": "^24.1.0", | ||
"typescript": "5.6.3", | ||
"vitest": "^2.1.4" | ||
}, | ||
@@ -115,3 +106,10 @@ "peerDependencies": { | ||
}, | ||
"packageManager": "pnpm@9.5.0" | ||
} | ||
"scripts": { | ||
"build": "pkg build --strict --clean --check", | ||
"dev": "pnpm --filter 'react-rx-website' dev", | ||
"format": "prettier --cache --write .", | ||
"lint": "eslint --cache .", | ||
"test": "vitest run --typecheck", | ||
"watch": "pnpm build -- --watch" | ||
} | ||
} |
import {act, render} from '@testing-library/react' | ||
import {createElement, Fragment, StrictMode, useEffect} from 'react' | ||
import {createElement, Fragment, StrictMode, useEffect, useMemo} from 'react' | ||
import {BehaviorSubject, Observable} from 'rxjs' | ||
@@ -42,3 +42,3 @@ import {expect, test} from 'vitest' | ||
test('Strict mode should unsubscribe the source observable on unmount', () => { | ||
test('Strict mode should unsubscribe the source observable on unmount', async () => { | ||
const subscribed: number[] = [] | ||
@@ -61,5 +61,29 @@ const unsubscribed: number[] = [] | ||
const {rerender} = render(createElement(StrictMode, null, createElement(ObservableComponent))) | ||
expect(subscribed).toEqual([0, 1]) | ||
expect(subscribed).toEqual([0]) | ||
rerender(createElement(StrictMode, null, createElement('div'))) | ||
expect(unsubscribed).toEqual([0, 1]) | ||
await Promise.resolve() | ||
expect(unsubscribed).toEqual([0]) | ||
}) | ||
test('Strict mode should unsubscribe the source observable on unmount if its created in a useMemo', async () => { | ||
let subscriberCount: number = 0 | ||
const getObservable = () => | ||
new Observable(() => { | ||
subscriberCount++ | ||
return () => { | ||
subscriberCount-- | ||
} | ||
}) | ||
function ObservableComponent() { | ||
const memoObservable = useMemo(() => getObservable(), []) | ||
useObservable(memoObservable) | ||
return createElement(Fragment, null) | ||
} | ||
const {rerender} = render(createElement(StrictMode, null, createElement(ObservableComponent))) | ||
expect(subscriberCount, 'Subscriber count should be 2').toBe(2) | ||
rerender(createElement(StrictMode, null, createElement('div'))) | ||
await Promise.resolve() | ||
expect(subscriberCount, 'Subscriber count should be 0').toBe(0) | ||
}) |
@@ -25,4 +25,4 @@ import {of} from 'rxjs' | ||
expectTypeOf(useObservable(observable, 1)).toEqualTypeOf<string | number>() | ||
expectTypeOf(useObservable(observable, () => 1)).toEqualTypeOf<string | number>() | ||
expectTypeOf(useObservable(observable, 'foo')).toEqualTypeOf<string>() | ||
}) |
@@ -9,3 +9,3 @@ import {act, render, renderHook} from '@testing-library/react' | ||
test('should subscribe immediately on component mount and unsubscribe on component unmount', () => { | ||
test('should subscribe immediately on component mount and unsubscribe on component unmount', async () => { | ||
let subscribed = false | ||
@@ -25,6 +25,7 @@ const observable = new Observable(() => { | ||
unmount() | ||
await Promise.resolve() | ||
expect(subscribed).toBe(false) | ||
}) | ||
test('should only subscribe once when given same observable on re-renders', () => { | ||
test('should only subscribe once when given same observable on re-renders', async () => { | ||
let subscriptionCount = 0 | ||
@@ -42,2 +43,3 @@ const observable = new Observable(() => { | ||
unmount() | ||
await Promise.resolve() | ||
@@ -109,3 +111,3 @@ renderHook(() => useObservable(observable)) | ||
test('should rerender with initial value if component unmounts and then remounts', () => { | ||
test('should rerender with initial value if component unmounts and then remounts', async () => { | ||
const values$ = new Subject<string>() | ||
@@ -120,2 +122,3 @@ const firstHook = renderHook(() => useObservable(values$, 'initial')) | ||
firstHook.unmount() | ||
await Promise.resolve() | ||
@@ -127,3 +130,3 @@ const nextHook = renderHook(() => useObservable(values$, 'initial2')) | ||
test('should share the observable between each concurrent subscribing hook', () => { | ||
test('should share the observable between each concurrent subscribing hook', async () => { | ||
let subscribeCount = 0 | ||
@@ -139,2 +142,3 @@ const observable = new Observable<number>((subscriber) => { | ||
secondHook.unmount() | ||
await Promise.resolve() | ||
@@ -146,3 +150,3 @@ const thirdHook = renderHook(() => useObservable(observable)) | ||
test('should restart any completed observable on mount', () => { | ||
test('should restart any completed observable on mount', async () => { | ||
let subscribeCount = 0 | ||
@@ -189,2 +193,3 @@ let unsubscribeCount = 0 | ||
firstHook.unmount() | ||
await Promise.resolve() | ||
@@ -196,2 +201,4 @@ const secondHook = renderHook(() => useObservable(observable)) | ||
secondHook.unmount() | ||
await Promise.resolve() | ||
expect(unsubscribeCount).toBe(2) | ||
@@ -198,0 +205,0 @@ }) |
@@ -1,3 +0,4 @@ | ||
import {useEffect, useMemo, useRef, useSyncExternalStore} from 'react' | ||
import {useMemo, useSyncExternalStore} from 'react' | ||
import { | ||
asapScheduler, | ||
catchError, | ||
@@ -9,3 +10,3 @@ finalize, | ||
share, | ||
type Subscription, | ||
timer, | ||
} from 'rxjs' | ||
@@ -19,3 +20,2 @@ import {map, tap} from 'rxjs/operators' | ||
interface CacheRecord<T> { | ||
subscription: Subscription | ||
observable: Observable<void> | ||
@@ -40,3 +40,3 @@ snapshot: T | ||
observable: ObservableType, | ||
initialValue: InitialValue, | ||
initialValue: InitialValue | (() => InitialValue), | ||
): InitialValue | ObservedValueOf<ObservableType> | ||
@@ -48,50 +48,34 @@ /** @public */ | ||
): InitialValue | ObservedValueOf<ObservableType> { | ||
/** | ||
* Store the initialValue in a ref, as we don't want a changed `initialValue` to trigger a re-subscription. | ||
* But we also don't want the initialValue to be stale if the observable changes. | ||
*/ | ||
const initialValueRef = useRef(getValue(initialValue) as ObservedValueOf<ObservableType>) | ||
if (!cache.has(observable)) { | ||
const entry: Partial<CacheRecord<ObservedValueOf<ObservableType>>> = { | ||
snapshot: getValue(initialValue) as ObservedValueOf<ObservableType>, | ||
} | ||
entry.observable = observable.pipe( | ||
map((value) => ({snapshot: value, error: undefined})), | ||
catchError((error) => of({snapshot: undefined, error})), | ||
tap(({snapshot, error}) => { | ||
entry.snapshot = snapshot | ||
entry.error = error | ||
}), | ||
// Note: any value or error emitted by the provided observable will be mapped to the cache entry's mutable state | ||
// and the observable is thereafter only used as a notifier to call `onStoreChange`, hence the `void` return type. | ||
map((value) => void value), | ||
// Ensure that the cache entry is deleted when the observable completes or errors. | ||
finalize(() => cache.delete(observable)), | ||
share({resetOnRefCountZero: () => timer(0, asapScheduler)}), | ||
) | ||
/** | ||
* Ensures that the initialValue is always up-to-date in case the observable changes. | ||
*/ | ||
useEffect(() => { | ||
initialValueRef.current = getValue(initialValue) as ObservedValueOf<ObservableType> | ||
}, [initialValue]) | ||
// Eagerly subscribe to sync set `entry.currentValue` to what the observable returns, and keep the observable alive until the component unmounts. | ||
const subscription = entry.observable.subscribe() | ||
subscription.unsubscribe() | ||
cache.set(observable, entry as CacheRecord<ObservedValueOf<ObservableType>>) | ||
} | ||
const store = useMemo(() => { | ||
if (!cache.has(observable)) { | ||
const entry: Partial<CacheRecord<ObservedValueOf<ObservableType>>> = { | ||
snapshot: initialValueRef.current, | ||
} | ||
entry.observable = observable.pipe( | ||
map((value) => ({snapshot: value, error: undefined})), | ||
catchError((error) => of({snapshot: undefined, error})), | ||
tap(({snapshot, error}) => { | ||
entry.snapshot = snapshot | ||
entry.error = error | ||
}), | ||
// Note: any value or error emitted by the provided observable will be mapped to the cache entry's mutable state | ||
// and the observable is thereafter only used as a notifier to call `onStoreChange`, hence the `void` return type. | ||
map((value) => void value), | ||
// Ensure that the cache entry is deleted when the observable completes or errors. | ||
finalize(() => cache.delete(observable)), | ||
share(), | ||
) | ||
// Eagerly subscribe to sync set `entry.currentValue` to what the observable returns, and keep the observable alive until the component unmounts. | ||
entry.subscription = entry.observable.subscribe() | ||
cache.set(observable, entry as CacheRecord<ObservedValueOf<ObservableType>>) | ||
} | ||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||
const instance = cache.get(observable)! | ||
if (instance.subscription.closed) { | ||
instance.subscription = instance.observable.subscribe() | ||
} | ||
return { | ||
subscribe: (onStoreChange: () => void) => { | ||
const subscription = instance.observable.subscribe(onStoreChange) | ||
instance.subscription.unsubscribe() | ||
return () => { | ||
@@ -113,4 +97,6 @@ subscription.unsubscribe() | ||
store.getSnapshot, | ||
typeof initialValueRef.current === 'undefined' ? undefined : () => initialValueRef.current, | ||
typeof initialValue === 'undefined' | ||
? undefined | ||
: () => getValue(initialValue) as ObservedValueOf<ObservableType>, | ||
) | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
570
41374
1