mirror of
https://github.com/hanzoai/cd-ui.git
synced 2026-10-30 20:42:45 +00:00
* chore: fix Rules-of-React violations for React Compiler readiness Add eslint-plugin-react-hooks v7 (recommended-latest) and fix all error-level Rules-of-React findings so the React Compiler can optimize the component library. The healthcheck reports 100% of components compilable; these fixes clear the 34 ESLint errors across the src and v2 component trees: - refs: stop accessing ref.current during render (incl. the react-form compat shim, whose public ref-forwarding API is preserved) - set-state-in-effect: convert to derived state or restructure effects - immutability: clone-before-mutate; stop mutating props/state - rules-of-hooks: hoist conditional hook calls to top level All 47 unit tests pass; behavior is preserved. Signed-off-by: Jonathan Winters <[email protected]> * chore: resolve react-hooks deps from public npm registry The eslint-plugin-react-hooks@7 dependency subtree was locked to an internal mirror, which the public CI runner can't reach (ETIMEDOUT). Rewrite the 21 affected resolved URLs to registry.yarnpkg.com. Signed-off-by: Jonathan Winters <[email protected]> * useKeybinding -> registerKeybinding Signed-off-by: Jonathan Winters <[email protected]> * chore: fix Rules-of-React refs violations from registerKeybinding rename The useKeybinding->registerKeybinding rename turned hook calls into plain function calls in the render body; since registerKeybinding takes ref targets, the new react-hooks/refs lint flagged them. Move the calls into useEffect (registration is a side effect) in sliding-panel and the v2 autocomplete. In the v1 autocomplete, the flagged value is a forwarded callback ref, not a render-time read, so suppress that one with a comment. Also dedupe pnpm-lock.yaml. Signed-off-by: Jonathan Winters <[email protected]> --------- Signed-off-by: Jonathan Winters <[email protected]>
197 lines
6.8 KiB
TypeScript
197 lines
6.8 KiB
TypeScript
import * as React from 'react';
|
|
import {fromEvent, Observable, Observer, Subscription} from 'rxjs';
|
|
import {bufferTime, debounceTime, delay, filter, map, mergeMap, repeat, retryWhen, scan, timeout} from 'rxjs/operators';
|
|
|
|
enum ReadyState {
|
|
CONNECTING = 0,
|
|
OPEN = 1,
|
|
CLOSED = 2,
|
|
DONE = 4,
|
|
}
|
|
|
|
export interface ListState<T> {
|
|
loading: boolean;
|
|
items: T[];
|
|
}
|
|
|
|
export const useLoading = (list: any[], minLength?: number) => {
|
|
const [loading, setLoading] = React.useState(true);
|
|
// Once enough items have arrived we are no longer loading. This is sticky:
|
|
// loading only ever transitions from true -> false, mirroring the original
|
|
// effect which never set loading back to true. Adjusting state during render
|
|
// (rather than in an effect) avoids the cascading-render that setState-in-effect
|
|
// would cause.
|
|
if (loading && list && list.length > (minLength || 0)) {
|
|
setLoading(false);
|
|
}
|
|
return loading;
|
|
};
|
|
|
|
function fromEventSource(url: string): Observable<string> {
|
|
return Observable.create((observer: Observer<any>) => {
|
|
let eventSource = new EventSource(url);
|
|
eventSource.onmessage = (msg) => observer.next(msg.data);
|
|
eventSource.onerror = (e) => () => {
|
|
observer.error(e);
|
|
};
|
|
|
|
const interval = setInterval(() => {
|
|
if (eventSource && eventSource.readyState === ReadyState.CLOSED) {
|
|
observer.error('connection got closed unexpectedly');
|
|
}
|
|
}, 500);
|
|
return () => {
|
|
clearInterval(interval);
|
|
eventSource.close();
|
|
eventSource = null;
|
|
};
|
|
});
|
|
}
|
|
|
|
const BUFFER_TIME = 500;
|
|
|
|
export function handlePageVisibility<T>(src: () => Observable<T>): Observable<T> {
|
|
return new Observable<T>((observer: Observer<T>) => {
|
|
let subscription: Subscription;
|
|
const ensureUnsubscribed = () => {
|
|
if (subscription) {
|
|
subscription.unsubscribe();
|
|
subscription = null;
|
|
}
|
|
};
|
|
const start = () => {
|
|
ensureUnsubscribed();
|
|
subscription = src().subscribe(
|
|
(item: T) => observer.next(item),
|
|
(err) => observer.error(err),
|
|
() => observer.complete(),
|
|
);
|
|
};
|
|
|
|
if (!document.hidden) {
|
|
start();
|
|
}
|
|
|
|
const visibilityChangeSubscription = fromEvent(document, 'visibilitychange')
|
|
.pipe(debounceTime(500))
|
|
.subscribe(() => {
|
|
if (document.hidden && subscription) {
|
|
ensureUnsubscribed();
|
|
} else if (!document.hidden && !subscription) {
|
|
start();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
visibilityChangeSubscription.unsubscribe();
|
|
ensureUnsubscribed();
|
|
};
|
|
});
|
|
}
|
|
|
|
interface WatchEvent {
|
|
type?: string;
|
|
}
|
|
|
|
// NOTE: findItem and getItem must be React.useCallback functions
|
|
export function useWatchList<T, E extends WatchEvent>(url: string, findItem: (item: T, change: E) => boolean, getItem: (change: E) => T, init?: T[]): T[] {
|
|
const [items, setItems] = React.useState(init as T[]);
|
|
|
|
// Reset items to `init` whenever the watch inputs change, before the new
|
|
// subscription starts delivering data. The original code did this with a
|
|
// synchronous setItems(init) at the top of the effect; storing the previous
|
|
// inputs in state and resetting during render (React's "adjust state on a
|
|
// changed key" pattern) preserves that reset behavior while avoiding a
|
|
// cascading render from setState-in-effect. The async setItems([...l]) from
|
|
// the subscription remains in its callback below.
|
|
const [prevKey, setPrevKey] = React.useState<{init?: T[]; url: string; findItem: typeof findItem; getItem: typeof getItem}>({init, url, findItem, getItem});
|
|
if (prevKey.init !== init || prevKey.url !== url || prevKey.findItem !== findItem || prevKey.getItem !== getItem) {
|
|
setPrevKey({init, url, findItem, getItem});
|
|
setItems(init);
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
const stream = fromEventSource(url).pipe(map((res) => JSON.parse(res).result as E));
|
|
let watch = stream.pipe(
|
|
repeat(),
|
|
retryWhen((errors) => errors.pipe(delay(500))),
|
|
scan((aItems, change) => {
|
|
const index = aItems.findIndex((i) => findItem(i, change));
|
|
switch (change.type) {
|
|
case 'Deleted':
|
|
if (index > -1) {
|
|
aItems.splice(index, 1);
|
|
}
|
|
break;
|
|
case 'Updated':
|
|
if (index > -1) {
|
|
const updated = {...aItems[index], ...getItem(change)};
|
|
aItems[index] = updated as T;
|
|
}
|
|
break;
|
|
default:
|
|
if (index > -1) {
|
|
aItems[index] = getItem(change) as T;
|
|
} else {
|
|
aItems.unshift(getItem(change) as T);
|
|
}
|
|
break;
|
|
}
|
|
return aItems;
|
|
}, init || []),
|
|
bufferTime(BUFFER_TIME),
|
|
mergeMap((l) => l),
|
|
);
|
|
|
|
const sub = handlePageVisibility(() => watch).subscribe((l) => {
|
|
setItems([...l]);
|
|
});
|
|
|
|
return () => {
|
|
sub.unsubscribe();
|
|
watch = null;
|
|
};
|
|
}, [init, url, findItem, getItem]);
|
|
return items;
|
|
}
|
|
|
|
export function useWatch<T>(url: string, subscribe: boolean, isEqual: (a: T, b: T) => boolean, timeoutAfter?: number) {
|
|
const [item, setItem] = React.useState({} as T);
|
|
React.useEffect(() => {
|
|
if (!subscribe) {
|
|
return;
|
|
}
|
|
const stream = fromEventSource(url).pipe(map((res) => JSON.parse(res).result as T));
|
|
const watch = stream.pipe(
|
|
repeat(),
|
|
retryWhen((errors) => errors.pipe(delay(500))),
|
|
scan(
|
|
(acc, update) => {
|
|
return {data: update, updated: !isEqual(update, acc.data)};
|
|
},
|
|
{data: {} as T, updated: true},
|
|
),
|
|
filter((i) => i.updated),
|
|
map((i) => i.data),
|
|
);
|
|
|
|
let liveStream = handlePageVisibility(() =>
|
|
watch.pipe(
|
|
bufferTime(BUFFER_TIME),
|
|
mergeMap((r) => r),
|
|
),
|
|
);
|
|
|
|
if (timeoutAfter > 0) {
|
|
liveStream = liveStream.pipe(timeout(timeoutAfter));
|
|
}
|
|
|
|
const sub = liveStream.subscribe((i) => setItem(i));
|
|
return () => {
|
|
liveStream = null;
|
|
sub.unsubscribe();
|
|
};
|
|
}, [url, subscribe, timeoutAfter, isEqual]);
|
|
return item;
|
|
}
|