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
7 changes: 5 additions & 2 deletions docs/pages/docs/reflect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Each entry is `{ source, fn }`:
- an **array** of stores — `value` is the resolved tuple (`[Store<A>, Store<B>]` → `[A, B]`).
- `fn` — `(value, props) => derivedProp`, where `value` is the resolved `source` value (its type is inferred — no annotation needed) and `props` are the component's own props.

> Each key in `mapProps` must be a prop of the `view` — a typo'd or unknown key is a type error at the key itself. A key must not appear in both `bind` and `mapProps` — this is a type error. For best type inference, pass `source` as an inline literal; a variable typed as `Store<any>` will widen `fn`'s `value` argument to `any`.

```tsx
import { reflect } from '@effector/reflect';
import { createStore } from 'effector';
Expand Down Expand Up @@ -143,9 +145,10 @@ const Hello = reflect({

The component re-renders only when the `source` changes. A prop computed via `mapProps`
is made **optional** in the resulting component's type and can still be overridden explicitly at
the usage site (an explicitly passed prop wins over the derived value).
the usage site (an explicitly passed prop wins over the derived value — in that case `fn` is
not invoked for the overridden key).

> Note: like `bind`, the `mapProps` field is supported by all Reflect operators — `reflect`, `createReflect`, `variant` and `list`.
> Note: like `bind`, the `mapProps` field is supported by all Reflect operators — `reflect`, `createReflect`, `variant` and `list`. In `list`, a key must not appear in both `mapItem` and `mapProps` — `mapItem` automatically omits keys that are derived via `mapProps`.

### Fork API auto-compatibility

Expand Down
42 changes: 26 additions & 16 deletions public-types/reflect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,23 @@ type SourceValue<S> = S extends Store<infer V>
* `Sources` is a separate generic that captures the `source` of every entry. Because the
* sources are inferred independently of the `fn`s, `fn`'s `value` argument is inferred as the
* resolved source value (no manual annotation needed), and `props` is the view's `Props`.
* `fn` must return the prop type - a key that is not a prop of the view resolves to `never`.
* Each key in `mapProps` must be a prop of the `view` - a typo'd or unknown key resolves its
* entry to `never`, so the object literal assigned to it is a type error at the key site.
* A key that is also present in `bind` resolves to `never` as well — a prop must not be
* both bound and derived.
*/
type MapPropsFromSources<Props, Sources extends Record<string, SourceShape>> = {
[K in keyof Sources]: {
source: Sources[K];
fn: (
value: SourceValue<Sources[K]>,
props: Props,
) => K extends keyof Props ? Props[K] : never;
};
type MapPropsFromSources<Props, Bind, Sources extends Record<string, SourceShape>> = {
[K in keyof Sources]: K extends keyof Props
? K extends keyof Bind
? never
: {
source: Sources[K];
fn: (
value: SourceValue<Sources[K]>,
props: Props,
) => Props[K & keyof Props];
}
: never;
};

/**
Expand Down Expand Up @@ -144,7 +151,7 @@ export function reflect<
/**
* Derives props for the `view` from a store value combined with the component's props.
*/
mapProps?: MapPropsFromSources<Props, Sources>;
mapProps?: MapPropsFromSources<Props, Bind, Sources>;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
Expand Down Expand Up @@ -187,7 +194,7 @@ export function createReflect<
/**
* Derives props for the `view` from a store value combined with the component's props.
*/
mapProps?: MapPropsFromSources<Props, Sources>;
mapProps?: MapPropsFromSources<Props, Bind, Sources>;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
Expand Down Expand Up @@ -226,7 +233,10 @@ export function list<
Props extends ComponentProps<View>,
Item,
MapItem extends {
[M in keyof Omit<Props, keyof Bind>]: (item: Item, index: number) => Props[M];
[M in keyof Omit<Props, keyof Bind | keyof Sources>]: (
item: Item,
index: number,
) => Props[M];
},
Bind extends BindFromProps<Props> = object,
// eslint-disable-next-line @typescript-eslint/ban-types
Expand All @@ -238,7 +248,7 @@ export function list<
view: View;
bind?: Bind;
mapItem?: MapItem;
mapProps?: MapPropsFromSources<Props, Sources>;
mapProps?: MapPropsFromSources<Props, Bind, Sources>;
getKey?: (item: Item) => React.Key;
hooks?: Hooks<Props>;
/**
Expand All @@ -251,7 +261,7 @@ export function list<
view: View;
bind?: Bind;
mapItem: MapItem;
mapProps?: MapPropsFromSources<Props, Sources>;
mapProps?: MapPropsFromSources<Props, Bind, Sources>;
getKey?: (item: Item) => React.Key;
hooks?: Hooks<Props>;
/**
Expand Down Expand Up @@ -321,7 +331,7 @@ export function variant<
cases: Partial<Cases>;
default?: ComponentType<Props>;
bind?: Bind;
mapProps?: MapPropsFromSources<Props, Sources>;
mapProps?: MapPropsFromSources<Props, Bind, Sources>;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
Expand All @@ -333,7 +343,7 @@ export function variant<
then: ComponentType<Props>;
else?: ComponentType<Props>;
bind?: Bind;
mapProps?: MapPropsFromSources<Props, Sources>;
mapProps?: MapPropsFromSources<Props, Bind, Sources>;
hooks?: Hooks<Props>;
/**
* This configuration is passed directly to `useUnit`'s hook second argument.
Expand Down
3 changes: 3 additions & 0 deletions src/core/reflect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function reflectFactory(context: Context) {
);

for (const key of mapPropsKeys) {
// an explicitly passed prop or a bound store wins over the derived one — skip fn
if (key in props) continue;
if (key in storeProps) continue;
mappedProps[key] = (mapProps as any)[key].fn(
(mapPropsValues as any)[key],
propsForFn,
Expand Down
50 changes: 50 additions & 0 deletions src/no-ssr/create-reflect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,53 @@ describe('useUnitConfig', () => {
);
});
});

describe('mapProps', () => {
const Greeting: FC<{ testId: string; label: string }> = (props) => {
return <span data-testid={props.testId}>{props.label}</span>;
};
const greetingReflect = createReflect(Greeting);

test('derives a prop in createReflect via mapProps', async () => {
const setName = createEvent<string>();
const $name = restore(setName, 'Bob');

const Hello = greetingReflect(
{},
{
mapProps: {
label: {
source: $name,
fn: (name, props: { greeting: string }) => `${props.greeting}, ${name}!`,
},
},
},
);

const container = render(<Hello testId="hello" greeting="Hi" />);
expect(container.getByTestId('hello').textContent).toBe('Hi, Bob!');

await act(async () => {
setName('Alice');
});
expect(container.getByTestId('hello').textContent).toBe('Hi, Alice!');
});

test('explicitly passed prop wins over the derived one and fn is skipped', () => {
const $name = createStore('Bob');
const fn = vi.fn((name: string) => `Hello, ${name}!`);

const Hello = greetingReflect(
{},
{
mapProps: {
label: { source: $name, fn },
},
},
);

const container = render(<Hello testId="hello" label="overridden" />);
expect(container.getByTestId('hello').textContent).toBe('overridden');
expect(fn).not.toHaveBeenCalled();
});
});
50 changes: 49 additions & 1 deletion src/no-ssr/list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { list } from '@effector/reflect';
import { render } from '@testing-library/react';
import { allSettled, createEffect, createEvent, createStore, fork } from 'effector';
import {
allSettled,
createEffect,
createEvent,
createStore,
fork,
restore,
} from 'effector';
import { Provider, useStore } from 'effector-react';
import React, { FC, memo } from 'react';
import { act } from 'react-dom/test-utils';
Expand Down Expand Up @@ -565,3 +572,44 @@ describe('useUnitConfig', () => {
);
});
});

describe('mapProps', () => {
const Item: FC<{ testId: string; label?: string }> = (props) => {
return <li data-testid={props.testId}>{props.label}</li>;
};

test('derives a prop in list via mapProps using mapItem output', async () => {
const setName = createEvent<string>();
const $name = restore(setName, 'Bob');
const $items = createStore([{ key: 'a' }, { key: 'b' }]);

const Items = list({
source: $items,
view: Item,
bind: {},
mapItem: {
testId: (item) => item.key,
},
mapProps: {
label: {
source: $name,
fn: (name, props: { testId: string }) => `${props.testId}-${name}`,
},
},
});

const container = render(
<ul>
<Items />
</ul>,
);
expect(container.getByTestId('a').textContent).toBe('a-Bob');
expect(container.getByTestId('b').textContent).toBe('b-Bob');

await act(async () => {
setName('Alice');
});
expect(container.getByTestId('a').textContent).toBe('a-Alice');
expect(container.getByTestId('b').textContent).toBe('b-Alice');
});
});
30 changes: 25 additions & 5 deletions src/no-ssr/reflect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,22 +630,42 @@ describe('mapProps', () => {
expect(container.getByTestId('hello').textContent).toBe('Hi, Alice!');
});

test('explicitly passed prop wins over the derived one', () => {
test('explicitly passed prop wins over the derived one and fn is skipped', () => {
const $name = createStore('Bob');
const fn = vi.fn((name: string) => `Hello, ${name}!`);

const Hello = reflect({
view: Greeting,
bind: {},
mapProps: {
label: {
source: $name,
fn: (name) => `Hello, ${name}!`,
},
label: { source: $name, fn },
},
});

const container = render(<Hello testId="hello" label="overridden" />);
expect(container.getByTestId('hello').textContent).toBe('overridden');
expect(fn).not.toHaveBeenCalled();
});

test('bound store wins over mapProps and fn is skipped', () => {
const $a = createStore('from-bind');
const $b = createStore('from-mapProps');
const fn = vi.fn((b: string) => b);

// The type-level fix (B1) makes this a type error — a key can't be in
// both bind and mapProps. We bypass it here to test the runtime skip,
// which is a defense-in-depth for JS users and type-bypass scenarios.
const Hello = reflect({
view: Greeting,
bind: { label: $a },
mapProps: {
label: { source: $b, fn },
},
} as any);

const container = render(<Hello testId="hello" />);
expect(container.getByTestId('hello').textContent).toBe('from-bind');
expect(fn).not.toHaveBeenCalled();
});

test('combines an object of stores as source', async () => {
Expand Down
51 changes: 50 additions & 1 deletion src/no-ssr/variant.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { variant } from '@effector/reflect';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createEvent, createStore, restore } from 'effector';
import React from 'react';
import React, { FC } from 'react';

test('matches first', async () => {
const changeValue = createEvent<string>();
Expand Down Expand Up @@ -323,3 +323,52 @@ describe('useUnitConfig', () => {
);
});
});

describe('mapProps', () => {
const Greeting: FC<{ testId: string; label: string }> = (props) => {
return <span data-testid={props.testId}>{props.label}</span>;
};

test('derives a prop in variant via mapProps', async () => {
const setName = createEvent<string>();
const $name = restore(setName, 'Bob');
const $type = createStore<'a' | 'b'>('a');

const Input = variant({
source: $type,
bind: {},
cases: { a: Greeting, b: Greeting },
mapProps: {
label: {
source: $name,
fn: (name, props: { greeting: string }) => `${props.greeting}, ${name}!`,
},
},
});

const container = render(<Input testId="hello" greeting="Hi" />);
expect(container.getByTestId('hello').textContent).toBe('Hi, Bob!');

await act(async () => {
setName('Alice');
});
expect(container.getByTestId('hello').textContent).toBe('Hi, Alice!');
});

test('explicitly passed prop wins over the derived one', () => {
const $name = createStore('Bob');
const $type = createStore<'a' | 'b'>('a');

const Input = variant({
source: $type,
bind: {},
cases: { a: Greeting, b: Greeting },
mapProps: {
label: { source: $name, fn: (name) => `Hello, ${name}!` },
},
});

const container = render(<Input testId="hello" label="overridden" />);
expect(container.getByTestId('hello').textContent).toBe('overridden');
});
});
36 changes: 36 additions & 0 deletions src/ssr/create-reflect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,39 @@ test('with ssr for client', async () => {
const inputName = container.getByTestId('name') as HTMLInputElement;
expect(inputName.value).toBe('Bob');
});

test('mapProps derives a prop in createReflect from a scoped store', async () => {
const app = createDomain();

const setName = app.createEvent<string>();
const $name = restore(setName, 'Bob');

const Greeting: FC<{ testId: string; label: string }> = (props) => {
return <span data-testid={props.testId}>{props.label}</span>;
};
const greetingReflect = createReflect(Greeting);

const Hello = greetingReflect(
{},
{
mapProps: {
label: {
source: $name,
fn: (name, props: { greeting: string }) => `${props.greeting}, ${name}!`,
},
},
},
);

const scope = fork(app, { values: [[$name, 'Alice']] });

const container = render(
<Provider value={scope}>
<Hello testId="hello" greeting="Hi" />
</Provider>,
);

expect(container.getByTestId('hello').textContent).toBe('Hi, Alice!');
// global store is untouched
expect($name.getState()).toBe('Bob');
});
Loading
Loading