router icon indicating copy to clipboard operation
router copied to clipboard

feat: Update `useBlocker` hook to provide current and next location when conditionally blocking

Open tomrehnstrom opened this issue 1 year ago • 12 comments

Currently, when using the useBlocker hook there is no way to ascertain where the navigation being blocked is headed.

For my usage of blocking, this is necessary to allow navigation within a selected bunch of nested routes, but not allowing the user to exit the base route. Thought this could be a useful feature to have.

tomrehnstrom avatar Jun 19 '24 12:06 tomrehnstrom

we wanted to follow react-router's API for this:

https://reactrouter.com/en/main/hooks/use-blocker

can you adjust your PR to match it?

also, the location should be typesafe instead of just a string.

additionally, we would also need tests for useBlocker

schiller-manuel avatar Jun 19 '24 20:06 schiller-manuel

we wanted to follow react-router's API for this:

https://reactrouter.com/en/main/hooks/use-blocker

can you adjust your PR to match it?

also, the location should be typesafe instead of just a string.

additionally, we would also need tests for useBlocker

Yes sounds good, I will update the PR as soon as possible

tomrehnstrom avatar Jun 20 '24 06:06 tomrehnstrom

This implementation should now be providing the blockerFn inuseBlocker with a similar API to the hook in react-router. This also makes the blocker catch backwards navigation using the browser back buttons, which is very beneficial for our use case.

Things left to do:

  • [x] Tests
  • [ ] Update docs/examples?

I am not quite sure how to do with the old blockerFn since it now behaves in a completely different way now. So any hints from someone on this would be appreciated.

Would also like to give a quick pitch for a different interface that could be used for useBlocker which I have implemented in our codebase with these changes:

import React from 'react'
import type { BlockerFn } from '../../lib/router/qudak-router'
import { BlockerFn, useRouter } from '@tanstack/react-router'

type BlockerOpts = {
  blockerFn: BlockerFn
  disabled?: boolean
}

export function useBlocker({ blockerFn, disabled = false }: BlockerOpts) {
  const { history } = useRouter()

  React.useEffect(() => {
    return disabled ? undefined : history.block(blockerFn as BlockerFn)
  }, [blockerFn, disabled, history])
}

Which gives very clean implementations for custom UIs using hooks and could look something like this:

  useBlocker({
    blockerFn: () => {
      if (blockerDisabled && blockerDisabled.current) {
        blockerDisabled.current = false
        return false
      }
      if (!blockerDisabled && _blockerDisabled.current) {
        _blockerDisabled.current = false
        return false
      }
      const shouldBlock = props.blockerFn()
      if (!shouldBlock) return false

      const promise = new Promise<boolean>((resolve) => {
        modals.open({
          title: 'Are you sure you want to leave?',
          children: (
            <SaveBlocker
              onSave={onSave}
              onDiscard={onDiscard}
              close={() => {
                modals.closeAll()
                resolve(false)
              }}
              confirm={() => {
                modals.closeAll()
                resolve(true)
              }}
              reject={() => {
                modals.closeAll()
                resolve(true)
              }}
            />
          ),
          onClose: () => resolve(false)
        })
      })

      return promise
    },
    disabled
  })

Any thoughts on this?

tomrehnstrom avatar Jun 20 '24 09:06 tomrehnstrom

This would be a breaking change to useBlocker, right? then we cannot do it (now, only possible in v2).

schiller-manuel avatar Jun 20 '24 20:06 schiller-manuel

While this might be out of scope for this PR. I think we can likely do much better from a type safety point of view for useBlocker. Correct me if I'm wrong but useBlocker blocks a navigation and we usually describe navigations with from and to.

I was imagining a more type safe hook.

useBlocker({ from: Route.fullPath, to: '..', blockerFn: ({ fromMatch, toMatch }) => // have I saved? })
  • from would describe a route matching the current location and would narrow the fromMatch passed to blockerFn for the matching route. This will be optional, fromMatch will be loose if not provided
  • to would describe a route matching the destination location and would narrow the toMatch passed to blockerFn for the matching route. This will be optional, toMatch will be loose if not provided
  • blockerFn determines if the navigation should continue or block.
  • fromMatch/toMatch is a match like from useMatch which will provide the usual params, search, context, etc

When from or to is provided the blockerFn is only called when navigating to the location described by these two properties. If they are not provided it will always call blockerFn for any navigation

chorobin avatar Jun 20 '24 20:06 chorobin

we should make this as typesafe as possible, as per @chorobin's comment. @tomrehnstrom can you tackle this? if you need support let us know

schiller-manuel avatar Jun 21 '24 19:06 schiller-manuel

@tomrehnstrom, are you planning to continue working on this PR?

Does anyone know a quick and dirty workaround I can apply in the meanwhile?

ThomasStock avatar Aug 05 '24 13:08 ThomasStock

@tomrehnstrom, are you planning to continue working on this PR?

Does anyone know a quick and dirty workaround I can apply in the meanwhile?

Yes, but have been quite busy elsewhere recently. The way i have solved this quickly in our codebase is just to copy the history package index.ts file and make the changes required to make it work, and then use it as a custom history:

const history = createCustomBrowserHistory()

export const router = createRouter({
  routeTree,
  defaultPendingComponent: FullWidthLoader,
  defaultErrorComponent: RouterSuspenseErrorFallback,
  defaultNotFoundComponent: Error404,
  context: {
    auth: undefined!,
    queryClient
  },
  defaultPreload: 'intent',
  // Since we're using React Query, we don't want loader calls to ever be stale
  // This will ensure that the loader is always called when the route is preloaded or visited
  defaultPreloadStaleTime: 0,
  history
})

With the changes in my first commits to this pr

Then written a custom blocker hook like this:

type BlockerOpts = {
  blockerFn: CustomBlockerFn
  disabled?: boolean
}

export function useCustomBlocker({ blockerFn, disabled = false }: BlockerOpts) {
  const { history } = useRouter()

  React.useEffect(() => {
    return disabled ? undefined : history.block(blockerFn as BlockerFn)
  }, [blockerFn, disabled, history])
}

tomrehnstrom avatar Aug 05 '24 14:08 tomrehnstrom

Thanks @tomrehnstrom, I needed this urgently and managed to make do with the suggested temporary solution for now.

ThomasStock avatar Aug 06 '24 07:08 ThomasStock

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 2ee506f9cf2b18ee35ad0c225095c5e43a03d4e2. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this CI Pipeline Execution


🟥 Failed Commands
nx affected --targets=test:eslint,test:unit,test:e2e,test:types,test:build,build --parallel=3
✅ Successfully ran 1 target

Sent with 💌 from NxCloud.

nx-cloud[bot] avatar Aug 27 '24 15:08 nx-cloud[bot]

Open in Stackblitz

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@1790
@tanstack/create-router

npm i https://pkg.pr.new/@tanstack/create-router@1790
@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@1790
@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@1790
@tanstack/react-cross-context

npm i https://pkg.pr.new/@tanstack/react-cross-context@1790
@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@1790
@tanstack/react-router-with-query

npm i https://pkg.pr.new/@tanstack/react-router-with-query@1790
@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@1790
@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@1790
@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@1790
@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@1790
@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@1790
@tanstack/start

npm i https://pkg.pr.new/@tanstack/start@1790
@tanstack/start-vite-plugin

npm i https://pkg.pr.new/@tanstack/start-vite-plugin@1790
@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@1790
@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@1790
@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@1790

commit: 2ee506f

pkg-pr-new[bot] avatar Aug 27 '24 15:08 pkg-pr-new[bot]

For anyone stumbling across the PR and really wants this now, use my fork: https://github.com/tomrehnstrom/router/tree/better-blocker-router Until this can get merged :)

tomrehnstrom avatar Oct 25 '24 09:10 tomrehnstrom

I need to have a closer look, what's the current status of this PR? anything obvious missing ?

schiller-manuel avatar Oct 26 '24 19:10 schiller-manuel

The type safety and type narrowing described by @chorobin is not really implemented, but functionally of it should be complete, have been using this method in prod for quite a while and haven't had any issues so far. Maybe the old interface for useBlocker should still be supported and marked as deprecated though.

tomrehnstrom avatar Oct 27 '24 13:10 tomrehnstrom

is your PR breaking the current useBlocker API or is it backwards compatible?

schiller-manuel avatar Oct 27 '24 13:10 schiller-manuel

let's work on merging this. [] docs need updating

schiller-manuel avatar Nov 13 '24 23:11 schiller-manuel

@tomrehnstrom thanks for the update still not sure if the API is backwards compatible. can you comment on that please? if it is not backwards compatible can you please fix this so we can release this in a non breaking manner

schiller-manuel avatar Nov 21 '24 15:11 schiller-manuel

@tomrehnstrom thanks for the update still not sure if the API is backwards compatible. can you comment on that please? if it is not backwards compatible can you please fix this so we can release this in a non breaking manner

It should be backwards compatible now 👍 @schiller-manuel

tomrehnstrom avatar Nov 21 '24 17:11 tomrehnstrom

@schiller-manuel @ThomasStock I truly appreciate your help here. Thank you so much!

dev-webiks avatar Nov 25 '24 10:11 dev-webiks

Could this be modified to supply the navigation type to the notify function? I will try to make an addon for the history API after this PR merges which will keep track of the history state. The addon will help us hide back buttons when there is only 1 entry in the navigation state and, maybe, for implementing breadcrumbs. Also, can you explain why back, forward and go are of type "POP"? It would help me if they had their own navigation type. My rough ideea for the addon right now is that PUSH, POP, REPLACE would do their respective action (.push, .pop or .splice) on an array and increment/decrement a cursor variable, while BACK, FORWARD and GO would only increment/decrement the cursor variable.

Edit: Meant to say .splice not .replace

ferretwithaberet avatar Nov 26 '24 19:11 ferretwithaberet

@ferretwithaberet Sorry for the long wait, would the changes in my latest commit accomplished this?

tomrehnstrom avatar Dec 02 '24 15:12 tomrehnstrom

@ferretwithaberet Sorry for the long wait, would the changes in my latest commit accomplished this?

@tomrehnstrom I do not mind the wait, take your time, do not stress yourself. As to the implementation, I would need to get the index for the GO action, which actually is , as MDN calls it, a delta. I need it so I can know how much I would have to increase/decrease the cursor of the history state. Otherwise it looks good to me, great job.

ferretwithaberet avatar Dec 02 '24 22:12 ferretwithaberet

I tried to test the code manually. When using the browser back/forward buttons, it seems that the popstate event gets triggered for both buttons, which in turn triggers a POP event regardless of navigation type. Also, I could not find anything on the pushstate window event, does it even exist or am I missing something? I will not be able to look much on this until monday night EEST, I will try to look for a solution then.

ferretwithaberet avatar Dec 06 '24 08:12 ferretwithaberet

After this PR, I will be able to block based on the condition: currentUrl != nextUrl ? Thanks

dev-webiks avatar Dec 09 '24 09:12 dev-webiks

@sagi-webiks

After this PR, I will be able to block based on the condition: currentUrl != nextUrl ? Thanks

yes!

schiller-manuel avatar Dec 13 '24 23:12 schiller-manuel

I am working on making the API typesafe, stay tuned

schiller-manuel avatar Dec 14 '24 00:12 schiller-manuel

Cool! I think using setup will be much more useful than having fromand to in the hook args

tomrehnstrom avatar Dec 14 '24 13:12 tomrehnstrom

Anything here I can help with here @schiller-manuel?

tomrehnstrom avatar Dec 14 '24 13:12 tomrehnstrom

@tomrehnstrom

we need

  • type tests for useBlocker
  • more unit tests in a full router setup to check that action, current and next are correctly passed into shouldBlockFn
  • e2e tests for the legacy API and the new API
  • update docs to showcase the new typesafe API that allows narrowing

I have also added a TODO whether we should return current and next in case the resolver is used. I thought about this when editing the example since we only know the status then but don't know current/next which might be necessary for UI?

schiller-manuel avatar Dec 14 '24 13:12 schiller-manuel

Yes, I will jump on updating docs and supplying the current blocking state to the resolver.

tomrehnstrom avatar Dec 14 '24 13:12 tomrehnstrom