I'm using a ref to set an interval that's checking a buffer state of a media element.
The interval is successfully set, however, it's not being cleared.
const keepCheckingBuffer = useCallback(
() => {
const targetRef = !hasVideo ? audioRef.current : vidRef.current;
console.log(targetRef.readyState, targetRef.HAVE_FUTURE_DATA);
console.log('keep checking buffer');
if (targetRef.readyState <= targetRef.HAVE_FUTURE_DATA) {
bufferCheckInterval.current = setInterval(() => {
console.log('im running and interval!!!');
if (targetRef.readyState > targetRef.HAVE_FUTURE_DATA) {
setIsBuffered(true);
setIsVideoReadyToPlay(true);
}
}, 1000);
console.log('not buffering, setInterval');
} else {
console.log('clearInterval');
clearInterval(bufferCheckInterval.current);
}
},
[audioRef, vidRef, hasVideo],
);
I'd prefer not to use a useEffect since my component rerenders a lot (it is expected) and I don't want to tie the triggering to a state inside the dependecy array.
I also tried to remove the useCallback hook.
Thanks all,
Related
I have a long process that updates the state. I want to show red background when it's running and blue when it's done.
const MapBuilder = (props) => {
const [backgroundColor, setBackgroundColor] = useState(false);
const [fancyResult, setFancyResult] = useState(null);
console.log(`stop 1 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
const veryHardWork = () => {
setBackgroundColor("red");
console.log(`stop 2 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
for (let i = 0; i < 1000; i++) {
for (let j = 0; j < 1000; j++) {
console.log("So hard")
}
}
setFancyResult("done")
console.log(`stop 3 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
setBackgroundColor("blue")
console.log(`stop 4 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
}
return (<div style={{background: backgroundColor}}>
<button className="btn btn-primary" onClick={veryHardWork}></button>
</div>)
}
Here is an output of such run
stop 1 backgroundColor false fancyResult null
MapBuilder.js:13 stop 2 backgroundColor false fancyResult null
10000MapBuilder.js:16 So hard
MapBuilder.js:20 stop 3 backgroundColor false fancyResult null
MapBuilder.js:22 stop 4 backgroundColor false fancyResult null
MapBuilder.js:10 stop 1 backgroundColor blue fancyResult done
I understand from this that the state change only happens after the method veryHardWork is finished. In my real project, I actually want to show a spinner the question is how can I do it if the state is only changed at the end of the method.
I think some clarification needs to be added. In reality, I allow the user to choose a file after the user chooses the file it is loaded and some heavy processing is performed on files data while the processing is running I want to show a spinner no Asyn work involved.
Some of the answers sugested to use useEffect and moving it to a promise I tryied both but it did not help here is a different take on it which also did not work
const MapBuilder = (props) => {
const [backgroundColor, setBackgroundColor] = useState(false);
const [fancyResult, setFancyResult] = useState(null);
const [startProcessing, setStartProcessing] = useState(null);
useEffect(() => {
let myFunc = async () => {
if (startProcessing) {
setBackgroundColor("red");
await hardWork();
setBackgroundColor("blue");
setStartProcessing(false);
}
}
myFunc();
}, [startProcessing])
const hardWork = () => {
return new Promise((resolve)=> {
for (let i = 0; i < 500; i++) {
for (let j = 0; j < 100; j++) {
console.log("So hard")
}
}
setFancyResult("sdsadsad")
resolve("dfsdfds")
})
}
return (<div style={{background: backgroundColor}}>
<button className="btn btn-primary" onClick={() => setStartProcessing(true)}></button>
</div>)
}
export default MapBuilder;
The problem with the approach is that the heavy calculation is happening at the main loop with the same priority. The red color change will not ever cause any changes until all things at the event handler have been finished.
With Reach 18 you can make your heavy calculation to be with lower priority and let the UI changes happen with normal priority. You can make this happen with minor change on your code base:
const veryHardWork = () => {
setBackgroundColor("red");
// Set heavy calculation to happen with lower priority here...
startTransition(() => {
console.log(`stop 2 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
for (let i = 0; i < 1000; i++) {
for (let j = 0; j < 1000; j++) {
console.log("So hard")
}
}
setFancyResult("done")
setBackgroundColor("blue")
}
}
So I've made you a more real world example as the code you posted doesn't look like what you're actually wanting to achieve.
The scenario to my understanding is you want to preform some setup actions to get your state / data ready before showing it to the user.
cool, so first we will need some state to keep track of when we're ready to show content to the user lets call it isLoading. This will be a boolean that we can use to conditionally return either a loading spinner, or our content.
next we need some state to keep hold of our data, lets call this one content.
each state will be created from React.useState which can be imported with import { useState } from 'react';. We will then create variables in the following format:
const [state, setState] = useState(null);
nice, so now lets do somthing when the component mounts, for this we will use React.useEffect this hook can be used to tap into the lifecycle of a component.
inside our useEffect block we will preform our set up. In this case I'll say it's an async function that get some data from an API and then sets it to state.
lastly we will use our isLoading state to decide when we're ready to show the user something more interesting than a spinner.
All together we get something like this:
import { useState, useEffect } from 'react';
const MyComponent = () => {
// create some state to manage when what is shown
const [isLoading, setIsLoading] = useState(true);
// create some state to manage content
const [content, setContent] = useState(null);
// when the component is mounted preform some setup actions
useEffect(() => {
const setup = async () => {
// do some setup actions, like fetching from an API
const result = await fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(r => r.json());
// update our state that manages content
setContent(result);
// when we are happy everything is ready show the user the content
setIsLoading(false);
};
// run our setup function
setup();
}, [ ]);
// if we are not yet ready to show the user data, show a loading message
if (isLoading) {
return (
<div>
<p>spinner goes in this div</p>
</div>
)
}
// when we are ready to show the user data is will be shown in this return statement
return (
<div>
<p>this div will show when the `isLoading` state is true and do something with the content state is wanted</p>
</div>
)
}
I believe you'll find this more useful than the example you provided
You already control state of fancyResult,
or you can use showSpinner state for only reason to show spinner
You can use for long progress Promise [Link] And Async/Await [Link]
const veryHardWork = async () => {
setBackgroundColor("red");
const awaiting = await new Promise((resolve, reject) => {
for (let i = 0; i < 1000; i++) {
for (let j = 0; j < 1000; j++) {
resolve(console.log("So hard"));
}
}
})
// After Finished Awating Hardwork
setFancyResult("done");
setBackgroundColor("blue") ;
}
return (
<div style={{background: fancyResult === 'done' ? 'blue': 'red'}}>
<button className="btn btn-primary" onClick={veryHardWork}></button>
{ fancyResult === 'done' && 'Show Spinner' }
</div>
)
Try this:
import { useState, useEffect } from "react";
import { flushSync } from "react-dom";
const MapBuilder = (props) => {
const [backgroundColor, setBackgroundColor] = useState(false);
const [fancyResult, setFancyResult] = useState(null);
// this will only be called on the first mount
useEffect(() => {
console.log(
`backgroundColor ${backgroundColor} fancyResult ${fancyResult}`
);
}, [backgroundColor, fancyResult]);
const veryHardWork = () => {
setBackgroundColor("red");
setTimeout(() => {
for (let i = 0; i < 1000; i++) {
for (let j = 0; j < 1000; j++) {
for (let k = 0; k < 1000; k++) {
// console.log("So hard");
}
}
}
setFancyResult("done");
// flushSync(() => setFancyResult("done"));
console.log(
`inside closure - OUTDATED: backgroundColor ${backgroundColor} fancyResult ${fancyResult}`
);
setBackgroundColor("blue");
}, 0);
};
return (
<div style={{ background: backgroundColor }}>
<button className="btn btn-primary" onClick={veryHardWork}>
Work!
</button>
</div>
);
};
export default MapBuilder;
CodeSandbox
Explanation:
Unblocking the UI thread
In order for the color to change to red, the UI thread must be freed up to
make the state changes (set background color to red) and
do the re-render.
One way to achieve this is by using setTimeout. This puts a function on a queue to be run later, allowing the UI thread to finish the above 2 tasks before tackling the actual work. You should note though, this doesn’t actually run your work on a new thread, so once the work starts getting done, the UI will be unresponsive until the work is done. Consider using a Web Worker to solve this in the future.
Logging the current state
The other thing to understand about React is that every time a re-render occurs, the entire function ('MapBuilder’ in this case) is re-run. This means that your ‘stop 1’ message will be displayed every re-render, and therefore every time the state changes.
Additionally, logging the state from within veryHardWork will log the state when the function was defined. This means that the value will be outdated, i.e. stale. This is because of a functional concept called closures. From Wikipedia “Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.”
So how should we log the current state when it is changed? By using the useEffect hook. This function will be re-run whenever any of the dependencies change ([backgroundColor, fancyResult] in this case).
Console.log undefined behavior
Another thing to note is that many console.logs should not be used as the ‘work’. The rendering of the log will happen asynchronously, so ‘firing’ the logs will be much quicker than they will actually show up. This leads the observer watching the console to think that the ‘red’ stage has been skipped. Instead, we can just loop more times, or do some math in the loop, etc (which is closer to what your actual synchronous work will be anyway). In fact, console.log seems to be quite unpredictable, as noted here.
Automatic Batching
You might be wondering why “done” and “blue” show up as a single state update (i.e. stop 3 and 4 happen at the same time). This is because of automatic batching. As a performance optimization, react attempts to ‘batch’ state changes to prevent additional re-renders. To prevent this behavior, you can uncomment line 27 flushSync(() => setFancyResult("done”)). This is not necessary for this use-case, as the batching is appropriate here, but it’s helpful to understand what’s going on.
I have a react app that uses the MS Graph API (so it's a bit difficult to post a minimal reproducible example). It has a state variable called chats that is designed to hold the result of fetching a list of chats from the graph API. I have to poll the API frequently to get new chats.
I query the chats endpoint, build an array of newChats and then setChats. I then set a timeout that refreshes the data every 10 seconds (it checks for premature invocation through the timestamp property stored in the state). If the component is unmounted, a flag is set, live (useRef), which stops the refresh process. Each chat object is then rendered by the Chat component (not shown).
Here's the code (I've edited by hand here to remove some irrelevant bits around styles and event propagation so it's possible that typo's have crept in -- it compiles and runs in reality).
const Chats = () => {
const [chats, setChats] = useState({ chats: [], timestamp: 0 });
const live = useRef(true);
const fetchChats = () => {
if (live.current && Date.now() - chats.timestamp < 9000) return;
fetchData(`${baseBeta}/me/chats`).then(res => {
if (res.value.length === chats.chats.length) return;
const chatIds = chats.chats.map(chat => chat.id);
const newChats = res.value.filter(chat => !chatIds.includes(chat.id));
if (newChats.length > 0) {
setChats(c => ({ chats: [...c.chats, ...newChats], timestamp: Date.now() }));
}
setTimeout(fetchChats, 10000);
});
};
useEffect(() => {
fetchChats();
return () => (live.current = false);
}, [chats]);
return (
<div>
{chats.chats.map(chat => (
<Chat chat={chat} />
))}
</div>
);
};
The Chat component must also make some async calls for data before it is rendered.
This code works, for a second or two. I see the Chat component rendered on the screen with the correct details (chat member names, avatars, etc.), but almost before it has completed rendering I see the list elements being removed, apparently one at a time, though that could just be the way its rendered -- it could be all at once. The list collapses on the screen, showing that the chat state has been cleared out. I don't know why this is happening.
I've stepped through the code in the debugger and I can see the newChats array being populated. I can see the setChats call happen. If I put a breakpoint on that line then it is only invoked once and that's the only line that sets that particular state.
So, what's going on? I'm pretty sure React isn't broken. I've used it before without much trouble. What's changed recently is the inclusion of the refresh code. I'm suspicious that the reset is taking away the state. My understanding is that the fetchChats method will be rendered every time the chats state changes and so should see the current value of the chats state. Just in case this wasn't happening, I passed the chats state from the useEffect like this:
useEffect(() => {
fetchChats(chats);
return () => (live.current = false);
}, [chats]);
With the necessary changes in fetchChats to make this work as expected. I get the same result, the chats state is lost after a few seconds.
Edit
Still Broken:
After #Aleks answer my useEffect now looks like this:
useEffect(() => {
let cancel = null;
let live = true;
const fetchChats = () => {
if (Date.now() - chats.timestamp < 9000) return;
fetchData(`${baseBeta}/me/chats`).then(res => {
if (res.value.length === chats.chats.length) return;
const chatIds = chats.chats.map(chat => chat.id);
const newChats = res.value.filter(chat => chat.chatType === "oneOnOne" && !chatIds.includes(chat.id));
if (newChats.length > 0 && live) {
setChats(c => ({ chats: [...c.chats, ...newChats], timestamp: Date.now() }));
}
cancel = setTimeout(fetchChats, 10000);
});
};
fetchChats();
return () => {
live = false;
cancel?.();
};
}, []);
The result of this is that the chats are loaded, cleared, and loaded again, repeatedly. This is better, at least they're reloading now, whereas previously they would disappear forever. They are reloaded every 10 seconds, and cleared out almost immediately still.
Eventually, probably due to random timings in the async calls, the entries in the list are duplicated and the 2nd copy starts being removed immediately instead of the first copy.
There are multiple problems. First this
setTimeout(fetchChats, 10000); will trigger
useEffect(() => {
fetchChats(chats);
return () => (live.current = false);
}, [chats])
You will get 2 fetches one after another.
But the bug you're seeing is because of this
return () => (live.current = false);
On second useEffect trigger, clean up function above with run and live.current will be forever false from now on.
And as Nikki9696 said you you need to clear Timeout in clean up function
The easiest fix to this is, probably
useEffect(() => {
let cancel = null;
let live = true;
const fetchChats = () => {
// not needed
//if ( Date.now() - chats.timestamp < 9000) return;
fetchData(`${baseBeta}/me/chats`).then(res => {
//this line is not needed
//if (res.value.length === chats.chats.length) return;
// remove all the filtering, it can be done elsewhere where
// you can access fresh chat state
//const chatIds = chats.chats.map(chat => chat.id);
//const newChats = res.value.filter(chat =>
//!chatIds.includes(chat.id));
if (res.value?.length > 0&&live) {
setChats(c => ({ chats: [...c.chats, ...res.value], timestamp: Date.now() }));
cancel = setTimeout(fetchChats, 10000);
}
});
};
fetchChats()
return () => { live=false; if(cancel)window.clearTimeout(cancel) };
}, []);
Edit: typo cancel?.() to window.clearTimeout(cancel);
Ok, I have an idea what's happening and how to fix it. I am still not sure why it is behaving like this, so please comment if you understand it better than me.
Basically, for some reason I don't understand, the function fetchChats only ever sees the initial state of chats. I am making the mistake of filtering my newly fetched list against this state, in which the array is empty.
If I change my useEffect code to do this instead:
setChats(c => {
return {
chats: [
...c.chats,
...res.value.filter(cc => {
const a = c.chats.map(chat => chat.id);
return !a.includes(cc.id);
})
],
timestamp: Date.now()
};
});
Then my filter is passed the current value of the state for chats rather than the initial state.
I thought that because the function containing this code is in the function that declares the chat state, whenever that state changed the whole function would be rendered with the new value of chats making it available to its nested functions. This isn't the case here and I don't understand why.
The solution, to only trust the values of the state that is handed to me during the setState (setChats) call, works fine and I'll go with it, but I'd love to know what is wrong with reading the state directly.
Trying to create an auto-clicker/idle game. So far the entire application works except for this loop. After the loop begins, if I update the counter, different values update in intervals. So my counter will display those different values, going back and forth between them depending on how many times I've tried to mess with the counter while its looping.
I've tried using while loops, if statements, and for loops. And for each of those loops I've tried both setInterval() and setTimeout(). They either lead to the problem above, or the browser crashing.
Here's a video of the issue:
Youtube Link
Here's the relevant code I've got currently:
const Counter = () => {
const [counter, setCounter] = useState(1);
const [minions, setMinions] = useState(0);
let minionCost = minions * 10 + 6;
let autoMinions = () => {
if (minions > 0) {
setTimeout(() => {
setCounter(minions + counter);
}, 1000);
} else {
return null;
}
};
const onClickMinion = () => {
if (counter < minionCost) {
console.log(`you don't have ${minionCost} to spend`);
} else {
setCounter(counter - minionCost);
setMinions(minions + 1);
}
};
autoMinions();
};
If you're computing state based off of a previous state, you should use functional updates.
Try passing setCounter a function that receives the previous state instead of using counter directly (do this with any of your useState hooks that depend on previous state):
setCounter(prevCounter => prevCounter + minions)
I have some recoil state, that i want to reset.
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
...
//should be used for flushing the global recoil state, whenever a user submits an invoice
const resetLabelInvoiceState = useResetRecoilState(labelInvoiceState);
const resetMetaDataState = useResetRecoilState(metadataState);
const resetGlobalAnnotationsState = useResetRecoilState(globalAnnotationState)
I have made function, that i suppoes to reset all the states like this. I have both tried with and without the reset function.
const flushRecoilState = () =>{
console.log('flushed state')
return(
resetLabelInvoiceState(),
resetMetaDataState(),
resetGlobalAnnotationsState()
)
}
...
flushRecoilState()
return history.push('/historyinvoices')
...
When i check the state it is not reset. Is it because the `useResetRecoilState´ is not working properly from the library, is not implemented properly, or is there some other problem.
I could just use the regular useRecoilState hook, and just set the state back to the default value.
Does anybody know why this could be?
I had the same problem today, it turns out to be my own fault. Just put it here for future reference.
My problem was that I changed the set method in the selector, if you customized the set method, you need to check if the incoming value is a DefaultValue.
const targetYear = selector({
key: 'targetYear',
get: ({get}) => get(targetYearAtom),
set: ({set, get}, method) => {
const currentTargetYear = get(targetYearAtom);
switch(method) {
case 'prevYear':
set(targetYearAtom, currentTargetYear - 1);
return;
case 'nextYear':
set(targetYearAtom, currentTargetYear + 1);
return;
default:
if (method instanceof DefaultValue) {
set(targetYearAtom, method);
}
return;
}
},
})
My code is causing an unexpected amount of re-renders.
function App() {
const [isOn, setIsOn] = useState(false)
const [timer, setTimer] = useState(0)
console.log('re-rendered', timer)
useEffect(() => {
let interval
if (isOn) {
interval = setInterval(() => setTimer(timer + 1), 1000)
}
return () => clearInterval(interval)
}, [isOn])
return (
<div>
{timer}
{!isOn && (
<button type="button" onClick={() => setIsOn(true)}>
Start
</button>
)}
{isOn && (
<button type="button" onClick={() => setIsOn(false)}>
Stop
</button>
)}
</div>
);
}
Note the console.log on line 4. What I expected is the following to be logged out:
re-rendered 0
re-rendered 0
re-rendered 1
The first log is for the initial render. The second log is for the re-render when the "isOn" state changes via the button click. The third log is when setInterval calls setTimer so it's re-rendered again. Here is what I actually get:
re-rendered 0
re-rendered 0
re-rendered 1
re-rendered 1
I can't figure out why there is a fourth log. Here's a link to a REPL of it:
https://codesandbox.io/s/kx393n58r7
***Just to clarify, I know the solution is to use setTimer(timer => timer + 1), but I would like to know why the code above causes a fourth render.
The function with the bulk of what happens when you call the setter returned by useState is dispatchAction within ReactFiberHooks.js (currently starting at line 1009).
The block of code that checks to see if the state has changed (and potentially skips the re-render if it has not changed) is currently surrounded by the following condition:
if (
fiber.expirationTime === NoWork &&
(alternate === null || alternate.expirationTime === NoWork)
) {
My assumption on seeing this was that this condition evaluated to false after the second setTimer call. To verify this, I copied the development CDN React files and added some console logs to the dispatchAction function:
function dispatchAction(fiber, queue, action) {
!(numberOfReRenders < RE_RENDER_LIMIT) ? invariant(false, 'Too many re-renders. React limits the number of renders to prevent an infinite loop.') : void 0;
{
!(arguments.length <= 3) ? warning$1(false, "State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().') : void 0;
}
console.log("dispatchAction1");
var alternate = fiber.alternate;
if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
// This is a render phase update. Stash it in a lazily-created map of
// queue -> linked list of updates. After this render pass, we'll restart
// and apply the stashed updates on top of the work-in-progress hook.
didScheduleRenderPhaseUpdate = true;
var update = {
expirationTime: renderExpirationTime,
action: action,
eagerReducer: null,
eagerState: null,
next: null
};
if (renderPhaseUpdates === null) {
renderPhaseUpdates = new Map();
}
var firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
if (firstRenderPhaseUpdate === undefined) {
renderPhaseUpdates.set(queue, update);
} else {
// Append the update to the end of the list.
var lastRenderPhaseUpdate = firstRenderPhaseUpdate;
while (lastRenderPhaseUpdate.next !== null) {
lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
}
lastRenderPhaseUpdate.next = update;
}
} else {
flushPassiveEffects();
console.log("dispatchAction2");
var currentTime = requestCurrentTime();
var _expirationTime = computeExpirationForFiber(currentTime, fiber);
var _update2 = {
expirationTime: _expirationTime,
action: action,
eagerReducer: null,
eagerState: null,
next: null
};
// Append the update to the end of the list.
var _last = queue.last;
if (_last === null) {
// This is the first update. Create a circular list.
_update2.next = _update2;
} else {
var first = _last.next;
if (first !== null) {
// Still circular.
_update2.next = first;
}
_last.next = _update2;
}
queue.last = _update2;
console.log("expiration: " + fiber.expirationTime);
if (alternate) {
console.log("alternate expiration: " + alternate.expirationTime);
}
if (fiber.expirationTime === NoWork && (alternate === null || alternate.expirationTime === NoWork)) {
console.log("dispatchAction3");
// The queue is currently empty, which means we can eagerly compute the
// next state before entering the render phase. If the new state is the
// same as the current state, we may be able to bail out entirely.
var _eagerReducer = queue.eagerReducer;
if (_eagerReducer !== null) {
var prevDispatcher = void 0;
{
prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
var currentState = queue.eagerState;
var _eagerState = _eagerReducer(currentState, action);
// Stash the eagerly computed state, and the reducer used to compute
// it, on the update object. If the reducer hasn't changed by the
// time we enter the render phase, then the eager state can be used
// without calling the reducer again.
_update2.eagerReducer = _eagerReducer;
_update2.eagerState = _eagerState;
if (is(_eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
// It's still possible that we'll need to rebase this update later,
// if the component re-renders for a different reason and by that
// time the reducer has changed.
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
{
ReactCurrentDispatcher$1.current = prevDispatcher;
}
}
}
}
{
if (shouldWarnForUnbatchedSetState === true) {
warnIfNotCurrentlyBatchingInDev(fiber);
}
}
scheduleWork(fiber, _expirationTime);
}
}
and here's the console output with some additional comments for clarity:
re-rendered 0 // initial render
dispatchAction1 // setIsOn
dispatchAction2
expiration: 0
dispatchAction3
re-rendered 0
dispatchAction1 // first call to setTimer
dispatchAction2
expiration: 1073741823
alternate expiration: 0
re-rendered 1
dispatchAction1 // second call to setTimer
dispatchAction2
expiration: 0
alternate expiration: 1073741823
re-rendered 1
dispatchAction1 // third and subsequent calls to setTimer all look like this
dispatchAction2
expiration: 0
alternate expiration: 0
dispatchAction3
NoWork has a value of zero. You can see that the first log of fiber.expirationTime after setTimer has a non-zero value. In the logs from the second setTimer call, that fiber.expirationTime has been moved to alternate.expirationTime still preventing the state comparison so re-render will be unconditional. After that, both the fiber and alternate expiration times are 0 (NoWork) and then it does the state comparison and avoids a re-render.
This description of the React Fiber Architecture is a good starting point for trying to understand the purpose of expirationTime.
The most relevant portions of the source code for understanding it are:
ReactFiberExpirationTime.js
ReactFiberScheduler.js
I believe the expiration times are mainly relevant for concurrent mode which is not yet enabled by default. The expiration time indicates the point in time after which React will force a commit of the work at the earliest opportunity. Prior to that point in time, React may choose to batch updates. Some updates (such as from user interactions) have a very short (high priority) expiration, and other updates (such as from async code after a fetch completes) have a longer (low priority) expiration. The updates triggered by setTimer from within the setInterval callback would fall in the low priority category and could potentially be batched (if concurrent mode were enabled). Since there is the possibility of that work having been batched or potentially discarded, React queues a re-render unconditionally (even when the state is unchanged since the previous update) if the previous update had an expirationTime.
You can see my answer here to learn a bit more about finding your way through the React code to get to this dispatchAction function.
For others who want to do some digging of their own, here's a CodeSandbox with my modified version of React:
The react files are modified copies of these files:
https://unpkg.com/react#16/umd/react.development.js
https://unpkg.com/react-dom#16/umd/react-dom.development.js