From 0826096de8a34233a63e82fc2b58bf1288451dd0 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 10 Jan 2024 15:32:54 +0800 Subject: [PATCH] feat: hack route blocker (#236) * chore: backup Signed-off-by: Innei * fix: update Signed-off-by: Innei * chore: cleanup Signed-off-by: Innei --------- Signed-off-by: Innei --- .../modules/dashboard/home/Version.tsx | 1 - .../dashboard/writing/BaseWritingProvider.tsx | 21 ++- src/hooks/common/use-before-unload.ts | 148 ++++++++++++++++++ src/providers/root/index.tsx | 2 + src/queries/definition/note.ts | 2 + 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/hooks/common/use-before-unload.ts diff --git a/src/components/modules/dashboard/home/Version.tsx b/src/components/modules/dashboard/home/Version.tsx index 6af7fef3bd..0d0ed9cfde 100644 --- a/src/components/modules/dashboard/home/Version.tsx +++ b/src/components/modules/dashboard/home/Version.tsx @@ -18,7 +18,6 @@ export const Version = () => { ) - console.log(version, 'a') return (

diff --git a/src/components/modules/dashboard/writing/BaseWritingProvider.tsx b/src/components/modules/dashboard/writing/BaseWritingProvider.tsx index f58b6cddcf..a49e578707 100644 --- a/src/components/modules/dashboard/writing/BaseWritingProvider.tsx +++ b/src/components/modules/dashboard/writing/BaseWritingProvider.tsx @@ -1,9 +1,12 @@ -import { createContext, useContext, useMemo } from 'react' +import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { produce } from 'immer' import { atom, useAtom } from 'jotai' import type { PrimitiveAtom } from 'jotai' import type { PropsWithChildren } from 'react' +import { EmitKeyMap } from '~/constants/keys' +import { useBeforeUnload } from '~/hooks/common/use-before-unload' + const BaseWritingContext = createContext>(null!) type BaseModelType = { @@ -17,6 +20,22 @@ type BaseModelType = { export const BaseWritingProvider = ( props: { atom: PrimitiveAtom } & PropsWithChildren, ) => { + const [isFormDirty, setIsDirty] = useState(false) + useEffect(() => { + const handler = () => { + setIsDirty(true) + } + window.addEventListener(EmitKeyMap.EditDataUpdate, handler) + + return () => { + window.removeEventListener(EmitKeyMap.EditDataUpdate, handler) + } + }, []) + useBeforeUnload(isFormDirty) + + useBeforeUnload.forceRoute(() => { + console.log('forceRoute') + }) return ( {props.children} diff --git a/src/hooks/common/use-before-unload.ts b/src/hooks/common/use-before-unload.ts new file mode 100644 index 0000000000..975acda7b4 --- /dev/null +++ b/src/hooks/common/use-before-unload.ts @@ -0,0 +1,148 @@ +/// useBeforeUnload.ts +'use client' + +import { useEffect, useId } from 'react' +import { useRouter } from 'next/navigation' + +let isForceRouting = false +const activeIds: string[] = [] +let lastKnownHref: string + +export const useBeforeUnload = (isActive = true) => { + const id = useId() + + // Handle clicks & onbeforeunload(attemptimg to close/refresh browser) + useEffect(() => { + if (!isActive) return + lastKnownHref = window.location.href + + activeIds.push(id) + + const handleAnchorClick = (e: Event) => { + const targetUrl = (e.currentTarget as HTMLAnchorElement).href, + currentUrl = window.location.href + + if (targetUrl !== currentUrl) { + const res = beforeUnloadFn() + if (!res) e.preventDefault() + lastKnownHref = window.location.href + } + } + + let anchorElements: HTMLAnchorElement[] = [] + + const disconnectAnchors = () => { + anchorElements.forEach((anchor) => { + anchor.removeEventListener('click', handleAnchorClick) + }) + } + + const handleMutation = () => { + disconnectAnchors() + + anchorElements = Array.from(document.querySelectorAll('a[href]')) + anchorElements.forEach((anchor) => { + anchor.addEventListener('click', handleAnchorClick) + }) + } + + const mutationObserver = new MutationObserver(handleMutation) + mutationObserver.observe(document.body, { childList: true, subtree: true }) + addEventListener('beforeunload', beforeUnloadFn) + + return () => { + removeEventListener('beforeunload', beforeUnloadFn) + disconnectAnchors() + mutationObserver.disconnect() + + activeIds.splice(activeIds.indexOf(id), 1) + } + }, [isActive, id]) +} + +const beforeUnloadFn = (event?: BeforeUnloadEvent) => { + if (isForceRouting) return true + + const message = 'Discard unsaved changes?' + + if (event) { + event.returnValue = message + return message + } else { + return confirm(message) + } +} + +const BeforeUnloadProvider = ({ children }: React.PropsWithChildren) => { + const router = useRouter() + useEffect(() => { + lastKnownHref = window.location.href + }) + + // Hack nextjs13 popstate impl, so it will include route cancellation. + // This Provider has to be rendered in the layout phase wrapping the page. + useEffect(() => { + let nextjsPopStateHandler: (...args: any[]) => void + + function popStateHandler(...args: any[]) { + useBeforeUnload.ensureSafeNavigation( + () => { + nextjsPopStateHandler(...args) + lastKnownHref = window.location.href + }, + () => { + router.replace(lastKnownHref, { scroll: false }) + }, + ) + } + + addEventListener('popstate', popStateHandler) + const originalAddEventListener = window.addEventListener + window.addEventListener = (...args: any[]) => { + if (args[0] === 'popstate') { + nextjsPopStateHandler = args[1] + window.addEventListener = originalAddEventListener + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + originalAddEventListener(...args) + } + } + + originalAddEventListener('popstate', (e) => { + e.preventDefault() + }) + + // window.addEventListener('popstate', () => { + // history.pushState(null, '', null) + // }) + return () => { + window.addEventListener = originalAddEventListener + removeEventListener('popstate', popStateHandler) + } + }, []) + + return children +} + +useBeforeUnload.Provider = BeforeUnloadProvider + +useBeforeUnload.forceRoute = async (cb: () => void | Promise) => { + try { + isForceRouting = true + await cb() + } finally { + isForceRouting = false + } +} + +useBeforeUnload.ensureSafeNavigation = ( + onPerformRoute: () => void, + onRouteRejected?: () => void, +) => { + if (activeIds.length === 0 || beforeUnloadFn()) { + onPerformRoute() + } else { + onRouteRejected?.() + } +} diff --git a/src/providers/root/index.tsx b/src/providers/root/index.tsx index 85c360aa68..6b44967bed 100644 --- a/src/providers/root/index.tsx +++ b/src/providers/root/index.tsx @@ -11,6 +11,7 @@ import type { PropsWithChildren } from 'react' import { PeekPortal } from '~/components/modules/peek/PeekPortal' import { ModalStackProvider } from '~/components/ui/modal' +import { useBeforeUnload } from '~/hooks/common/use-before-unload' import { ProviderComposer } from '../../components/common/ProviderComposer' import { AuthProvider } from './auth-provider' @@ -58,6 +59,7 @@ export function WebAppProviders({ children }: PropsWithChildren) { const dashboardContexts: JSX.Element[] = baseContexts.concat( , , + , ) export function DashboardAppProviders({ children }: PropsWithChildren) { return ( diff --git a/src/queries/definition/note.ts b/src/queries/definition/note.ts index 5a230ece66..8b72121d2b 100644 --- a/src/queries/definition/note.ts +++ b/src/queries/definition/note.ts @@ -1,5 +1,6 @@ import { useMutation } from '@tanstack/react-query' import dayjs from 'dayjs' +import { revalidateTag } from 'next/cache' import type { NoteModel, NoteWrappedPayload } from '@mx-space/api-client' import type { NoteDto } from '~/models/writing' @@ -92,6 +93,7 @@ export const useCreateNote = () => }) }, onSuccess: () => { + revalidateTag('note') toast.success('εˆ›ε»ΊζˆεŠŸ') }, })