amplify-ui icon indicating copy to clipboard operation
amplify-ui copied to clipboard

useAuthenticator doesn't trigger re-render when using <Authenticator.Provider> at the app level

Open vymao opened this issue 2 years ago • 53 comments

Before creating a new issue, please confirm:

On which framework/platform are you having an issue?

React

Which UI component?

Authenticator

How is your app built?

Create React App

Please describe your bug.

I am trying to use the useAuthenticator hook to set a global authentication state.

However, several problems have arisen:

  1. When I use const { user, signOut } = useAuthenticator((context) => [context.user]);, as is mentioned here, there is no app-level re-rendering. So several other components that are also using the hook and are dependent on authentication state don't change.
  2. If I refresh the page, React renders everything as signed out. When I go to sign in, it then signs me in automatically without me having to manually sign in. It should ideally render everything as signed in from the start, since I am already signed in.

What's the expected behaviour?

When I sign in or out, React should trigger an app-level re-render with the new authentication state. Furthermore, if I refresh the page, the authentication state should not change.

Help us reproduce the bug!

Following the instructions, I added Authenticator.Provider at the app level:

ReactDOM.render(
  <Authenticator.Provider>
    <HelmetProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </HelmetProvider>
  </Authenticator.Provider>,
  document.getElementById('root')
);

I use useAuthenticator to Login:

const { route } = useAuthenticator(context => [context.route]);
    return (
        route === 'authenticated' ? <Navigate to="/dashboard/app" />: (
            <Authenticator
                // Default to Sign Up screen
                initialState="signUp"
                // Customize `Authenticator.SignUp.FormFields`
                signUpAttributes={['preferred_username', 'birthdate']}
                components={components}
                services={{
                    async validateCustomSignUp(formData) {
                        if (!formData.acknowledgement) {
                            return {
                                acknowledgement: 'You must agree to the Terms & Conditions',
                            };
                        }
                    },
                }}
            />
        )
            );

I define const { user, signOut } = useAuthenticator((context) => [context.user]); in components, not at the app level, where I want to obtain the authentication state. Following the guide, I use the conditional typeof user === 'undefined' to see if the user is authenticated.

Code Snippet

// Put your code below this line.

Additional information and screenshots

No response

vymao avatar Mar 09 '22 04:03 vymao

Hi @vymao !

Yes, we have seen the issue with the useAuthenticator hook not updating on refresh. We are tracking that issue here as well.

I believe the issue you are having with it not tracking on signIn/signOut are related. We'll be looking at this problem soon.

ErikCH avatar Mar 09 '22 16:03 ErikCH

Is there a recommended workaround?

vymao avatar Mar 09 '22 21:03 vymao

Yep, this is a bug -- I believe the root cause is the same as #1332. as of now <Authenticator /> needs to be in the component tree for it to properly transition to the route 'authenticated' .

Meaning whenever you're on /dashboard/app, there's no Authenticator on that DOM tree, and useAuthenticator fails to load the current auth state.

As per workaround, can you try putting an "invisible" authenticator inside your /dashboard/app and let us know if that works? We'll prioritize a fix meanwhile.

wlee221 avatar Mar 09 '22 23:03 wlee221

I'm not sure I understand. Is adding Authenticator.Provider not enough? From my understanding of context in React, using a proper hook should only look for a provider up the DOM tree, and since I place the provider at the App level, it in theory should re-render everything when context changes, no? dashboard/app is not what I mean by "App level"; it is simply a route I define. When I mean App level, I mean like so:

ReactDOM.render(
  <HelmetProvider>
    <BrowserRouter>
      <Authenticator.Provider>
        <App />
      </Authenticator.Provider>
    </BrowserRouter>
  </HelmetProvider>,
  document.getElementById('root')
);

Also I'm not sure what you mean by "invisible" authenticator. I have the actual Authenticator as a separate component because I want to still enable users to use part of the app while not signed in.

vymao avatar Mar 10 '22 00:03 vymao

Yep your comment on React Context is right. And this is only a workaround, we're fixing it holistically soon.

The root cause is that Authenticator, when rendered, is sending some data to Authenticator.Provider to initialize the auth state management. So whenever there's an <Authenticator.Provider> but not an <Authenticator />, <Authenticator.Provider /> does not have the auth information ready and will not provide the up-to-date context. In other words the flow is like this:

  1. <Authenticator.Provider /> renders, but have not started auth state management yet
  2. <Authenticator /> renders, and Authenticator starts the auth flow inside Authenticator.Provider

which is an anti-pattern in React and what we'll prioritize fixing.

That's why I mentioned an invisible Authenticator: Assume you have signed in and refresh in dashboard/app page. In dashboard/app, (I assume) that signed in pages does not have an Authenticator in the tree, and so it'll think that the user is not authenticated. So this should address your problem (2).


In terms of your first problem, that is app-level re-rendering, do you have the same problem if you have useAuthenticator(context => [context.route])? It should cause a re-render whenever there's a valid user in the tree. Without that, you won't be even able to automatically traverse to dashboard/app, right? The authenticator state should change when you sign in with the Authenticator.

wlee221 avatar Mar 10 '22 01:03 wlee221

I guess I mean to say: how does one incorporate an invisible Authenticator? I'm probably misunderstanding, but wouldn't adding Authenticator to the dashboard/app component render a new sign in page instead? That isn't my intent.

vymao avatar Mar 10 '22 02:03 vymao

@vymao Could you try adding an <Authenticator> to the route and add css to hide it.

[data-amplify-authenticator] {
display:none;
}

ErikCH avatar Mar 10 '22 17:03 ErikCH

Seems to work that way, thanks. But I think this still isn't ideal; my app has a separate login page that actually uses Authenticator; placing this hidden Authenticator above that also hides the actual Authenticator. So it seems one can only use this in the DOM tree which doesn't have another Authenticator in use below it, which can be a hassle to manage.

vymao avatar Mar 10 '22 20:03 vymao

Yep, +1 on it not being ideal at all, and It's an anti-pattern. We'll have a holistic fix soon.

wlee221 avatar Mar 10 '22 20:03 wlee221

Is this issue underlying the behavior I'm seeing, described in this SO?

davegravy avatar Mar 11 '22 20:03 davegravy

@davegravy yep, correct. Taking a look at this now.

wlee221 avatar Mar 22 '22 19:03 wlee221

Wanted to come back to this and mention that @wlee221 and @ErikCH the solution involving hiding Authenticator seems to only work if the user is signed in. If the user is signed out, the Authenticator still appears.

To provide more context for how I used this: In my routes, I use layouts for different pages:


export default function Router() {
  return useRoutes([
    {
      path: '/dashboard',
      element: <DashboardLayout />,
      children: [
        { path: '', element: <Navigate to="/dashboard/app" /> },
        { path: 'app', element: <DashboardApp /> },
        { path: 'user', element: <User /> },
        { path: 'products', element: <Products /> },
        { path: 'blog', element: <Blog /> },
      ]
    },
    { path: '*', element: <Navigate to="/404" replace /> }
  ]);
}

Such a layout is like:

export default function DashboardLayout() {
  const [open, setOpen] = useState(false);

  return (
    <RootStyle>
      <Authenticator />
      <DashboardNavbar onOpenSidebar={() => setOpen(true)} />
      <DashboardSidebar isOpenSidebar={open} onCloseSidebar={() => setOpen(false)} />
      <MainStyle>
        <Outlet />
      </MainStyle>
    </RootStyle>
  );
}

which imports the CSS file containing:

[data-amplify-authenticator] {
    display: none;
}

vymao avatar Apr 07 '22 01:04 vymao

Hi @vymao !

Does this problem still occur?

ErikCH avatar Apr 18 '22 23:04 ErikCH

Yes. Was there an update that fixed this?

vymao avatar Apr 23 '22 19:04 vymao

Hi @vymao !

Yes, so we made a change with #1580 that improves the experiences for users that are on multiple routes. So now as long as you have your application surrounded by Authenticator.Provider the useAuthenticator will work on any route you're on. Before, if you didn't have an Authenticator on your page it wouldn't work.

There is still one more outstanding issue with this solution. On refresh the route will temporarily be in a setup or idle state before it transitions to an authenticated state. If you check route as soon as the page loads, and redirect somewhere while it's in the idle or setup state, that could be an issue.

I created a guide on authenticated routes here. In it I describe this scenario, and work around for it.

In this scenario if someone goes to an authenticated route, and it's an idle or setup state then we redirect back to /login. Which will then by that time see the user is authenticated and will re-route back to the authenticated page.

Let me know if that's what you're experiencing and if this work around helps in the mean time.

ErikCH avatar Apr 28 '22 17:04 ErikCH

It seems to work for now, will report back if any issues. Thanks!

vymao avatar Apr 29 '22 00:04 vymao

Not entirely sure but I think this is related: I have a route where I sign in the user via input from a custom form and no Authenticator is in the tree. I used to do this via Auth.signIn but this does not propagate AuthState into Authenticator or useAuthenticator although everything is wrapped in Authenticator.Provider. My current workaround is to use submitForm from useAuthenticator hook and dispatch an event like this:

submitForm({
      type: "SIGN_IN",
      username: "[email protected]",
      password: "12345678",
});

This works but as far as I can tell it is undocumented and relies on the internal event name and structure. Can you elaborate on the use case for submitForm and the validity of this workaround? Also: Am I missing some way to signIn the user with pre-obtained credentials via the useAuthenticator hook without displaying an Authenticator? Feels like this should be possible and is kind of what I expected to find in the docs under "Headless Usage".

silberistgold avatar May 02 '22 14:05 silberistgold

Hi @silberistgold !

If you're using Auth.signIn, that will not propagate to useAuthenticator or the auth state. You are correct.

The only thing we listen for is Auth.signOut. That will propagate. We are looking at adding more hub events to listen to, so you can interact directly with the JS library, outside of the Authenticator.

The submitForm is an internal event name, and it's not mentioned in our documentation. It's used for both sending the signUp and signIn information. Just beware, you need to be on the correct route, for it to work. You can use the toSignUp or toSignIn to get to that.

With all that said, I wouldn't recommend using it in the long term, since it could change. However, we are looking into adding better documentation and more utilities to make creating your own headless Authenticator a better experience for advanced used cases like yours.

ErikCH avatar May 02 '22 17:05 ErikCH

Hey @ErikCH

Thanks for you explanation. That is very helpful.

Would be very nice to be able to update the authentication state manually in a documented way. Maybe by manually feeding the response from Auth.signIn into an event.

To come back to the issue discussed here: I am still not able to use the methods from useAuthenticator if there is no Authenticator in the tree. Without Authenticator the route is stuck at setup and using toSignIn or submitForm has no effect (as it is expected for invalid transitions). I am using version 2.16.0.

At the moment I am working around this by hiding Authenticator on these routes using emotion's Global component to conditionally alter the global css. This way I can have my custom sign up forms on some routes and the login and reset password components from the Authenticator on other routes.

Would be great to be able to use useAuthenticator without rendering a hidden Authenticator. I guess some of the initialisation logic has to be moved into Authenticator.Provider for this to be working. Are there any plans for doing this?

silberistgold avatar May 05 '22 13:05 silberistgold

Hi all, we'll close this issue because @vymao's original issue is resolved.

For @silberistgold's comment:

I guess some of the initialisation logic has to be moved into Authenticator.Provider for this to be working. Are there any plans for doing this?

Yep, precisely. We are looking to move some props from Authenticator to Authenticator.Provider so that underlying auth service can start independently of Authenticator. This will have to be in next major release because it'll be a breaking change. cc @calebpollman

Without Authenticator the route is stuck at setup and using toSignIn or submitForm has no effect (as it is expected for invalid transitions). I am using version 2.16.0.

This is likely an edge case we missed. Does this happen after you authenticate, or before? If it's after, this is a bug; #1580 was intended to resolve this by automatically switching route to authenticated if there's a current user whether or not there's an Authenticator in the DOM tree. If it's before, it's very likely a use case we missed. Can you open a new issue if so?

wlee221 avatar May 19 '22 02:05 wlee221

It baffles my mind that this has not been fixed. The reason we are trying to use Authentication.Provider and useAuthenticator in our application is because if we create a custom login/sign-up form without it, it appears there is no way to automatically sign-in the user after they confirm their email without using these. Even using them, it's unclear whether this is possible.

https://github.com/aws-amplify/amplify-js/issues/2562

If I use my own custom components and call Auth methods manually, I have to have the user login again after confirmation (ruins user experience and onboarding) or temporarily store the login credentials in the browser as mentioned in the issue above.

I don't understand why there is even documentation for a headless usage of Authenticator if it doesn't respond to Auth.signIn events and only responds to Auth.signOut events. It led me down a rabbit hole of developer pain and in my opinion, this lack of functionality makes it completely useless.

In addition, there appears to be no way to trigger a transition to verifyUser so that I can use the sendForm internal method in the workaround discussed above.

It also appears nonsensical to me how there can be all of these different authStates but there are only transitions available for just a few of them. If this is the case, how am I meant to manually transition to any of the others?

This is not an advanced use case. This is a use case that tons of developers will have. Assuming developers will just lightly modify the slots or CSS variables for the Authenticator is crazy. Obviously developers will want to build their own custom authentication UI components. In my case, I just have a custom form and want to use that to perform authentication instead of the pre-built UI.

kyokosdream avatar May 24 '22 21:05 kyokosdream

Reopened since we need to address the overall use cases mentioned by @kyokosdream

reesscot avatar Jan 19 '23 16:01 reesscot

@kyokosdream It is now possible to Automatically sign in using the JS Auth API's. See https://docs.amplify.aws/lib/auth/emailpassword/q/platform/js/#auto-sign-in-after-sign-up

We are working on supporting your use cases above, and will update this ticket when we have more detailed plan.

reesscot avatar Jan 27 '23 17:01 reesscot

In the meanwhile, I've used "window.location.reload(true)" right after I sign in in order to force the authenticator provider to update. It's not a good practice but it worked for now..

snirlugassy avatar Feb 18 '23 15:02 snirlugassy

It's quite unbelievable that this has not yet been fixed after 1 year! Come on guys, lots of devs rely on this stuff.

siulca avatar Mar 21 '23 16:03 siulca

I ran into this as well, especially if I refreshed the application. I ended up putting together a quick user provider of my own:

import { createContext, useState, useEffect } from 'react';
import { Hub, Auth } from 'aws-amplify';

export const UserContext = createContext();

export const UserProvider = ({ children }) => {
    const [user, setUser] = useState(null);
    const signOut = () => Auth.signOut();

    useEffect(() => {
        // Check the current user when the app loads
        Auth.currentAuthenticatedUser().then(user => setUser(user)).catch(() => console.log('Not signed in'));

        // Listen for changes to the Auth state and set the local state
        const hubListenerCancelToken = Hub.listen('auth', (data) => {
            const { payload } = data;
            console.log('A new auth event has happened: ', data.payload.event);
            onAuthEvent(payload);
        });

        return () => {
            hubListenerCancelToken();
        }
    }, []);

    const onAuthEvent = (payload) => {
        switch (payload.event) {
            case 'signIn':
                return setUser(payload.data);
            case 'signOut':
                return setUser(null);
            default:
                return;
        }
    }

    return (
        <UserContext.Provider value={{ user, signOut }}>
            {children}
        </UserContext.Provider>
    );
}

Then I use it where ever I need user info like a top menu or anything else:

import { useContext } from 'react';
import { UserContext } from '../components/UserContext';

function TopMenuBar() {
    const { user, signOut } = useContext(UserContext);
    ....

That works for both reloading and auth changes. I have a separate Login page where I just use the Authenticator and my own context to check if I should redirect back:

import { useEffect, useContext } from "react";

import { UserContext } from "../components/UserContext";

import { Authenticator, View } from "@aws-amplify/ui-react";
import '@aws-amplify/ui-react/styles.css';

import { useNavigate, useLocation } from "react-router-dom";

export default function Login() {
    const { user } = useContext(UserContext);
    const location = useLocation();
    const navigate = useNavigate();
    const from = location.state?.from || "/";

    useEffect(() => {
        if(user) {
            navigate(from, { replace: true });
        }
    }, [user, navigate, from]);

    return (
        <View className="auth-wrapper">
            <Authenticator />
        </View>
    );
}

Hope this helps someone in the meantime!

kallsbo avatar Mar 22 '23 19:03 kallsbo

@kallsbo Using the latest version of @aws-amplify/ui-react you should be able to use:

const { route, user, signOut } = useAuthenticator((context) => [context.route, context.user]);

without the need to implement a custom provider. Please let us know what issues you are running into.

ioanabrooks avatar Mar 22 '23 21:03 ioanabrooks

@kallsbo Using the latest version of @aws-amplify/ui-react you should be able to use:

const { route, user, signOut } = useAuthenticator((context) => [context.route, context.user]);

without the need to implement a custom provider. Please let us know what issues you are running into.

When I have a page refresh I lose the updates when the user changes status like logout etc.

kallsbo avatar Mar 23 '23 08:03 kallsbo

@kallsbo Can you explain more what type of state you are losing when you refresh the page? Are you navigating between routes in your application, or is this a full browser page refresh? Are you not seeing the user object updated when you refresh the page? Would love to learn more about why the default useAuthenticator is not working for you

reesscot avatar Apr 04 '23 22:04 reesscot

Would we be able to update the docs about the submitForm method and its respective payloads for the following:

  • 'CHANGE'
  • 'RESET_PASSWORD'
  • 'SIGN_IN'
  • 'SIGN_OUT'
  • 'SUBMIT'

yannickrocks avatar Apr 06 '23 14:04 yannickrocks