Skip to content

Commit

Permalink
feat(reflect/hooks): pass component props to mounted hook (#89)
Browse files Browse the repository at this point in the history
* feat(reflect/hooks): pass component props to `mounted` hook

Closes #88

* test(type-tests/reflect): should error if mounted event doesn't satisfy component props
  • Loading branch information
evist0 authored Apr 8, 2024
1 parent 4c8b159 commit b7aef67
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 32 deletions.
8 changes: 4 additions & 4 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ To learn more, please read the [full Motivation article](https://reflect.effecto
## Release process

1. Check out the [draft release](https://github.com/effector/reflect/releases).
1. All PRs should have correct labels and useful titles. You can [review available labels here](https://github.com/effector/reflect/blob/master/.github/release-drafter.yml).
1. Update labels for PRs and titles, next [manually run the release drafter action](https://github.com/effector/reflect/actions/workflows/release-drafter.yml) to regenerate the draft release.
1. Review the new version and press "Publish"
1. If required check "Create discussion for this release"
2. All PRs should have correct labels and useful titles. You can [review available labels here](https://github.com/effector/reflect/blob/master/.github/release-drafter.yml).
3. Update labels for PRs and titles, next [manually run the release drafter action](https://github.com/effector/reflect/actions/workflows/release-drafter.yml) to regenerate the draft release.
4. Review the new version and press "Publish"
5. If required check "Create discussion for this release"
20 changes: 10 additions & 10 deletions public-types/reflect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ type UseUnitConfig = Parameters<typeof useUnit>[1];

type UnbindableProps = 'key' | 'ref';

type Hooks = {
mounted?: EventCallable<void> | (() => unknown);
type Hooks<Props> = {
mounted?: EventCallable<Props> | EventCallable<void> | ((props: Props) => unknown);
unmounted?: EventCallable<void> | (() => unknown);
};

Expand All @@ -26,7 +26,7 @@ type BindFromProps<Props> = {
| ((...args: Parameters<Props[K]>) => ReturnType<Props[K]>)
// Edge-case: allow to pass an event listener without any parameters (e.g. onClick: () => ...)
| (() => ReturnType<Props[K]>)
// Edge-case: allow to pass an Store, which contains a function
// Edge-case: allow to pass a Store, which contains a function
| Store<Props[K]>
: Store<Props[K]> | Props[K];
};
Expand All @@ -35,7 +35,7 @@ type BindFromProps<Props> = {
* Computes final props type based on Props of the view component and Bind object.
*
* Props that are "taken" by Bind object are made **optional** in the final type,
* so it is possible to owerrite them in the component usage anyway
* so it is possible to overwrite them in the component usage anyway
*/
type FinalProps<Props, Bind extends BindFromProps<Props>> = Show<
Omit<Props, keyof Bind> & {
Expand All @@ -62,7 +62,7 @@ type FinalProps<Props, Bind extends BindFromProps<Props>> = Show<
export function reflect<Props, Bind extends BindFromProps<Props>>(config: {
view: ComponentType<Props>;
bind: Bind;
hooks?: Hooks;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
*/
Expand Down Expand Up @@ -95,7 +95,7 @@ export function createReflect<Props, Bind extends BindFromProps<Props>>(
): (
bind: Bind,
features?: {
hooks?: Hooks;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
*/
Expand Down Expand Up @@ -143,7 +143,7 @@ export function list<
bind?: Bind;
mapItem?: MapItem;
getKey?: (item: Item) => React.Key;
hooks?: Hooks;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
*/
Expand All @@ -155,7 +155,7 @@ export function list<
bind?: Bind;
mapItem: MapItem;
getKey?: (item: Item) => React.Key;
hooks?: Hooks;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
*/
Expand Down Expand Up @@ -200,7 +200,7 @@ export function variant<
cases: Partial<Record<CaseType, ComponentType<Props>>>;
default?: ComponentType<Props>;
bind?: Bind;
hooks?: Hooks;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
*/
Expand All @@ -211,7 +211,7 @@ export function variant<
then: ComponentType<Props>;
else?: ComponentType<Props>;
bind?: Bind;
hooks?: Hooks;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/core/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function listFactory(context: Context) {
[K in keyof Props]: (item: Item, index: number) => Props[K];
};
getKey?: (item: Item) => React.Key;
hooks?: Hooks;
hooks?: Hooks<Props>;
useUnitConfig?: UseUnitConifg;
}): React.FC {
const ItemView = reflect<Props, Bind>({
Expand Down
16 changes: 8 additions & 8 deletions src/core/reflect.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Effect, Event, EventCallable, is, scopeBind, Store } from 'effector';
import { Effect, Event, is, scopeBind, Store } from 'effector';
import { useProvidedScope } from 'effector-react';
import React from 'react';
import React, { PropsWithoutRef, RefAttributes } from 'react';

import { BindProps, Context, Hook, Hooks, UseUnitConifg, View } from './types';
import { BindProps, Context, Hooks, UseUnitConifg, View } from './types';

export interface ReflectConfig<Props, Bind extends BindProps<Props>> {
view: View<Props>;
bind: Bind;
hooks?: Hooks;
hooks?: Hooks<Props>;
useUnitConfig?: UseUnitConifg;
}

Expand All @@ -25,11 +25,11 @@ export function reflectCreateFactory(context: Context) {
export function reflectFactory(context: Context) {
return function reflect<Props, Bind extends BindProps<Props> = BindProps<Props>>(
config: ReflectConfig<Props, Bind>,
): React.ExoticComponent<{}> {
): React.ExoticComponent<PropsWithoutRef<Props> & RefAttributes<unknown>> {
const { stores, events, data, functions } = sortProps(config.bind);
const hooks = sortProps(config.hooks || {});

return React.forwardRef((props, ref) => {
return React.forwardRef((props: Props, ref) => {
const storeProps = context.useUnit(stores, config.useUnitConfig);
const eventsProps = context.useUnit(events as any, config.useUnitConfig);
const functionProps = useBoundFunctions(functions);
Expand All @@ -47,10 +47,10 @@ export function reflectFactory(context: Context) {
const functionsHooks = useBoundFunctions(hooks.functions);

React.useEffect(() => {
const hooks: Hooks = Object.assign({}, functionsHooks, eventsHooks);
const hooks: Hooks<Props> = Object.assign({}, functionsHooks, eventsHooks);

if (hooks.mounted) {
hooks.mounted();
hooks.mounted(props);
}

return () => {
Expand Down
11 changes: 7 additions & 4 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ export type BindProps<Props> = {
[K in keyof Props]: Props[K] | Store<Props[K]> | EventCallable<void>;
};

export type Hook = (() => any) | EventCallable<void> | Effect<void, any, any>;
export type Hook<Props> =
| ((props: Props) => any)
| EventCallable<Props>
| Effect<Props, any, any>;

export type Hooks = {
mounted?: Hook;
unmounted?: Hook;
export type Hooks<Props> = {
mounted?: Hook<Props>;
unmounted?: Hook<void>;
};

export type UseUnitConifg = Parameters<typeof useUnit>[1];
4 changes: 2 additions & 2 deletions src/core/variant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ export function variantFactory(context: Context) {
source: Store<Variant>;
bind?: Bind;
cases: Record<Variant, View<Props>>;
hooks?: Hooks;
hooks?: Hooks<Props>;
default?: View<Props>;
useUnitConfig?: UseUnitConifg;
}
| {
if: Store<boolean>;
then: View<Props>;
else?: View<Props>;
hooks?: Hooks;
hooks?: Hooks<Props>;
bind?: Bind;
useUnitConfig?: UseUnitConifg;
},
Expand Down
67 changes: 65 additions & 2 deletions src/no-ssr/reflect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ test('InputCustom [replace value]', async () => {
});

// Example 2 (InputBase)
const InputBase: FC<InputHTMLAttributes<HTMLInputElement>> = (props) => {
type InputBaseProps = InputHTMLAttributes<HTMLInputElement>;
const InputBase: FC<InputBaseProps> = (props) => {
return <input {...props} />;
};

Expand Down Expand Up @@ -375,6 +376,37 @@ describe('hooks', () => {
expect(scope.getState($isMounted)).toBe(true);
});

test('callback with props', () => {
const mounted = createEvent<InputBaseProps>();
const $lastProps = restore(mounted, null);

const $value = createStore('test');

const scope = fork();

const Name = reflect({
view: InputBase,
bind: {
value: $value,
},
hooks: {
mounted: (props: InputBaseProps) => mounted(props),
},
});

render(
<Provider value={scope}>
<Name data-testid="name" />
</Provider>,
);

expect($lastProps.getState()).toBeNull();
expect(scope.getState($lastProps)).toStrictEqual({
value: 'test',
'data-testid': 'name',
});
});

test('event', () => {
const changeName = createEvent<string>();
const $name = restore(changeName, '');
Expand Down Expand Up @@ -419,10 +451,41 @@ describe('hooks', () => {
expect($isMounted.getState()).toBe(false);
expect(scope.getState($isMounted)).toBe(true);
});

test('event with props', () => {
const mounted = createEvent<InputBaseProps>();
const $lastProps = restore(mounted, null);

const $value = createStore('test');

const scope = fork();

const Name = reflect({
view: InputBase,
bind: {
value: $value,
},
hooks: { mounted },
});

render(
<Provider value={scope}>
<Name data-testid="name" />
</Provider>,
);

expect($lastProps.getState()).toBeNull();
expect(scope.getState($lastProps)).toStrictEqual({
value: 'test',
'data-testid': 'name',
});
});
});

describe('unmounted', () => {
const changeVisible = createEffect<boolean, void>({ handler: () => {} });
const changeVisible = createEffect<boolean, void>({
handler: () => {},
});
const $visible = restore(
changeVisible.finally.map(({ params }) => params),
true,
Expand Down
53 changes: 52 additions & 1 deletion type-tests/types-reflect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { reflect } from '@effector/reflect';
import { createEvent, createStore } from 'effector';
import React, { ComponentType, PropsWithChildren, ReactNode } from 'react';
import React, { ComponentType, FC, PropsWithChildren, ReactNode } from 'react';
import { expectType } from 'tsd';

// basic reflect
Expand Down Expand Up @@ -374,3 +374,54 @@ function localize(value: string): unknown {

const Test: ComponentType<{ value: string; children: ReactNode }> = Input;
}

// reflect supports mounted as EventCallable<void>
{
type Props = { loading: boolean };

const mounted = createEvent();

const Foo: FC<Props> = (props) => <></>;

const $loading = createStore(true);

const Bar = reflect({
view: Foo,
bind: {
loading: $loading,
},
hooks: { mounted },
});
}

// reflect supports mounted as EventCallable<Props>
{
type Props = { loading: boolean };

const mounted = createEvent<Props>();

const Foo: FC<Props> = (props) => <></>;

const $loading = createStore(true);

const Bar = reflect({
view: Foo,
bind: {
loading: $loading,
},
hooks: { mounted },
});
}

// should error if mounted event doesn't satisfy component props
{
const mounted = createEvent<{ foo: string }>();

const Foo: FC<{ bar: number }> = () => null;

const Bar = reflect({
view: Foo,
// @ts-expect-error
hooks: { mounted },
});
}

0 comments on commit b7aef67

Please sign in to comment.