Skip to content

Commit

Permalink
feat: hack route blocker (#236)
Browse files Browse the repository at this point in the history
* chore: backup

Signed-off-by: Innei <i@innei.in>

* fix: update

Signed-off-by: Innei <i@innei.in>

* chore: cleanup

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei authored Jan 10, 2024
1 parent 5861fa4 commit 0826096
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 2 deletions.
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

1 comment on commit 0826096

@vercel
Copy link

@vercel vercel bot commented on 0826096 Jan 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

shiro – ./

springtide.vercel.app
shiro-git-main-innei.vercel.app
shiro-innei.vercel.app
innei.in

Please sign in to comment.