diff --git a/package-lock.json b/package-lock.json index b473555d..cdd16d02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34194,6 +34194,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zustand": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", + "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "packages/elements-react": { "name": "@ory/elements-react", "version": "1.0.0-next.10", @@ -34205,7 +34234,8 @@ "lodash.merge": "4.6.2", "react-hook-form": "7.53.0", "react-intl": "6.7.0", - "tailwind-merge": "2.5.2" + "tailwind-merge": "2.5.2", + "zustand": "5.0.0" }, "devDependencies": { "@hookform/devtools": "^4.3.1", diff --git a/packages/elements-react/.eslintrc.js b/packages/elements-react/.eslintrc.js index 912a5548..23503739 100644 --- a/packages/elements-react/.eslintrc.js +++ b/packages/elements-react/.eslintrc.js @@ -39,6 +39,8 @@ module.exports = { caughtErrorsIgnorePattern: "^_", }, ], + // TODO(jonas): prettier and eslint sometimes disagree about this + "no-extra-semi": "off", }, env: { jest: true, diff --git a/packages/elements-react/api-report/elements-react.api.json b/packages/elements-react/api-report/elements-react.api.json index a9073745..651466bc 100644 --- a/packages/elements-react/api-report/elements-react.api.json +++ b/packages/elements-react/api-report/elements-react.api.json @@ -2977,6 +2977,39 @@ "parameters": [], "name": "useOryFlow" }, + { + "kind": "Function", + "canonicalReference": "@ory/elements-react!useSession:function(1)", + "docComment": "/**\n * A hook to get the current session from the Ory Network.\n *\n * Usage:\n * ```ts\n * const { session, error, isLoading } = useSession()\n * ```\n *\n * @returns The current session, error and loading state.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "useSession: () => " + }, + { + "kind": "Content", + "text": "{\n session: " + }, + { + "kind": "Reference", + "text": "Session", + "canonicalReference": "@ory/client-fetch!Session:interface" + }, + { + "kind": "Content", + "text": " | undefined;\n error: string | undefined;\n isLoading: boolean;\n}" + } + ], + "fileUrlPath": "dist/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], + "name": "useSession" + }, { "kind": "TypeAlias", "canonicalReference": "@ory/elements-react!VerificationFlowContainer:type", diff --git a/packages/elements-react/api-report/elements-react.api.md b/packages/elements-react/api-report/elements-react.api.md index 3e5cdc1d..fd157a08 100644 --- a/packages/elements-react/api-report/elements-react.api.md +++ b/packages/elements-react/api-report/elements-react.api.md @@ -21,6 +21,7 @@ import { PropsWithChildren } from 'react'; import * as react_jsx_runtime from 'react/jsx-runtime'; import { RecoveryFlow } from '@ory/client-fetch'; import { RegistrationFlow } from '@ory/client-fetch'; +import { Session } from '@ory/client-fetch'; import { SettingsFlow } from '@ory/client-fetch'; import { UiNode } from '@ory/client-fetch'; import { UiNodeAnchorAttributes } from '@ory/client-fetch'; @@ -315,6 +316,13 @@ export function useNodeSorter(): (a: UiNode, b: UiNode, ctx: { // @public export function useOryFlow(): FlowContextValue; +// @public +export const useSession: () => { + session: Session | undefined; + error: string | undefined; + isLoading: boolean; +}; + // @public export type VerificationFlowContainer = OryFlow; diff --git a/packages/elements-react/package.json b/packages/elements-react/package.json index 7dac6e98..85b1f51d 100644 --- a/packages/elements-react/package.json +++ b/packages/elements-react/package.json @@ -34,7 +34,8 @@ "lodash.merge": "4.6.2", "react-hook-form": "7.53.0", "react-intl": "6.7.0", - "tailwind-merge": "2.5.2" + "tailwind-merge": "2.5.2", + "zustand": "5.0.0" }, "peerDependencies": { "react": "18.3.1", diff --git a/packages/elements-react/src/hooks/index.ts b/packages/elements-react/src/hooks/index.ts new file mode 100644 index 00000000..90d884e7 --- /dev/null +++ b/packages/elements-react/src/hooks/index.ts @@ -0,0 +1,4 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +export { useSession } from "./useSession" diff --git a/packages/elements-react/src/hooks/useSession.spec.tsx b/packages/elements-react/src/hooks/useSession.spec.tsx new file mode 100644 index 00000000..ce6eff94 --- /dev/null +++ b/packages/elements-react/src/hooks/useSession.spec.tsx @@ -0,0 +1,162 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +// useSession.test.tsx + +import { Session } from "@ory/client-fetch" +import "@testing-library/jest-dom" +import "@testing-library/jest-dom/jest-globals" +import { act, render, screen, waitFor } from "@testing-library/react" +import { useOryFlow } from "../context/flow-context" +import { frontendClient } from "../util/client" +import { sessionStore, useSession } from "./useSession" + +// Mock the necessary imports +jest.mock("../context/flow-context", () => ({ + useOryFlow: jest.fn(), +})) + +jest.mock("../util/client", () => ({ + frontendClient: jest.fn(() => ({ + toSession: jest.fn(), + })), +})) + +// Create a test component to use the hook +const TestComponent = () => { + const { session, isLoading, error } = useSession() + + if (isLoading) return
Loading...
+ if (error) return
Error: {error}
+ if (session) return
Session: {session.id}
+ + return
No session
+} + +describe("useSession", () => { + const mockSession: Session = { + id: "test-session-id", + identity: { + id: "test-identity-id", + traits: {}, + schema_id: "", + schema_url: "", + }, + expires_at: new Date(), + } + const mockConfig = { + sdk: { url: "https://mock-sdk-url" }, + } + + beforeEach(() => { + jest.clearAllMocks() + // Mock the flow context + ;(useOryFlow as jest.Mock).mockReturnValue({ config: mockConfig }) + sessionStore.setState({ + isLoading: false, + session: undefined, + error: undefined, + }) + }) + + it("fetches and sets session successfully", async () => { + ;(frontendClient as jest.Mock).mockReturnValue({ + toSession: jest.fn().mockResolvedValue(mockSession), + }) + + render() + + // Initially, it should show loading + expect(screen.getByText("Loading...")).toBeInTheDocument() + + // Wait for the hook to update + await waitFor(() => + expect( + screen.getByText(`Session: ${mockSession.id}`), + ).toBeInTheDocument(), + ) + + // Verify that the session data is displayed + expect(screen.getByText(`Session: ${mockSession.id}`)).toBeInTheDocument() + }) + + it("doesn't refetch session if a session is set", async () => { + ;(frontendClient as jest.Mock).mockReturnValue({ + toSession: jest.fn().mockResolvedValue(mockSession), + }) + + render() + + // Initially, it should show loading + expect(screen.getByText("Loading...")).toBeInTheDocument() + + // Wait for the hook to update + await waitFor(() => + expect( + screen.getByText(`Session: ${mockSession.id}`), + ).toBeInTheDocument(), + ) + + // Verify that the session data is displayed + expect(screen.getByText(`Session: ${mockSession.id}`)).toBeInTheDocument() + + // this is fine, because jest is not calling the function + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(frontendClient(mockConfig.sdk.url).toSession).toHaveBeenCalledTimes( + 1, + ) + + act(() => { + render() + }) + + // this is fine, because jest is not calling the function + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(frontendClient(mockConfig.sdk.url).toSession).toHaveBeenCalledTimes( + 1, + ) + }) + + it("handles errors during session fetching", async () => { + const errorMessage = "Failed to fetch session" + ;(frontendClient as jest.Mock).mockReturnValue({ + toSession: jest.fn().mockRejectedValue(new Error(errorMessage)), + }) + + render() + + // Initially, it should show loading + expect(screen.getByText("Loading...")).toBeInTheDocument() + + // Wait for the hook to update after the error + await waitFor(() => + expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument(), + ) + + // Verify that the error message is displayed + expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument() + }) + + it("does not fetch session if already loading or session is set", async () => { + ;(frontendClient as jest.Mock).mockReturnValue({ + toSession: jest.fn(), + }) + + // First render: no session, simulate loading + render() + + // Initially, it should show loading + expect(screen.getByText("Loading...")).toBeInTheDocument() + + // Simulate session already being set in the store + await waitFor(() => + expect(screen.getByText("No session")).toBeInTheDocument(), + ) + + // this is fine, because jest is not calling the function + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(frontendClient(mockConfig.sdk.url).toSession).toHaveBeenCalledTimes( + 1, + ) + }) +}) diff --git a/packages/elements-react/src/hooks/useSession.ts b/packages/elements-react/src/hooks/useSession.ts new file mode 100644 index 00000000..5e58ee7a --- /dev/null +++ b/packages/elements-react/src/hooks/useSession.ts @@ -0,0 +1,74 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Session } from "@ory/client-fetch" +import { useCallback, useEffect } from "react" +import { create, useStore } from "zustand" +import { subscribeWithSelector } from "zustand/middleware" +import { useOryFlow } from "../context/flow-context" +import { frontendClient } from "../util/client" + +type SessionStore = { + setIsLoading: (loading: boolean) => void + setSession: (session: Session) => void + isLoading: boolean + session: Session | undefined + error: string | undefined + setError: (error: string | undefined) => void +} + +export const sessionStore = create()( + subscribeWithSelector((set) => ({ + isLoading: false, + setIsLoading: (isLoading: boolean) => set({ isLoading }), + session: undefined, + setSession: (session: Session) => set({ session }), + error: undefined, + setError: (error: string | undefined) => set({ error }), + })), +) + +/** + * A hook to get the current session from the Ory Network. + * + * Usage: + * ```ts + * const { session, error, isLoading } = useSession() + * ``` + * + * @returns The current session, error and loading state. + */ +export const useSession = () => { + const { config } = useOryFlow() + const store = useStore(sessionStore) + + const fetchSession = useCallback(async () => { + const { session, isLoading, setSession, setIsLoading, setError } = + sessionStore.getState() + + if (!!session || isLoading) { + return + } + + setIsLoading(true) + + try { + const sessionData = await frontendClient(config.sdk.url).toSession() + setSession(sessionData) + } catch (e) { + setError(e instanceof Error ? e.message : "Unknown error occurred") + } finally { + setIsLoading(false) + } + }, [config.sdk.url]) + + useEffect(() => { + void fetchSession() + }, [fetchSession]) + + return { + session: store.session, + error: store.error, + isLoading: store.isLoading, + } +} diff --git a/packages/elements-react/src/index.ts b/packages/elements-react/src/index.ts index 81c2da38..9632dd24 100644 --- a/packages/elements-react/src/index.ts +++ b/packages/elements-react/src/index.ts @@ -5,4 +5,5 @@ export type * from "./types" export * from "./components" export * from "./context" export * from "./util" +export * from "./hooks" export { locales as OryLocales } from "./locales"