Skip to content

Commit 033c3d4

Browse files
committed
Implement PendingSequence
1 parent da57aef commit 033c3d4

File tree

4 files changed

+262
-41
lines changed

4 files changed

+262
-41
lines changed

src/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export {
1313

1414
export { useAsync, shareAsync, sharedAsyncMap, observeAsync } from './async';
1515

16-
export { usePending, useSetPending } from './pending';
16+
export {
17+
usePendingInstance,
18+
PendingBoundary,
19+
PendingSequence,
20+
} from './pending';
1721

1822
export { useAsyncElement } from './asyncElement';

src/pending.tsx

Lines changed: 175 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,182 @@
1-
import React, { useCallback } from 'react';
2-
import { observeAsyncState, asyncStateContext } from './asyncState';
3-
import { useInitialize } from './utils';
4-
5-
const pendingState = asyncStateContext(() => {
6-
return observeAsyncState<Set<Symbol>, [Symbol, boolean]>(
7-
new Set<Symbol>(),
8-
(state, [sym, pending]) => {
9-
if (pending) {
10-
state.add(sym);
11-
} else {
12-
state.delete(sym);
13-
}
14-
return state;
15-
}
1+
import React, {
2+
createContext,
3+
isValidElement,
4+
ReactNode,
5+
useContext,
6+
useEffect,
7+
useMemo,
8+
} from 'react';
9+
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
10+
import { map, skipWhile, switchMap, take } from 'rxjs/operators';
11+
import { useInitialize, useObservedProp } from './utils';
12+
13+
export type PendingState = 'init' | 'pending' | 'ready';
14+
15+
export type PendingInstance = {
16+
state: BehaviorSubject<PendingState>;
17+
};
18+
19+
export type PendingContextInstance = {
20+
registerInstance(instance: PendingInstance): void;
21+
unregisterInstance(instance: PendingInstance): void;
22+
teardown(): void;
23+
pendingStates$: Observable<PendingState[]>;
24+
};
25+
26+
function createInstance(): PendingContextInstance {
27+
const instances = new BehaviorSubject<Set<PendingInstance>>(new Set());
28+
const observer$ = instances.pipe(
29+
switchMap(set => combineLatest(Array.from(set.values()).map(v => v.state)))
1630
);
17-
});
31+
const pendingStates$ = new BehaviorSubject<PendingState[]>([]);
32+
const sub = observer$.subscribe(pendingStates$);
33+
return {
34+
registerInstance(instance: PendingInstance) {
35+
instances.next(instances.value.add(instance));
36+
},
37+
unregisterInstance(instance: PendingInstance) {
38+
const val = instances.value;
39+
val.delete(instance);
40+
instances.next(val);
41+
},
42+
teardown() {
43+
sub.unsubscribe();
44+
},
45+
pendingStates$,
46+
};
47+
}
48+
49+
const pendingContext = createContext(createInstance());
1850

19-
/**
20-
* This will reflect the pending state of any `useAsync` or `useAsyncCallback` operations, inside the pending boundary context.
21-
*/
22-
export function usePending() {
23-
return pendingState.useSelect(symbols => symbols.size > 0, []);
51+
export function usePendingInstance() {
52+
const { registerInstance, unregisterInstance } = useContext(pendingContext);
53+
const instance = useMemo(() => {
54+
const res = {
55+
state: new BehaviorSubject<PendingState>('init'),
56+
};
57+
registerInstance(res);
58+
return res;
59+
}, [registerInstance]);
60+
useEffect(() => () => unregisterInstance(instance), [
61+
instance,
62+
unregisterInstance,
63+
]);
64+
return instance;
2465
}
2566

26-
/**
27-
* Use this callback if you want to use the pending context outside of the `useAsync` and `useAsyncCallback` hooks.
28-
*/
29-
export function useSetPending() {
30-
const sym = useInitialize(() => Symbol('Pending state identifier'));
31-
const dispatch = pendingState.useDispatch();
32-
return useCallback(
33-
(pending: boolean) => {
34-
return dispatch([sym, pending]);
35-
},
36-
[dispatch, sym]
67+
export type PendingBoundaryProps = {
68+
onInit?: () => void;
69+
};
70+
71+
export const PendingBoundary: React.FC<PendingBoundaryProps> = ({
72+
children,
73+
onInit,
74+
}) => {
75+
const ctx = useInitialize(() => {
76+
return createInstance();
77+
});
78+
const onInit$ = useObservedProp(onInit);
79+
useEffect(() => {
80+
const initSub = ctx.pendingStates$
81+
.pipe(
82+
skipWhile(val => val.includes('init')),
83+
take(1),
84+
switchMap(() => onInit$)
85+
)
86+
.subscribe(fn => fn && fn());
87+
return () => {
88+
if (initSub) {
89+
initSub.unsubscribe();
90+
}
91+
};
92+
}, [ctx.pendingStates$, onInit$]);
93+
useEffect(() => () => ctx.teardown(), [ctx]);
94+
return (
95+
<pendingContext.Provider value={ctx}>{children}</pendingContext.Provider>
3796
);
97+
};
98+
99+
function behaviorGuard(
100+
item: BehaviorSubject<boolean> | null
101+
): item is BehaviorSubject<boolean> {
102+
return item !== null;
38103
}
39104

40-
export const PendingBoundary: React.FC<{}> = ({ children }) => (
41-
<pendingState.Provider>{children}</pendingState.Provider>
42-
);
105+
export const PendingSequence: React.FC<Omit<
106+
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
107+
'ref'
108+
>> = ({ children, ...rest }) => {
109+
const [list, cache] = useInitialize(() => {
110+
return [
111+
new Array(React.Children.count(children)).fill(
112+
null
113+
) as (null | BehaviorSubject<boolean>)[],
114+
{} as { [key: string]: BehaviorSubject<boolean> },
115+
];
116+
});
117+
useEffect(() => {
118+
return () => {
119+
Object.values(cache).forEach(item => {
120+
if (item) {
121+
item.complete();
122+
}
123+
});
124+
};
125+
}, [cache]);
126+
return (
127+
<>
128+
{React.Children.map(children, (child: ReactNode, index) => {
129+
if (isValidElement(child)) {
130+
const key = child.key || index;
131+
if (child.type === PendingBoundary) {
132+
let init$ =
133+
cache[key] || (cache[key] = new BehaviorSubject<boolean>(true));
134+
list[index] = init$;
135+
const dependants = list
136+
.slice(0, index)
137+
.filter<BehaviorSubject<boolean>>(behaviorGuard);
138+
const initialHide = dependants.some(v => v.value);
139+
let sub: Subscription | undefined;
140+
const hideRef = initialHide
141+
? (instance: HTMLDivElement | null) => {
142+
if (sub) {
143+
sub.unsubscribe();
144+
}
145+
if (instance) {
146+
instance.style.display = 'none';
147+
sub = combineLatest(dependants)
148+
.pipe(
149+
map(values => values.some(item => item === true)),
150+
skipWhile(v => v),
151+
take(1)
152+
)
153+
.subscribe(() => {
154+
instance.style.display = rest?.style?.display || '';
155+
});
156+
}
157+
}
158+
: undefined;
159+
return React.cloneElement(child, {
160+
key,
161+
onInit: () => {
162+
if (typeof child.props.onInit === 'function') {
163+
child.props.onInit();
164+
}
165+
init$?.next(false);
166+
},
167+
children: (
168+
<div {...rest} ref={hideRef}>
169+
{child.props.children}
170+
</div>
171+
),
172+
});
173+
}
174+
return React.cloneElement(child, {
175+
key,
176+
});
177+
}
178+
return child;
179+
})}
180+
</>
181+
);
182+
};

src/useAsyncBase.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
22
import { Observable } from 'rxjs';
3-
import { useSetPending } from './pending';
3+
import { usePendingInstance } from './pending';
44
import { AsyncBase } from './types';
55
import { Monitor } from './utils';
66

@@ -11,17 +11,19 @@ export function useAsyncBase<S extends AsyncBase<unknown, unknown>>(
1111
const monitor = useMemo(() => new Monitor(), []);
1212
const [output, setOutput] = useState<S>(initialValue);
1313
const outputRef = useRef<S>(output);
14-
const setPending = useSetPending();
14+
const pendingInstance = usePendingInstance();
1515
outputRef.current = output;
1616
useEffect(() => {
1717
const sub = result$.subscribe(item => {
1818
if (item.pending) {
19-
setPending(true);
19+
if (pendingInstance.state.value !== 'init') {
20+
pendingInstance.state.next('pending');
21+
}
2022
if (monitor.usingPending) {
2123
setOutput(item);
2224
}
2325
} else {
24-
setPending(false);
26+
pendingInstance.state.next('ready');
2527
const old = outputRef.current;
2628
if (
2729
old.result !== item.result ||
@@ -33,7 +35,7 @@ export function useAsyncBase<S extends AsyncBase<unknown, unknown>>(
3335
}
3436
});
3537
return () => sub.unsubscribe();
36-
}, [result$, monitor, outputRef, setPending]);
38+
}, [result$, monitor, outputRef, pendingInstance]);
3739
const final = useMemo(
3840
() => monitor.wrap(output),
3941
// eslint-disable-next-line react-hooks/exhaustive-deps

stories/Pending.stories.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React, { useState } from 'react';
2+
import { Meta, Story } from '@storybook/react';
3+
import { useAsync } from '../src';
4+
import { PendingBoundary, PendingSequence } from '../src/pending';
5+
6+
function asyncQuery(text: string, timeout: number): Promise<string> {
7+
return new Promise(resolve =>
8+
setTimeout(resolve, timeout, `Response for: ${text}`)
9+
);
10+
}
11+
12+
function Foobar({ input, timeout }: any) {
13+
const { result, refresh } = useAsync(() => {
14+
return asyncQuery('Test' + input, timeout);
15+
}, [timeout]);
16+
return <div onClick={refresh}>Result: {result}</div>;
17+
}
18+
19+
function AsyncComponent({ input }: any) {
20+
const [result, setState] = useState(0);
21+
const refresh = () => setState(c => c + 1);
22+
return (
23+
<>
24+
<p onClick={refresh}>Render count: {result}</p>
25+
<PendingSequence>
26+
<PendingBoundary>
27+
<p>
28+
With pending sequence all the async elements will appear in a strict
29+
sequence. Even if some elements completes more quickly then the once
30+
above them
31+
</p>
32+
</PendingBoundary>
33+
<PendingBoundary>
34+
<Foobar input="text" timeout={1000} />
35+
</PendingBoundary>
36+
<PendingBoundary>
37+
<Foobar input="text2" timeout={100} />
38+
</PendingBoundary>
39+
<PendingBoundary>
40+
<Foobar input="text3" timeout={3000} />
41+
</PendingBoundary>
42+
</PendingSequence>
43+
</>
44+
);
45+
}
46+
47+
const meta: Meta = {
48+
title: 'Pending',
49+
component: AsyncComponent,
50+
argTypes: {
51+
input: {
52+
control: {
53+
type: 'text',
54+
},
55+
},
56+
children: {
57+
control: {
58+
type: 'text',
59+
},
60+
},
61+
},
62+
parameters: {
63+
controls: { expanded: true },
64+
},
65+
};
66+
67+
export default meta;
68+
69+
const Template: Story<{}> = args => <AsyncComponent input="" {...args} />;
70+
71+
// By passing using the Args format for exported stories, you can control the props for a component for reuse in a test
72+
// https://storybook.js.org/docs/react/workflows/unit-testing
73+
export const Default = Template.bind({});
74+
75+
Default.args = {};

0 commit comments

Comments
 (0)