Skip to content

Commit

Permalink
Merge 4f63c0e into b64db5d
Browse files Browse the repository at this point in the history
  • Loading branch information
hlysine committed Jun 12, 2023
2 parents b64db5d + 4f63c0e commit 85b9ee3
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 104 deletions.
83 changes: 79 additions & 4 deletions src/__tests__/component.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isReadonly,
isShallow,
makeReactive,
makeReactiveHook,
reactive,
ref,
useComputed,
Expand Down Expand Up @@ -90,45 +91,107 @@ describe('makeReactive', () => {
});
it('renders custom hooks without crashing', async () => {
const count = ref(0);
const useCount = makeReactive(function useCount() {
const useCount = makeReactiveHook(function useCount() {
return count.value;
});
const Tester = function Tester() {
const count = useCount();
return <p>{count}</p>;
};

const { findByText } = render(<Tester />);
const { renderCount } = perf(React);

const { findByText, unmount } = render(<Tester />);
const content1 = await findByText('0');
expect(content1).toBeTruthy();
expect(renderCount.current.Tester).toBeRenderedTimes(1);

act(() => {
count.value++;
});

const content2 = await findByText('1');
expect(content2).toBeTruthy();
expect(renderCount.current.Tester).toBeRenderedTimes(2);

unmount();
act(() => {
count.value++;
});

expect(renderCount.current.Tester).toBeRenderedTimes(2);
});
it('renders custom hooks without crashing (Strict Mode)', async () => {
// use orginal return value to restore React development mode
getFiberInDev.mockRestore();

const renderedHook = jest.fn();

const count = ref(0);
const useCount = makeReactiveHook(function useCount() {
renderedHook();
return count.value;
});
const Tester = function Tester() {
const count = useCount();
return <p>{count}</p>;
};

const { findByText, unmount } = render(
<React.StrictMode>
<Tester />
</React.StrictMode>
);
const content1 = await findByText('0');
expect(content1).toBeTruthy();
expect(renderedHook).toBeCalledTimes(4);

act(() => {
count.value++;
});

const content2 = await findByText('1');
expect(content2).toBeTruthy();
expect(renderedHook).toBeCalledTimes(6);

unmount();
act(() => {
count.value++;
});

expect(renderedHook).toBeCalledTimes(6);
});
it('renders custom hooks in reactive component without crashing', async () => {
const count = ref(0);
const useCount = makeReactive(function useCount() {
const useCount = makeReactiveHook(function useCount() {
return count.value;
});
const Tester = makeReactive(function Tester() {
const count = useCount();
return <p>{count}</p>;
});

const { findByText } = render(<Tester />);
const { renderCount } = perf(React);

const { findByText, unmount } = render(<Tester />);
const content1 = await findByText('0');
expect(content1).toBeTruthy();
expect(renderCount.current.Tester).toBeRenderedTimes(1);

act(() => {
count.value++;
});

const content2 = await findByText('1');
expect(content2).toBeTruthy();
expect(renderCount.current.Tester).toBeRenderedTimes(2);

unmount();
act(() => {
count.value++;
});

expect(renderCount.current.Tester).toBeRenderedTimes(2);
});
it('accepts props', async () => {
const Tester = makeReactive(function Tester(props: {
Expand All @@ -154,6 +217,18 @@ describe('makeReactive', () => {
const content = await findByText('Test component');
expect(content).toBeTruthy();
});
it('accepts args in hook version', async () => {
const useCount = makeReactiveHook(function useCount(count: number) {
return count;
});
const { result, rerender } = renderHook(useCount, { initialProps: 0 });

expect(result.current).toBe(0);

rerender(1);

expect(result.current).toBe(1);
});
it('updates props', async () => {
const mockEffect = jest.fn();
const Tester = makeReactive(function Tester(props: { value?: string }) {
Expand Down
214 changes: 116 additions & 98 deletions src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
shallowReadonly,
shallowReactive,
} from '@vue/reactivity';
import { getFiberInDev } from './helper';
import { assignDiff, getFiberInDev } from './helper';
import {
MutableRefObject,
useDebugValue,
Expand All @@ -24,22 +24,6 @@ function destroyReactivityRef(
}
}

function assignDiff(target: Record<any, any>, source: Record<any, any>) {
let remainingKeys = Object.keys(target);
if (source !== null && source !== undefined && typeof source === 'object') {
Object.entries(source).forEach(([key, value]) => {
remainingKeys = remainingKeys.filter((k) => k !== key);
if (key in target && Object.is(target[key], value)) {
return;
}
target[key] = value;
});
}
remainingKeys.forEach((key) => {
delete target[key];
});
}

interface ReactiveRerenderRef<T> {
reactive: T;
readonly: T;
Expand Down Expand Up @@ -109,17 +93,35 @@ interface ComponentReactivity {
}

function useReactivityInternals<P extends {}>(
props: P,
component: React.FC<P>
component: React.FC<P>,
props: P
): MutableRefObject<ComponentReactivity | null>;
function useReactivityInternals<T extends unknown[]>(
hook: (...args: T) => any,
args: T
): MutableRefObject<ComponentReactivity | null>;
function useReactivityInternals<P extends {}>(
func: React.FC<P>,
propsOrArgs: P
) {
const reactivityRef = useRef<ComponentReactivity | null>(null);

// Stop reactive re-render when updating props because the component is already going to re-render
if (reactivityRef.current !== null)
reactivityRef.current.updatingProps = true;
const reactiveProps = useReactiveRerender(props);
if (reactivityRef.current !== null)
reactivityRef.current.updatingProps = false;
let reactiveProps: P;
if (Array.isArray(propsOrArgs)) {
// if propsOrArgs is an array, it comes from a hook function, which should not have reactive args
reactiveProps = propsOrArgs;
if (reactivityRef.current !== null) {
reactivityRef.current.props = reactiveProps;
}
} else {
// Stop reactive re-render when updating props because the component is already going to re-render
if (reactivityRef.current !== null)
reactivityRef.current.updatingProps = true;
// eslint-disable-next-line react-hooks/rules-of-hooks
reactiveProps = useReactiveRerender(propsOrArgs);
if (reactivityRef.current !== null)
reactivityRef.current.updatingProps = false;
}

const [, setTick] = useState(0);
const rerender = () => setTick((v) => v + 1);
Expand All @@ -130,10 +132,16 @@ function useReactivityInternals<P extends {}>(
scope.run(() => {
const runner = effect(
function reactiveRender() {
return component(
reactivityRef.current!.props,
reactivityRef.current!.ctx
);
if (Array.isArray(reactivityRef.current!.props)) {
return (func as (...args: any) => any)(
...reactivityRef.current!.props
);
} else {
return func(
reactivityRef.current!.props,
reactivityRef.current!.ctx
);
}
},
{
lazy: true,
Expand Down Expand Up @@ -170,78 +178,41 @@ function useReactivityInternals<P extends {}>(
return reactivityRef;
}

interface MakeReactive {
/**
* Converts a function component into a reactive component. A reactive component receives reactive props and
* re-renders automatically when its data dependencies are modified.
*
* If your function component makes use of a reactive value, the component has to be wrapped by `makeReactive` so
* that it can re-render when the reactive value changes.
*
* @example
* Simple usage of `makeReactive`.
* ```tsx
* export default makeReactive(function App() {
* const state = useReactive({ count: 1 });
* return <p>{state.count}</p>;
* });
* ```
*
* @example
* Once a component is made reactive, it may access reactive values from any sources, not just from props, contexts
* and hooks.
* ```tsx
* import { reactiveState } from './anotherFile';
*
* export default makeReactive(function App() {
* return <p>{reactiveState.count}</p>;
* });
* ```
* @typeParam P - The props of a React function component.
* @param component The function component to be made reactive.
* @returns A reactive function component.
*/
<P extends {}>(component: React.FC<P>): React.FC<P>;
/**
* Converts a custom hook to be reactive. A reactive component receives reactive props and
* re-renders automatically when its data dependencies are modified.
*
* If your custom hook makes use of a reactive value, the function has to be wrapped by `makeReactive` so
* that it can trigger a re-render on the component when the reactive value changes.
*
* @example
* Simple usage of `makeReactive`.
* ```tsx
* export default makeReactive(function useCount() {
* const state = useReactive({ count: 1 });
* return state.count;
* });
* ```
*
* @example
* Once a custom hook is made reactive, it may access reactive values from any sources, not just from props, contexts
* and hooks.
* ```tsx
* import { reactiveState } from './anotherFile';
*
* export default makeReactive(function useReactiveState() {
* return reactiveState.count;
* });
* ```
* @typeParam T - A React custom hook function.
* @param component The custom hook to be made reactive.
* @returns A reactive custom hook.
*/
<T extends (...args: any) => any>(
hook: T extends React.FC<any> ? never : T
): T;
}

export const makeReactive: MakeReactive = <P extends {}>(
/**
* Converts a function component into a reactive component. A reactive component receives reactive props and
* re-renders automatically when its data dependencies are modified.
*
* If your function component makes use of a reactive value, the component has to be wrapped by `makeReactive` so
* that it can re-render when the reactive value changes.
*
* @example
* Simple usage of `makeReactive`.
* ```tsx
* export default makeReactive(function App() {
* const state = useReactive({ count: 1 });
* return <p>{state.count}</p>;
* });
* ```
*
* @example
* Once a component is made reactive, it may access reactive values from any sources, not just from props, contexts
* and hooks.
* ```tsx
* import { reactiveState } from './anotherFile';
*
* export default makeReactive(function App() {
* return <p>{reactiveState.count}</p>;
* });
* ```
* @typeParam P - The props of a React function component.
* @param component The function component to be made reactive.
* @returns A reactive function component.
*/
export const makeReactive = <P extends {}>(
component: React.FC<P>
): React.FC<P> => {
const ReactiveFC: React.FC<P> = (props, ctx) => {
const reactivityRef = useReactivityInternals(props, component);
const reactivityRef = useReactivityInternals(component, props);

reactivityRef.current!.ctx = ctx;
const ret = reactivityRef.current!.scope.run(function scopedRender() {
Expand All @@ -262,3 +233,50 @@ export const makeReactive: MakeReactive = <P extends {}>(
});
return ReactiveFC;
};

/**
* Converts a custom hook to be reactive. A reactive hook causes the component to re-render automatically when its data
* dependencies are modified.
*
* If your custom hook makes use of a reactive value, the function has to be wrapped by `makeReactive` so
* that it can trigger a re-render on the component when the reactive value changes.
*
* @example
* Simple usage of `makeReactive`.
* ```tsx
* export default makeReactive(function useCount() {
* const state = useReactive({ count: 1 });
* return state.count;
* });
* ```
*
* @example
* Once a custom hook is made reactive, it may access reactive values from any sources, not just from props, contexts
* and hooks.
* ```tsx
* import { reactiveState } from './anotherFile';
*
* export default makeReactive(function useReactiveState() {
* return reactiveState.count;
* });
* ```
* @typeParam T - A React custom hook function.
* @param component The custom hook to be made reactive.
* @returns A reactive custom hook.
*/
export const makeReactiveHook = <T extends (...args: any) => any>(
hook: T
): T => {
const useReactiveHook: T = ((...args: any) => {
const reactivityRef = useReactivityInternals(hook, args);

const ret = reactivityRef.current!.scope.run(function scopedRender() {
return reactivityRef.current!.effect();
});
if (reactivityRef.current!.destroyAfterUse) {
destroyReactivityRef(reactivityRef);
}
return ret;
}) as T;
return useReactiveHook;
};
Loading

0 comments on commit 85b9ee3

Please sign in to comment.