diff --git a/docs/react.asyncresult.data.md b/docs/react.asyncresult.data.md deleted file mode 100644 index 6c11d252..00000000 --- a/docs/react.asyncresult.data.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [@cerbos/react](./react.md) > [AsyncResult](./react.asyncresult.md) > [data](./react.asyncresult.data.md) - -## AsyncResult.data property - -**Signature:** - -```typescript -data: T | undefined; -``` diff --git a/docs/react.asyncresult.error.md b/docs/react.asyncresult.error.md deleted file mode 100644 index 8aeaf297..00000000 --- a/docs/react.asyncresult.error.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [@cerbos/react](./react.md) > [AsyncResult](./react.asyncresult.md) > [error](./react.asyncresult.error.md) - -## AsyncResult.error property - -**Signature:** - -```typescript -error: Error | undefined; -``` diff --git a/docs/react.asyncresult.isloading.md b/docs/react.asyncresult.isloading.md deleted file mode 100644 index 7af54a41..00000000 --- a/docs/react.asyncresult.isloading.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [@cerbos/react](./react.md) > [AsyncResult](./react.asyncresult.md) > [isLoading](./react.asyncresult.isloading.md) - -## AsyncResult.isLoading property - -**Signature:** - -```typescript -isLoading: boolean; -``` diff --git a/docs/react.asyncresult.md b/docs/react.asyncresult.md index 61c9b89b..caf3112b 100644 --- a/docs/react.asyncresult.md +++ b/docs/react.asyncresult.md @@ -2,87 +2,23 @@ [Home](./index.md) > [@cerbos/react](./react.md) > [AsyncResult](./react.asyncresult.md) -## AsyncResult interface +## AsyncResult type **Signature:** ```typescript -export interface AsyncResult +export type AsyncResult = { + isLoading: true; + data: undefined; + error: undefined; +} | { + isLoading: false; + data: T; + error: undefined; +} | { + isLoading: false; + data: undefined; + error: Error; +}; ``` - -## Properties - - - - - -
- -Property - - - - -Modifiers - - - - -Type - - - - -Description - - -
- -[data](./react.asyncresult.data.md) - - - - - - - -T \| undefined - - - - - -
- -[error](./react.asyncresult.error.md) - - - - - - - -Error \| undefined - - - - - -
- -[isLoading](./react.asyncresult.isloading.md) - - - - - - - -boolean - - - - - -
diff --git a/docs/react.md b/docs/react.md index 1906cda1..499adf8c 100644 --- a/docs/react.md +++ b/docs/react.md @@ -89,7 +89,7 @@ Description -[AsyncResult](./react.asyncresult.md) +[CerbosProviderProps](./react.cerbosproviderprops.md) @@ -97,9 +97,24 @@ Description - + -[CerbosProviderProps](./react.cerbosproviderprops.md) +## Type Aliases + + +
+ +Type Alias + + + + +Description + + +
+ +[AsyncResult](./react.asyncresult.md) diff --git a/packages/react/.eslintrc.yaml b/packages/react/.eslintrc.yaml index e4179aa6..df818c41 100644 --- a/packages/react/.eslintrc.yaml +++ b/packages/react/.eslintrc.yaml @@ -1,5 +1,2 @@ -plugins: - - react-hooks - extends: - plugin:react-hooks/recommended diff --git a/packages/react/package.json b/packages/react/package.json index 7ee716ea..29bd0af4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -46,15 +46,15 @@ "React" ], "peerDependencies": { - "react": ">=16.8.0" + "react": ">=16.13.0" }, "dependencies": { - "@cerbos/core": "^0.16.0" + "@cerbos/core": "^0.16.0", + "use-deep-compare-effect": "^1.8.1" }, "devDependencies": { "@types/react": "18.2.67", - "eslint-plugin-react-hooks": "4.6.0", - "react": "18.2.0" + "eslint-plugin-react-hooks": "4.6.0" }, "publishConfig": { "access": "public", diff --git a/packages/react/src/cerbos-provider.tsx b/packages/react/src/cerbos-provider.tsx index 56badf4f..0e2cc4e6 100644 --- a/packages/react/src/cerbos-provider.tsx +++ b/packages/react/src/cerbos-provider.tsx @@ -6,6 +6,7 @@ import type { } from "@cerbos/core"; import type { ReactElement, ReactNode } from "react"; import { createContext, useMemo } from "react"; +import { useDeepCompareMemoize } from "use-deep-compare-effect"; export const CerbosContext = createContext( undefined, @@ -63,9 +64,12 @@ export function CerbosProvider({ principal, auxData, }: CerbosProviderProps): ReactElement { + const principalMemo = useDeepCompareMemoize(principal); + const auxDataMemo = useDeepCompareMemoize(auxData); + const value = useMemo( - () => client.withPrincipal(principal, auxData), - [auxData, client, principal], + () => client.withPrincipal(principalMemo, auxDataMemo), + [client, principalMemo, auxDataMemo], ); return ( diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index cf9f8224..34b65551 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,9 +1,8 @@ -export { CerbosProvider } from "./cerbos-provider"; -export type { CerbosProviderProps } from "./cerbos-provider"; +export { type CerbosProviderProps, CerbosProvider } from "./cerbos-provider"; export { useCerbos } from "./use-cerbos"; export { + type AsyncResult, useCheckResource, useCheckResources, useIsAllowed, } from "./use-cerbos-request"; -export type { AsyncResult } from "./use-cerbos-request"; diff --git a/packages/react/src/is-equal.ts b/packages/react/src/is-equal.ts deleted file mode 100644 index ab484206..00000000 --- a/packages/react/src/is-equal.ts +++ /dev/null @@ -1,40 +0,0 @@ -export function isEqual( - a: T1 | undefined | null, - b: T2 | undefined | null, -): boolean { - if (typeof a !== typeof b) { - return false; - } - - // they are of the same type - if (typeof a !== "object" || typeof b !== "object") { - // they are both primitives - return a === b; - } - - // ignore more complex types like Map, Symbol, Date etc... - - // they are both objects - if (a === null || b === null) { - // one of them is null - return a === b; - } - - // they are both non null objects (arrays are objects too) - const entriesA = Object.entries(a); - const entriesB = Object.entries(b); - - if (entriesA.length !== entriesB.length) { - return false; - } - - for (const [key, valueA] of entriesA) { - // @ts-expect-error -- typescript complains that b can be an array and string cannot be used to index it but that's fine because we are checking for equality - const valueB = b[key] as unknown; - if (!isEqual(valueA, valueB)) { - return false; - } - } - - return true; -} diff --git a/packages/react/src/use-cerbos-request.ts b/packages/react/src/use-cerbos-request.ts index 4c9a08fe..0732a118 100644 --- a/packages/react/src/use-cerbos-request.ts +++ b/packages/react/src/use-cerbos-request.ts @@ -7,86 +7,74 @@ import type { IsAllowedRequest, RequestOptions, } from "@cerbos/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useDeepCompareMemoize } from "use-deep-compare-effect"; -import { isEqual } from "./is-equal"; import { useCerbos } from "./use-cerbos"; /** * @public */ -export interface AsyncResult { - isLoading: boolean; - data: T | undefined; - error: Error | undefined; -} +export type AsyncResult = + | { isLoading: true; data: undefined; error: undefined } + | { isLoading: false; data: T; error: undefined } + | { isLoading: false; data: undefined; error: Error }; -function useCerbosRequest< - TRequest extends keyof Pick< - ClientWithPrincipal, - "checkResource" | "checkResources" | "isAllowed" - >, - TRequestParams extends Parameters[0], ->( - request: TRequest, - requestParams: TRequestParams, - options?: RequestOptions, -): AsyncResult>> { - const client = useCerbos(); - const [data, setData] = - useState>>(); - const [error, setError] = useState(); - const [isLoading, setIsLoading] = useState(true); +type Methods = "checkResource" | "checkResources" | "isAllowed"; - const previousRequest = useRef(); - const previousOptions = useRef(); +type Result = Awaited< + ReturnType +>; - const checkResource = useCallback( - async ( - request: TRequest, - requestParams: TRequestParams, - options?: RequestOptions, - ) => { - setIsLoading(true); - setError(undefined); - setData(undefined); +function useCerbosRequest( + method: Method, + ...params: Parameters +): AsyncResult> { + const [isLoading, setIsLoading] = useState(true); + const [data, setData] = useState>(); + const [error, setError] = useState(); + + const client = useCerbos(); + const paramsMemo = useDeepCompareMemoize(params); - try { - const result = await (client[request]( - //@ts-expect-error --- this is strange because the generic types narrow nicely when this hook is called but not here :( - requestParams, - options, - ) as ReturnType); - setData(result); - } catch (error) { - setError( - error instanceof Error - ? error - : new Error("An unexpected error occurred", { cause: error }), - ); - } finally { - setIsLoading(false); - } - }, - [client], + const load = useCallback<() => Promise>>( + // @ts-expect-error -- https://github.com/microsoft/TypeScript/issues/30581 + async () => await client[method](...paramsMemo), + [client, method, paramsMemo], ); useEffect(() => { - if ( - !isEqual(requestParams, previousRequest.current) || - !isEqual(options, previousOptions.current) - ) { - previousRequest.current = requestParams; - previousOptions.current = options; - void checkResource(request, requestParams, options); - } - }, [checkResource, options, request, requestParams]); + let aborted = false; + setIsLoading(true); + setData(undefined); + setError(undefined); + + load() + .then((data) => { + if (!aborted) { + setIsLoading(false); + setData(data); + setError(undefined); + } + }) + .catch((error: unknown) => { + if (!aborted) { + setIsLoading(false); + setData(undefined); + setError( + error instanceof Error + ? error + : new Error("An unexpected error occurred", { cause: error }), + ); + } + }); + + return () => { + aborted = true; + }; + }, [load]); - return { - data, - isLoading, - error, - }; + return { isLoading, data, error } as AsyncResult>; } /** diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 705f7e28..dc4cdbda 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -3,9 +3,9 @@ "include": ["src"], "references": [], "compilerOptions": { + "jsx": "react-jsx", "outDir": "lib", "rootDir": "src", - "tsBuildInfoFile": "lib/react.tsbuildinfo", - "jsx": "react-jsx" + "tsBuildInfoFile": "lib/react.tsbuildinfo" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 562c8e69..d1b24a01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,12 @@ importers: '@cerbos/core': specifier: ^0.16.0 version: link:../core + react: + specifier: '>=16.8.0' + version: 18.2.0 + use-deep-compare-effect: + specifier: ^1.8.1 + version: 1.8.1(react@18.2.0) devDependencies: '@types/react': specifier: 18.2.67 @@ -140,9 +146,6 @@ importers: eslint-plugin-react-hooks: specifier: 4.6.0 version: 4.6.0(eslint@8.57.0) - react: - specifier: 18.2.0 - version: 18.2.0 packages/test: devDependencies: @@ -285,7 +288,6 @@ packages: engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.1 - dev: true /@babel/template@7.23.9: resolution: {integrity: sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==} @@ -1750,6 +1752,11 @@ packages: resolution: {integrity: sha512-PwuBojGMQAYbWkMXOY9Pd/NWCDNHVH12pnS7WHqZkTSeMESe4hwnKKRp0yR87g37113x4JPbo/oIvXY+s/f56Q==} dev: true + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + dev: false + /detect-file@1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} engines: {node: '>=0.10.0'} @@ -2669,7 +2676,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true /js-tokens@8.0.3: resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==} @@ -2803,7 +2809,7 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 - dev: true + dev: false /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} @@ -3173,7 +3179,7 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - dev: true + dev: false /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} @@ -3184,7 +3190,6 @@ packages: /regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - dev: true /regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} @@ -3690,6 +3695,17 @@ packages: punycode: 2.3.1 dev: true + /use-deep-compare-effect@1.8.1(react@18.2.0): + resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13' + dependencies: + '@babel/runtime': 7.23.8 + dequal: 2.0.3 + react: 18.2.0 + dev: false + /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true