diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index decd27a..c87a552 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,12 @@ jobs: npm install react@^${{ matrix.react-version }} react-dom@^${{ matrix.react-version }} @types/react@^${{ matrix.react-version }} @types/react-dom@^${{ matrix.react-version }} @testing-library/react@^16.1.0 fi - - name: Run tests + - name: Run tests (excluding Suspense tests for React 16/17) + if: matrix.react-version == '16.8.0' || matrix.react-version == '17.0.0' + run: npm test -- --coverage --watchAll=false --testPathIgnorePatterns=".*useAsyncMemoSuspense.*" --collectCoverageFrom="src/**/*.ts" --collectCoverageFrom="!src/useAsyncMemoSuspense.ts" + + - name: Run all tests (React 18+) + if: matrix.react-version == '18.0.0' || matrix.react-version == '19.0.0' run: npm test -- --coverage --watchAll=false - name: Upload coverage to Codecov diff --git a/README.md b/README.md index 44fbbfc..c263446 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ React hooks for async effects and memoization with proper dependency tracking an [![GitHub stars](https://img.shields.io/github/stars/davemecha/use-async-effekt?style=social)](https://github.com/davemecha/use-async-effekt/stargazers) [![issues](https://img.shields.io/github/issues/davemecha/use-async-effekt)](https://github.com/davemecha/use-async-effekt/issues) +Note: Tests are vibe coded. Specific tests are added when bugs are reported. + ## Installation ```bash @@ -310,7 +312,7 @@ module.exports = { "react-hooks/exhaustive-deps": [ "warn", { - additionalHooks: "(useAsyncEffekt|useAsyncMemo)", + additionalHooks: "(useAsyncEffekt|useAsyncMemo|useAsyncMemoSuspense)", }, ], }, @@ -325,7 +327,7 @@ Or if you're using `.eslintrc.json`: "react-hooks/exhaustive-deps": [ "warn", { - "additionalHooks": "(useAsyncEffekt|useAsyncMemo)" + "additionalHooks": "(useAsyncEffekt|useAsyncMemo|useAsyncMemoSuspense)" } ] } @@ -360,6 +362,52 @@ This configuration tells ESLint to treat `useAsyncEffekt` and `useAsyncMemo` the **Returns:** `T | undefined` - The memoized value, or `undefined` while loading +### `useAsyncMemoSuspense(factory, deps?, options?)` + +**Parameters:** + +- `factory: () => Promise | T` - The async function to execute. +- `deps?: DependencyList` - Optional dependency array (same as `useMemo`). +- `options?: { scope?: string }` - Optional options object. + - `scope?: string` - An optional scope to isolate the cache. This is useful when you have multiple instances of the hook with the same factory and dependencies but you want to keep their caches separate. + +**Returns:** `T` - The memoized value. It suspends the component while the async operation is in progress. + +**Important Notes:** + +- **SSR Environments (e.g., Next.js):** In a server-side rendering environment, this hook will always return `undefined` on the server. The component will suspend on the client during hydration (not on initial render on the server). This means the suspense fallback will be displayed on hydration, and nothing will be displayed on the server-side render. +- **Client Component:** This hook must be used within a "client component" (e.g., in Next.js, the file must have the `"use client";` directive at the top). +- **Experimental:** This hook is experimental and its API might change in future versions. + +**Example:** + +```tsx +import { Suspense } from "react"; +import { useAsyncMemoSuspense } from "use-async-effekt-hooks"; + +function UserProfile({ userId }) { + const user = useAsyncMemoSuspense(async () => { + const response = await fetch(`https://api.example.com/users/${userId}`); + return response.json(); + }, [userId]); + + return ( +
+

{user.name}

+

{user.email}

+
+ ); +} + +function App() { + return ( + Loading...}> + + + ); +} +``` + ## Features - ✅ Full TypeScript support diff --git a/package-lock.json b/package-lock.json index f486915..611f298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "semver": "^7.7.2", - "ts-jest": "^29.1.0", + "ts-jest": "^29.4.0", "typescript": "^5.0.0" }, "peerDependencies": { diff --git a/package.json b/package.json index 964ef4c..d1b1757 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "semver": "^7.7.2", - "ts-jest": "^29.1.0", + "ts-jest": "^29.4.0", "typescript": "^5.0.0" }, "jest": { @@ -77,7 +77,8 @@ "src/**/*.{ts,tsx}", "!src/__tests__/**/*.{ts,tsx}", "!src/**/*.d.ts", - "!src/setupTests.ts" + "!src/setupTests.ts", + "!src/**/test-utils.ts" ], "coverageReporters": [ "text", diff --git a/src/__tests__/useAsyncMemo.test.ts b/src/__tests__/useAsyncMemo.test.ts index e85cf43..6f4b087 100644 --- a/src/__tests__/useAsyncMemo.test.ts +++ b/src/__tests__/useAsyncMemo.test.ts @@ -4,6 +4,7 @@ import { useAsyncMemo } from "../useAsyncMemo"; describe("useAsyncMemo", () => { beforeEach(() => { + jest.clearAllMocks(); jest.clearAllTimers(); jest.useRealTimers(); }); diff --git a/src/__tests__/useAsyncMemoSuspense.test.tsx b/src/__tests__/useAsyncMemoSuspense.test.tsx new file mode 100644 index 0000000..3c6e250 --- /dev/null +++ b/src/__tests__/useAsyncMemoSuspense.test.tsx @@ -0,0 +1,706 @@ +import React, { Suspense, startTransition } from "react"; +import { render, screen, waitFor, act as rtlAct } from "@testing-library/react"; +import { renderHook, act } from "./test-utils"; +import { useAsyncMemoSuspense } from "../useAsyncMemoSuspense"; + +// Type-safe helper component for testing +interface TestComponentProps { + factory: () => Promise | T; + deps: React.DependencyList; + scope?: string; + testId?: string; +} + +function TestComponent({ + factory, + deps, + scope, + testId, +}: TestComponentProps) { + const value = useAsyncMemoSuspense(factory, deps, { scope }); + return ( +
Value: {String(value)}
+ ); +} + +// Realistic test component that demonstrates proper usage +interface UserProfileProps { + userId: string; + includeDetails?: boolean; +} + +function UserProfile({ userId, includeDetails = false }: UserProfileProps) { + const user = useAsyncMemoSuspense( + async () => { + // Simulate realistic API call + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + details: includeDetails ? `Details for ${userId}` : undefined, + }; + }, + [userId, includeDetails], // Realistic dependency usage + { scope: `user-profile-${userId}-${includeDetails}` } // Proper scoping + ); + + return ( +
+

{user?.name}

+

{user?.email}

+ {user?.details &&

{user.details}

} +
+ ); +} + +// Error boundary for testing error scenarios +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends React.Component< + React.PropsWithChildren, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+ Error: {this.state.error?.message} +
+ ); + } + return this.props.children; + } +} + +describe("useAsyncMemoSuspense - Core Functionality", () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe("Basic async operations", () => { + it("should suspend and then render resolved value", async () => { + const factory = jest + .fn() + .mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve("async-result"), 50) + ) + ); + + render( + Loading...}> + + + ); + + expect(screen.getByTestId("loading")).toBeInTheDocument(); + expect(factory).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(50); + }); + + await waitFor(() => + expect(screen.getByTestId("async-result")).toHaveTextContent( + "Value: async-result" + ) + ); + }); + + it("should handle synchronous values without suspending", async () => { + const factory = jest.fn().mockReturnValue("sync-result"); + + render( + Loading...}> + + + ); + + // Wait a tick to ensure React has processed the synchronous result + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); + expect(screen.getByTestId("sync-result")).toHaveTextContent( + "Value: sync-result" + ); + }); + expect(factory).toHaveBeenCalledTimes(1); + }); + }); + + describe("Error handling", () => { + it("should throw async errors to error boundary", async () => { + const error = new Error("Async operation failed"); + const factory = jest.fn().mockRejectedValue(error); + + render( + + Loading...}> + + + + ); + + await waitFor( + () => + expect(screen.getByTestId("error-boundary")).toHaveTextContent( + "Error: Async operation failed" + ), + { timeout: 1000 } + ); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("should throw sync errors to error boundary", () => { + const error = new Error("Sync operation failed"); + const factory = jest.fn().mockImplementation(() => { + throw error; + }); + + render( + + Loading...}> + + + + ); + + expect(screen.getByTestId("error-boundary")).toHaveTextContent( + "Error: Sync operation failed" + ); + }); + }); + + describe("Dependency management", () => { + it("should recompute when dependencies change", async () => { + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByText("User 1")).toBeInTheDocument() + ); + + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByText("User 2")).toBeInTheDocument() + ); + }); + + it("should handle boolean dependency changes", async () => { + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByText("User 1")).toBeInTheDocument() + ); + expect(screen.queryByText("Details for 1")).not.toBeInTheDocument(); + + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByText("Details for 1")).toBeInTheDocument() + ); + }); + }); + + describe("Scoping", () => { + it("should differentiate caches with different scopes", async () => { + const factory1 = jest.fn().mockResolvedValue("scoped-value-1"); + const factory2 = jest.fn().mockResolvedValue("scoped-value-2"); + + render( + Loading...}> +
+ + +
+
+ ); + + await waitFor(() => { + expect(screen.getByTestId("result-1")).toHaveTextContent( + "Value: scoped-value-1" + ); + expect(screen.getByTestId("result-2")).toHaveTextContent( + "Value: scoped-value-2" + ); + }); + + expect(factory1).toHaveBeenCalledTimes(1); + expect(factory2).toHaveBeenCalledTimes(1); + }); + }); + + describe("React 18 concurrent features", () => { + it("should work with startTransition", async () => { + let resolver: ((value: string) => void) | undefined; + const factory = jest + .fn() + .mockImplementationOnce( + () => + new Promise((res) => { + resolver = res; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => resolve("transition-value-2"), 10) + ) + ); + + const App = ({ dep }: { dep: number }) => { + const value = useAsyncMemoSuspense(factory, [dep], { + scope: `transition-test-${dep}`, + }); + return
Value: {value}
; + }; + + const { rerender } = render( + Loading...}> + + + ); + + expect(screen.getByTestId("loading")).toBeInTheDocument(); + + await rtlAct(async () => { + resolver!("transition-value-1"); + }); + + await waitFor(() => + expect(screen.getByTestId("transition-result")).toHaveTextContent( + "Value: transition-value-1" + ) + ); + + startTransition(() => { + rerender( + Loading...}> + + + ); + }); + + // With startTransition, should show old value while loading + expect(screen.getByTestId("transition-result")).toHaveTextContent( + "Value: transition-value-1" + ); + + await act(async () => { + jest.advanceTimersByTime(10); + }); + + await waitFor(() => + expect(screen.getByTestId("transition-result")).toHaveTextContent( + "Value: transition-value-2" + ) + ); + expect(factory).toHaveBeenCalledTimes(2); + }); + }); + + describe("Cache behavior and edge cases", () => { + it("should reuse cache when dependencies haven't changed", async () => { + const factory = jest.fn().mockResolvedValue("cached-result"); + + const TestCacheComponent = ({ rerender }: { rerender: boolean }) => { + const value = useAsyncMemoSuspense(factory, ["static-dep"], { + scope: "cache-test", + }); + return ( +
+ Value: {value} - Render: {rerender ? "second" : "first"} +
+ ); + }; + + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("cache-result")).toHaveTextContent( + "Value: cached-result - Render: first" + ) + ); + expect(factory).toHaveBeenCalledTimes(1); + + // Rerender with same dependencies - should use cache + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("cache-result")).toHaveTextContent( + "Value: cached-result - Render: second" + ) + ); + // Factory should still only be called once due to caching + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("should handle multiple components with same cache key", async () => { + const factory = jest.fn().mockResolvedValue("shared-result"); + + const ComponentA = () => { + const value = useAsyncMemoSuspense(factory, ["shared"], { + scope: "shared-scope", + }); + return
A: {value}
; + }; + + const ComponentB = () => { + const value = useAsyncMemoSuspense(factory, ["shared"], { + scope: "shared-scope", + }); + return
B: {value}
; + }; + + render( + Loading...}> + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("component-a")).toHaveTextContent( + "A: shared-result" + ); + expect(screen.getByTestId("component-b")).toHaveTextContent( + "B: shared-result" + ); + }); + + // Factory should only be called once since both components share the same cache + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("should handle complex dependency types", async () => { + const complexObj = { nested: { value: 42 }, array: [1, 2, 3] }; + const factory = jest.fn().mockResolvedValue("complex-deps-result"); + + const TestComplexDeps = ({ + obj, + num, + str, + bool, + nullVal, + undefinedVal, + }: { + obj: any; + num: number; + str: string; + bool: boolean; + nullVal: null; + undefinedVal: undefined; + }) => { + const value = useAsyncMemoSuspense( + factory, + [obj, num, str, bool, nullVal, undefinedVal], + { scope: "complex-deps" } + ); + return
Value: {value}
; + }; + + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("complex-result")).toHaveTextContent( + "Value: complex-deps-result" + ) + ); + expect(factory).toHaveBeenCalledTimes(1); + + // Rerender with same complex dependencies - should use cache + rerender( + Loading...}> + + + ); + + // Should still use cache + expect(factory).toHaveBeenCalledTimes(1); + + // Change one dependency - should invalidate cache + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("complex-result")).toHaveTextContent( + "Value: complex-deps-result" + ) + ); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("should handle empty dependencies array", async () => { + const factory = jest.fn().mockResolvedValue("empty-deps-result"); + + const TestEmptyDeps = () => { + const value = useAsyncMemoSuspense(factory, [], { + scope: "empty-deps-test", + }); + return
Value: {value}
; + }; + + render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("empty-deps-result")).toHaveTextContent( + "Value: empty-deps-result" + ) + ); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("should handle no dependencies parameter (default empty array)", async () => { + const factory = jest.fn().mockResolvedValue("no-deps-result"); + + const TestNoDeps = () => { + // Test calling without deps parameter + const value = useAsyncMemoSuspense(factory, undefined, { + scope: "no-deps-test", + }); + return
Value: {value}
; + }; + + render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("no-deps-result")).toHaveTextContent( + "Value: no-deps-result" + ) + ); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("should handle zero and negative zero in dependencies", async () => { + const factory = jest.fn().mockResolvedValue("zero-result"); + + const TestZero = ({ zeroValue }: { zeroValue: number }) => { + const value = useAsyncMemoSuspense(factory, [zeroValue], { + scope: "zero-test", + }); + return
Value: {value}
; + }; + + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("zero-result")).toHaveTextContent( + "Value: zero-result" + ) + ); + expect(factory).toHaveBeenCalledTimes(1); + + // Rerender with -0 - should be treated as different due to Object.is(0, -0) being false + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("zero-result")).toHaveTextContent( + "Value: zero-result" + ) + ); + // Should be called twice since Object.is(0, -0) is false + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("should handle functions with identical string representations but different behavior", async () => { + // Two different functions that might have similar toString() output + const factory1 = jest.fn().mockResolvedValue("factory1-result"); + const factory2 = jest.fn().mockResolvedValue("factory2-result"); + + const TestIdenticalFunctions = ({ useFirst }: { useFirst: boolean }) => { + const factory = useFirst ? factory1 : factory2; + const value = useAsyncMemoSuspense(factory, [useFirst]); + return
Value: {value}
; + }; + + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("identical-result")).toHaveTextContent( + "Value: factory1-result" + ) + ); + expect(factory1).toHaveBeenCalledTimes(1); + + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("identical-result")).toHaveTextContent( + "Value: factory2-result" + ) + ); + expect(factory2).toHaveBeenCalledTimes(1); + }); + }); + + // Note: Error recovery is already tested in the main error handling tests + + describe("Performance and memory considerations", () => { + it("should handle rapid dependency changes efficiently", async () => { + const factory = jest + .fn() + .mockImplementation((dep: number) => Promise.resolve(`rapid-${dep}`)); + + const TestRapidChanges = ({ dep }: { dep: number }) => { + const value = useAsyncMemoSuspense(() => factory(dep), [dep], { + scope: "rapid-test", + }); + return
Value: {value}
; + }; + + const { rerender } = render( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("rapid-result")).toHaveTextContent( + "Value: rapid-1" + ) + ); + + // Rapidly change dependencies + for (let i = 2; i <= 5; i++) { + rerender( + Loading...}> + + + ); + + await waitFor(() => + expect(screen.getByTestId("rapid-result")).toHaveTextContent( + `Value: rapid-${i}` + ) + ); + } + + expect(factory).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 44ad500..e547b6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { useAsyncEffekt } from './useAsyncEffekt'; export { useAsyncMemo } from './useAsyncMemo'; +export { useAsyncMemoSuspense } from './useAsyncMemoSuspense'; diff --git a/src/useAsyncMemoSuspense.ts b/src/useAsyncMemoSuspense.ts new file mode 100644 index 0000000..48fe862 --- /dev/null +++ b/src/useAsyncMemoSuspense.ts @@ -0,0 +1,117 @@ +import { DependencyList, useRef } from "react"; + +type CacheEntry = + | { status: "pending"; promise: Promise; deps: readonly unknown[] } + | { status: "success"; result: T; deps: readonly unknown[] } + | { status: "error"; error: unknown; deps: readonly unknown[] } + | undefined; + +const sameDeps = (a: readonly unknown[], b: readonly unknown[]) => + a.length === b.length && a.every((v, i) => Object.is(v, b[i])); + +// Global cache for async memoization +let asyncMemoCache: Map>; + +function getCache() { + if (!asyncMemoCache) asyncMemoCache = new Map>(); + return asyncMemoCache as Map>; +} + +function getCacheKey( + factory: () => Promise | unknown, + deps: DependencyList, + scope?: string +): string { + // Use function toString and JSON.stringify for deps as a simple cache key + // In production, you might want a more sophisticated key generation + return JSON.stringify([factory.toString(), deps, scope || ""]); +} + +/** + * @experimental This hook is experimental and should be used with caution. + * + * A hook for memoizing async computations that integrates with React Suspense. + * + * This hook allows you to perform an asynchronous operation and suspend the component + * until the operation is complete. It's useful for data fetching or any other async + * task that needs to be resolved before rendering. + * + * In SSR environments (e.g., Next.js), the hook always returns `undefined` on the + * server for prerendering. This means the suspense fallback will be displayed on + * hydration, and nothing will be displayed on the server-side render. + * + * This hook requires to be used in a client component. + * + * @param factory - The async function to execute. + * @param deps - The dependency array for the memoization. + * @param options - An optional options object. + * @param options.scope - An optional scope to isolate the cache. + * @returns The memoized value, or it suspends the component. + * + * @example + * ```tsx + * import { Suspense } from 'react'; + * import { useAsyncMemoSuspense } from 'use-async-effekt-hooks'; + * + * function UserProfile({ userId }) { + * const user = useAsyncMemoSuspense(async () => { + * const response = await fetch(`https://api.example.com/users/${userId}`); + * return response.json(); + * }, [userId]); + * + * return ( + *
+ *

{user.name}

+ *

{user.email}

+ *
+ * ); + * } + * + * function App() { + * return ( + * Loading...}> + * + * + * ); + * } + * ``` + */ +export function useAsyncMemoSuspense( + factory: () => Promise | T, + deps: DependencyList = [], + options?: { scope?: string } +): T | undefined { + // this is just to force the using component to be a client component + useRef(undefined); + + const isClient = typeof window !== "undefined"; + if (!isClient) return undefined; + + const cacheKey = getCacheKey(factory, deps, options?.scope); + let cacheEntry = getCache().get(cacheKey); + + // Check if dependencies have changed or no cache entry exists + if (!cacheEntry || !sameDeps(cacheEntry.deps, deps)) { + const promise = Promise.resolve(factory()); + const newCacheEntry: CacheEntry = { + status: "pending", + promise, + deps: [...deps], + }; + + newCacheEntry.promise + .then((result) => + Object.assign(newCacheEntry, { status: "success", result }) + ) + .catch((error) => + Object.assign(newCacheEntry, { status: "error", error }) + ); + + cacheEntry = newCacheEntry; + getCache().set(cacheKey, newCacheEntry); + } + + if (cacheEntry?.status === "success") return cacheEntry.result; + if (cacheEntry?.status === "error") throw cacheEntry.error; + throw cacheEntry?.promise; +}