Skip to content
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

feat: hack route blocker #236

Merged
merged 3 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/components/modules/dashboard/home/Version.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const Version = () => {
</div>
)

console.log(version, 'a')
return (
<div className="opacity-60">
<p className="text-center">
Expand Down
21 changes: 20 additions & 1 deletion src/components/modules/dashboard/writing/BaseWritingProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<PrimitiveAtom<BaseModelType>>(null!)

type BaseModelType = {
Expand All @@ -17,6 +20,22 @@ type BaseModelType = {
export const BaseWritingProvider = <T extends BaseModelType>(
props: { atom: PrimitiveAtom<T> } & 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 (
<BaseWritingContext.Provider value={props.atom as any}>
{props.children}
Expand Down
148 changes: 148 additions & 0 deletions src/hooks/common/use-before-unload.ts
Original file line number Diff line number Diff line change
@@ -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 <Link> 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<void>) => {
try {
isForceRouting = true
await cb()
} finally {
isForceRouting = false
}
}

useBeforeUnload.ensureSafeNavigation = (
onPerformRoute: () => void,
onRouteRejected?: () => void,
) => {
if (activeIds.length === 0 || beforeUnloadFn()) {
onPerformRoute()
} else {
onRouteRejected?.()
}
}
2 changes: 2 additions & 0 deletions src/providers/root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -58,6 +59,7 @@ export function WebAppProviders({ children }: PropsWithChildren) {
const dashboardContexts: JSX.Element[] = baseContexts.concat(
<ReactQueryProviderForDashboard key="reactQueryProvider" />,
<AuthProvider key="auth" />,
<useBeforeUnload.Provider />,
)
export function DashboardAppProviders({ children }: PropsWithChildren) {
return (
Expand Down
2 changes: 2 additions & 0 deletions src/queries/definition/note.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -92,6 +93,7 @@ export const useCreateNote = () =>
})
},
onSuccess: () => {
revalidateTag('note')
toast.success('创建成功')
},
})
Expand Down