aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.42k stars 2.12k forks source link

`Hub.listen` not firing when inside `useEffect` #13436

Open cekpowell opened 3 months ago

cekpowell commented 3 months ago

Before opening, please confirm:

JavaScript Framework

Next.js

Amplify APIs

Authentication

Amplify Version

v6

Amplify Categories

auth

Backend

Other

Environment information

``` # Put output below this line System: OS: macOS 14.0 CPU: (12) arm64 Apple M2 Pro Memory: 118.72 MB / 16.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 18.18.2 - ~/.nvm/versions/node/v18.18.2/bin/node Yarn: 1.22.19 - /usr/local/bin/yarn npm: 9.8.1 - ~/.nvm/versions/node/v18.18.2/bin/npm Watchman: 2024.05.02.00 - /opt/homebrew/bin/watchman Browsers: Chrome: 125.0.6422.78 Safari: 17.0 npmPackages: @ampproject/toolbox-optimizer: undefined () @aws-amplify/adapter-nextjs: ^1.2.1 => 1.2.1 @aws-amplify/adapter-nextjs/api: undefined () @aws-amplify/adapter-nextjs/data: undefined () @babel/core: undefined () @babel/runtime: 7.22.5 @edge-runtime/cookies: 4.1.1 @edge-runtime/ponyfill: 2.4.2 @edge-runtime/primitives: 4.1.0 @emotion/react: ^11.11.4 => 11.11.4 @emotion/styled: ^11.11.5 => 11.11.5 @hapi/accept: undefined () @hookform/resolvers: ^3.4.2 => 3.4.2 @hookform/resolvers/ajv: 1.0.0 @hookform/resolvers/arktype: 1.0.0 @hookform/resolvers/class-validator: 1.0.0 @hookform/resolvers/computed-types: 1.0.0 @hookform/resolvers/effect-ts: 1.0.0 @hookform/resolvers/io-ts: 1.0.0 @hookform/resolvers/joi: 1.0.0 @hookform/resolvers/nope: 1.0.0 @hookform/resolvers/superstruct: 1.0.0 @hookform/resolvers/typanion: 1.0.0 @hookform/resolvers/typebox: 1.0.0 @hookform/resolvers/valibot: 1.0.0 @hookform/resolvers/vest: 1.0.0 @hookform/resolvers/yup: 1.0.0 @hookform/resolvers/zod: 1.0.0 @mswjs/interceptors: undefined () @mui/material: ^5.15.17 => 5.15.17 @napi-rs/triples: undefined () @next/font: undefined () @opentelemetry/api: undefined () @svgr/webpack: ^8.1.0 => 8.1.0 @trivago/prettier-plugin-sort-imports: ^4.3.0 => 4.3.0 @types/eslint: 8.56.5 => 8.56.5 @types/node: ^20 => 20.12.12 @types/react: ^18 => 18.3.2 @types/react-dom: ^18 => 18.3.0 @types/voca: ^1.4.5 => 1.4.5 @typescript-eslint/eslint-plugin: 7.9.0 => 7.9.0 @typescript-eslint/parser: ^7.9.0 => 7.9.0 (7.2.0) @vercel/nft: undefined () @vercel/og: 0.6.2 acorn: undefined () amphtml-validator: undefined () anser: undefined () arg: undefined () assert: undefined () async-retry: undefined () async-sema: undefined () aws-amplify: ^6.3.4 => 6.3.4 aws-amplify/adapter-core: undefined () aws-amplify/analytics: undefined () aws-amplify/analytics/kinesis: undefined () aws-amplify/analytics/kinesis-firehose: undefined () aws-amplify/analytics/personalize: undefined () aws-amplify/analytics/pinpoint: undefined () aws-amplify/api: undefined () aws-amplify/api/server: undefined () aws-amplify/auth: undefined () aws-amplify/auth/cognito: undefined () aws-amplify/auth/cognito/server: undefined () aws-amplify/auth/enable-oauth-listener: undefined () aws-amplify/auth/server: undefined () aws-amplify/data: undefined () aws-amplify/data/server: undefined () aws-amplify/datastore: undefined () aws-amplify/in-app-messaging: undefined () aws-amplify/in-app-messaging/pinpoint: undefined () aws-amplify/push-notifications: undefined () aws-amplify/push-notifications/pinpoint: undefined () aws-amplify/storage: undefined () aws-amplify/storage/s3: undefined () aws-amplify/storage/s3/server: undefined () aws-amplify/storage/server: undefined () aws-amplify/utils: undefined () babel-packages: undefined () browserify-zlib: undefined () browserslist: undefined () buffer: undefined () bytes: undefined () ci-info: undefined () cli-select: undefined () client-only: 0.0.1 commander: undefined () comment-json: undefined () compression: undefined () conf: undefined () constants-browserify: undefined () content-disposition: undefined () content-type: undefined () cookie: undefined () cross-spawn: undefined () crypto-browserify: undefined () cspell: ^8.8.1 => 8.8.1 css.escape: undefined () data-uri-to-buffer: undefined () debug: undefined () devalue: undefined () domain-browser: undefined () edge-runtime: undefined () eslint: ^8 => 8.57.0 eslint-config-next: 14.2.3 => 14.2.3 eslint-config-prettier: ^9.0.0 => 9.1.0 eslint-plugin-ft-flow: 2.0.3 => 2.0.3 eslint-plugin-jest: 28.5.0 => 28.5.0 eslint-plugin-jsx-expressions: 1.3.2 => 1.3.2 eslint-plugin-prettier: 5.1.3 => 5.1.3 eslint-plugin-react-hooks: 4.6.2 => 4.6.2 events: undefined () find-cache-dir: undefined () find-up: undefined () fresh: undefined () get-orientation: undefined () glob: undefined () gzip-size: undefined () http-proxy: undefined () http-proxy-agent: undefined () https-browserify: undefined () https-proxy-agent: undefined () husky: ^9.0.11 => 9.0.11 icss-utils: undefined () ignore-loader: undefined () image-size: undefined () is-animated: undefined () is-docker: undefined () is-wsl: undefined () jest-worker: undefined () json5: undefined () jsonwebtoken: undefined () jwt-decode: 3.1.2 => 3.1.2 lint-staged: ^15.2.2 => 15.2.2 loader-runner: undefined () loader-utils: undefined () lodash.curry: undefined () lottie-react: ^2.4.0 => 2.4.0 lru-cache: undefined () mini-css-extract-plugin: undefined () nanoid: undefined () native-url: undefined () neo-async: undefined () next: 14.2.3 => 14.2.3 next-intl: ^3.14.0 => 3.14.0 node-fetch: undefined () node-html-parser: undefined () ora: undefined () os-browserify: undefined () p-limit: undefined () path-browserify: undefined () picomatch: undefined () platform: undefined () postcss-flexbugs-fixes: undefined () postcss-modules-extract-imports: undefined () postcss-modules-local-by-default: undefined () postcss-modules-scope: undefined () postcss-modules-values: undefined () postcss-preset-env: undefined () postcss-safe-parser: undefined () postcss-scss: undefined () postcss-value-parser: undefined () prettier: ^3.2.5 => 3.2.5 process: undefined () punycode: undefined () querystring-es3: undefined () raw-body: undefined () react: ^18 => 18.3.1 react-builtin: undefined () react-device-detect: ^2.2.3 => 2.2.3 react-dom: ^18 => 18.3.1 react-dom-builtin: undefined () react-dom-experimental-builtin: undefined () react-experimental-builtin: undefined () react-hook-form: ^7.51.5 => 7.51.5 react-is: 18.2.0 react-markdown: ^9.0.1 => 9.0.1 react-qr-code: ^2.0.13 => 2.0.13 react-refresh: 0.12.0 react-server-dom-turbopack-builtin: undefined () react-server-dom-turbopack-experimental-builtin: undefined () react-server-dom-webpack-builtin: undefined () react-server-dom-webpack-experimental-builtin: undefined () regenerator-runtime: 0.13.4 sass-loader: undefined () scheduler-builtin: undefined () scheduler-experimental-builtin: undefined () schema-utils: undefined () semver: undefined () send: undefined () server-only: 0.0.1 setimmediate: undefined () sharp: 0.32.6 => 0.32.6 shell-quote: undefined () source-map: undefined () source-map08: undefined () stacktrace-parser: undefined () stream-browserify: undefined () stream-http: undefined () string-hash: undefined () string_decoder: undefined () strip-ansi: undefined () superstruct: undefined () svgo: ^3.3.2 => 3.3.2 tar: undefined () terser: undefined () text-table: undefined () timers-browserify: undefined () tty-browserify: undefined () typescript: ^5 => 5.4.5 ua-parser-js: undefined () unistore: undefined () util: undefined () vm-browserify: undefined () voca: ^1.4.1 => 1.4.1 watchpack: undefined () web-vitals: undefined () webpack: undefined () webpack-sources: undefined () ws: undefined () zod: ^3.23.8 => 3.23.8 () npmGlobalPackages: @aws-amplify/cli: 12.11.1 corepack: 0.19.0 dotenv-cli: 7.4.2 firebase-tools: 12.0.0 get-graphql-schema: 2.1.2 ios-deploy: 1.12.2 jest: 29.7.0 npm: 9.8.1 serverless: 3.38.0 vercel: 34.1.14 ```

Describe the bug

TLDR

If i place a Hub.listen into a useEffect inside a component, the Hub never fires any events, and so I cannot handle the events and update my state accordingly. If i take the Hub out of the useEffect, it correctly fires, but I cannot update component state from outside of the useEffect as the component has not yet mounted.

Full Description & Context

I have a NextJS app that uses the Amplify SDK to handle Authentication. I am not using the Amplify CLI, but connecting to an existing AWS backend I have defined.

I have implemented sign in via social providers (Google, Apple and Facebook), and I am successfully re-directed to the provider, able to sign in, and be re-directed back to the app. Now, I would like to listen for the redirect auth event in my app so that I can update my system auth state (managed in a context) with the user's details, or handle errors if there are any, and then re-direct the user to the next screen.

As per the docs, I am trying to do this with a Hub.listen inside a useEffect in the page component. However, no event is ever fired when I am re-directed back to the app. If I take the Hub.listen out of a useEffect, or out of the component all together, then the event is fired, but I am no longer able to update my system context (as this must be done inside a component after mounting).

I am guessing the event is not firing inside useEffect because the app is unmounted when the redirect takes place, but then this raises the question of how I am meant to listen to re-direct auth events using the hub?

I have found this existing issue which has been closed, but recent comments suggest others are still facing this problem.

Expected behavior

The Hub listener should fire an event for signInWithRedirect.

Reproduction steps

Code Snippet

// Put your code below this line.

Log output

``` // Put your logs below this line ```

aws-exports.js

No response

Manual configuration

No response

Additional configuration

No response

Mobile Device

No response

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

No response

amoffat commented 3 months ago

I'm seeing a similar issue. In my case, I'm trying to listen for the tokenRefresh event, and I've set up my Hub listener inside a useEffect with an empty dependency list. Despite my tokens having a very short expiration (5 minutes), the tokenRefresh event never fires. I think we might have a similar underlying cause.

cekpowell commented 3 months ago

@amoffat could be the case. I would imagine this issue is effecting all events rather than just the signInWithRedirect that we are wanting.

israx commented 3 months ago

hello @cekpowell . Did you try placing Hub inside a useEffect in the layout file of your next.js App ?

cekpowell commented 3 months ago

Hey @israx - does it specifically have to be the root layout for the app? Mine is a server component and I was under the impression making it a client component would be bad practice. Is this the setup you use?

Anyway, I tested it with a layout file for the signIn page and still no difference - the Hub is still not catching any events.

israx commented 3 months ago

Hub was design to be used in SPA applications, meaning that Hub events will be dispatched in the same window object where it was initiated. So if Hub is initialized at the top of the element tree, then Amplify APIs called in any children components will be able to trigger the dispatch of events.

If you are calling an API on the server, and initializing Hub on the client, that would not work because there are 2 different instances of Hub.

amoffat commented 3 months ago

I'm using Hub client side in an SPA. Here's what I noticed: the auth event with the tokenRefreshed payload is only received by Hub when I call fetchAuthSession. Apparently this triggers Amplify to realize that the token is expired, which in turn fires the Hub event. But I was under the impression that the token refreshes would happen out-of-band, and that I could respond to those asynchronously through Hub, without triggering Amplify via fetchAuthSession

cekpowell commented 3 months ago

Thanks @israx. I have tried placing the Hub in my AuthProvider which sits at the top of my app, and im my root layout.tsx file and im still finding that if the Hub is placed inside a useEffect, no events are being fired.

Should the Hub work in this case? I need to listen to auth events so I can update state, and I can only update state inside a useEffect.

israx commented 3 months ago

@amoffat the library will check and emit the tokenRefresh event only when the access token is expired and the fetchAuthSession API is called. In order to support your use case the library needs to setup a listener and call fetchAuthSession when tokens are expired. Do you mind opening a feature request with us to keep track of this feature ?

israx commented 3 months ago

@cekpowell could you share code snippets and show how you are setting up Hub and what APIs you are using to trigger the dispatch of events ?

cekpowell commented 3 months ago

Sure thing:

I have an AuthProvider component which provides auth state/update methods. it also has a Hub defined in a useEffect which should be able to listen to auth events. Here is a slimmed down version of it:

'use client'

import { decodeCognitoIdToken } from '@/utils/jwt'
import { AppSessionStorage, AppSessionStorageKeys } from '@/utils/sessionStorage'
import { Amplify } from 'aws-amplify'
import {
    signInWithRedirect as amplifySignInViaSocial,
    AuthError,
    fetchAuthSession,
    JWT,
} from 'aws-amplify/auth'
import {Hub} from 'aws-amplify/utils'
import { createContext, ReactNode, useCallback, useContext, useState } from 'react'

/**
 * * ------------------------------------------------------------------------
 * * MARK: Amplify Initialization
 * * ------------------------------------------------------------------------
 */

Amplify.configure(AMPLIFY_CONFIG, { ssr: true })

/**
 * * ------------------------------------------------------------------------
 * * MARK: Types
 * * ------------------------------------------------------------------------
 */

/** Supported providers for social sign-in in the app. */
export type AppSocialSignInProviders = 'Google' | 'Facebook' | 'Apple'

export interface SignInSocialParams {
    /** Social provider to sign the user into the app with. */
    provider: AppSocialSignInProviders
}

export enum SignInViaSocialErrors {
    /** Unknown error occured (i.e., error we do not account for). Can also occur if theres no internet connection. */
    unknown = 'unknown',
}

/**
 * * ------------------------------------------------------------------------
 * * MARK: Provider
 * * ------------------------------------------------------------------------
 */

export interface AppUserDetails {
    /** User's ID */
    userId: string
    /** User's email */
    email: string
    /** User's name */
    name: string
    /** URL to user's profile picture */
    picture?: string
    /** Access Token (JWT) */
    accessToken: JWT
    /** idToken (JWT) */
    idToken: JWT
}

export interface AuthContextType {
    /**
     * Data
     */

    /** Is there a user currently signed in? */
    isSignedIn: boolean
    /** `AppUserDetails` for currently signed in user. Undefined if no user is signed in. */
    userDetails?: AppUserDetails

    /**
     * Operations
     */

    /** Refreshes the user's auth state. */
    refreshAuthState: () => Promise<void>
    /** Sign the user in/up to the app via a social provider. */
    signInViaSocial: (params: SignInSocialParams) => Promise<void>
}

export const AuthContext = createContext<AuthContextType>({
    // data
    isSignedIn: false,
    userDetails: undefined,

    // operations
    refreshAuthState: async () => {},
    signInViaSocial: async () => {},
})

interface AuthProviderProps {
    /** Clear the cache of the `AuthProvider` on intialisation? */
    clearCache?: boolean
    /** Provider children */
    children?: ReactNode
}

/**
 * Provides and manages the authentication state of the app.
 */
export const AuthProvider = ({ children }: AuthProviderProps) => {
    // getting auth state via `useAuthProvider` hook.
    const auth = useAuthProvider()

    // provider
    return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
}

/**
 * Hook that provides and manages the authentication state. We define this hook
 * to save defining all of the auth logic inside the `AuthProvider` component.
 */
const useAuthProvider = () => {
    /**
     * Data
     */

    // is the user signed in?
    const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
    // details for currently signed in user
    const [userDetails, setUserDetails] = useState<AppUserDetails | undefined>(undefined)

    useEffect(() => {
        const unsubscribe = Hub.listen('auth', async ({payload}) => {
            console.log(payload)
        })

        return unsubscribe
    }, [])

    /**
     * Operations
     */

    /**
     * Refresh Auth Context
     */

    /**
     * Refreshes the auth context based on the current auth session.
     *
     * Should be called whenever the users auth state changes and we need to update
     * our context - e.g., sign in, sign out, etc.
     */
    const refreshAuthState = useCallback(async () => {
        // fetching user tokens
        const authSession = await fetchAuthSession()
        const accessToken = authSession.tokens?.accessToken
        const idToken = authSession.tokens?.idToken
        const userAttributes = idToken && decodeCognitoIdToken(idToken.toString())

        // if user is signed in -> lets update state with user info
        if (accessToken && idToken && userAttributes) {
            setIsSignedIn(true)
            setUserDetails({
                userId: userAttributes.preferred_username,
                email: userAttributes.email,
                name: userAttributes.name,
                picture: userAttributes.picture,
                accessToken: accessToken,
                idToken: idToken,
            })
        }

        // if user is not signed in -> lets clear the state
        if (!accessToken || !idToken || !userAttributes) {
            setIsSignedIn(false)
            setUserDetails(undefined)
        }
    }, [])

    /**
     * Sign Up
     */

    /**
     * Sign in via Social
     */

    /**
     * Sign the user in/up to the app via a social provider.
     *
     * **NOTE**: This method will re-direct the user to the social provider's login
     * page, and they will then be send back to the app. You must handle this re-direct
     * back to the app in order to complete the sign-in/sign-up.
     */
    const signInViaSocial = async ({ provider }: SignInSocialParams) => {
        try {
            // setting the provider into storage (so we can access it after the re-direct)
            AppSessionStorage.setItem(AppSessionStorageKeys.ACTIVE_SOCIAL_PROVIDER, provider)

            // signing in via social
            await amplifySignInViaSocial({
                provider: provider,
                customState: 'my-custom-state',
            })
        } catch (e) {
            // cleaning up session storage
            AppSessionStorage.removeItem(AppSessionStorageKeys.ACTIVE_SOCIAL_PROVIDER)

            // throwing custom error
            throw new Error(SignInViaSocialErrors.unknown)
        }
    }

    /**
     * Returning auth state.
     */

    return {
        // data
        isSignedIn,
        userDetails,

        // operations
        refreshAuthState,
        signInViaSocial,
    }
}

/**
 * * ------------------------------------------------------------------------
 * * MARK: Hooks
 * * ------------------------------------------------------------------------
 */

/**
 * Returns the Auth context for the app.
 */
export const useAuth = () => {
    return useContext(AuthContext)
}

This AuthProvider is then wrapped around my RootLayout component like so:


const RootLayout = async ({
    children,
}: Readonly<{
    children: React.ReactNode
}>) => {
    // fetching app locale using next-intl
    const locale = await getLocale()

    return (
        <html lang={locale} suppressHydrationWarning>
            {/* remove default margin */}
            <body style={{ margin: 0 }}>
                <AuthProvider>{children}</AuthProvider>
            </body>
        </html>
    )
}

export default RootLayout

Finally, in my Sign In screen, I am calling the signInViaSocial method from the auth provider for one of Google, Facebook and Apple based on what button the user presses. This functionality works fine - I can log in, be redirected back to the app, its just that no event is fired from the Hub.

amoffat commented 3 months ago

@amoffat the library will check and emit the tokenRefresh event only when the access token is expired and the fetchAuthSession API is called. In order to support your use case the library needs to setup a listener and call fetchAuthSession when tokens are expired. Do you mind opening a feature request with us to keep track of this feature ?

Gotcha, so my Hub issue is unrelated to this issue then. I'll open the feature request.

cekpowell commented 3 months ago

@cwomack @israx - just a thought here. It seems the underlying issue is that the app is unmounted following signInWithRedirect, and thus the Hub is removed. Is it possible to perform the signInWithRedirect in a new tab to prevent this?

israx commented 3 months ago

Yes you can signIn in a different tab as long as Hub.listen was called before signInWithRedirect API and the listener is still active. Probably you can stop the listener after a successful event and not on a component unmount

cwomack commented 3 months ago

@cekpowell, did the comments from @israx help unblock or clear the issue up here? Let us know if there is anything else that we can help with.

cekpowell commented 2 months ago

Hi @cwomack - unfortunately not - this issue is still blocking me, and I have resulted to using a combination of a useEffect and local storage data.

Are you able to confirm if this is in-fact a bug with the SDK? It seems like a very crucial part of functionality that isn't working properly.

r2data-systems commented 1 month ago

Hi, I hope my comments are useful as this is new to me. However, following this tutorial https://www.youtube.com/watch?v=wcSMnICY-_8 I encounter the same problem! Using the code from the github library specified in the video i.e. https://github.com/ErikCH/CodeFirstTypeSafety I can get it working, but effectively I'm regressing to the previous version specified in the video. And believing its a version problem, here's the diff of my two package.json's

<   "name": "NOT WORKING,
---
>   "name": "WORKING",
12,15c12,15
<     "@aws-amplify/adapter-nextjs": "^1.2.7",
<     "@aws-amplify/ui-react": "^6.1.13",
<     "aws-amplify": "^6.4.0",
<     "next": "14.2.5",
---
>     "@aws-amplify/adapter-nextjs": "^1.0.8",
>     "@aws-amplify/ui-react": "^6.0.7",
>     "aws-amplify": "^6.4.3",
>     "next": "14.0.4",
21c21
<     "@aws-amplify/backend-cli": "^1.2.0",
---
>     "@aws-amplify/backend-cli": "^1.2.1",
25,26c25,27
<     "aws-cdk": "^2.149.0",
<     "aws-cdk-lib": "^2.149.0",
---
>     "autoprefixer": "^10.0.1",
>     "aws-cdk": "^2.150.0",
>     "aws-cdk-lib": "^2.150.0",
30c31
<     "eslint-config-next": "14.2.5",
---
>     "eslint-config-next": "14.0.4",
32c33
<     "tailwindcss": "^3.4.1",
---
>     "tailwindcss": "^3.3.0",
34c35
<     "typescript": "^5.5.3"
---
>     "typescript": "^5.5.4"

Hope this helps! C.

P.S. This turned out to be a caching problem; clearing the "token" cache in the browser, resolved the problem and to be fair the video explains this ...

apettiigrew commented 1 month ago

The same issue is happening to me with the same setup. Has this been verified as a real bug, or are we doing something wrong with the setup? I was blocked for a few days with this and had to opt for manually handling it. It would be nice to have an official guide, though.