diff --git a/packages/ui-next-plugin-sample/ui/before.tsx b/packages/ui-next-plugin-sample/ui/before.tsx index 08c87007a..a60ec9ea7 100644 --- a/packages/ui-next-plugin-sample/ui/before.tsx +++ b/packages/ui-next-plugin-sample/ui/before.tsx @@ -1,4 +1,5 @@ export default function BeforeComponent() { console.log('before app'); + // throw new Error('test error boundary in before interceptor'); return
before app via @hydrooj/ui-next-plugin-sample
; } diff --git a/packages/ui-next/src/registry/error-boundary.tsx b/packages/ui-next/src/registry/error-boundary.tsx new file mode 100644 index 000000000..2ab8f9e28 --- /dev/null +++ b/packages/ui-next/src/registry/error-boundary.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +interface SlotErrorBoundaryProps { + slotName: string; + label?: string; + children: React.ReactNode; +} + +interface SlotErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class SlotErrorBoundary extends React.Component { + state: SlotErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): SlotErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + const tag = this.props.label + ? `[Hydro] SlotErrorBoundary(${this.props.slotName}/${this.props.label})` + : `[Hydro] SlotErrorBoundary(${this.props.slotName})`; + console.error(tag, error, info.componentStack); + } + + render(): React.ReactNode { + if (!this.state.hasError) return this.props.children; + + const { slotName, label } = this.props; + return ( +
+ Slot Error in {slotName} + {label && <> / {label}} +
+          {this.state.error?.message}
+        
+
+ ); + } +} diff --git a/packages/ui-next/src/registry/index.ts b/packages/ui-next/src/registry/index.ts index e7455917d..51d3076a7 100644 --- a/packages/ui-next/src/registry/index.ts +++ b/packages/ui-next/src/registry/index.ts @@ -1,3 +1,4 @@ +export { SlotErrorBoundary } from './error-boundary'; export { after, before, intercept, patch, replace, wrap } from './interceptors'; export { installPlugin } from './plugin'; export type { PluginAPI, PluginDefinition } from './plugin'; diff --git a/packages/ui-next/src/registry/interceptors.tsx b/packages/ui-next/src/registry/interceptors.tsx index 79e61934e..a14b6aa44 100644 --- a/packages/ui-next/src/registry/interceptors.tsx +++ b/packages/ui-next/src/registry/interceptors.tsx @@ -1,3 +1,4 @@ +import { SlotErrorBoundary } from './error-boundary'; import { store } from './store'; import type { Interceptor, InterceptorOptions, SlotName } from './types'; @@ -15,11 +16,14 @@ export function before

= Record>( Comp: React.FC

, opts?: InterceptorOptions, ): () => void { + const label = `before:${Comp.displayName || Comp.name || '?'}`; return intercept

( name, (props, next) => ( <> - + + + {next()} ), @@ -33,12 +37,15 @@ export function after

= Record>( Comp: React.FC

, opts?: InterceptorOptions, ): () => void { + const label = `after:${Comp.displayName || Comp.name || '?'}`; return intercept

( name, (props, next) => ( <> {next()} - + + + ), { priority: 100, ...opts }, @@ -64,9 +71,14 @@ export function replace

= Record>( Replacement: React.FC

, opts?: { id?: string, priority?: number }, ): () => void { + const label = `replace:${Replacement.displayName || Replacement.name || '?'}`; return intercept

( name, - (props, _next) => , + (props, _next) => ( + + + + ), { priority: 0, ...opts }, ); } @@ -77,9 +89,14 @@ export function wrap

= Record>( Wrapper: React.FC<{ children: React.ReactNode } & P>, opts?: InterceptorOptions, ): () => void { + const label = `wrap:${Wrapper.displayName || Wrapper.name || '?'}`; return intercept

( name, - (props, next) => {next()}, + (props, next) => ( + + {next()} + + ), { priority: -10, ...opts }, ); } diff --git a/packages/ui-next/src/registry/slot.tsx b/packages/ui-next/src/registry/slot.tsx index 2c017868c..20cc6dbbb 100644 --- a/packages/ui-next/src/registry/slot.tsx +++ b/packages/ui-next/src/registry/slot.tsx @@ -1,21 +1,30 @@ import { useMemo, useSyncExternalStore } from 'react'; +import { SlotErrorBoundary } from './error-boundary'; import { store } from './store'; import type { InterceptorEntry, SlotName } from './types'; function buildChain

>( interceptors: InterceptorEntry

[], DefaultComp: React.FC

, + slotName: SlotName, ): (props: P) => React.ReactNode { - let pipeline: (props: P) => React.ReactNode = (props) => ; + let pipeline: (props: P) => React.ReactNode = (props) => ( + + + + ); for (let i = interceptors.length - 1; i >= 0; i--) { - const { interceptor } = interceptors[i]; + const { interceptor, id } = interceptors[i]; const downstream = pipeline; - pipeline = (props: P) => - interceptor(props, (overrideProps) => - downstream(overrideProps ? { ...props, ...overrideProps } : props), - ); + pipeline = (props: P) => ( + + {interceptor(props, (overrideProps) => + downstream(overrideProps ? { ...props, ...overrideProps } : props), + )} + + ); } return pipeline; @@ -34,9 +43,13 @@ export function defineSlot

>( const version = useSyncExternalStore(subscribeSlot, getSnapshot); // eslint-disable-next-line react-hooks/exhaustive-deps - const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!), [version]); + const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!, name), [version]); - return <>{chain(props)}; + return ( + + {chain(props)} + + ); }; SlotComponent.displayName = `Slot(${name})`;