🐛(frontend) prevent toasts from remounting components

useToastProvider()'s `toast` function is recreated by the provider
every time any toast is shown anywhere in the app. useUploadFile
listed it as a dependency of `uploadFile`, which is itself a
dependency of useCreateBlockNote, so an unrelated toast (e.g. "Link
copied!") recreated `uploadFile` and made BlockNote rebuild the whole
editor instance, resetting the scroll position to the top of the
document.

Added useToast, a drop-in wrapper around useToastProvider that keeps
`toast`'s identity stable behind a ref, and switched every call site
in the app to use it instead of the provider's hook directly.
This commit is contained in:
Anthony LC
2026-09-17 17:40:41 +02:00
parent 5b661d7224
commit 6deb07c0b2
22 changed files with 85 additions and 59 deletions
@@ -5,17 +5,14 @@ import {
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import {
Loader,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { Loader, VariantType } from '@gouvfr-lasuite/ui-components';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Icon } from '@/components';
import { useDocOptions, useDocStore } from '@/docs/doc-management/';
import { useToast } from '@/hooks';
import {
AITransformActions,
@@ -312,7 +309,7 @@ const AIMenuItem = ({
icon,
}: PropsWithChildren<AIMenuItemProps>) => {
const Components = useComponentsContext();
const { toast } = useToastProvider();
const { toast } = useToast();
const { t } = useTranslation();
const editor = useBlockNoteEditor();
@@ -357,7 +354,7 @@ const AIMenuItem = ({
};
const useHandleAIError = () => {
const { toast } = useToastProvider();
const { toast } = useToast();
const { t } = useTranslation();
return (error: unknown) => {
@@ -1,11 +1,12 @@
import { Block } from '@blocknote/core';
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { captureException } from '@sentry/nextjs';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { useConfig } from '@/core';
import { useToast } from '@/hooks';
import { formatFileSize } from '@/utils';
import { isSafeUrl } from '@/utils/url';
@@ -17,7 +18,7 @@ const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // Default to 10MB
export const useUploadFile = (docId: string) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { data: config } = useConfig();
const {
mutateAsync: createDocAttachment,
@@ -5,7 +5,6 @@ import {
ModalSize,
Select,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import i18next from 'i18next';
import JSZip from 'jszip';
@@ -17,6 +16,7 @@ import { Box, ButtonCloseModal, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { type Doc, useTrans } from '@/docs/doc-management';
import { useToast } from '@/hooks';
import { fallbackLng } from '@/i18n/config';
import ModulesExport from '../hooks/';
@@ -36,7 +36,7 @@ interface ModalExportProps {
export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { editor } = useEditorStore();
const [isExporting, setIsExporting] = useState(false);
const { untitledDocument } = useTrans();
@@ -1,8 +1,4 @@
import {
Button,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { Button, VariantType } from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
import { Box, Card, Icon } from '@/components';
@@ -16,10 +12,11 @@ import {
} from '@/docs/doc-management';
import { KEY_DOC_TREE } from '@/docs/doc-tree';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { useToast } from '@/hooks';
export const AlertRestore = ({ doc }: { doc: Doc }) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { spacingsTokens } = useCunninghamTheme();
const { mutate: restoreDoc, error } = useRestoreDoc({
listInvalidQueries: [
@@ -1,4 +1,4 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import {
UseMutationOptions,
useMutation,
@@ -9,6 +9,7 @@ import * as Y from 'yjs';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { useToast } from '@/hooks';
import { toBase64 } from '@/utils/string';
import { useProviderStore } from '../stores';
@@ -55,7 +56,7 @@ type DuplicateDocOptions = UseMutationOptions<
export function useDuplicateDoc(options?: DuplicateDocOptions) {
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const { toast } = useToast();
const { t } = useTranslation();
const { provider } = useProviderStore();
@@ -1,4 +1,4 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import {
UseMutationOptions,
useMutation,
@@ -12,6 +12,7 @@ import {
errorCauses,
fetchAPI,
} from '@/api';
import { useToast } from '@/hooks';
import { Doc } from '../types';
@@ -71,7 +72,7 @@ export const importDoc = async ([file, mimeType]: [
type UseImportDocOptions = UseMutationOptions<Doc, APIError, [File, string]>;
export function useImportDoc(props?: UseImportDocOptions) {
const { toast } = useToastProvider();
const { toast } = useToast();
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -5,7 +5,6 @@ import {
TreeViewMoveModeEnum,
VariantType,
useModal,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import dynamic from 'next/dynamic';
import { useState } from 'react';
@@ -21,6 +20,7 @@ import {
DocsGridItemDate,
DocsGridItemTitle,
} from '@/docs/docs-grid/components/DocsGridItem';
import { useToast } from '@/hooks';
import { useResponsiveStore } from '@/stores';
const AlertModalRequestAccess = dynamic(
@@ -106,7 +106,7 @@ export const DocMoveModal = ({
const { isDesktop, isTablet, isMobile } = useResponsiveStore();
const isModal = (isDesktop || isTablet) && !isMobile;
const handleInputSearch = useDebouncedCallback(setSearch, 700);
const { toast } = useToastProvider();
const { toast } = useToast();
const handleSelect = (docSelected: Doc) => {
setDocSelected(docSelected);
@@ -4,7 +4,6 @@ import {
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -12,7 +11,7 @@ import { Trans, useTranslation } from 'react-i18next';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { useConfig } from '@/core';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { useKeyboardAction } from '@/hooks';
import { useKeyboardAction, useToast } from '@/hooks';
import { KEY_DOC, KEY_LIST_FAVORITE_DOC } from '../api';
import { KEY_LIST_DOC } from '../api/useDocs';
@@ -31,7 +30,7 @@ export const ModalRemoveDoc = ({
onClose,
onSuccess,
}: ModalRemoveDocProps) => {
const { toast } = useToastProvider();
const { toast } = useToast();
const { t } = useTranslation();
const { data: config } = useConfig();
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
@@ -1,11 +1,12 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useToast } from '@/hooks';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const { toast } = useToast();
const { t } = useTranslation();
return async (asFormat: 'html' | 'markdown') => {
@@ -1,9 +1,10 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { t } from 'i18next';
import { useCallback, useMemo } from 'react';
import { useDropzone } from 'react-dropzone';
import { useConfig } from '@/core';
import { useToast } from '@/hooks';
import { formatFileSize } from '@/utils';
import { ContentTypes, useImportDoc } from '../api/useImportDoc';
@@ -19,7 +20,7 @@ interface AcceptedMap {
}
export const useImport = ({ onDragOver, onImportSuccess }: UseImportProps) => {
const { toast } = useToastProvider();
const { toast } = useToast();
const { data: config } = useConfig();
const MAX_FILE_SIZE = useMemo(() => {
@@ -1,13 +1,10 @@
import {
Button,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { Button, VariantType } from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
import { useToast } from '@/hooks';
import { useUpdateDocLink } from '../api/useUpdateDocLink';
@@ -21,7 +18,7 @@ interface DocDesynchronizedProps {
export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { toast } = useToast();
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
@@ -1,10 +1,11 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
import { Access, Doc, Role, useTrans } from '@/docs/doc-management/';
import { useToast } from '@/hooks';
import { useDeleteDocAccess, useDeleteDocInvitation } from '../api';
import { Invitation, isInvitation } from '../types';
@@ -34,7 +35,7 @@ export const DocRoleDropdown = ({
}: DocRoleDropdownProps) => {
const { t } = useTranslation();
const { transRole, translatedRoles } = useTrans();
const { toast } = useToastProvider();
const { toast } = useToast();
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onError: (error) => {
@@ -2,7 +2,6 @@ import {
Button,
ButtonProps,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { MouseEventHandler, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -21,6 +20,7 @@ import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { AccessRequest, Doc, Role } from '@/docs/doc-management/';
import { useAuth } from '@/features/auth';
import { useToast } from '@/hooks';
import { useResponsiveStore } from '@/stores';
import {
@@ -52,7 +52,7 @@ type Props = {
const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => {
const { t } = useTranslation();
const { isSmallMobile } = useResponsiveStore();
const { toast } = useToastProvider();
const { toast } = useToast();
const { spacingsTokens } = useCunninghamTheme();
const { mutate: acceptDocAccessRequests } = useAcceptDocAccessRequest();
const [role, setRole] = useState(accessRequest.role);
@@ -209,7 +209,7 @@ export const ButtonAccessRequest = ({
page: 1,
});
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { mutate: createRequest } = useCreateDocAccessRequest({
onSuccess: () => {
toast(t('Access request sent successfully.'), VariantType.SUCCESS, {
@@ -1,8 +1,4 @@
import {
Button,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { Button, VariantType } from '@gouvfr-lasuite/ui-components';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,6 +7,7 @@ import { Box, Card } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useToast } from '@/hooks';
import { useResponsiveStore } from '@/stores';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
@@ -38,7 +35,7 @@ export const DocShareAddMemberList = ({
afterInvite,
}: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { isSmallMobile } = useResponsiveStore();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens } = useCunninghamTheme();
@@ -1,4 +1,4 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -15,6 +15,7 @@ import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useToast } from '@/hooks';
import { useDocInvitationsInfinite, useUpdateDocInvitation } from '../api';
import { Invitation } from '../types';
@@ -42,7 +43,7 @@ export const DocShareInvitationItem = ({
is_first_connection: false,
};
const { toast } = useToastProvider();
const { toast } = useToast();
const canUpdate = doc.abilities.accesses_manage;
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
@@ -1,4 +1,4 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,6 +7,7 @@ import { QuickSearchData } from '@/components/quick-search';
import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { useToast } from '@/hooks';
import { useDocAccesses, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/';
@@ -26,7 +27,7 @@ export const DocShareMemberItem = ({
}: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
const { toast } = useToast();
const { spacingsTokens } = useCunninghamTheme();
const message = isLastOwner
@@ -1,4 +1,4 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -21,6 +21,7 @@ import {
getDocLinkRole,
useDocUtils,
} from '@/docs/doc-management';
import { useToast } from '@/hooks';
import { useResponsiveStore } from '@/stores';
import { useUpdateDocLink } from '../api/useUpdateDocLink';
@@ -42,7 +43,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { isDesynchronized } = useDocUtils(doc);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
const { toast } = useToastProvider();
const { toast } = useToast();
const description =
docLinkRole === LinkRole.READER
@@ -3,7 +3,6 @@ import {
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
@@ -12,6 +11,7 @@ import { Box, Text } from '@/components';
import { useThreadStore } from '@/docs/doc-comments/stores/useThreadStore';
import { Doc, base64ToYDoc, useProviderStore } from '@/docs/doc-management/';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
import { useToast } from '@/hooks';
import { useDocVersion } from '../api';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
@@ -42,7 +42,7 @@ export const ModalConfirmationVersion = ({
versionId,
});
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { provider } = useProviderStore();
const { threadStore } = useThreadStore();
const { mutate: updateDocContent } = useDocContentUpdate({
@@ -2,7 +2,6 @@ import {
ButtonProps,
DropdownMenuItem,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
@@ -14,6 +13,7 @@ import {
useRestoreDoc,
} from '@/docs/doc-management';
import { DocToolBox } from '@/docs/doc-management/components/DocToolBox';
import { useToast } from '@/hooks';
import MoreIcon from '@/icons/more_horiz.svg';
import { KEY_LIST_DOC_TRASHBIN } from '../api';
@@ -51,7 +51,7 @@ export const DocsGridTrashbinActions = ({
doc,
}: DocsGridTrashbinActionsProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { toast } = useToast();
const { mutate: restoreDoc } = useRestoreDoc({
listInvalidQueries: [
KEY_LIST_DOC,
@@ -2,3 +2,4 @@ export * from './useClipboard';
export * from './useCmdK';
export * from './useDate';
export * from './useKeyboardAction';
export * from './useToast';
@@ -1,9 +1,11 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from './useToast';
export const useClipboard = () => {
const { toast } = useToastProvider();
const { toast } = useToast();
const { t } = useTranslation();
return useCallback(
@@ -0,0 +1,27 @@
import {
ToastProviderContext,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { useCallback, useRef } from 'react';
/**
* The provider's `toast` function is recreated every time any toast is shown
* anywhere in the app, which breaks memoization for anything that lists it as
* a dependency - it once caused the BlockNote editor to fully remount (and
* reset scroll) whenever an unrelated toast fired. Use this instead of
* `useToastProvider` directly to get a `toast` with a stable identity.
*
* @TODO Modify the ui-components library to provide a stable toast function directly.
*/
export const useToast = (): ToastProviderContext => {
const { toast } = useToastProvider();
const toastRef = useRef(toast);
toastRef.current = toast;
const stableToast = useCallback<ToastProviderContext['toast']>(
(...args) => toastRef.current(...args),
[],
);
return { toast: stableToast };
};