Repository: VKCOM/VKUI
Pull request #8327: fix(useTodayDate): Use new date when date is updated in timeout
Author: login: andrey-medvedev-vk
Labels: name: type:bug
Created: 2025-03-04T13:23:17Z | Merged: 2025-03-18T08:28:48Z



---

<!-- Чеклист. Лишние пункты можно удалить если изменения не подразумевают их наличие. Иначе, необходимо обоснование по каждому пункту. -->
- [x] Unit-тесты
- [ ] e2e-тесты
- [ ] Дизайн-ревью
- [ ] Документация фичи
- [x] Release notes

## Описание
Обнаружилось две проблемы с обновлением даты, если флаг `listenDayChangesForUpdate` на подписку на смену дня включен.

1. Дата для обновления в коллбэке `setTimeout` берётся из замыкания и дата следующего дня не устанавливается.
  https://github.com/VKCOM/VKUI/blob/de20a812771766e0a09287ba2c5874c50e383adf/packages/vkui/src/hooks/useTodayDate.ts#L35-L37
  А она равна значению даты в момент когда мы создаём таймер, то есть даже если таймер сработает, то дату следующего дня мы не получим, мы установим дату предыдущего дня, дату создания таймера.

Решаем использованием `new Date()` внутри коллбэка `setTimeout`.

2. Таймер устанавливается на конец текущего дня, а не на начало сегодняшнего.
  https://github.com/VKCOM/VKUI/blob/de20a812771766e0a09287ba2c5874c50e383adf/packages/vkui/src/hooks/useTodayDate.ts#L29
  Есть маленькая вероятность, что таймер вызовет колббэк всё ещё в конце текущего дня,  а не в начале следующего, из-за того, что мы высчитываем время до последней миллисекунды текущего дня.
По крайней мере в тестах такое легко поймать, когда даты замоканы.

Решаем использованием функции `startOfTomorrow()` вместо `endOfToday()`.

Добавлены тесты хука. В них старался поймать и лишний рендер из-за того, что в `setTimeout` дата для следующего дня обновляется, и правильное время вызова `setTimeout`.

## Release notes
## Исправления
- исправлено обновление даты в хуке useTodayDate по таймеру
<!-- 
Необходимо описать основные изменения, которые будут отображены в release notes релиза, в который попадет задача
Формат следующий:
- Если вы не хотите, чтобы в Release notes попала информация о вашем PR, то оставьте просто "-"
- Изменения нужно сгруппировать в секции. Можно указать несколько секций, порядок не важен. Секция должна быть заголовом второго уровня (`## ${заголовок}`). Ниже список секций
  - Новые компоненты
  - Улучшения
  - Исправления
  - Документация
  - Зависимости
  - BREAKING CHANGE
- В каждой секции нужно указать список изменений
- Каждый пункт изменений должен начинаться с '-'
- Если изменение касается какого-то конкретного компонента, то его название должно быть указано и отделено от описания через ':'
Пример:
> - CustomSelect: поправлен баг с неправильным позиционированием

или

> - [CustomSelect](https://vkcom.github.io/VKUI/${version}/#/CustomSelect): поправил баг с неправильным позиционированием 

- Если изменений по одному компоненту несколько, их нужно указать в следующем формате
> - CustomSelect:
>   - Поправлен баг с позиционированием
>   - Добавлен новый props

- Если изменение не касается конкретного компонента, то его нужно также указать через '-' и далее в свободной форме
Пример:
> - Переделан механизм отображения всех модальных окон

- Для каждого пункта можно добавить дополнительную информацию. Ее можно указать на следующей строке после описания изменения

Пример:
> ## Новые компоненты
> - Button: компонент, для отображения кнопок
> Более подробное описание
> {Картинка нового компонента}

-->



Diff:
diff --git a/packages/vkui/jest.config.ts b/packages/vkui/jest.config.ts
index f905abbe416..f40b574ace3 100644
--- a/packages/vkui/jest.config.ts
+++ b/packages/vkui/jest.config.ts
@@ -14,6 +14,7 @@ const config: Config = {
   displayName: 'unit',
   roots: [path.join(__dirname, 'src')],
   setupFilesAfterEnv: [path.join(__dirname, 'jest.setup.ts')],
+  globalSetup: '<rootDir>/jest.global-setup.ts',
   testRegex: '\\.test\\.tsx?$',
   collectCoverage: true,
   collectCoverageFrom: ['src/*/**/**.{ts,tsx}'],
diff --git a/packages/vkui/jest.global-setup.ts b/packages/vkui/jest.global-setup.ts
new file mode 100644
index 00000000000..5388599b2cf
--- /dev/null
+++ b/packages/vkui/jest.global-setup.ts
@@ -0,0 +1,9 @@
+module.exports = async function () {
+  // чтобы быть уверенными, что тесты, связанные с датами, не будут
+  // иметь разные результаты в зависимости от временной зоны машины
+  // на которой эти тесты запущены.
+  // UTC выбрана для того, чтобы упростить дебаг тестов с датами и не
+  // путаться если в локальной зоне (+03) должен быть следующий день
+  // а в логах видим предыдущий.
+  process.env.TZ = 'UTC';
+};
diff --git a/packages/vkui/src/hooks/useTodayDate.test.ts b/packages/vkui/src/hooks/useTodayDate.test.ts
new file mode 100644
index 00000000000..f5df79a2284
--- /dev/null
+++ b/packages/vkui/src/hooks/useTodayDate.test.ts
@@ -0,0 +1,148 @@
+import { fireEvent, renderHook } from '@testing-library/react';
+import { useTodayDate } from './useTodayDate';
+
+describe(useTodayDate, () => {
+  beforeEach(() => {
+    jest.useFakeTimers();
+  });
+
+  it('returns same date if day is not changed', () => {
+    const currentDate = new Date(2024, 2, 3);
+    currentDate.setHours(10);
+    jest.setSystemTime(currentDate);
+
+    const listenDayChangesForUpdate = true;
+    const hookResult = renderHook(() => useTodayDate(listenDayChangesForUpdate));
+
+    expect(hookResult.result.current).toStrictEqual(currentDate);
+
+    // чуть меняем время, но день остаётся тот же
+    let now = new Date(currentDate);
+    now.setHours(now.getHours() + 2);
+    jest.setSystemTime(now);
+
+    hookResult.rerender();
+
+    // возвращается всё тот же день
+    expect(hookResult.result.current.getDate()).toStrictEqual(currentDate.getDate());
+
+    hookResult.unmount();
+  });
+
+  it('returns same date if time is changed but there is no timeout or visibilitychange event', () => {
+    const currentDate = new Date(2024, 2, 3);
+    currentDate.setHours(10);
+    jest.setSystemTime(currentDate);
+
+    const listenDayChangesForUpdate = true;
+    const hookResult = renderHook(() => useTodayDate(listenDayChangesForUpdate));
+
+    expect(hookResult.result.current).toStrictEqual(currentDate);
+
+    // меняем текущую дату на следующий день
+    let now = new Date(currentDate);
+    now.setDate(now.getDate() + 1);
+    jest.setSystemTime(now);
+
+    hookResult.rerender();
+
+    // возвращается всё тот же день
+    expect(hookResult.result.current.getDate()).toStrictEqual(currentDate.getDate());
+
+    hookResult.unmount();
+  });
+
+  it('returns next day if timer is fired', () => {
+    const currentDate = new Date(2024, 2, 3);
+    currentDate.setHours(10);
+    jest.setSystemTime(currentDate);
+    const setTimeoutStub = jest.spyOn(window, 'setTimeout');
+
+    const listenDayChangesForUpdate = true;
+    const hookResult = renderHook(() => useTodayDate(listenDayChangesForUpdate));
+
+    expect(hookResult.result.current).toStrictEqual(currentDate);
+
+    // меняем текущую дату на следующий день
+    jest.runAllTimers();
+
+    hookResult.rerender();
+
+    // возвращается следующий день
+    expect(hookResult.result.current.getDate()).toStrictEqual(4);
+    // проверяем, что не было лишних ререндеров
+    // если бы день через таймер не обновился бы на новый, то
+    // был бы третий вызов setTimout, так как сработал бы код
+    // с проверкой на то, изменился ли день в таймауте и стейт обновился бы снова.
+    expect(setTimeoutStub).toHaveBeenCalledTimes(2);
+  });
+
+  it('returns same day if visibilitychange event fired but day is not changed', () => {
+    const currentDate = new Date(2024, 2, 3);
+    currentDate.setHours(10);
+    jest.setSystemTime(currentDate);
+
+    const listenDayChangesForUpdate = true;
+    const hookResult = renderHook(() => useTodayDate(listenDayChangesForUpdate));
+
+    expect(hookResult.result.current).toStrictEqual(currentDate);
+
+    jest.spyOn(document, 'visibilityState', 'get').mockImplementation(() => 'visible');
+    fireEvent(document, new Event('visibilitychange'));
+
+    hookResult.rerender();
+
+    // возвращается тот же день
+    expect(hookResult.result.current.getDate()).toStrictEqual(3);
+  });
+
+  it('returns same day if visibilitychange event fired with hidden state even if day is changed', () => {
+    const currentDate = new Date(2024, 2, 3);
+    currentDate.setHours(10);
+    jest.setSystemTime(currentDate);
+
+    const listenDayChangesForUpdate = true;
+    const hookResult = renderHook(() => useTodayDate(listenDayChangesForUpdate));
+
+    expect(hookResult.result.current).toStrictEqual(currentDate);
+
+    // меняем текущую дату на следующий день
+    let now = new Date(currentDate);
+    now.setDate(now.getDate() + 1);
+    jest.setSystemTime(now);
+
+    // visibilityState = hidden
+    jest.spyOn(document, 'visibilityState', 'get').mockImplementation(() => 'hidden');
+    fireEvent(document, new Event('visibilitychange'));
+
+    hookResult.rerender();
+
+    // возвращается тот же день
+    expect(hookResult.result.current.getDate()).toStrictEqual(3);
+  });
+
+  it('returns next day when visibilitychange event fired and day is changed', () => {
+    const currentDate = new Date(2024, 2, 3);
+    currentDate.setHours(10);
+    jest.setSystemTime(currentDate);
+
+    const listenDayChangesForUpdate = true;
+    const hookResult = renderHook(() => useTodayDate(listenDayChangesForUpdate));
+
+    expect(hookResult.result.current).toStrictEqual(currentDate);
+
+    // меняем текущую дату на следующий день
+    let now = new Date(currentDate);
+    now.setDate(now.getDate() + 1);
+    jest.setSystemTime(now);
+
+    // visibilityState = hidden
+    jest.spyOn(document, 'visibilityState', 'get').mockImplementation(() => 'visible');
+    fireEvent(document, new Event('visibilitychange'));
+
+    hookResult.rerender();
+
+    // возвращается следующий день
+    expect(hookResult.result.current.getDate()).toStrictEqual(4);
+  });
+});
diff --git a/packages/vkui/src/hooks/useTodayDate.ts b/packages/vkui/src/hooks/useTodayDate.ts
index 215efbf6319..a29a261c748 100644
--- a/packages/vkui/src/hooks/useTodayDate.ts
+++ b/packages/vkui/src/hooks/useTodayDate.ts
@@ -1,5 +1,5 @@
 import * as React from 'react';
-import { differenceInMilliseconds, endOfToday, isSameDay } from 'date-fns';
+import { differenceInMilliseconds, isSameDay, startOfTomorrow } from 'date-fns';
 import { useDOM } from '../lib/dom';
 
 /**
@@ -15,46 +15,49 @@ export function useTodayDate(listenDayChangesForUpdate = false): Date {
   const { document, window } = useDOM();
   const [todayDate, setTodayDate] = React.useState(() => new Date());
 
-  React.useEffect(() => {
-    if (!listenDayChangesForUpdate || !document || !window) {
-      return;
-    }
+  React.useEffect(
+    function setupTodaysDateRecalculationListener() {
+      if (!listenDayChangesForUpdate || !document || !window) {
+        return;
+      }
 
-    let timeout: number | undefined = undefined;
+      let timeout: number | undefined = undefined;
 
-    const recalcTimeout = () => {
-      if (document.visibilityState === 'visible') {
-        const now = new Date();
+      const recalcTimeout = () => {
+        if (document.visibilityState === 'visible') {
+          const now = new Date();
 
-        const timeToDayChange = differenceInMilliseconds(endOfToday(), now);
+          const timeToDayChange = differenceInMilliseconds(startOfTomorrow(), now);
 
-        // Удаляем старый таймаут
-        window.clearTimeout(timeout);
+          // Удаляем старый таймаут
+          window.clearTimeout(timeout);
 
-        // Создаем новый таймаут
-        timeout = window.setTimeout(() => {
-          setTodayDate(now);
-        }, timeToDayChange);
+          // Создаем новый таймаут
+          timeout = window.setTimeout(() => {
+            setTodayDate(new Date());
+          }, timeToDayChange);
 
-        // Если todayDate не обновился в таймаут - обновить при заходе на вкладку
-        if (!isSameDay(todayDate, now)) {
-          setTodayDate(now);
+          // Если todayDate не обновился в таймаут - обновить при заходе на вкладку
+          if (!isSameDay(todayDate, now)) {
+            setTodayDate(now);
+          }
         }
-      }
-    };
+      };
 
-    recalcTimeout();
+      recalcTimeout();
 
-    // Создаем слушатель visibilitychange, чтобы предотвратить пропуск обновления стейта после заморозки вкладки
-    // Если человек ее долго не трогал или закрывал крышку ноута и тп
-    // https://developer.chrome.com/blog/page-lifecycle-api/
-    document.addEventListener('visibilitychange', recalcTimeout);
+      // Создаем слушатель visibilitychange, чтобы предотвратить пропуск обновления стейта после заморозки вкладки
+      // Если человек ее долго не трогал или закрывал крышку ноута и тп
+      // https://developer.chrome.com/blog/page-lifecycle-api/
+      document.addEventListener('visibilitychange', recalcTimeout);
 
-    return () => {
-      window.clearTimeout(timeout);
-      document.removeEventListener('visibilitychange', recalcTimeout);
-    };
-  }, [document, listenDayChangesForUpdate, todayDate, window]);
+      return () => {
+        window.clearTimeout(timeout);
+        document.removeEventListener('visibilitychange', recalcTimeout);
+      };
+    },
+    [document, listenDayChangesForUpdate, todayDate, window],
+  );
 
   return todayDate;
 }


Reviews:
user: login: EldarMuhamethanov
body: 🔥
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T14:19:58Z
user: login: SevereCloud
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:49:35Z
user: login: SevereCloud
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T11:22:34Z
user: login: inomdzhon
body: 👍 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:02:57Z
user: login: andrey-medvedev-vk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:21:00Z
user: login: EldarMuhamethanov
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T08:17:26Z

Review comments:
diff_hunk: @@ -13,6 +13,7 @@ declare module 'process' {
       interface Process {
         env: {
           readonly NODE_ENV: 'development' | 'production' | 'test';
+          TZ: string;
path: packages/vkui/types/env.d.ts
user: login: SevereCloud
body: Нужно удалить отсюда, так как кто-то может случайно заюзать в коде и сломать сборку на vite
created_at: 2025-03-14T10:46:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,7 @@ declare module 'process' {
       interface Process {
         env: {
           readonly NODE_ENV: 'development' | 'production' | 'test';
+          TZ: string;
path: packages/vkui/types/env.d.ts
user: login: andrey-medvedev-vk
body: Убрал, но было никак не убрать ошибку по поводу того, что типа для этой переменной нет.
Оказалось, я случайно закоммитил пустой `jest.global-setup.ts`, он ошибки и создавал. Удалил, и проблем нет, прописывать типы для `TZ` нигде не надо.
⚡ https://github.com/VKCOM/VKUI/compare/6da1e86...mendrew/fix/useTodayDate-update-date-in-background
created_at: 2025-03-14T15:21:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-04T13:26:06Z
body: ## size-limit report 📦 
| Path                         | Size                  |
| ---------------------------- | --------------------- |
| JS                           | 398.32 KB (+0.05% 🔺) |
| JS (gzip)                    | 120.86 KB (+0.04% 🔺) |
| JS (brotli)                  | 99.31 KB (-0.11% 🔽)  |
| JS import Div (tree shaking) | 1.56 KB (0%)          |
| CSS                          | 348.96 KB (0%)        |
| CSS (gzip)                   | 43.23 KB (0%)         |
| CSS (brotli)                 | 34.45 KB (0%)         |
author_association: CONTRIBUTOR
user: login: codesandbox-ci[bot]
created_at: 2025-03-04T13:26:11Z
body: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/VKCOM/VKUI/pr/8327/builds/597365) or the icon next to each commit SHA.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-04T13:29:52Z
body: <!--GitHub Comment Builder-->
## e2e tests

 <a href="https://vkui-screenshot.hb.bizmrg.com/pull/8327/109956ed36062f78b7cec19132474a224a63592c/playwright-report/index.html" target="_blank">Playwright Report</a>


author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-03-04T13:30:00Z
body: ## [Codecov](https://app.codecov.io/gh/VKCOM/VKUI/pull/8327?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.59%. Comparing base [(`de20a81`)](https://app.codecov.io/gh/VKCOM/VKUI/commit/de20a812771766e0a09287ba2c5874c50e383adf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) to head [(`abf4dc0`)](https://app.codecov.io/gh/VKCOM/VKUI/commit/abf4dc0369ed3823ed4d40bcd1643d453f61189e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM).
> Report is 31 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #8327      +/-   ##
==========================================
+ Coverage   95.46%   95.59%   +0.12%     
==========================================
  Files         406      406              
  Lines       11610    11611       +1     
  Branches     3844     3844              
==========================================
+ Hits        11083    11099      +16     
+ Misses        527      512      -15     
```

| [Flag](https://app.codecov.io/gh/VKCOM/VKUI/pull/8327/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/VKCOM/VKUI/pull/8327/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) | `95.59% <100.00%> (+0.12%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/VKCOM/VKUI/pull/8327?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-04T13:30:04Z
body: <!-- docs_urls -->
## 👀 Docs deployed

- ✅ [Styleguide](https://vkui-screenshot.hb.bizmrg.com/pull/8327/109956ed36062f78b7cec19132474a224a63592c/styleguide/index.html)
- ✅ [Storybook](https://vkui-screenshot.hb.bizmrg.com/pull/8327/109956ed36062f78b7cec19132474a224a63592c/storybook/index.html)
- ✅ [Docs beta](https://vkui-screenshot.hb.bizmrg.com/pull/8327/109956ed36062f78b7cec19132474a224a63592c/docs-beta/overview/about.html)

Commit 109956ed36062f78b7cec19132474a224a63592c
author_association: CONTRIBUTOR
user: login: vkcom-publisher
created_at: 2025-03-26T10:20:58Z
body: <!-- disable_global_notification -->✅ <a href="https://github.com/VKCOM/VKUI/releases/tag/v7.2.0" target="_blank">v7.2.0</a> 🎉
author_association: CONTRIBUTOR