Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 103 additions & 101 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/integration-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "",
"private": true,
"scripts": {
"build": "if test \"$CI\" = \"true\"; then mkdir -p ./test/webDriver/static/js/; cp `node --eval=\"console.log(require('path').resolve(require('resolve-cwd')('@testduet/wait-for'), '../../dist/**'))\"` ./test/webDriver/static/js/; else mkdir -p ./test/webDriver/static/; ln --relative --symbolic `node --eval=\"console.log(require('path').resolve(require('resolve-cwd')('@testduet/wait-for'), '../../dist'))\"` ./test/webDriver/static/js; fi",
"build": "if test \"$CI\" = \"true\"; then mkdir -p ./test/webDriver/static/js/; cp `node --eval=\"console.log(require('path').resolve(require('resolve-cwd')('@testduet/wait-for'), '../../dist/**'))\"` ./test/webDriver/static/js/; else mkdir -p ./test/webDriver/static/; ln --force --relative --symbolic `node --eval=\"console.log(require('path').resolve(require('resolve-cwd')('@testduet/wait-for'), '../../dist'))\"` ./test/webDriver/static/js; fi",
"bump": "npm run bump:prod && npm run bump:dev",
"bump:dev": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localPeerDependencies // {}) as $L | (.devDependencies // {}) | to_entries | map(select(.key as $K | $L | has($K) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install $PACKAGES_TO_BUMP || true",
"bump:prod": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localPeerDependencies // {}) as $L | (.dependencies // {}) | to_entries | map(select(.key as $K | $L | has($K) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install $PACKAGES_TO_BUMP || true",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import React, {
type ReactElement,
type ReactNode
} from 'react';
import { type SetOptional } from 'type-fest';
import { custom, function_, object, parse, safeParse } from 'valibot';

import arePropsEqual from './private/arePropsEqual.ts';
Expand Down Expand Up @@ -84,12 +85,26 @@ type ComponentMiddleware<Request, Props extends BaseProps, Init = undefined> = (
init: Init
) => ComponentEnhancer<Request, Props>;

type ReactComponentHandlerResult<Props extends object> = <P extends Props>(
type ReactComponentInit<
Props extends BaseProps,
W extends (BaseProps & { children?: ReactNode | undefined }) | void = void
> = W extends void
? {
wrapperComponent?: undefined;
wrapperProps?: undefined;
}
: {
wrapperComponent: ComponentType<W>;
wrapperProps: W | ((props: Props) => W);
};

type ReactComponentHandlerResult<Props extends BaseProps> = <
P extends Props,
W extends (BaseProps & { children?: ReactNode | undefined }) | void = void
>(
component: ComponentType<P>,
bindProps?:
| (Partial<Props> & Omit<P, keyof Props>)
| ((props: Props) => Partial<Props> & Omit<P, keyof Props>)
| undefined
bindProps?: SetOptional<P, keyof Props> | ((props: Props) => SetOptional<P, keyof Props>) | undefined,
init?: ReactComponentInit<Props, W>
) => ComponentHandlerResult<Props>;

type UseBuildRenderCallbackOptions<Props> = {
Expand Down Expand Up @@ -177,32 +192,54 @@ function createChainOfResponsibility<
})
);

function reactComponent<P extends Props>(
function reactComponent<P extends Props, W extends (BaseProps & { children?: ReactNode | undefined }) | void = void>(
component: ComponentType<P>,
// For `bindProps` of type function, do not do side-effect in it, it may not be always called in all scenarios.
bindProps?:
| (Partial<Props> & Omit<P, keyof Props>)
| ((props: Props) => Partial<Props> & Omit<P, keyof Props>)
| undefined
bindProps?: SetOptional<P, keyof Props> | ((props: Props) => SetOptional<P, keyof Props>) | undefined,
init?: ReactComponentInit<Props, W> | undefined
): ComponentHandlerResult<Props> {
return createComponentHandlerResult((overridingProps?: Partial<Props> | undefined) => (
<ComponentWithProps
bindProps={bindProps}
component={component as ComponentType<Props>}
overridingProps={overridingProps}
/>
));
// memo() and generic type do not play well together.
const TypedWrapperComponent = WrapperComponent as ComponentType<WrapperComponentProps<P, W>>;

if (init?.wrapperComponent && init.wrapperProps) {
return createComponentHandlerResult((overridingProps?: Partial<Props> | undefined) => (
<TypedWrapperComponent
bindProps={bindProps}
component={component}
overridingProps={overridingProps}
wrapperComponent={init.wrapperComponent as ComponentType<W>}
wrapperProps={init.wrapperProps as W}
/>
));
} else {
return createComponentHandlerResult((overridingProps?: Partial<Props> | undefined) => (
<TypedWrapperComponent bindProps={bindProps} component={component} overridingProps={overridingProps} />
));
}
}

const ComponentWithProps = memo(function ComponentWithProps({
type WrapperComponentProps<
P extends Props,
W extends (BaseProps & { children?: ReactNode | undefined }) | void = void
> = {
readonly bindProps: SetOptional<P, keyof Props> | ((props: Props) => SetOptional<P, keyof Props>) | undefined;
readonly component: ComponentType<P>;
readonly overridingProps: Partial<Props> | undefined;
readonly wrapperComponent?: ComponentType<W> | undefined;
readonly wrapperProps?: W | ((props: Props) => W) | undefined;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WrapperComponent = memo<WrapperComponentProps<any, any>>(function WrapperComponent<
P extends Props,
W extends BaseProps & { children?: ReactNode | undefined }
>({
bindProps,
component: Component,
overridingProps
}: {
readonly bindProps?: Partial<Props> | ((props: Props) => Partial<Props>) | undefined;
readonly component: ComponentType<Props>;
readonly overridingProps?: Partial<Props> | undefined;
}) {
overridingProps,
wrapperComponent: WrapperComponent,
wrapperProps
}: WrapperComponentProps<P, W>) {
const { allowOverrideProps } = options;
const { originalProps: renderCallbackProps } = useContext(RenderContext);

Expand All @@ -214,7 +251,22 @@ function createChainOfResponsibility<
allowOverrideProps ? { ...renderCallbackProps, ...overridingProps } : { ...renderCallbackProps }
);

return <Component {...props} {...(typeof bindProps === 'function' ? bindProps(props) : bindProps)} />;
const child = (
<Component
{...({
...props,
...(typeof bindProps === 'function' ? bindProps(props) : bindProps)
} as P)}
/>
);

return WrapperComponent && wrapperProps ? (
<WrapperComponent {...(typeof wrapperProps === 'function' ? wrapperProps(props) : wrapperProps)}>
{child}
</WrapperComponent>
) : (
child
);
});

const useBuildRenderCallback: () => UseBuildRenderCallback<Request, Props> = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import React, { Fragment, type ReactNode } from 'react';
import createChainOfResponsibility, { type InferMiddleware } from '../createChainOfResponsibilityAsRenderCallback';

type Props = {
readonly children?: never;
readonly value: number;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** @jest-environment jsdom */
/// <reference types="@types/jest" />

import { scenario } from '@testduet/given-when-then';
import { render } from '@testing-library/react';
import React, { Fragment, type ReactElement, type ReactNode } from 'react';

import createChainOfResponsibility, {
type ComponentHandlerResult,
type InferMiddleware
} from '../createChainOfResponsibilityAsRenderCallback';

type Props = { readonly children?: never; readonly value: number };
type Request = number;

type MyWrapperProps = { readonly children?: ReactNode | undefined; readonly request: number };

function MyWrapper({ children, request }: MyWrapperProps) {
return (
<Fragment>
{`<MyWrapper request={${request}}>`}
{children}
{'</MyWrapper>'}
</Fragment>
);
}

type MyComponentProps = Props & { readonly value: number };

function MyComponent({ value }: MyComponentProps) {
return <Fragment>Hello, World! ({value})</Fragment>;
}

type UpstreamProps = Props & { readonly result: ComponentHandlerResult<Props> | undefined };

function Upstream({ result, value }: UpstreamProps): ReactElement | null {
return <Fragment>{result?.render?.({ value: value + 1 })}</Fragment>;
}

scenario('with wrapper component and overriding props and request', bdd => {
bdd
.given('a TestComponent using chain of responsibility', () => {
const { Provider, Proxy, reactComponent } = createChainOfResponsibility<Request, Props>({
allowOverrideProps: true,
passModifiedRequest: true
});

const middleware: readonly InferMiddleware<typeof Provider>[] = [
() => next => request => {
return reactComponent(
Upstream,
{ result: next(request * 10) },
{ wrapperComponent: Fragment, wrapperProps: {} }
);
},
() => () => request =>
reactComponent(MyComponent, {}, { wrapperComponent: MyWrapper, wrapperProps: { request } })
];

return function TestComponent() {
return (
<Provider middleware={middleware}>
<Proxy request={1} value={1} />
</Provider>
);
};
})
.when('the component is rendered', TestComponent => render(<TestComponent />))
.then('textContent should match', (_, { container }) =>
expect(container).toHaveProperty('textContent', '<MyWrapper request={10}>Hello, World! (2)</MyWrapper>')
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** @jest-environment jsdom */
/// <reference types="@types/jest" />

import { scenario } from '@testduet/given-when-then';
import { render } from '@testing-library/react';
import React, { Fragment, type ReactNode } from 'react';

import createChainOfResponsibility, { type InferMiddleware } from '../createChainOfResponsibilityAsRenderCallback';

type Props = { readonly children?: never; readonly value: number };
type Request = number;

type MyWrapperProps = { readonly children?: ReactNode | undefined; readonly request: number };

function MyWrapper({ children, request }: MyWrapperProps) {
return (
<Fragment>
{`<MyWrapper request={${request}}>`}
{children}
{'</MyWrapper>'}
</Fragment>
);
}

type MyComponentProps = Props & { readonly value: number };

function MyComponent({ value }: MyComponentProps) {
return <Fragment>Hello, World! ({value})</Fragment>;
}

scenario('with wrapper component', bdd => {
bdd
.given('a TestComponent using chain of responsiblity', () => {
const { Provider, Proxy, reactComponent } = createChainOfResponsibility<Request, Props>();

const middleware: readonly InferMiddleware<typeof Provider>[] = [
() => () => request =>
reactComponent(MyComponent, {}, { wrapperComponent: MyWrapper, wrapperProps: { request } })
];

return function TestComponent() {
return (
<Provider middleware={middleware}>
<Proxy request={1} value={1} />
</Provider>
);
};
})
.when('the component is rendered', TestComponent => render(<TestComponent />))
.then('textContent should match', (_, { container }) =>
expect(container).toHaveProperty('textContent', '<MyWrapper request={1}>Hello, World! (1)</MyWrapper>')
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** @jest-environment jsdom */
/// <reference types="@types/jest" />

import { scenario } from '@testduet/given-when-then';
import { render } from '@testing-library/react';
import React, { Fragment, type ReactNode } from 'react';

import createChainOfResponsibility, { type InferMiddleware } from '../createChainOfResponsibilityAsRenderCallback';

type Props = { readonly children?: never; readonly value: number };
type Request = number;

type MyWrapperProps = { readonly children?: ReactNode | undefined; readonly value: number };

function MyWrapper({ children, value }: MyWrapperProps) {
return (
<Fragment>
{`<MyWrapper value={${value}}>`}
{children}
{'</MyWrapper>'}
</Fragment>
);
}

type MyComponentProps = Props & { readonly value: number };

function MyComponent({ value }: MyComponentProps) {
return <Fragment>Hello, World! ({value})</Fragment>;
}

scenario('with wrapper component', bdd => {
bdd
.given('a TestComponent using chain of responsibility', () => {
const { Provider, Proxy, reactComponent } = createChainOfResponsibility<Request, Props>();

const middleware: readonly InferMiddleware<typeof Provider>[] = [
() => () => request =>
reactComponent(
MyComponent,
{},
{ wrapperComponent: MyWrapper, wrapperProps: props => ({ value: request + props.value }) }
)
];

return function TestComponent() {
return (
<Provider middleware={middleware}>
<Proxy request={1} value={2} />
</Provider>
);
};
})
.when('the component is rendered', TestComponent => render(<TestComponent />))
.then('textContent should match', (_, { container }) =>
expect(container).toHaveProperty('textContent', '<MyWrapper value={3}>Hello, World! (2)</MyWrapper>')
);
});