Skip to content

Commit 50ed8a6

Browse files
committed
ui-next: error boundary
1 parent 8232b89 commit 50ed8a6

5 files changed

Lines changed: 97 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export default function BeforeComponent() {
22
console.log('before app');
3+
// throw new Error('test error boundary in before interceptor');
34
return <div>before app via @hydrooj/ui-next-plugin-sample</div>;
45
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
3+
interface SlotErrorBoundaryProps {
4+
slotName: string;
5+
label?: string;
6+
children: React.ReactNode;
7+
}
8+
9+
interface SlotErrorBoundaryState {
10+
hasError: boolean;
11+
error: Error | null;
12+
}
13+
14+
export class SlotErrorBoundary extends React.Component<SlotErrorBoundaryProps, SlotErrorBoundaryState> {
15+
state: SlotErrorBoundaryState = { hasError: false, error: null };
16+
17+
static getDerivedStateFromError(error: Error): SlotErrorBoundaryState {
18+
return { hasError: true, error };
19+
}
20+
21+
componentDidCatch(error: Error, info: React.ErrorInfo): void {
22+
const tag = this.props.label
23+
? `[Hydro] SlotErrorBoundary(${this.props.slotName}/${this.props.label})`
24+
: `[Hydro] SlotErrorBoundary(${this.props.slotName})`;
25+
console.error(tag, error, info.componentStack);
26+
}
27+
28+
render(): React.ReactNode {
29+
if (!this.state.hasError) return this.props.children;
30+
31+
const { slotName, label } = this.props;
32+
return (
33+
<div
34+
style={{
35+
padding: '8px 12px',
36+
margin: '4px 0',
37+
border: '1px solid #e74c3c',
38+
borderRadius: '4px',
39+
backgroundColor: '#fdf0ed',
40+
color: '#c0392b',
41+
fontSize: '13px',
42+
fontFamily: 'monospace',
43+
}}
44+
>
45+
<strong>Slot Error</strong> in <code>{slotName}</code>
46+
{label && <> / <code>{label}</code></>}
47+
<pre style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
48+
{this.state.error?.message}
49+
</pre>
50+
</div>
51+
);
52+
}
53+
}

packages/ui-next/src/registry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { SlotErrorBoundary } from './error-boundary';
12
export { after, before, intercept, patch, replace, wrap } from './interceptors';
23
export { installPlugin } from './plugin';
34
export type { PluginAPI, PluginDefinition } from './plugin';

packages/ui-next/src/registry/interceptors.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SlotErrorBoundary } from './error-boundary';
12
import { store } from './store';
23
import type { Interceptor, InterceptorOptions, SlotName } from './types';
34

@@ -15,11 +16,14 @@ export function before<P extends Record<string, any> = Record<string, any>>(
1516
Comp: React.FC<P>,
1617
opts?: InterceptorOptions,
1718
): () => void {
19+
const label = `before:${Comp.displayName || Comp.name || '?'}`;
1820
return intercept<P>(
1921
name,
2022
(props, next) => (
2123
<>
22-
<Comp {...props} />
24+
<SlotErrorBoundary slotName={name} label={label}>
25+
<Comp {...props} />
26+
</SlotErrorBoundary>
2327
{next()}
2428
</>
2529
),
@@ -33,12 +37,15 @@ export function after<P extends Record<string, any> = Record<string, any>>(
3337
Comp: React.FC<P>,
3438
opts?: InterceptorOptions,
3539
): () => void {
40+
const label = `after:${Comp.displayName || Comp.name || '?'}`;
3641
return intercept<P>(
3742
name,
3843
(props, next) => (
3944
<>
4045
{next()}
41-
<Comp {...props} />
46+
<SlotErrorBoundary slotName={name} label={label}>
47+
<Comp {...props} />
48+
</SlotErrorBoundary>
4249
</>
4350
),
4451
{ priority: 100, ...opts },
@@ -64,9 +71,14 @@ export function replace<P extends Record<string, any> = Record<string, any>>(
6471
Replacement: React.FC<P>,
6572
opts?: { id?: string, priority?: number },
6673
): () => void {
74+
const label = `replace:${Replacement.displayName || Replacement.name || '?'}`;
6775
return intercept<P>(
6876
name,
69-
(props, _next) => <Replacement {...props} />,
77+
(props, _next) => (
78+
<SlotErrorBoundary slotName={name} label={label}>
79+
<Replacement {...props} />
80+
</SlotErrorBoundary>
81+
),
7082
{ priority: 0, ...opts },
7183
);
7284
}
@@ -77,9 +89,14 @@ export function wrap<P extends Record<string, any> = Record<string, any>>(
7789
Wrapper: React.FC<{ children: React.ReactNode } & P>,
7890
opts?: InterceptorOptions,
7991
): () => void {
92+
const label = `wrap:${Wrapper.displayName || Wrapper.name || '?'}`;
8093
return intercept<P>(
8194
name,
82-
(props, next) => <Wrapper {...props}>{next()}</Wrapper>,
95+
(props, next) => (
96+
<SlotErrorBoundary slotName={name} label={label}>
97+
<Wrapper {...props}>{next()}</Wrapper>
98+
</SlotErrorBoundary>
99+
),
83100
{ priority: -10, ...opts },
84101
);
85102
}

packages/ui-next/src/registry/slot.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
import { useMemo, useSyncExternalStore } from 'react';
2+
import { SlotErrorBoundary } from './error-boundary';
23
import { store } from './store';
34
import type { InterceptorEntry, SlotName } from './types';
45

56
function buildChain<P extends Record<string, any>>(
67
interceptors: InterceptorEntry<P>[],
78
DefaultComp: React.FC<P>,
9+
slotName: SlotName,
810
): (props: P) => React.ReactNode {
9-
let pipeline: (props: P) => React.ReactNode = (props) => <DefaultComp {...props} />;
11+
let pipeline: (props: P) => React.ReactNode = (props) => (
12+
<SlotErrorBoundary slotName={slotName} label="default">
13+
<DefaultComp {...props} />
14+
</SlotErrorBoundary>
15+
);
1016

1117
for (let i = interceptors.length - 1; i >= 0; i--) {
12-
const { interceptor } = interceptors[i];
18+
const { interceptor, id } = interceptors[i];
1319
const downstream = pipeline;
1420

15-
pipeline = (props: P) =>
16-
interceptor(props, (overrideProps) =>
17-
downstream(overrideProps ? { ...props, ...overrideProps } : props),
18-
);
21+
pipeline = (props: P) => (
22+
<SlotErrorBoundary slotName={slotName} label={`interceptor:${id}`}>
23+
{interceptor(props, (overrideProps) =>
24+
downstream(overrideProps ? { ...props, ...overrideProps } : props),
25+
)}
26+
</SlotErrorBoundary>
27+
);
1928
}
2029

2130
return pipeline;
@@ -34,9 +43,13 @@ export function defineSlot<P extends Record<string, any>>(
3443
const version = useSyncExternalStore(subscribeSlot, getSnapshot);
3544

3645
// eslint-disable-next-line react-hooks/exhaustive-deps
37-
const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!), [version]);
46+
const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!, name), [version]);
3847

39-
return <>{chain(props)}</>;
48+
return (
49+
<SlotErrorBoundary slotName={name} label="slot">
50+
{chain(props)}
51+
</SlotErrorBoundary>
52+
);
4053
};
4154

4255
SlotComponent.displayName = `Slot(${name})`;

0 commit comments

Comments
 (0)