Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[useEvent] Non-stable function identity #25473

Merged
merged 5 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
15 changes: 11 additions & 4 deletions packages/react-reconciler/src/ReactFiberHooks.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -1928,10 +1928,9 @@ function useEventImpl<Args, Return, F: (...Array<Args>) => Return>(
}
}

function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
function wrapEventFunction<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): EventFunctionWrapper<Args, Return, F> {
const hook = mountWorkInProgressHook();
const eventFn: EventFunctionWrapper<Args, Return, F> = function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
Expand All @@ -1942,9 +1941,16 @@ function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
return eventFn._impl.apply(undefined, arguments);
};
eventFn._impl = callback;
return eventFn;
}

useEventImpl(eventFn, callback);
function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): EventFunctionWrapper<Args, Return, F> {
const hook = mountWorkInProgressHook();
const eventFn = wrapEventFunction(callback);
hook.memoizedState = eventFn;
useEventImpl(eventFn, callback);
return eventFn;
}

Expand All @@ -1954,7 +1960,8 @@ function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
const hook = updateWorkInProgressHook();
const eventFn = hook.memoizedState;
useEventImpl(eventFn, callback);
return eventFn;
// Always return a new function
return wrapEventFunction(callback);
}

function mountInsertionEffect(
Expand Down
15 changes: 11 additions & 4 deletions packages/react-reconciler/src/ReactFiberHooks.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -1928,10 +1928,9 @@ function useEventImpl<Args, Return, F: (...Array<Args>) => Return>(
}
}

function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
function wrapEventFunction<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): EventFunctionWrapper<Args, Return, F> {
const hook = mountWorkInProgressHook();
const eventFn: EventFunctionWrapper<Args, Return, F> = function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
Expand All @@ -1942,9 +1941,16 @@ function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
return eventFn._impl.apply(undefined, arguments);
};
eventFn._impl = callback;
return eventFn;
}

useEventImpl(eventFn, callback);
function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): EventFunctionWrapper<Args, Return, F> {
const hook = mountWorkInProgressHook();
const eventFn = wrapEventFunction(callback);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Now that we don't reuse the eventFn anymore, there's not any advantage to using it in place of a ref-style mutable object. So I would change this back to something like {impl: eventFn} to avoid confusion.

Also I'm noticing now that during initial render, you don't need to schedule a commit effect. It's already initialized to the correct implementation.

hook.memoizedState = eventFn;
useEventImpl(eventFn, callback);
return eventFn;
}

Expand All @@ -1954,7 +1960,8 @@ function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
const hook = updateWorkInProgressHook();
const eventFn = hook.memoizedState;
useEventImpl(eventFn, callback);
return eventFn;
// Always return a new function
return wrapEventFunction(callback);
}

function mountInsertionEffect(
Expand Down
93 changes: 91 additions & 2 deletions packages/react-reconciler/src/__tests__/useEvent-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,95 @@ describe('useEvent', () => {
expect(Scheduler).toHaveYielded(['Effect value: 2', 'Event value: 2']);
});

// @gate enableUseEventHook
it("doesn't provide a stable identity", () => {
function Counter({shouldRender, value}) {
const onClick = useEvent(() => {
Scheduler.unstable_yieldValue(
'onClick, shouldRender=' + shouldRender + ', value=' + value,
);
});

// onClick doesn't have a stable function identity so this effect will fire on every render.
// In a real app useEvent functions should *not* be passed as a dependency, this is for
// testing purposes only.
useEffect(() => {
onClick();
}, [onClick]);
poteto marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
onClick();
}, [shouldRender]);

return <></>;
}

ReactNoop.render(<Counter shouldRender={true} value={0} />);
expect(Scheduler).toFlushAndYield([
'onClick, shouldRender=true, value=0',
'onClick, shouldRender=true, value=0',
]);

ReactNoop.render(<Counter shouldRender={true} value={1} />);
expect(Scheduler).toFlushAndYield(['onClick, shouldRender=true, value=1']);

ReactNoop.render(<Counter shouldRender={false} value={2} />);
expect(Scheduler).toFlushAndYield([
'onClick, shouldRender=false, value=2',
'onClick, shouldRender=false, value=2',
]);
});

// @gate enableUseEventHook
it('event handlers always see the latest committed value', async () => {
let committedEventHandler = null;

function App({value}) {
const event = useEvent(() => {
return 'Value seen by useEvent: ' + value;
});

// Set up an effect that registers the event handler with an external
// event system (e.g. addEventListener).
useEffect(
() => {
// Log when the effect fires. In the test below, we'll assert that this
// only happens during initial render, not during updates.
Scheduler.unstable_yieldValue('Commit new event handler');
committedEventHandler = event;
return () => {
committedEventHandler = null;
};
},
// Note that we've intentionally omitted the event from the dependency
// array. But it will still be able to see the latest `value`. This is the
// key feature of useEvent that makes it different from a regular closure.
[],
);
return 'Latest rendered value ' + value;
}

// Initial render
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App value={1} />);
});
expect(Scheduler).toHaveYielded(['Commit new event handler']);
expect(root).toMatchRenderedOutput('Latest rendered value 1');
expect(committedEventHandler()).toBe('Value seen by useEvent: 1');

// Update
await act(async () => {
root.render(<App value={2} />);
});
// No new event handler should be committed, because it was omitted from
// the dependency array.
expect(Scheduler).toHaveYielded([]);
// But the event handler should still be able to see the latest value.
expect(root).toMatchRenderedOutput('Latest rendered value 2');
expect(committedEventHandler()).toBe('Value seen by useEvent: 2');
});

// @gate enableUseEventHook
it('integration: implements docs chat room example', () => {
function createConnection() {
Expand Down Expand Up @@ -597,7 +686,7 @@ describe('useEvent', () => {
});
connection.connect();
return () => connection.disconnect();
}, [roomId, onConnected]);
}, [roomId]);

return <Text text={`Welcome to the ${roomId} room!`} />;
}
Expand Down Expand Up @@ -676,7 +765,7 @@ describe('useEvent', () => {

const onVisit = useEvent(visitedUrl => {
Scheduler.unstable_yieldValue(
'url: ' + url + ', numberOfItems: ' + numberOfItems,
'url: ' + visitedUrl + ', numberOfItems: ' + numberOfItems,
);
});

Expand Down