+
{status === "loading" ? "loading" : JSON.stringify(session)}
)
}
+
+function changeTabVisibility(status) {
+ const visibleStates = ["visible", "hidden"]
+
+ if (!visibleStates.includes(status)) return
+
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: status,
+ })
+
+ document.dispatchEvent(new Event("visibilitychange"))
+}
diff --git a/src/client/__tests__/helpers/utils.js b/src/client/__tests__/helpers/utils.js
index bdc430a44d..df2844a183 100644
--- a/src/client/__tests__/helpers/utils.js
+++ b/src/client/__tests__/helpers/utils.js
@@ -6,3 +6,9 @@ export function getBroadcastEvents() {
return { eventName, value: rest }
})
}
+
+export function printFetchCalls(mockCalls) {
+ return mockCalls.map(([path, { method = "GET" }]) => {
+ return `${method.toUpperCase()} ${path}`
+ })
+}
diff --git a/src/client/__tests__/use-session-hook.test.js b/src/client/__tests__/use-session-hook.test.js
new file mode 100644
index 0000000000..7b00ff652c
--- /dev/null
+++ b/src/client/__tests__/use-session-hook.test.js
@@ -0,0 +1,142 @@
+import { rest } from "msw"
+import { renderHook } from "@testing-library/react-hooks"
+import { render, waitFor } from "@testing-library/react"
+import { SessionProvider, useSession, signOut } from "../react"
+import { server, mockSession } from "./helpers/mocks"
+
+const origConsoleError = console.error
+const origLocation = window.location
+const locationReplace = jest.fn()
+
+beforeAll(() => {
+ // Prevent noise on the terminal... `next-auth` will log to `console.error`
+ // every time a request fails, which makes the tests output very noisy...
+ console.error = jest.fn()
+
+ // Allows to spy on `window.location.replace`...
+ delete window.location
+ window.location = { ...origLocation, replace: locationReplace }
+
+ server.listen()
+})
+
+afterEach(() => {
+ server.resetHandlers()
+ locationReplace.mockClear()
+
+ // clear the internal session cache...
+ signOut({ redirect: false })
+})
+
+afterAll(() => {
+ console.error = origConsoleError
+ window.location = origLocation
+ server.close()
+})
+
+test("it won't allow to fetch the session in isolation without a session context", () => {
+ function App() {
+ useSession()
+ return null
+ }
+
+ expect(() => render(
)).toThrow(
+ "[next-auth]: `useSession` must be wrapped in a
"
+ )
+})
+
+test("when fetching the session, there won't be `data` and `status` will be 'loading'", () => {
+ const { result } = renderHook(() => useSession(), {
+ wrapper: SessionProvider,
+ })
+
+ expect(result.current.data).toBe(undefined)
+ expect(result.current.status).toBe("loading")
+})
+
+test("when session is fetched, `data` will contain the session data and `status` will be 'authenticated'", async () => {
+ const { result } = renderHook(() => useSession(), {
+ wrapper: SessionProvider,
+ })
+
+ await waitFor(() => {
+ expect(result.current.data).toEqual(mockSession)
+ expect(result.current.status).toBe("authenticated")
+ })
+})
+
+test("when it fails to fetch the session, `data` will be null and `status` will be 'unauthenticated'", async () => {
+ server.use(
+ rest.get(`/api/auth/session`, (req, res, ctx) =>
+ res(ctx.status(401), ctx.json({}))
+ )
+ )
+
+ const { result } = renderHook(() => useSession(), {
+ wrapper: SessionProvider,
+ })
+
+ return waitFor(() => {
+ expect(result.current.data).toEqual(null)
+ expect(result.current.status).toBe("unauthenticated")
+ })
+})
+
+test("it'll redirect to sign-in page if the session is required and the user is not authenticated", async () => {
+ server.use(
+ rest.get(`/api/auth/session`, (req, res, ctx) =>
+ res(ctx.status(401), ctx.json({}))
+ )
+ )
+
+ const { result } = renderHook(() => useSession({ required: true }), {
+ wrapper: SessionProvider,
+ })
+
+ await waitFor(() => {
+ expect(result.current.data).toEqual(null)
+ expect(result.current.status).toBe("loading")
+ })
+
+ expect(locationReplace).toHaveBeenCalledTimes(1)
+
+ expect(locationReplace).toHaveBeenCalledWith(
+ expect.stringContaining("/api/auth/signin")
+ )
+
+ expect(locationReplace).toHaveBeenCalledWith(
+ expect.stringContaining(
+ new URLSearchParams({
+ error: "SessionRequired",
+ callbackUrl: window.location.href,
+ }).toString()
+ )
+ )
+})
+
+test("will call custom redirect logic if supplied when the user could not authenticate", async () => {
+ server.use(
+ rest.get(`/api/auth/session`, (req, res, ctx) =>
+ res(ctx.status(401), ctx.json({}))
+ )
+ )
+
+ const customRedirect = jest.fn()
+
+ const { result } = renderHook(
+ () => useSession({ required: true, onUnauthenticated: customRedirect }),
+ {
+ wrapper: SessionProvider,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toEqual(null)
+ expect(result.current.status).toBe("loading")
+ })
+
+ // it shouldn't have tried to re-direct to sign-in page (default behavior)
+ expect(locationReplace).not.toHaveBeenCalled()
+
+ expect(customRedirect).toHaveBeenCalledTimes(1)
+})
diff --git a/src/client/react.js b/src/client/react.js
index 2fe6bec422..9b1312709f 100644
--- a/src/client/react.js
+++ b/src/client/react.js
@@ -47,7 +47,9 @@ export function useSession(options = {}) {
const value = React.useContext(SessionContext)
if (process.env.NODE_ENV !== "production" && !value) {
- throw new Error("useSession must be wrapped in a SessionProvider")
+ throw new Error(
+ "[next-auth]: `useSession` must be wrapped in a
"
+ )
}
const { required, onUnauthenticated } = options
@@ -74,6 +76,7 @@ export function useSession(options = {}) {
export async function getSession(ctx) {
const session = await _fetchData("session", ctx)
+
if (ctx?.broadcast ?? true) {
broadcast.post({ event: "session", data: { trigger: "getSession" } })
}
@@ -166,8 +169,10 @@ export async function signOut(options = {}) {
json: true,
}),
}
+
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
const data = await res.json()
+
broadcast.post({ event: "session", data: { trigger: "signout" } })
if (redirect) {
@@ -185,9 +190,8 @@ export async function signOut(options = {}) {
/** @param {import("types/react-client").SessionProviderProps} props */
export function SessionProvider(props) {
- const { children, baseUrl, basePath, staleTime = 0 } = props
+ const { children, basePath, staleTime = 0 } = props
- if (baseUrl) __NEXTAUTH.baseUrl = baseUrl
if (basePath) __NEXTAUTH.basePath = basePath
/**
@@ -270,12 +274,13 @@ export function SessionProvider(props) {
}, [])
React.useEffect(() => {
- // Set up visibility change
- // Listen for document visibility change events and
- // if visibility of the document changes, re-fetch the session.
+ // Listen for when the page is visible, if the user switches tabs
+ // and makes our tab visible again, re-fetch the session.
const visibilityHandler = () => {
- !document.hidden && __NEXTAUTH._getSession({ event: "visibilitychange" })
+ if (document.visibilityState === "visible")
+ __NEXTAUTH._getSession({ event: "visibilitychange" })
}
+
document.addEventListener("visibilitychange", visibilityHandler, false)
return () =>
document.removeEventListener("visibilitychange", visibilityHandler, false)