diff --git a/.changeset/pretty-pumpkins-jam.md b/.changeset/pretty-pumpkins-jam.md new file mode 100644 index 000000000..e0f5e1b10 --- /dev/null +++ b/.changeset/pretty-pumpkins-jam.md @@ -0,0 +1,5 @@ +--- +"@suspensive/react": minor +--- + +feat(react): add experimental wrap to remove unnecessary hocs' implementation diff --git a/packages/react/src/AsyncBoundary.tsx b/packages/react/src/AsyncBoundary.tsx index 55a69c66b..8db1b5444 100644 --- a/packages/react/src/AsyncBoundary.tsx +++ b/packages/react/src/AsyncBoundary.tsx @@ -4,6 +4,7 @@ import { ErrorBoundary } from './ErrorBoundary' import type { ErrorBoundaryProps } from './ErrorBoundary' import { Suspense } from './Suspense' import type { PropsWithoutChildren } from './types' +import { wrap } from './wrap' export type AsyncBoundaryProps = Omit & Omit & { @@ -46,36 +47,10 @@ export const AsyncBoundary = BaseAsyncBoundary as typeof BaseAsyncBoundary & { AsyncBoundary.CSROnly = CSROnlyAsyncBoundary export const withAsyncBoundary = = Record>( - Component: ComponentType, + component: ComponentType, asyncBoundaryProps: PropsWithoutChildren -) => { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withAsyncBoundary(${name})` - } - - return Wrapped -} +) => wrap.AsyncBoundary(asyncBoundaryProps).on(component) withAsyncBoundary.CSROnly = = Record>( - Component: ComponentType, + component: ComponentType, asyncBoundaryProps: PropsWithoutChildren -) => { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withAsyncBoundary.CSROnly(${name})` - } - - return Wrapped -} +) => wrap.AsyncBoundary.CSROnly(asyncBoundaryProps).on(component) diff --git a/packages/react/src/Delay.tsx b/packages/react/src/Delay.tsx index 8db099133..7521dbe26 100644 --- a/packages/react/src/Delay.tsx +++ b/packages/react/src/Delay.tsx @@ -2,6 +2,7 @@ import type { ComponentProps, ComponentType, PropsWithChildren } from 'react' import { createContext, useContext, useState } from 'react' import { useTimeout } from './hooks' import type { PropsWithoutChildren } from './types' +import { wrap } from './wrap' export type DelayProps = PropsWithChildren<{ ms?: number @@ -14,23 +15,13 @@ export const Delay = ({ ms, children }: DelayProps) => { useTimeout(() => setIsDelayed(true), delayMs) return <>{isDelayed ? children : null} } +if (process.env.NODE_ENV !== 'production') { + Delay.displayName = 'Delay' +} export const DelayContext = createContext>({ ms: 0 }) export const withDelay = = Record>( - Component: ComponentType, - delayProps?: PropsWithoutChildren -) => { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withDelay(${name})` - } - - return Wrapped -} + component: ComponentType, + delayProps: PropsWithoutChildren = {} +) => wrap.Delay(delayProps).on(component) diff --git a/packages/react/src/ErrorBoundary.tsx b/packages/react/src/ErrorBoundary.tsx index f3dd0932e..e157c96a9 100644 --- a/packages/react/src/ErrorBoundary.tsx +++ b/packages/react/src/ErrorBoundary.tsx @@ -13,6 +13,7 @@ import { import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup' import type { PropsWithoutChildren } from './types' import { assert, hasResetKeysChanged } from './utils' +import { wrap } from './wrap' export type ErrorBoundaryFallbackProps = { /** @@ -125,22 +126,9 @@ if (process.env.NODE_ENV !== 'production') { } export const withErrorBoundary = = Record>( - Component: ComponentType, + component: ComponentType, errorBoundaryProps: PropsWithoutChildren -) => { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withErrorBoundary(${name})` - } - - return Wrapped -} +) => wrap.ErrorBoundary(errorBoundaryProps).on(component) const ErrorBoundaryContext = createContext<({ reset: () => void } & ErrorBoundaryState) | null>(null) diff --git a/packages/react/src/ErrorBoundaryGroup.tsx b/packages/react/src/ErrorBoundaryGroup.tsx index 1d766513f..6151375c4 100644 --- a/packages/react/src/ErrorBoundaryGroup.tsx +++ b/packages/react/src/ErrorBoundaryGroup.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useEffect, useMemo } from 'react' import { useIsChanged, useKey } from './hooks' import type { PropsWithoutChildren } from './types' import { assert } from './utils' +import { wrap } from './wrap' export const ErrorBoundaryGroupContext = createContext<{ reset: () => void; resetKey: number } | undefined>(undefined) if (process.env.NODE_ENV !== 'production') { @@ -36,6 +37,9 @@ export const ErrorBoundaryGroup = ({ blockOutside = false, children }: ErrorBoun return {children} } +if (process.env.NODE_ENV !== 'production') { + ErrorBoundaryGroup.displayName = 'ErrorBoundaryGroup' +} const ErrorBoundaryGroupReset = ({ trigger: Trigger, @@ -67,19 +71,6 @@ export const useErrorBoundaryGroup = () => { } export const withErrorBoundaryGroup = = Record>( - Component: ComponentType, - errorBoundaryGroupProps?: PropsWithoutChildren -) => { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withErrorBoundaryGroup(${name})` - } - - return Wrapped -} + component: ComponentType, + errorBoundaryGroupProps: PropsWithoutChildren = {} +) => wrap.ErrorBoundaryGroup(errorBoundaryGroupProps).on(component) diff --git a/packages/react/src/Suspense.tsx b/packages/react/src/Suspense.tsx index 6f5d2b307..735479f7c 100644 --- a/packages/react/src/Suspense.tsx +++ b/packages/react/src/Suspense.tsx @@ -2,6 +2,7 @@ import type { ComponentProps, ComponentType, ReactNode, SuspenseProps as ReactSu import { Suspense as ReactSuspense, createContext, useContext } from 'react' import { useIsClient } from './hooks' import type { PropsWithoutChildren } from './types' +import { wrap } from './wrap' export type SuspenseProps = ReactSuspenseProps @@ -43,37 +44,11 @@ export const Suspense = DefaultSuspense as typeof DefaultSuspense & { } Suspense.CSROnly = CSROnlySuspense -export function withSuspense = Record>( - Component: ComponentType, - suspenseProps?: PropsWithoutChildren -) { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withSuspense(${name})` - } - - return Wrapped -} - -withSuspense.CSROnly = function withSuspenseCSROnly< - TProps extends ComponentProps = Record, ->(Component: ComponentType, suspenseProps?: PropsWithoutChildren) { - const Wrapped = (props: TProps) => ( - - - - ) - - if (process.env.NODE_ENV !== 'production') { - const name = Component.displayName || Component.name || 'Component' - Wrapped.displayName = `withSuspense.CSROnly(${name})` - } - - return Wrapped -} +export const withSuspense = = Record>( + component: ComponentType, + suspenseProps: PropsWithoutChildren = {} +) => wrap.Suspense(suspenseProps).on(component) +withSuspense.CSROnly = = Record>( + component: ComponentType, + suspenseProps: PropsWithoutChildren = {} +) => wrap.Suspense.CSROnly(suspenseProps).on(component) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index dfe975c9f..1d1a67ae2 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,6 +5,8 @@ export { ErrorBoundaryGroup, withErrorBoundaryGroup, useErrorBoundaryGroup } fro export { AsyncBoundary, withAsyncBoundary } from './AsyncBoundary' export { Delay, withDelay } from './Delay' +export { wrap } from './wrap' + export type { SuspenseProps } from './Suspense' export type { ErrorBoundaryProps, ErrorBoundaryFallbackProps } from './ErrorBoundary' export type { ErrorBoundaryGroupProps } from './ErrorBoundaryGroup' diff --git a/packages/react/src/wrap.ts b/packages/react/src/wrap.ts new file mode 100644 index 000000000..cb05c433c --- /dev/null +++ b/packages/react/src/wrap.ts @@ -0,0 +1,110 @@ +import { createElement } from 'react' +import type { ComponentProps, ComponentType } from 'react' +import type { PropsWithoutChildren } from './types' +import { AsyncBoundary, Delay, ErrorBoundary, ErrorBoundaryGroup, Suspense } from '.' + +type WrapperItem< + TWrapperComponent extends + | typeof Suspense + | typeof Suspense.CSROnly + | typeof ErrorBoundary + | typeof ErrorBoundaryGroup + | typeof AsyncBoundary + | typeof AsyncBoundary.CSROnly + | typeof Delay, +> = [TWrapperComponent, PropsWithoutChildren>] + +type Wrapper = + | WrapperItem + | WrapperItem + | WrapperItem + | WrapperItem + | WrapperItem + | WrapperItem + | WrapperItem + +class WrapWithoutCSROnly { + constructor(private wrappers: Wrapper[]) {} + Suspense = (props: PropsWithoutChildren> = {}) => { + this.wrappers.unshift([Suspense, props]) + return this + } + ErrorBoundary = (props: PropsWithoutChildren>) => { + this.wrappers.unshift([ErrorBoundary, props]) + return this + } + ErrorBoundaryGroup = (props: PropsWithoutChildren> = {}) => { + this.wrappers.unshift([ErrorBoundaryGroup, props]) + return this + } + AsyncBoundary = (props: PropsWithoutChildren>) => { + this.wrappers.unshift([AsyncBoundary, props]) + return this + } + Delay = (props: PropsWithoutChildren> = {}) => { + this.wrappers.unshift([Delay, props]) + return this + } + + on = >(component: ComponentType) => { + const wrappedComponent = (props: TProps) => + this.wrappers.reduce( + (acc, [wrapperComponent, wrapperProps]) => createElement(wrapperComponent as any, wrapperProps as any, acc), + createElement(component, props) + ) + + if (process.env.NODE_ENV !== 'production') { + wrappedComponent.displayName = this.wrappers.reduce( + (acc, [wrapperComponent]) => `with${wrapperComponent.displayName}(${acc})`, + component.displayName || component.name || 'Component' + ) + } + + return wrappedComponent + } +} + +type Wrap = WrapWithoutCSROnly & { + Suspense: WrapWithoutCSROnly['Suspense'] & { + CSROnly: (props?: PropsWithoutChildren>) => Wrap + } + AsyncBoundary: WrapWithoutCSROnly['AsyncBoundary'] & { + CSROnly: (props: PropsWithoutChildren>) => Wrap + } +} + +const createWrap = () => { + const wrappers: Wrapper[] = [] + const builder = new WrapWithoutCSROnly(wrappers) as Wrap + builder.Suspense.CSROnly = (props: PropsWithoutChildren> = {}) => { + wrappers.unshift([Suspense.CSROnly, props]) + return builder + } + builder.AsyncBoundary.CSROnly = (props: PropsWithoutChildren>) => { + wrappers.unshift([AsyncBoundary.CSROnly, props]) + return builder + } + return builder +} + +const wrapSuspense = (...[props = {}]: Parameters) => createWrap().Suspense(props) +wrapSuspense.CSROnly = (...[props = {}]: Parameters) => + createWrap().Suspense.CSROnly(props) +const wrapErrorBoundary = (...[props]: Parameters) => createWrap().ErrorBoundary(props) +const wrapErrorBoundaryGroup = (...[props = {}]: Parameters) => + createWrap().ErrorBoundaryGroup(props) +const wrapAsyncBoundary = (...[props]: Parameters) => createWrap().AsyncBoundary(props) +wrapAsyncBoundary.CSROnly = (...[props]: Parameters) => + createWrap().AsyncBoundary.CSROnly(props) +const wrapDelay = (...[props = {}]: Parameters) => createWrap().Delay(props) + +/** + * @experimental This is experimental feature. + */ +export const wrap = { + Suspense: wrapSuspense, + ErrorBoundary: wrapErrorBoundary, + ErrorBoundaryGroup: wrapErrorBoundaryGroup, + AsyncBoundary: wrapAsyncBoundary, + Delay: wrapDelay, +} diff --git a/websites/visualization/src/app/react/experimental/wrap/after/page.tsx b/websites/visualization/src/app/react/experimental/wrap/after/page.tsx new file mode 100644 index 000000000..c45a44f9c --- /dev/null +++ b/websites/visualization/src/app/react/experimental/wrap/after/page.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useErrorBoundary, wrap } from '@suspensive/react' + +const logError = (error: Error) => console.error(error) + +const Page = wrap + .ErrorBoundaryGroup({ blockOutside: false }) + .ErrorBoundary({ fallback: (props) =>
{props.error.message}
, onError: logError }) + .Suspense.CSROnly({ fallback: 'loading...' }) + .on(({ text }: { text: string }) => { + const errorBoundary = useErrorBoundary() + + return ( +
+ + {text} +
+ ) + }) + +export default Page diff --git a/websites/visualization/src/app/react/experimental/wrap/before/page.tsx b/websites/visualization/src/app/react/experimental/wrap/before/page.tsx new file mode 100644 index 000000000..f0790dfab --- /dev/null +++ b/websites/visualization/src/app/react/experimental/wrap/before/page.tsx @@ -0,0 +1,28 @@ +'use client' +import { useErrorBoundary, withErrorBoundary, withErrorBoundaryGroup, withSuspense } from '@suspensive/react' +import { UseSuspenseQuery } from '~/components' +import { api } from '~/utils' + +const logError = (error: Error) => console.error(error) + +export default withErrorBoundaryGroup( + withErrorBoundary( + withSuspense.CSROnly( + () => { + const errorBoundary = useErrorBoundary() + + return ( + <> + + api.delay(200, { percentage: 50 })} /> + + ) + }, + { fallback: <>loading... } + ), + { fallback: (props) => <>{props.error.message}, onError: logError } + ), + { blockOutside: false } +) diff --git a/websites/visualization/src/components/UseSuspenseQuery.tsx b/websites/visualization/src/components/UseSuspenseQuery.tsx index 513946ef9..fe119a39c 100644 --- a/websites/visualization/src/components/UseSuspenseQuery.tsx +++ b/websites/visualization/src/components/UseSuspenseQuery.tsx @@ -4,7 +4,7 @@ import { useSuspenseQuery } from '@suspensive/react-query' import { Box, Description } from './uis' type Props = { - queryKey: Readonly<['query' | 'boundary', number]> + queryKey: Readonly<[string, number]> queryFn: () => Promise }