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
useMutableSource: bugfix for new getSnapshot with mutation #18297
Changes from 4 commits
9bee11d
4d25d4e
1a73af5
db511f4
86da5ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -31,6 +31,7 @@ import type { | |
import ReactSharedInternals from 'shared/ReactSharedInternals'; | ||
import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; | ||
|
||
import {markRootExpiredAtTime} from './ReactFiberRoot'; | ||
import {NoWork, Sync} from './ReactFiberExpirationTime'; | ||
import {readContext} from './ReactFiberNewContext'; | ||
import {createDeprecatedResponderListener} from './ReactFiberDeprecatedEvents'; | ||
|
@@ -74,7 +75,7 @@ import { | |
getCurrentPriorityLevel, | ||
} from './SchedulerWithReactIntegration'; | ||
import { | ||
getPendingExpirationTime, | ||
getLastPendingExpirationTime, | ||
getWorkInProgressVersion, | ||
markSourceAsDirty, | ||
setPendingExpirationTime, | ||
|
@@ -886,6 +887,7 @@ function rerenderReducer<S, I, A>( | |
type MutableSourceMemoizedState<Source, Snapshot> = {| | ||
refs: { | ||
getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>, | ||
setSnapshot: Snapshot => void, | ||
}, | ||
source: MutableSource<any>, | ||
subscribe: MutableSourceSubscribeFn<Source, Snapshot>, | ||
|
@@ -914,16 +916,14 @@ function readFromUnsubcribedMutableSource<Source, Snapshot>( | |
isSafeToReadFromSource = currentRenderVersion === version; | ||
} else { | ||
// If there's no version, then we should fallback to checking the update time. | ||
const pendingExpirationTime = getPendingExpirationTime(root); | ||
const pendingExpirationTime = getLastPendingExpirationTime(root); | ||
|
||
if (pendingExpirationTime === NoWork) { | ||
isSafeToReadFromSource = true; | ||
} else { | ||
// If the source has pending updates, we can use the current render's expiration | ||
// time to determine if it's safe to read again from the source. | ||
isSafeToReadFromSource = | ||
pendingExpirationTime === NoWork || | ||
pendingExpirationTime >= renderExpirationTime; | ||
isSafeToReadFromSource = pendingExpirationTime >= renderExpirationTime; | ||
} | ||
|
||
if (isSafeToReadFromSource) { | ||
|
@@ -972,7 +972,8 @@ function useMutableSource<Source, Snapshot>( | |
|
||
const dispatcher = ReactCurrentDispatcher.current; | ||
|
||
const [currentSnapshot, setSnapshot] = dispatcher.useState(() => | ||
// eslint-disable-next-line prefer-const | ||
let [currentSnapshot, setSnapshot] = dispatcher.useState(() => | ||
readFromUnsubcribedMutableSource(root, source, getSnapshot), | ||
); | ||
let snapshot = currentSnapshot; | ||
|
@@ -1000,7 +1001,49 @@ function useMutableSource<Source, Snapshot>( | |
|
||
// Sync the values needed by our subscribe function after each commit. | ||
dispatcher.useEffect(() => { | ||
const didGetSnapshotChange = !is(refs.getSnapshot, getSnapshot); | ||
refs.getSnapshot = getSnapshot; | ||
|
||
// Normally the dispatch function for a state hook never changes, | ||
// but in the case of this hook, it will change if getSnapshot changes. | ||
// In that case, the subscription below will have cloesd over the previous function, | ||
// so we use a ref to ensure that handleChange() always has the latest version. | ||
refs.setSnapshot = setSnapshot; | ||
|
||
// This effect runs on mount, even though getSnapshot hasn't changed. | ||
// In that case we can avoid the additional checks for a changed snapshot, | ||
// because the subscription effect below will cover this. | ||
if (didGetSnapshotChange) { | ||
// Because getSnapshot is shared with subscriptions via a ref, | ||
// we don't resubscribe when getSnapshot changes. | ||
// This means that we also don't check for any missed mutations | ||
// between the render and the passive commit though. | ||
// So we need to check here, just like when we newly subscribe. | ||
const maybeNewVersion = getVersion(source._source); | ||
if (!is(version, maybeNewVersion)) { | ||
const maybeNewSnapshot = getSnapshot(source._source); | ||
if (!is(snapshot, maybeNewSnapshot)) { | ||
setSnapshot(maybeNewSnapshot); | ||
|
||
const currentTime = requestCurrentTimeForUpdate(); | ||
const suspenseConfig = requestCurrentSuspenseConfig(); | ||
const expirationTime = computeExpirationForFiber( | ||
currentTime, | ||
fiber, | ||
suspenseConfig, | ||
); | ||
setPendingExpirationTime(root, expirationTime); | ||
|
||
// If the source mutated between render and now, | ||
// there may be state updates already scheduled from the old getSnapshot. | ||
// Those updates should not commit without this value. | ||
// There is no mechanism currently to associate these updates though, | ||
// so for now we fall back to synchronously flushing all pending updates. | ||
// TODO: Improve this later. | ||
markRootExpiredAtTime(root, getLastPendingExpirationTime(root)); | ||
} | ||
} | ||
} | ||
}, [getSnapshot]); | ||
|
||
// If we got a new source or subscribe function, | ||
|
@@ -1009,8 +1052,10 @@ function useMutableSource<Source, Snapshot>( | |
dispatcher.useEffect(() => { | ||
const handleChange = () => { | ||
const latestGetSnapshot = refs.getSnapshot; | ||
const latestSetSnapshot = refs.setSnapshot; | ||
|
||
try { | ||
setSnapshot(latestGetSnapshot(source._source)); | ||
latestSetSnapshot(latestGetSnapshot(source._source)); | ||
|
||
// Record a pending mutable source update with the same expiration time. | ||
const currentTime = requestCurrentTimeForUpdate(); | ||
|
@@ -1027,9 +1072,11 @@ function useMutableSource<Source, Snapshot>( | |
// e.g. it might try to read from a part of the store that no longer exists. | ||
// In this case we should still schedule an update with React. | ||
// Worst case the selector will throw again and then an error boundary will handle it. | ||
setSnapshot(() => { | ||
throw error; | ||
}); | ||
latestSetSnapshot( | ||
(() => { | ||
throw error; | ||
}: any), | ||
); | ||
} | ||
}; | ||
|
||
|
@@ -1048,6 +1095,20 @@ function useMutableSource<Source, Snapshot>( | |
const maybeNewSnapshot = getSnapshot(source._source); | ||
if (!is(snapshot, maybeNewSnapshot)) { | ||
setSnapshot(maybeNewSnapshot); | ||
|
||
const currentTime = requestCurrentTimeForUpdate(); | ||
const suspenseConfig = requestCurrentSuspenseConfig(); | ||
const expirationTime = computeExpirationForFiber( | ||
currentTime, | ||
fiber, | ||
suspenseConfig, | ||
); | ||
setPendingExpirationTime(root, expirationTime); | ||
|
||
// We missed a mutation before committing. | ||
// It's possible that other components using this source also have pending updates scheduled. | ||
// In that case, we should ensure they all commit together. | ||
markRootExpiredAtTime(root, getLastPendingExpirationTime(root)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Need to mark this new There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change makes sense conceptually, but it breaks the most recent test case I added. In this new test I...
Before this change, both components A and B rendered together after we flushed passive effects. After this change, component B renders and commits, then component A (and we tear in between). This is because- with the change in place, we're marking the root as expired with a higher priority (e.g. 1073741296 instead of 2/idle). I think this indicates that we should be marking root as expired with the first expiration time rather than the last in this case? At least, doing that fixes this test (and also the other two failing tests that were tearing before). This also means that we aren't directly using last expiration time for anything at this point (although indicate it determines when we reset first expiration time in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
} | ||
|
||
|
@@ -1065,11 +1126,31 @@ function useMutableSource<Source, Snapshot>( | |
// | ||
// In both cases, we need to throw away pending udpates (since they are no longer relevant) | ||
// and treat reading from the source as we do in the mount case. | ||
const didGetSnapshotChange = !is(prevGetSnapshot, getSnapshot); | ||
if ( | ||
!is(prevSource, source) || | ||
!is(prevSubscribe, subscribe) || | ||
!is(prevGetSnapshot, getSnapshot) | ||
didGetSnapshotChange | ||
) { | ||
if (didGetSnapshotChange) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can remove this nested check. Need to drop updates from new sources, too. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I wasn't sure about that... I'll remove it 😄 Thanks for the review! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, I think removing this check actually breaks some subtle assumptions about the effects and when we need to update the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it works because when When There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I see how to fix it. Will let me simplify things a bit too... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, here's what 86da5ce did: Recreating the queue and dispatch if So I added There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Weird. I didn't see any of your interleaved commits until just now, after I left mine. Heh. Didn't mean to seem like I was ignoring you.
This requires us to over-subscribe though, (since we can't share that effect without running the previous cleanup function and unsubscribing). Once we replace There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To be clear, I'm trying to avoid re-subscribing unless actually necessary (not just convenient) because subscriptions can be expensive for some of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, I can see why you did it this way. I think we might want to revisit the trade off in the future, though, since changing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah, absolutely. Maybe there's something we could do to detect when people are passing inline functions, so we could warn. |
||
// Create a new queue and setState method, | ||
// So if there are interleaved updates, they get pushed to the older queue. | ||
// When this becomes current, the previous queue and dispatch method will be discarded, | ||
// including any interleaving updates that occur. | ||
const newQueue = { | ||
pending: null, | ||
dispatch: null, | ||
lastRenderedReducer: basicStateReducer, | ||
lastRenderedState: snapshot, | ||
}; | ||
newQueue.dispatch = setSnapshot = (dispatchAction.bind( | ||
null, | ||
currentlyRenderingFiber, | ||
newQueue, | ||
): any); | ||
stateHook.queue = newQueue; | ||
} | ||
|
||
stateHook.baseQueue = null; | ||
snapshot = readFromUnsubcribedMutableSource(root, source, getSnapshot); | ||
stateHook.memoizedState = stateHook.baseState = snapshot; | ||
|
@@ -1087,6 +1168,7 @@ function mountMutableSource<Source, Snapshot>( | |
hook.memoizedState = ({ | ||
refs: { | ||
getSnapshot, | ||
setSnapshot: (null: any), | ||
}, | ||
source, | ||
subscribe, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you need to do the same
markRootExpiredAtTime
thing during initial mount and when resubscribing, too, to avoid a momentary flicker.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm...do we?
A change firing between render and subscription means that we missed an update, but we have other checks in place to avoid actually tearing between other components that read from the source.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Put another way, the reason we added it where we did was because there's a potential tear- but in the subscribe-on-commit case, there's not a tear, just a potential missed update. Also at that point, we would have already shown the older value because we subscribe in a passive effect.
Unless I'm misunderstanding what you're suggesting?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow up on the Dave /Recoil comment: Been stepping through that today, and it looks like the Recoil getter is also a setter, which is causing a loop. (In other words I think it's a Recoil problem, not a uMS problem.)